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..e38efd88b6 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,82 @@ 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: Check utopia-php/cache Version + run: | + docker compose exec -T tests php -r ' + require "vendor/autoload.php"; + $version = \Composer\InstalledVersions::getPrettyVersion("utopia-php/cache"); + echo "utopia-php/cache {$version}", PHP_EOL; + exit(\str_starts_with((string) $version, "5.") ? 0 : 1); + ' - 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..dd311614b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# Utopia Database + +PHP database abstraction library with a unified API across MariaDB 10.5, MySQL 8.0, PostgreSQL 13+, SQLite 3.38+, and MongoDB. + +## 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 + - `Adapter/` -- engine implementations: MariaDB, MySQL, Postgres, SQLite, Mongo, Memory, Redis, Pool, ReadWritePool + - `Adapter/SQL.php` -- shared SQL adapter base (MariaDB, MySQL, Postgres, SQLite extend this) + - `Adapter/Feature/` -- capability interfaces for adapter features + - `Document.php` -- JSON document model (extends ArrayObject) + - `Mirror.php` -- database mirroring/replication + - `Query.php` -- query builder extension + - `Attribute.php` -- attribute type definitions + - `Index.php` -- index management + - `Relationship.php` -- relationship definitions + - `Traits/` -- Database.php composition: Attributes, Collections, Databases, Documents, Indexes, Relationships, Transactions + - `Hook/` -- event hooks and interceptors: Lifecycle, Named, Decorator, Transform, Read, Write, WriteContext, Interceptor, Permissions, Relationships, Tenancy, PermissionFilter, TenantFilter, the join and raw-builder filters, Mongo/PermissionFilter, Mongo/TenantFilter + - `Event/` -- Domain, DispatcherHook, plus Collection/{Created,Deleted}, Document/{Created,Deleted,Updated} and Documents/{Created,Deleted,Updated} + - `Cache/` -- `find()` query cache (QueryCache, Invalidator) and cache bookkeeping + - `State/` -- per-coroutine state (Value) and Snapshot + - `Validator/` -- input validators (21 top-level + Authorization/, Queries/, Query/) + - `Helpers/` -- ID, Permission, Role utilities + - `Exception/` -- 20 exception types (Authorization, Duplicate, Limit, Query, Timeout, etc.) + +- **tests/unit/** -- unit tests for validators, helpers, etc. +- **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. Hooks registered on the Database instance. + +**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 7 traits in `Traits/` for organization. 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 validators and helpers +- 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 or Adapter interface may break appwrite. Run `composer test` in both repos after adapter changes. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..51762d0d6f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,759 @@ +# 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). +- `createCollection()`, `createAttribute()`, `createIndex()` and `createRelationship()` take `Collection`, + `Attribute`, `Index` and `Relationship` models, and `getCollection()` returns a `Collection`. See + [Schema: typed models](UPGRADE.md#schema-typed-models). +- `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()`. See + [Lifecycle events are hooks](UPGRADE.md#lifecycle-events-are-hooks). +- The 51 `Adapter::getSupportFor*()` methods are removed: check `supports(Capability::...)` or + `hasFeature(Feature\...::class)`. `Adapter` implements the `Adapter\Feature` interfaces, `Adapter\SQLite` extends + `Adapter\SQL` instead of `Adapter\MariaDB`, and the adapter signatures take models and enum cases. See + [Adapters](UPGRADE.md#adapters). +- 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`), and `Query::groupByType()` returns a `ParsedQuery` object + (`Query::groupForDatabase()` returns the 7.x array shape). +- `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 `skipDuplicates()` 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). + +### Added + +- **Joins.** `Query::join()`, `leftJoin()`, `rightJoin()`, `fullOuterJoin()` and `crossJoin()`, with conditions + given inline or with `Query::on()`, 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`. Generated aliases (`j0`, `j1`, ...) never repeat a declared one. 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. Joined values are never + returned under a bare attribute name. + - `select('alias.*')` next to other selects returns the joined `$id` and attributes, as a join without a select + does. 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 selected). 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 both the `join(table, left, right, operator, alias)` and + the `on()` form: 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()` + and `iterate()` over a joined read whose rows lack such a value throw 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` you build yourself accepts joins and aggregations only with its new + `supportForJoins` and `supportForAggregations` constructor flags, which `Database` sets from the adapter's + capabilities. `Validator\Queries\Document` takes the same `supportForJoins` flag, defaulting to `true`. + `Validator\Queries::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()`, in `find()` on the SQL adapters + (`Capability::Aggregations`). `Database::aggregate()` is an alias of `find()` for these queries. + - 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 `find()`, `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. + - Adapters report `Capability::StatisticalAggregates` and `Capability::BitwiseAggregates`, and SQLite reports + neither: `find()` rejects those aggregates there. 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::execute()` runs a built statement, and `Database::schema()` returns a schema builder. `from()` and + `execute()` 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. +- **`find()` query cache.** `Database::setQueryCache(new Cache\QueryCache($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` (row writes, such as `Hook\Permissions`) and `Hook\Relationships`. 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. `Event\DispatcherHook` is a selective + lifecycle hook with listeners per domain event class (`Event\Document\Created`, `Updated`, `Deleted`, + `Event\Documents\Created`, `Updated`, `Deleted` for bulk writes with their count, and `Event\Collection\Created`, + `Deleted`); it handles an event only while a listener or a PSR-14 dispatcher can receive it. +- **Typed models.** `Collection`, `Attribute`, `Index` and `Relationship`, with factories per type + (`Attribute::string()`, `Index::key()`, `Relationship::oneToMany()`, ...) and one class per storable attribute type + in `Utopia\Database\Attribute`. `Attribute::TYPES` lists the storable column types and `Attribute::availableTypes()` + narrows it to an adapter's capabilities. `Attribute::persistedType()`, `normalizeType()` and `tryNormalizeType()` + convert between `ColumnType` cases and stored type strings. A `float` attribute type (`ColumnType::Float`) joins + `double`. +- **Enums.** `Utopia\Database\Event`, `PermissionType`, `RelationType`, `RelationSide`, `SetType`, `OperatorType` and + `Capability`, and from utopia-php/query `Method`, `ColumnType`, `IndexType`, `Order`, `OrderDirection`, + `ForeignKeyAction` and `CursorDirection`. +- **Adapter capabilities.** `Adapter::supports(Capability)`, `capabilities()` and `hasFeature()`, and the + `Adapter\Feature` interfaces. `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). +- **Custom types.** Implement `Utopia\Database\Type\Custom` (`name()`, `encode()`, `decode()`) and register the type + on a `Utopia\Database\Type\TypeRegistry`. Give the registry to a `Database` with `setTypeRegistry()`, and list the + type's name in an attribute's `filters`. The type applies only to handles that share that registry. On those + handles it takes precedence over a global filter of the same name (`Database::addFilter()`). Filters passed to the + `Database` constructor take precedence over both. `register()` rejects the built-in filter names + (`Database::DEFAULT_FILTERS`) with `Utopia\Database\Exception\Duplicate`. Document and query cache keys include + each registered type's class. +- **`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 `getHostname()` always names the write pool's host. +- **Query profiling.** `Database::enableProfiling()`, `disableProfiling()` and `getProfiler()` with + `Profiler\QueryProfiler`, which keeps the newest `QueryProfiler::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 `QueryLog` 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::fromStorage()`, which hydrates a stored document like the constructor but drops + non-string permissions instead of rejecting them, and `fromRow()`, `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 (`getBuilder()` 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 (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()` and `execute()` 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::withStatus(bool $status, callable $callback)`, `Authorization::withRoles()`, + `Adapter::withTenant()`, `Database::snapshot()` and `Database::withSnapshot(Snapshot $snapshot, callable + $callback)`: run work started in another coroutine under the caller's authorization, relationship, silence, tenant + and toggle state. `Hook\Relationships::withEnabled()`, `withCheckExist()` and `withSnapshot()` scope the hook's + flags the same way. +- `Database::setCacheWriterTimeout()` and `QueryCache`'s `writerTimeout` argument bound how long an unfinished + invalidation keeps a collection's cache off. +- `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) and + `Validator\Structure`'s `storedAttributes` parameter. + +### Changed + +- `createAttributes()` fires `attribute_create` once per attribute, with a `Document` payload, and then + `attributes_create` once with the list (7.x fired `attribute_create` once, with an array, and never fired + `attributes_create`). +- `silent($callback, $listeners)` 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()`, + `skipFilters()`, `skipValidation()`, `withPreserveDates()`, `withPreserveSequence()`, `withTenant()`, + `withRequestTimestamp()`, `skipDuplicates()` 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 such a scope, 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. +- Relationship population reads its chunks of related ids concurrently only on `Adapter\Pool`, inside a coroutine + and outside a transaction; 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()`, `upsertDocumentsWithIncrease()`, `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, and writes to one document reach the destination in the order they were + made; `createDocument()`, `updateDocument()`, `increaseDocumentAttribute()` and `decreaseDocumentAttribute()`, + which replicate before returning, first wait for the pending replications of their document. Outside a coroutine + every replication finishes before the call returns. +- `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. SQLite and MongoDB report `Capability::QueryContains`. +- 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). +- On Memory and Redis, `setSupportForAttributes()` returns `true`: these adapters always enforce the collection's + attributes. It returned the requested value without applying it. +- Schema calls no longer retry deterministic failures of their metadata write (validation, authorization, missing + or duplicate documents, limits) and no longer sleep before rethrowing them. +- `createCollection()`, `createAttribute()`, `createAttributes()`, `createIndex()` and their update, rename and + delete siblings keep the table, column or index when only the cache invalidation after their committed definition + failed, and do not repeat the write. `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`). +- `Database::ATTRIBUTE_FILTER_TYPES` is renamed `Database::ATTRIBUTE_FILTER_COLUMN_TYPES` and holds `ColumnType` + cases. +- 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::persistedType()` and read one with `Attribute::normalizeType()`. +- `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). + +### 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 (except `TYPE_ELEM_MATCH`), the `Operator::TYPE_*` constants and the + `Document::SET_TYPE_*` constants, replaced by enums. See [Constants are now enums](UPGRADE.md#constants-are-now-enums). +- The 51 `Adapter::getSupportFor*()` methods. See + [Capabilities and feature interfaces](UPGRADE.md#capabilities-and-feature-interfaces). +- The SQL adapters' string query builders and the hooks behind them (`getSQLConditions()`, `getSQLCondition()`, + `getSQLPermissionsCondition()`, `getFulltextValue()`, `getTenantQuery()`, `getLikeOperator()`, + `getRegexOperator()`, `getAttributeProjection()`, the insert and upsert statement hooks, and others). See + [Removed adapter methods](UPGRADE.md#removed-adapter-methods). + +### Fixed + +- 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()`, `enableFilters()`, `disableFilters()`, `skipFilters()`, `enableLocks()`, `enableProfiling()`, + `disableProfiling()`, `setMigrating()` and `setTypeRegistry()` 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::enableLocks()` reports a destination that cannot apply the setting through `onError()` (action + `enableLocks`) 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 `Mirroring\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 `Mirror::upsertDocumentsWithIncrease()` run on the source, replicate to the + destination and fire `documents_upsert` once to hooks registered through the mirror. +- `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 `skipDuplicates()` 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 `skipDuplicates()` 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. +- `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\Index` 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 `ValueError`. + `Validator\Index` counts big integer columns (big integer, id, integer of size 8 or more) 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. +- `updateRelationship()` fires `attribute_update` with string relationship options, as `createRelationship()` does. +- `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), and the adapter reports `Capability::Regex` whenever that function is + registered. +- 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. 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. `Adapter\Mongo::createCollection()` returns `true` for a collection that already exists. +- 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 `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` 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 `QueryCache`'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 are + back at or below 7.x's (create 3, update and delete 4, 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. + - 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()` and `QueryCache`'s `writerTimeout`). + - `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 + `upsertDocumentsWithIncrease()` 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()`, `skipDuplicates()`) 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. + - `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. + - `Document::findAndReplace()` and `Document::findAndRemove()` match the subject as 7.3.x did again: without an + array subject only the top-level key is matched, and a Document subject is searched inside instead of being + replaced or removed whole. + - 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. + - Custom types stay on the handles that share their `TypeRegistry` 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. + - `skipDuplicates()` 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:** + - `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::enableLocks()` 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 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. + - Writes to one document reach the destination in the order they were made through the mirror; a failed + replication is reported to `onError()` and does not hold back later ones. Concurrent replications no longer + share the destination's preserve-dates and skip-duplicates settings, and schema changes wait for queued + replications of their collection. + - 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 action + 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). + - 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. +- **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 + (observed on MariaDB and MySQL shared tables): index the attributes your join conditions compare. + +### Dependencies + +- Requires `utopia-php/query` 0.6 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. + +### 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. +- `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..e9d6666aa1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,13 +80,13 @@ 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/[unit|e2e]/[FILE_PATH] ``` To run static code analysis, use the following phpstan command: @@ -110,7 +110,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 +120,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 +129,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..0e36905e1e 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,20 @@ 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 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. Filters added with `Database::addFilter()` apply to every `Database` instance in the process. There are also instance-level filters that can only be defined while constructing the `Database` instance, and custom types registered on a `Utopia\Database\Type\TypeRegistry` that apply to the instances you give the registry to with `setTypeRegistry()`. Instance level filters override the static filters if they have the same name. ### 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 +76,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. +Attribute 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 +93,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 +133,25 @@ 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`. + #### 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, -]; -$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $pdoConfig); +$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); $cache = new Cache(new Memory()); // or use any cache adapter you wish @@ -157,30 +163,22 @@ $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, -]; -$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $pdoConfig); +$pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); $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 +186,18 @@ $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, -]; -$pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, $pdoConfig); +$pdo = new PDO("pgsql:host={$dbHost};port={$dbPort}", $dbUser, $dbPass, Postgres::getPDOAttributes()); $cache = new Cache(new Memory()); // or use any cache adapter you wish @@ -219,23 +209,15 @@ $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, -]; -$pdo = new PDO("sqlite:{$dbPath}", $pdoConfig); +$pdo = new PDO("sqlite:{$dbPath}", null, null, SQLite::getPDOAttributes()); $cache = new Cache(new Memory()); // or use any cache adapter you wish @@ -247,14 +229,14 @@ $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; +$dbPort = 27017; $dbUser = 'root'; $dbPass = 'password'; $dbName = 'dbName'; @@ -266,10 +248,64 @@ $cache = new Cache(new Memory()); // or use any cache adapter you wish $database = new Database(new Mongo($mongoClient), $cache); ``` -### Database Methods +### 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($database)); +``` + +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. It receives every event (`Utopia\Database\Event`), so check the event inside `handle()`. A hook that also implements `Named` replaces the hook already registered under its name, and `silent()` can silence it by name. ```php +use Utopia\Database\Event; +use Utopia\Database\Hook\Lifecycle; +use Utopia\Database\Hook\Named; + +final class AuditLog implements Lifecycle, Named +{ + /** @var array */ + public array $entries = []; + + public function getName(): string + { + return 'audit-log'; + } + public function handle(Event $event, mixed $data): void + { + if ($event === Event::DocumentCreate || $event === Event::DocumentDelete) { + $this->entries[] = $event->value; + } + } +} + +$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. + +### Database Methods + +```php // Get namespace $database->getNamespace(); @@ -286,65 +322,30 @@ $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(); // Returns an array of all databases $database->list(); -// Delete database -$database->delete( - name: 'mydb' -); - -// 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', + database: 'dbName', 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 - } ); +// Ping database it returns true if the database is alive +$database->ping(); + // Get Database Adapter $database->getAdapter(); @@ -355,63 +356,37 @@ $database->getKeywords(); ### 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'], - ]) -]; +use Utopia\Database\Attribute; +use Utopia\Database\Collection; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; +use Utopia\Query\Schema\Order; -$database->createCollection( - id: 'users', - attributes: $attributes, - indexes: $indexes -); +// 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(new Collection( + id: 'users', + attributes: [ + Attribute::string(key: 'name', size: 256), + Attribute::integer(key: 'age'), + ], + indexes: [ + Index::key(key: 'idx_name', attributes: ['name'], lengths: [256], orders: [Order::Asc]), + Index::key(key: 'idx_name_age', attributes: ['name', 'age'], lengths: [128, null], orders: [Order::Asc, Order::Desc]), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + documentSecurity: true, +)); // Update Collection Permissions $database->updateCollection( id: 'users', permissions: [ + Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()) @@ -426,290 +401,314 @@ $database->getCollection( // 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' + collectionId: 'users' +); + +// Deletes the collection and its permissions table +$database->createCollection(new Collection(id: 'drafts')); +$database->deleteCollection( + id: '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\Collection; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Structure; +use Utopia\Query\Schema\ColumnType; +use Utopia\Validator\Range; +$database->createCollection(new Collection( + 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, +// Creates a new attribute named 'name' in the 'movies' collection. +// Every type has a factory: Attribute::string(), integer(), float(), boolean(), datetime(), ... +$database->createAttribute('movies', Attribute::string( + key: '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: [], +$database->createAttribute('movies', Attribute::string( + key: 'genres', + size: 128, + required: false, + default: null, + signed: true, + array: true, + format: null, + formatOptions: [], filters: [] -); +)); + +// The same with the model's constructor +$database->createAttribute('movies', new Attribute( + key: 'director', + type: ColumnType::String, + size: 128 +)); + +// Creates several attributes at once +$database->createAttributes('movies', [ + Attribute::integer(key: 'year'), + Attribute::float(key: 'price'), + Attribute::boolean(key: 'active'), +]); -// Updates the attribute named '$attributeName' in the '$namespace_$collectionName' collection. -$database-> updateAttribute( - collection: 'movies', +// Updates the attribute named 'genres' in the 'movies' collection. +$database->updateAttribute( + collection: 'movies', id: 'genres', - type: Database::VAR_STRING, - size: 128, - required: true, - default: null, - signed: true, - array: false, - format: null, - formatOptions: [], + type: ColumnType::String, + size: 128, + required: false, + default: null, + signed: true, + array: true, + format: null, + formatOptions: [], filters: [] ); // Update the required status of an attribute $database->updateAttributeRequired( - collection: 'movies', + collection: 'movies', id: 'genres', required: true ); -// Update the attribute format +// Update the attribute format. 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 +); + $database->updateAttributeFormat( - collection: 'movies', - id: 'genres', - format: null, + collection: 'movies', + id: 'year', + format: 'year' ); // Update the attribute format options $database->updateAttributeFormatOptions( - collection: 'movies', - id: 'genres', - formatOptions: [] + collection: 'movies', + id: 'year', + formatOptions: ['min' => 1888, 'max' => 2100] ); // Update the attribute filters $database->updateAttributeFilters( - collection: 'movies', + collection: 'movies', id: 'genres', filters: [] ); // Update the default value of an attribute $database->updateAttributeDefault( - collection: 'movies', - id: 'genres', - default: 'sci-fi' + collection: 'movies', + id: 'director', + 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 + attribute: Attribute::integer(key: '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(key: 'tagline', size: 256)); $database->renameAttribute( collection: 'movies', - old: 'genres', - new: 'genres2' + old: 'tagline', + new: 'slogan' ); -// Deletes the attribute in the '$namespace_$collectionName' collection. +// Deletes the attribute in the 'movies' collection. $database->deleteAttribute( - collection: 'movies', - id: 'genres' + collection: 'movies', + id: '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] -); - -// Rename index from old to new in the '$namespace_$collectionName' collection. +use Utopia\Database\Index; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; + +// Index types are cases of IndexType: Key, Unique, Fulltext, Spatial, Object, Trigram, Ttl, +// HnswEuclidean, HnswCosine and HnswDot. Every type has a factory: Index::key(), unique(), fullText(), ... +// Orders are Order::Asc and Order::Desc. + +// Creates a new index named 'index1' in the 'movies' collection. +$database->createIndex('movies', Index::key( + key: 'index1', + attributes: ['name', 'year'], + lengths: [128, null], + orders: [Order::Asc, Order::Desc] +)); + +// The same with the model's constructor +$database->createIndex('movies', new Index( + key: 'index_name_search', + type: IndexType::Fulltext, + attributes: ['name'] +)); + +// 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. +// Deletes the index in the 'movies' collection. $database->deleteIndex( - collection: 'movies', + collection: 'movies', id: '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\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ForeignKeyAction; + +// Relationship types are cases of RelationType: OneToOne, OneToMany, ManyToOne and ManyToMany. +// What happens to related documents when a document is deleted is a ForeignKeyAction: +// 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(new Relationship( + collection: 'movies', + relatedCollection: 'users', + type: RelationType::OneToOne, + 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' -); +// Create a relationship with custom reference attributes. Every type has a factory. +$database->createCollection(new Collection( + id: 'reviews', + attributes: [Attribute::string(key: 'body', size: 1024)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], +)); -// Relationship onDelete types -Database::RELATION_MUTATE_CASCADE, -Database::RELATION_MUTATE_SET_NULL, -Database::RELATION_MUTATE_RESTRICT, +$database->createRelationship(Relationship::oneToMany( + collection: 'movies', + relatedCollection: 'reviews', + twoWay: true, + key: 'reviews', + twoWayKey: 'movie', + onDelete: ForeignKeyAction::Cascade +)); // Update the relationship with the default reference attributes $database->updateRelationship( - collection: 'movies', - id: 'users', - onDelete: Database::RELATION_MUTATE_CASCADE -); + collection: 'movies', + id: 'users', + onDelete: ForeignKeyAction::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', + collection: 'movies', + id: 'users', + newKey: 'viewer', + newTwoWayKey: 'favoriteMovie', twoWay: true ); // Delete the relationship with the default or custom reference attributes $database->deleteRelationship( - collection: 'movies', - id: 'users' + collection: 'movies', + id: 'viewer' ); ``` ### Document Methods ```php -use Utopia\Database\Document; +use Utopia\Database\Document; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; +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() -) +); Role::team( - identifier: ID::unique() - dimension: '123' //team:id/dimension -) + identifier: ID::unique(), + dimension: '123' // team:id/dimension +); Role::label( identifier: 'admin' -) -Role::members( +); +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(), 'name' => 'Captain Marvel', @@ -721,7 +720,7 @@ $document = new Document([ ]); $document = $database->createDocument( - collection: 'movies', + collection: 'movies', document: $document ); @@ -734,152 +733,185 @@ $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. - -$document->setAttribute(key: 'name', 'Chris Smoove') - ->setAttribute(key: 'age', 33, Document::SET_TYPE_ASSIGN); +// 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); -$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 +// Info regarding who has permission to read, 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 // 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) ] -); +); -// 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 document -$database->deleteDocument( - 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 +// Note: Cached Documents or Collections are automatically deleted when a document or collection is updated or deleted $database->purgeCachedDocument( - collection: 'movies', + collectionId: 'movies', id: $document->getId() ); +// Delete a document +$database->deleteDocument( + collection: 'movies', + id: $document->getId() +); +``` + +### Joins and Aggregations + +The SQL adapters run joins and aggregations (`Capability::Joins`, `Capability::Aggregations`). 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. + +```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', 'movie', '$id', '=', 'm'), + Query::select(['body', 'm.name']), +]); + +// Aggregate: one row per group, holding the groups and the aggregates +$database->find('movies', [ + Query::count('*', 'movies'), + Query::avg('price', 'averagePrice'), + Query::groupBy(['active']), +]); ``` ## 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..4b4e2daec9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -40,32 +40,30 @@ Each database adapter should support the following action for fast storing and r * delete **Collections** (Tables for MariaDB) -* createCollection($name) -* deleteCollection($name) +* createCollection(string $name, array $attributes = [], array $indexes = []) +* deleteCollection(string $id) **Attributes** (Table columns for MariaDB) -* createAttribute(string $collection, string $name, string $type) -* deleteAttribute(string $collection, string $name) +* createAttribute(string $collection, Attribute $attribute) +* deleteAttribute(string $collection, string $id) **Indices** (Table indices for MariaDB) -* createIndex(string $collection, string $name, string $type) -* deleteIndex(string $collection, string $name, string $type) +* createIndex(string $collection, Index $index) +* deleteIndex(string $collection, string $id) **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) +* deleteDocument(string $collection, string $id) ## 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..f49b53d0e1 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,1424 @@ +# 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.3.12) +to 8.0. [CHANGELOG.md](CHANGELOG.md) lists everything that is new in 8.0. + +- [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) +- [Queries](#queries) +- [Schema: typed models](#schema-typed-models) +- [Lifecycle events are hooks](#lifecycle-events-are-hooks) +- [Relationships](#relationships) +- [Documents](#documents) +- [Coroutines](#coroutines) +- [Errors](#errors) +- [Caches](#caches) +- [Adapters](#adapters) +- [Mirror](#mirror) +- [Validators and helpers](#validators-and-helpers) +- [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.6 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. + +## 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($database)); +``` + +- `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. `Database::getRelationshipHook()` + returns the registered hook. + +## 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_UUID7`, `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`, `Uuid7`, `Object`, `Vector`, `Relationship`, `Point`, `Linestring`, `Polygon` | +| `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::isSpatialType($type)` | +| `ATTRIBUTE_FILTER_TYPES` | `ATTRIBUTE_FILTER_COLUMN_TYPES`, which holds `ColumnType` cases (see [Attribute types](#attribute-types)) | +| `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` | Index orders: `Utopia\Query\Schema\Order::Asc`, `Desc`. Adapter order types: `Utopia\Query\OrderDirection::Asc`, `Desc` | +| `ORDER_RANDOM` | `Utopia\Query\OrderDirection::Random`, or `Query::orderRandom()` | +| `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\RelationType::OneToOne`, `OneToMany`, `ManyToOne`, `ManyToMany` | +| `RELATION_MUTATE_CASCADE`, `RELATION_MUTATE_RESTRICT`, `RELATION_MUTATE_SET_NULL` | `Utopia\Query\Schema\ForeignKeyAction::Cascade`, `Restrict`, `SetNull` | +| `RELATION_SIDE_PARENT`, `RELATION_SIDE_CHILD` | `Utopia\Database\RelationSide::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'`) | +| `COLLECTION` (protected) | `Database::collectionDefinition()` | + +### `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 kept. +- `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` models. +- `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()`, `iterate()`, `foreach()` | `PermissionType $forPermission = PermissionType::Read` | +| `Database::getQueryCacheField()` | `PermissionType $forPermission = PermissionType::Read` | +| `Database::setTimeout()`, `clearTimeout()` | `Event $event = Event::All` | +| `Database::updateRelationship()` | `?ForeignKeyAction $onDelete` | +| `Database::updateAttribute()` | `ColumnType\|string\|null $type` | +| `Document::setAttribute()` | `SetType $type = SetType::Assign` | +| `Document::getPermissionsByType()` | `PermissionType $type` | +| `Query::getMethod()` | returns a `Method` case (see [Queries](#queries)) | +| `Operator::__construct()`, `setMethod()` | `OperatorType $method` | +| `Operator::getMethod()` | returns an `OperatorType` case | + +## 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\Query\Builder\ParsedQuery` object instead of an array, and it no longer + carries the order attributes and order types. `Query::groupForDatabase()` returns the 7.x array shape (`filters`, + `selections`, `limit`, `offset`, `orderAttributes`, `orderTypes`, `cursor`, `cursorDirection`), plus the new + `aggregations`, `groupBy`, `having`, `joins` and `distinct` keys. Its `orderTypes` are `OrderDirection` cases, and + its `cursorDirection` is a `CursorDirection` case. +- `Query::cursorAfter()` and `Query::cursorBefore()` accept `mixed` instead of `Document`. Pass the cursor document, + as before. +- `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), and the adapter reports `Capability::QueryContains`. +- 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. + +## Schema: typed models + +The schema methods take model objects instead of long lists of scalar arguments. The models are +`Utopia\Database\Collection`, `Attribute`, `Index` and `Relationship`, and all four extend `Document`. + +```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); + +// 8.0 +$database->createCollection(new Collection( + 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: [Order::Desc])); +$database->createRelationship(new Relationship( + collection: 'movies', + relatedCollection: 'reviews', + type: RelationType::OneToMany, + twoWay: true, + key: 'reviews', + twoWayKey: 'movie', + onDelete: ForeignKeyAction::Cascade, +)); +``` + +| 7.x | 8.0 | +|---|---| +| `createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document` | `createCollection(Collection $collection): Collection`. `Collection` accepts `Attribute` and `Index` models, `Document`s or arrays | +| `getCollection(string $id): Document` | `getCollection(string $id): 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 = [])` | `createAttribute(string $collection, Attribute $attribute)`. Build the model with `new Attribute(key: ..., type: ColumnType::...)` or a factory: `Attribute::string()`, `varchar()`, `text()`, `mediumText()`, `longText()`, `integer()`, `bigInteger()`, `float()`, `double()`, `boolean()`, `datetime()`, `point()`, `linestring()`, `polygon()`, `vector()`, `id()`, `object()` | +| `updateAttribute(..., ?string $type = null, ...)` | `updateAttribute(..., ColumnType\|string\|null $type = null, ...)`. The other arguments are unchanged | +| `createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 1)` | `createIndex(string $collection, Index $index)`. Build the model with `new Index(key: ..., type: IndexType::...)` or a factory: `Index::key()`, `unique()`, `fullText()`, `spatial()`, `object()`, `trigram()`, `ttl()`, `hnswEuclidean()`, `hnswCosine()`, `hnswDot()`. Orders are `Order` cases or `null`; a string order throws `InvalidArgumentException` | +| `createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, ?string $id = null, ?string $twoWayKey = null, string $onDelete = Database::RELATION_MUTATE_RESTRICT)` | `createRelationship(Relationship $relationship)`. The `$id` argument is the model's `key`. `Relationship::oneToOne()`, `oneToMany()`, `manyToOne()` and `manyToMany()` build one per type | +| `updateRelationship(..., ?string $onDelete = null)` | `updateRelationship(..., ?ForeignKeyAction $onDelete = null)`: pass the case, not its `->value` | +| `checkAttribute(Document $collection, Document $attribute)` | `checkAttribute(Document $collection, Attribute $attribute)` | +| `updateAttributeMeta(): Document`, `updateIndexMeta(): Document` (protected) | Return `Attribute` and `Index` | + +To turn stored 7.x metadata into models, use `Attribute::fromDocument()`, `Index::fromDocument()`, +`Relationship::fromDocument()` and `Collection::fromArray()`. `Attribute::normalizeType()` turns a stored type +string into a `ColumnType` case. + +### 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 typed model (`$attribute->type === ColumnType::BigInteger`), normalise a + raw string with `Attribute::normalizeType($type)` or `Attribute::tryNormalizeType($type)` (both accept `'bigint'` + and `'biginteger'`), and write a stored type with `Attribute::persistedType($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. +- `Attribute::toDocument()`, `getAttribute('type')` on an `Attribute` model, the `attribute_create`, + `attributes_create` and `attribute_update` event payloads and the documents returned by `updateAttribute*()` report + bigint attributes as `'bigint'`. +- `Database::ATTRIBUTE_FILTER_TYPES` is renamed `Database::ATTRIBUTE_FILTER_COLUMN_TYPES` and holds `ColumnType` + cases instead of type strings. Compare with the typed model + (`in_array($attribute->type, Database::ATTRIBUTE_FILTER_COLUMN_TYPES, true)`), or normalise a stored string first + with `Attribute::normalizeType()`. +- The column types an attribute can use are listed in `Attribute::TYPES`. `Attribute::availableTypes(objects:, + spatial:, vectors:)` narrows the list to what an adapter supports. +- An empty `format` means no format. `Attribute` models store and report `null` for `format: ''`, including + attributes read from metadata written by 7.x, which stored `''`. Compare a stored attribute's format with `null`. + `$attribute->format` and `toDocument()` never return `''`. + +### Collections and attributes + +- `createCollection()` validates attribute types like `createAttribute()`: an unknown type throws + `Utopia\Database\Exception` (`Unknown attribute type: . Must be one of ...`) before anything is created, and + object, spatial and vector attributes need the adapter to support them. In 7.x the SQL adapters failed with + `Unknown type: ` and MongoDB created the collection. +- `createCollection()`, `createAttribute()` and `createAttributes()` no longer modify the `Attribute` and `Index` + objects passed to them. Two adjustments are applied to copies and appear only in the stored metadata: the filters + added for `datetime`, `object`, spatial and vector attributes, and the index `lengths` and `orders` adjusted for + the adapter. Read them back with `getCollection()`. In 7.x `createCollection()` wrote these changes into the + documents it was given. It is safe to pass shared definitions, such as the objects in a config array, directly. +- `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 definitions need a known type.** `Validator\Index::isValid()` fails for an index document without + `type`, with a type outside `IndexType`, or for a TTL index without `ttl`, as 7.x did. `Index::fromDocument()` no + longer throws for an unknown stored type (it reads it as `key`), so stored metadata keeps parsing during query + validation. +- **Index length accounting.** Big integer columns (big integer, id, and integer of size 8 or more) count 8 bytes + toward the maximum index length, and a `text()`, `mediumText()` or `longText()` attribute with size 0 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 schema index introspection, `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. + +## 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: + +| Hook interface | Receives | Replaces | +|---|---|---| +| `Utopia\Database\Hook\Lifecycle` | every 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` | row writes, such as `Hook\Permissions` | built into 7.x | +| `Utopia\Database\Hook\Relationships` | relationship resolution and mutation | built into 7.x | + +### Lifecycle hooks (replaces `on()` and the `EVENT_*` constants) + +A listener is now an object implementing `Utopia\Database\Hook\Lifecycle` and registered with +`Database::addHook()`. It receives every `Utopia\Database\Event`, so filter by event inside +`handle(Event $event, mixed $data)`. The event string values are unchanged +(`Event::DocumentCreate->value === 'document_create'`). 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). + +```php +// 7.x +$database->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', $listener); + +// 8.0 +final class Usage implements Lifecycle, Named +{ + public function getName(): string + { + return 'calculate-usage'; + } + + public function handle(Event $event, mixed $data): void + { + if ($event !== Event::DocumentCreate) { + return; + } + // ... + } +} + +$database->addHook(new Usage()); +``` + +On a `Mirror`, lifecycle hooks are registered on the source database. `Mirror::silent()` silences both the source +and the mirror. + +### `silent()` + +`silent(callable $callback, ?array $listeners = 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 payload is +`Document(['$id' => $id, '$collection' => $collectionId])`. 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` once per attribute, with that attribute's `Document` as payload (the +same shape `createAttribute()` sends). It then fires `attributes_create` 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` turns lifecycle events into domain event objects for listeners registered with +`on(string $eventClass, callable $listener)` and for an optional PSR-14 dispatcher: + +| Event | Domain event | +|---|---| +| `document_create`, `document_update`, `document_delete` | `Event\Document\Created`, `Updated` (with the document), `Deleted` (with the id) | +| `documents_create`, `documents_update`, `documents_delete` | `Event\Documents\Created`, `Updated`, `Deleted`, with the collection and `count`, the number of documents the call wrote | +| `collection_create`, `collection_delete` | `Event\Collection\Created` (with the collection document), `Deleted` | + +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); +``` + +### Subclasses of `Database` + +- The protected `trigger(string $event, mixed $args = null)` is now `trigger(Event $event, mixed $data = null)`. +- `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\Traits`. Its public methods are unchanged by that. +- 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 `disableFilters()` 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. +- **`createDocuments()` under `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. +- `Database::cursor($collection, $queries, $batchSize)` reads the matches in batches of `$batchSize`. A `limit()` in + the queries caps how many documents it yields; an `offset()` or `cursorAfter()` in them positions the first batch + only; `cursorBefore()` throws `Utopia\Database\Exception` (`Cursor before not supported in this method.`), as + `iterate()` does. + +## 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()`, `Authorization::withStatus()` and `Authorization::withRoles()`; +- `silent()`, `skipRelationships()`, `skipRelationshipsExistCheck()`, `skipFilters()`, `skipValidation()`, + `withPreserveDates()`, `withPreserveSequence()`, `withTenant()`, `withRequestTimestamp()`, and `skipDuplicates()` + on the database and on the adapter. + +The plain setters (`Authorization::setStatus()`, `enable()`, `disable()`, `reset()`, `addRole()`, `removeRole()` and +`cleanRoles()`, and `setTenant()`, `enableValidation()`, `disableValidation()`, `enableFilters()`, +`disableFilters()`, `setPreserveDates()` and `setPreserveSequence()`) still change the shared value when the calling +coroutine is outside every such scope. Inside one, 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, and a change +made by a coroutine started inside the scope never reaches the other coroutines sharing the handle. + +To run work started in another coroutine under the caller's state, take `$snapshot = $database->snapshot()` in the +caller and run the work inside `$database->withSnapshot($snapshot, $callback)`. A snapshot carries the authorization +status and roles, the relationship, silence and filter state, the tenant, the validation, preserve-dates, +preserve-sequence and skip-duplicates toggles, and the request timestamp. `Hook\Relationships::withEnabled()`, +`withCheckExist()` and `withSnapshot()` scope the hook's own flags the same way. + +Relationship population reads its chunks of related ids concurrently only on `Adapter\Pool`, inside a coroutine and +outside a transaction; 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 + +- **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`. +- **`skipDuplicates()` 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`, `Duplicate` (and `Unique`), `Limit`, `NotFound`, + `Order`, `Query`, `Relationship`, `Restricted`, `Structure` and `Type` are thrown on the first attempt. Other + failures are still attempted up to three times. +- **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, `createCollection()`, `createAttribute()`, `createAttributes()`, `createIndex()` and their + update, rename and delete siblings rethrow that failure unchanged and keep the table, column or index. + `createRelationship()` also completes the relationship's indexes before rethrowing it. Such a failure is not + retried. +- **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()` throws `Adapter does not support connection ids` + on adapters without `Feature\ConnectionId`. `schema()` throws `Schema builder is not supported by this adapter` + where `from()` throws for the query builder. `getSchemaAttributes()` and `getSchemaIndexes()` return `[]` + without the feature. +- **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 one side without a cache + during the overlap (for example with `Utopia\Cache\Adapter\None`), 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. +- **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 QueryCache($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)`, for the query cache `new QueryCache($cache, $cacheName, + writerTimeout: $seconds)`, both 3600 seconds by default. 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. +- **Filters that belong to one handle.** `Database::addFilter()` still registers a filter for every handle in the + process. A filter that belongs to one handle goes in the `Database` constructor's `$filters` argument, or on a + `TypeRegistry` given to that handle through `setTypeRegistry()` (see [Custom types](#custom-types)). + +## Adapters + +This section matters if you check adapter capabilities, subclass an adapter or write your own. + +### Capabilities and feature interfaces + +The 51 `getSupportFor*()` methods of `Adapter` are removed. A behaviour flag is now a `Utopia\Database\Capability` +case checked with `$adapter->supports(Capability::X)`. A group of methods an adapter may or may not implement is a +`Utopia\Database\Adapter\Feature\*` interface, checked with `$adapter->hasFeature(Feature\X::class)`. Prefer +`hasFeature()` to `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. + +| 7.x | 8.0 | +|---|---| +| `getSupportForAlterLocks()` | `supports(Capability::AlterLock)` | +| `getSupportForAttributeResizing()` | `supports(Capability::AttributeResizing)` | +| `getSupportForAttributes()` | `supports(Capability::DefinedAttributes)` | +| `getSupportForBatchCreateAttributes()` | `supports(Capability::BatchCreateAttributes)` | +| `getSupportForBatchOperations()` | `supports(Capability::BatchOperations)` | +| `getSupportForBoundaryInclusiveContains()` | `supports(Capability::BoundaryInclusive)` | +| `getSupportForCacheSkipOnFailure()` | `supports(Capability::CacheSkipOnFailure)` | +| `getSupportForCaching()` | `supports(Capability::Caching)` | +| `getSupportForCastIndexArray()` | `supports(Capability::CastIndexArray)` | +| `getSupportForCasting()` | `supports(Capability::Casting)` | +| `getSupportForDistanceBetweenMultiDimensionGeometryInMeters()` | `supports(Capability::MultiDimensionDistance)` | +| `getSupportForFulltextIndex()` | `supports(Capability::Fulltext)` | +| `getSupportForFulltextWildcardIndex()` | `supports(Capability::FulltextWildcard)` | +| `getSupportForGetConnectionId()` | `hasFeature(Feature\ConnectionId::class)` | +| `getSupportForHostname()` | `supports(Capability::Hostname)` | +| `getSupportForIdenticalIndexes()` | `supports(Capability::IdenticalIndexes)` | +| `getSupportForIndex()` | `supports(Capability::Index)` | +| `getSupportForIndexArray()` | `supports(Capability::IndexArray)` | +| `getSupportForIntegerBooleans()` | `supports(Capability::IntegerBooleans)` | +| `getSupportForInternalCasting()` | `hasFeature(Feature\InternalCasting::class)` | +| `getSupportForJSONOverlaps()` (SQL adapters) | `supports(Capability::JSONOverlaps)` | +| `getSupportForMultipleFulltextIndexes()` | `supports(Capability::MultipleFulltextIndexes)` | +| `getSupportForNestedTransactions()` | `supports(Capability::NestedTransactions)` | +| `getSupportForNumericCasting()` (SQL adapters) | `supports(Capability::NumericCasting)` | +| `getSupportForObject()` | `supports(Capability::Objects)` | +| `getSupportForObjectIndexes()` | `supports(Capability::ObjectIndexes)` | +| `getSupportForOperators()` | `supports(Capability::Operators)` | +| `getSupportForOptionalSpatialAttributeWithExistingRows()` | `supports(Capability::OptionalSpatial)` | +| `getSupportForOrderRandom()` | `supports(Capability::OrderRandom)` | +| `getSupportForPCRERegex()` | `supports(Capability::PCRE)` | +| `getSupportForPOSIXRegex()` | `supports(Capability::POSIX)` | +| `getSupportForQueryContains()` | `supports(Capability::QueryContains)` | +| `getSupportForReconnection()` | `supports(Capability::Reconnection)` | +| `getSupportForRegex()` | `supports(Capability::Regex)` | +| `getSupportForRelationships()` | `hasFeature(Feature\Relationships::class)` | +| `getSupportForSchemaAttributes()` | `hasFeature(Feature\SchemaAttributes::class)` | +| `getSupportForSchemaIndexes()` | `hasFeature(Feature\SchemaIndexes::class)` | +| `getSupportForSchemas()` | `supports(Capability::Schemas)` | +| `getSupportForSpatialAttributes()` | `hasFeature(Feature\Spatial::class)` | +| `getSupportForSpatialAxisOrder()` | `supports(Capability::SpatialAxisOrder)` | +| `getSupportForSpatialIndexNull()` | `supports(Capability::SpatialIndexNull)` | +| `getSupportForSpatialIndexOrder()` | `supports(Capability::SpatialIndexOrder)` | +| `getSupportForTTLIndexes()` | `supports(Capability::TTLIndexes)` | +| `getSupportForTimeouts()` | `hasFeature(Feature\Timeouts::class)` | +| `getSupportForTransactionRetries()` | `supports(Capability::TransactionRetries)` | +| `getSupportForTrigramIndex()` | `supports(Capability::TrigramIndex)` | +| `getSupportForUTCCasting()` | `hasFeature(Feature\UTCCasting::class)` | +| `getSupportForUniqueIndex()` | `supports(Capability::UniqueIndex)` | +| `getSupportForUnsignedBigInt()` | `supports(Capability::UnsignedBigInt)` | +| `getSupportForUpdateLock()` | `supports(Capability::UpdateLock)` | +| `getSupportForUpsertOnUniqueIndex()` | `supports(Capability::UpsertOnUniqueIndex)` | +| `getSupportForUpserts()` | `hasFeature(Feature\Upserts::class)` | +| `getSupportForVectors()` | `supports(Capability::Vectors)` | + +`Capability::Upserts`, `Capability::Subqueries`, `Capability::CTEs` and `Capability::WindowFunctions`, which the 8.0 +pre-releases had, are removed: check upsert support with `hasFeature(Feature\Upserts::class)`. Every remaining case +is declared by at least one adapter. SQLite and MongoDB now report `Capability::QueryContains`. On Memory and Redis +`setSupportForAttributes(false)` returns `true` and changes nothing: they always enforce the collection's +attributes, and only MongoDB has a schemaless mode. + +The methods that went with a feature moved to its interface: `getConnectionId()` (`Feature\ConnectionId`), +`getSchemaAttributes()` and `getSchemaIndexes()` (`Feature\SchemaAttributes`, `Feature\SchemaIndexes`), +`setTimeout()` and `clearTimeout()` (`Feature\Timeouts`), `createRelationship()`, `updateRelationship()` and +`deleteRelationship()` (`Feature\Relationships`), `upsertDocuments()` (`Feature\Upserts`), `getColumnType()` +(`Feature\ColumnTypes`), `decodePoint()`, `decodeLinestring()` and `decodePolygon()` (`Feature\Spatial`), +`castingBefore()` and `castingAfter()` (`Feature\InternalCasting`), and `setUTCDatetime()` (`Feature\UTCCasting`). +An adapter that does not support a feature no longer declares its methods: for example, only MongoDB implements +`Feature\InternalCasting` and `Feature\UTCCasting`, so the SQL, Memory and Redis adapters no longer have +`castingBefore()`, `castingAfter()` or `setUTCDatetime()`. The SQL adapters also implement `Feature\RawQuery` +(`rawQuery()`, `rawMutation()`) and `Feature\QueryBuilder` (`getBuilder()`, `getSchema()`). To list what an adapter +reports, call `$adapter->capabilities()`. + +### Writing or subclassing an adapter + +- `Adapter` now implements `Feature\Attributes`, `Feature\Collections`, `Feature\Databases`, `Feature\Documents`, + `Feature\Indexes` and `Feature\Transactions`. Report optional behaviour by overriding `capabilities()`, and + implement the `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 `getConnectionId()`, `setTimeout()` and `getViolatedKey()`, are no longer + available on it. +- Changed adapter signatures: + + | 7.x | 8.0 | + |---|---| + | `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, Attribute $attribute, ?string $newKey = null)` | + | `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 = [])` | + | `createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = '')` | `createRelationship(Relationship $relationship)` | + | `updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null)` | `updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null)` | + | `deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side)` | `deleteRelationship(Relationship $relationship)` | + | `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)` | `setTimeout(int $milliseconds, Event $event = Event::All)`, `clearTimeout(Event $event = Event::All)` | + | `getTimeout(): int` | `getTimeout(Event $event = Event::All): int` | + | `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 | + | `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 $id)` | + | `SQL::execute(mixed $stmt)` | `execute(mixed $stmt, ?Event $event = null)` | + | `SQL::getSQLType(string $type, ...)`, `getSQLIndexType(string $type)` | Take `ColumnType` and `IndexType` cases | + | `SQL::getOperatorSQL(string $column, Operator $operator, array &$binds)` | `getOperatorSQL(string $column, Operator $operator, int &$bindIndex)` | + +- `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). `SQL::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 `skipDuplicates()` 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 through `isRenamed()` 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::NestedTransactions` 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::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. + +### Removed adapter methods + +Queries now compile through the utopia-php/query builders (`getBuilder()`, `createBuilder()`), transforms through +`Hook\Transform`, and tenant and permission conditions through hooks in `Utopia\Database\Hook`. 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 (`getBuilder()` / `createBuilder()`) | +| `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\Hook` (`PermissionFilter` and the join filters) | +| `getSQLVectorDistance()` on `SQL` and `Postgres` | protected | The query builder | +| `Adapter::getTenantQuery()` (abstract) and its implementations on `SQL`, `Memory`, `Redis` and `Pool` (`Mongo` keeps its own) | public | `Hook\TenantFilter` (SQL) and `Hook\Mongo\TenantFilter` (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 (`compileSearchExpr()`) | +| `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 | + +`escapeWildcards()` is kept, and so are the public helpers `SQL::getSpatialTypeFromWKT()` and +`Query::isSpatialAttribute()`, even where nothing in the library calls them any more. + +### SQLite adapter subclasses that override `createBuilder()` + +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 `createBuilder()`, +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. + +### 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 while its enclosing transaction holds is still rolled back to + its savepoint and retried, and a top-level call that failed to begin, or whose work failed, is still retried. 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 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()`. `inTransaction()` reports the transaction until then. Code + that catches the connection error inside `withTransaction()` and carries on no longer writes on the new connection + in autocommit: the transaction fails and is rolled back instead. + +### 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. +- 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`. +2. For a `unique` index, look for duplicates first, because the rebuilt index enforces uniqueness and its creation + fails (error `11000`, `Exception\Duplicate` or `Exception\Unique`) while duplicates exist. 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 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()` is idempotent: creating a collection that already exists returns `true` instead +of throwing `Exception\Duplicate`, unless the server itself reports the collection as created concurrently (code 48) +outside shared tables. `Database::createCollection()` still throws `Duplicate` for a collection whose metadata +exists. + +### 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; patterns are PCRE + (`preg_match`, case-sensitive, `u` flag), and `supports(Capability::Regex)` is true whenever that function is + registered (`Utopia\Database\PDO` or `Pdo\Sqlite` connections). +- **`getSchemaIndexes()` returns index ids.** Each entry's `$id` and `indexName` are 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()`, + `getConnectionId()`, 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 + +- **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()`, `enableFilters()`, `disableFilters()`, `skipFilters()`, `enableLocks()`, + `enableProfiling()`, `disableProfiling()`, `setMigrating()` and `setTypeRegistry()` 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 action `setTimeout` or `clearTimeout`; the call itself succeeds when the + source applied it. `enableLocks()` reports a destination failure the same way, with the action `enableLocks`. +- **`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->enableProfiling()` 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()`, `skipValidation()` 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. `createDocument()`, `updateDocument()`, + `increaseDocumentAttribute()` and `decreaseDocumentAttribute()` replicate before returning, after the pending + replications of their document. 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. + - Writes to one document reach the destination in the order they were made through the mirror; a failed + replication is reported to `onError()` and does not hold back later ones. + - A schema change through a mirror waits for the queued replications of the collections it touches before it + reaches the destination. +- **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 action 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 `upsertDocumentsWithIncrease()` through a mirror now run on the source and are + replicated to the destination, like `upsertDocuments()`; in 7.x they were never replicated. Hooks registered + through the mirror receive `document_purge` for each upserted document and `documents_upsert` once per call. A + failed replication is reported to `onError()` as `upsertDocuments` for a plain upsert, as in 7.x, and as + `upsertDocumentsWithIncrease` for an increasing one. + +## Validators and helpers + +- `Utopia\Database\Validator\Queries\Documents` has new trailing constructor parameters: + `bool $supportForJoins = false`, `bool $supportForAggregations = false` and `bool $sharedTables = false`. By + default, a validator you construct yourself accepts only filters, ordering, selection and pagination, as in 7.x. + To accept `join`, `leftJoin`, `rightJoin`, `crossJoin` and `fullOuterJoin`, pass `supportForJoins: true`. To + accept aggregate functions, `groupBy`, `having` and `distinct`, pass `supportForAggregations: true`. `Database` + sets these flags from the adapter's `Capability::Joins`, `Capability::Aggregations` and shared-tables setting. +- `Utopia\Database\Validator\Query\Select`, `Aggregate` and `GroupBy` accept `$tenant` only when constructed with + `sharedTables: true` (third constructor argument); `Queries\Documents` and `Queries\Document` take the same flag as + their last argument. A validator built directly now rejects `select('$tenant')` unless given the flag; + `Database::find()` already rejected it without shared tables in 7.x. +- `Utopia\Database\Validator\Queries\Document` takes optional `idAttributeType`, `maxValuesCount`, `minAllowedDate`, + `maxAllowedDate`, `supportUnsignedBigInt` and `sharedTables` arguments for the join conditions of + `getDocument()`, with the same meaning as on `Queries\Documents`. It also takes a trailing + `bool $supportForJoins = true`: joins stay accepted by default, as `Database::getDocument()` expects, and with + `false` a join query is refused (`Invalid query method: join`), as `Queries\Documents` refuses it without its flag. +- 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. +- `Queries\Documents` and `Query\Filter` default `supportUnsignedBigInt` to `true`, as in 7.x. +- The protected `Database::getDocumentsValidator()` is now + `getDocumentsValidator(Document $collection, array $joinedCollections = [])`. Subclasses that override it must add + the parameter. +- Changed validator signatures: `Validator\Attribute`'s `check*()` methods take an `Attribute`, `Validator\Index`'s + take an `Index`, `Validator\Attribute::getRequiredFilters()` and `validateDefaultTypes()` take a `ColumnType`, + `Structure::addFormat()`, `getFormat()` and `hasFormat()` (and those of `PartialStructure`) take a `ColumnType`, + and `Query\Filter::isValidAttributeAndValues()` takes a `Method` case. `Validator\Operator` takes a trailing + `bool $supportUnsignedBigInt = true`. +- `Validator\Permissions` and `Helpers\Permission::aggregate()` take their allowed permission types as + `PermissionType` cases; a list of strings throws a `TypeError`. `Validator\Authorization\Input` accepts a + `PermissionType` case or a string. +- `Validator\Authorization`'s status is no longer a `protected bool $status` property; subclasses read and change it + through `getStatus()`, `setStatus()`, `skip()` and `withStatus()`. `skip()` and `withStatus()` are 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)). +- `Validator\Structure` takes a trailing `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. +- `Utopia\Database\Storage::joinAlias()` returns the alias an undeclared join is read under (`j0`, `j1`, ...). + +## 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 + +- 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 select may name `alias.*` next to other selects: it stands for what the join returns without a select, the + joined collection's `$id` and attributes as `alias.$id` and `alias.attribute`. An aggregation query still rejects + it as an ungrouped select, and an alias the query does not join is not found. +- 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 (observed on MariaDB and MySQL shared tables). +- 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. +- `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()`, `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()` leaves a paged join's `alias.$id` out has to select it to be paged. + `cursor()` and `iterate()` check 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 + +- 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 `find()`, `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` 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: + `. +- `stddev`, `stddevPop`, `stddevSamp`, `variance`, `varPop` and `varSamp` need + `Capability::StatisticalAggregates`, and `bitAnd`, `bitOr` and `bitXor` need `Capability::BitwiseAggregates`. + SQLite has neither, and `find()` throws `Utopia\Database\Exception\Query` for them there. Check + `$database->getAdapter()->supports(...)` before offering them. +- 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. + +### `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()`, `execute()` and `rawQuery()` + +`Database::from($collection)` returns a utopia-php/query builder over the collection's table, and +`Database::execute($statement)` runs a statement as written. Both 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: `insert()`, which writes exactly the columns you give it, +`$tenant` included; SQL you write yourself; builders not obtained from `from()` (subqueries, unions, lateral joins); +and the second table of a dialect's multi-table write (`updateJoin()`, `deleteJoin()`, `updateFrom()`, +`deleteUsing()`), whose main table keeps its tenant condition. + +### `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 QueryCache($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. + +### Custom types + +Implement `Utopia\Database\Type\Custom` (`name()`, `encode()`, `decode()`) and register the type on a +`Utopia\Database\Type\TypeRegistry`. Give the registry to a `Database` with `setTypeRegistry()`, and list the type's +name in an attribute's `filters`. The type applies only to handles that share that registry. On those handles it +takes precedence over a global filter of the same name (`Database::addFilter()`), and filters passed to the +`Database` constructor take precedence over both. `register()` rejects the built-in filter names +(`Database::DEFAULT_FILTERS`) with `Utopia\Database\Exception\Duplicate`. + +### Pools and profiling + +- **Query profiling.** `Database::enableProfiling()` attaches a `Utopia\Database\Profiler\QueryProfiler` that + records the SQL statements the adapter runs. The profiler keeps the newest `QueryProfiler::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. + `disableProfiling()` 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->releaseBorrowedAdapter($adapter)` before giving the connection back. + Each `Utopia\Database\Profiler\QueryLog` 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. `QueryLog` 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::enableLocks()` 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 + `setSupportForAttributes()`) are answered wherever a read would be and never open the sticky window. + `getHostname()` 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..b365c2decf 100644 --- a/bin/cli.php +++ b/bin/cli.php @@ -1,6 +1,6 @@ error() diff --git a/bin/tasks/index.php b/bin/tasks/index.php index 256f23ce10..29c60a3c04 100644 --- a/bin/tasks/index.php +++ b/bin/tasks/index.php @@ -1,10 +1,5 @@ '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: [Order::Desc, Order::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: [Order::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: [Order::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..6ede25950b 100644 --- a/bin/tasks/load.php +++ b/bin/tasks/load.php @@ -10,11 +10,14 @@ 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\Index; use Utopia\Database\PDO; use Utopia\Validator\Boolean; use Utopia\Validator\Integer; @@ -25,7 +28,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 +37,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 +48,27 @@ $database->getAuthorization()->addRole(Role::any()->toString()); $database->create(); - $database->createCollection('articles', permissions: [ + $database->createCollection(new Collection(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', size: 0, required: true, filters: ['datetime'])); + $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', size: 0, 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' => [ @@ -103,15 +103,16 @@ ], ]; - 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'], @@ -132,7 +133,7 @@ ->withHost($cfg['host']) ->withPort($cfg['port']) ->withDbName($name) - //->withCharset('utf8mb4') + // ->withCharset('utf8mb4') ->withUsername($cfg['user']) ->withPassword($cfg['pass']), 128 @@ -141,9 +142,9 @@ $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)) ->setDatabase($name) @@ -151,19 +152,17 @@ ->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 +175,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..da1dbf44a9 100644 --- a/bin/tasks/operators.php +++ b/bin/tasks/operators.php @@ -14,16 +14,18 @@ * 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; @@ -41,23 +43,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', @@ -91,14 +90,15 @@ '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; } @@ -128,8 +128,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 +140,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 +148,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(new Collection(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', size: 0, required: false, default: 0)); + $database->createAttribute('operators_test', Attribute::float(key: 'score', size: 0, required: false, default: 0.0)); + $database->createAttribute('operators_test', Attribute::float(key: 'multiplier', size: 0, required: false, default: 1.0)); + $database->createAttribute('operators_test', Attribute::float(key: 'divider', size: 0, required: false, default: 100.0)); + $database->createAttribute('operators_test', Attribute::integer(key: 'modulo_val', size: 0, required: false, default: 100)); + $database->createAttribute('operators_test', Attribute::float(key: 'power_val', size: 0, 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', size: 0, 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, signed: true, array: true)); + $database->createAttribute('operators_test', Attribute::integer(key: 'numbers', size: 0, required: false, default: null, signed: true, array: true)); + $database->createAttribute('operators_test', Attribute::string(key: 'items', size: 50, required: false, default: null, signed: true, 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', size: 0, required: false, default: null, signed: false, array: false, format: null, formatOptions: [], filters: ['datetime'])); + $database->createAttribute('operators_test', Attribute::datetime(key: 'updated_at', size: 0, required: false, default: null, signed: false, array: false, format: null, formatOptions: [], filters: ['datetime'])); // Seed documents if requested if ($seed > 0) { @@ -210,7 +211,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 +222,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 +244,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 +263,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 +344,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 +358,7 @@ function ($doc) { Operator::decrement(1), function ($doc) { $doc->setAttribute('counter', $doc->getAttribute('counter', 100) - 1); + return $doc; }, ['counter' => 100] @@ -369,6 +372,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 +386,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 +401,7 @@ function ($doc) { function ($doc) { $val = $doc->getAttribute('modulo_val', 100); $doc->setAttribute('modulo_val', $val % 7); + return $doc; }, ['modulo_val' => 100] @@ -409,6 +415,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 +429,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 +444,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 +458,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 +476,7 @@ function ($doc) { $tags = $doc->getAttribute('tags', ['initial']); $tags[] = 'new'; $doc->setAttribute('tags', $tags); + return $doc; }, ['tags' => ['initial']] @@ -481,6 +492,7 @@ function ($doc) { $tags = $doc->getAttribute('tags', ['initial']); array_unshift($tags, 'first'); $doc->setAttribute('tags', $tags); + return $doc; }, ['tags' => ['initial']] @@ -496,6 +508,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 +524,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 +539,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 +554,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 +569,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 +584,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 +601,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 +617,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 +631,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 +658,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 +671,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 +683,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 +701,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 +710,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 +739,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 +774,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 +794,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 +802,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 +814,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 +937,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 +960,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 +1009,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 +1017,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 +1060,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 +1083,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 +1122,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 +1130,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..3e73f5baa5 100644 --- a/bin/tasks/query.php +++ b/bin/tasks/query.php @@ -1,10 +1,5 @@ task('query') ->desc('Query mock data') @@ -38,15 +38,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', @@ -77,8 +75,9 @@ ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported"); + return; } @@ -104,38 +103,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 +144,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 +185,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..3bbf39c8f8 100644 --- a/bin/tasks/relationships.php +++ b/bin/tasks/relationships.php @@ -13,6 +13,8 @@ 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; @@ -20,6 +22,9 @@ use Utopia\Database\Helpers\Role; use Utopia\Database\PDO; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ForeignKeyAction; use Utopia\Validator\Boolean; use Utopia\Validator\Integer; use Utopia\Validator\Text; @@ -33,13 +38,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 +59,67 @@ } $database->getAuthorization()->addRole(Role::any()->toString()); $database->create(); - $database->createCollection('authors', permissions: [ + $database->createCollection(new Collection(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', size: 0, required: true, filters: ['datetime'])); + $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(new Collection(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', size: 0, required: true)); + $database->createAttribute('articles', Attribute::string(key: 'tags', size: 0, required: true, array: true)); + + $database->createCollection(new Collection(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(new Collection(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', size: 8, required: true, signed: false)); - $database->createCollection('profiles', permissions: [ + $database->createCollection(new Collection(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', size: 0, required: true)); - $database->createCollection('categories', permissions: [ + $database->createCollection(new Collection(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(new Relationship(collection: 'authors', relatedCollection: 'articles', type: RelationType::ManyToMany, twoWay: true, onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(new Relationship(collection: 'articles', relatedCollection: 'comments', type: RelationType::OneToMany, twoWay: true, twoWayKey: 'article', onDelete: ForeignKeyAction::Cascade)); + $database->createRelationship(new Relationship(collection: 'users', relatedCollection: 'comments', type: RelationType::OneToMany, twoWay: true, twoWayKey: 'user', onDelete: ForeignKeyAction::Cascade)); + $database->createRelationship(new Relationship(collection: 'authors', relatedCollection: 'profiles', type: RelationType::OneToOne, twoWay: true, twoWayKey: 'author', onDelete: ForeignKeyAction::Cascade)); + $database->createRelationship(new Relationship(collection: 'articles', relatedCollection: 'categories', type: RelationType::ManyToOne, twoWay: true, key: 'category', twoWayKey: 'articles', onDelete: ForeignKeyAction::SetNull)); }; $dbAdapters = [ @@ -148,8 +152,9 @@ ], ]; - if (!isset($dbAdapters[$adapter])) { + if (! isset($dbAdapters[$adapter])) { Console::error("Adapter '{$adapter}' not supported"); + return; } @@ -234,20 +239,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 +259,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 +295,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 +314,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 +326,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 +467,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 +528,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 +536,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..205c00217f 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,37 @@ "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.0", + "utopia-php/query": "^0.6", + "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-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", "mongodb/mongodb": "Needed to support MongoDB 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..0e641a4a3f 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": "198ed316d125173157fbd177b3ea5bc4", "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,9 +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" + "time": "2026-03-18T17:32:05+00:00" }, { "name": "mongodb/mongodb", @@ -409,16 +410,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 +476,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 +603,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 +662,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 +683,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 +706,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 +716,7 @@ ] }, "branch-alias": { - "dev-main": "1.14.x-dev" + "dev-main": "1.12.x-dev" } }, "autoload": { @@ -761,7 +759,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 +818,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 +1303,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 +1375,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 +1403,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1365,7 +1428,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 +1439,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 +1525,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 +1545,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 +1571,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1548,7 +1607,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 +1618,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 +1688,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 +1708,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 +1768,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 +1788,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,7 +1848,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -1813,20 +1868,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -1873,7 +1928,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -1893,20 +1948,20 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-04-26T13:10:57+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 +1979,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.7-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -1960,7 +2015,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 +2035,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "tbachert/spi", @@ -2034,24 +2089,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 +2206,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 +2265,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", @@ -2317,6 +2434,55 @@ }, "time": "2026-08-05T18:07:20+00:00" }, + { + "name": "utopia-php/query", + "version": "0.6.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/query.git", + "reference": "67f89d99593900fe9e60ba22575e65ce8c684093" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/query/zipball/67f89d99593900fe9e60ba22575e65ce8c684093", + "reference": "67f89d99593900fe9e60ba22575e65ce8c684093", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "brianium/paratest": "*", + "laravel/pint": "*", + "mongodb/mongodb": "^2.0", + "phpstan/phpstan": "*", + "phpunit/phpcov": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Query\\": "src/Query" + } + }, + "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.6.1" + }, + "time": "2026-09-21T05:06:19+00:00" + }, { "name": "utopia-php/telemetry", "version": "0.4.6", @@ -2411,35 +2577,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 +2635,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", @@ -2542,18 +2732,139 @@ }, "time": "2024-01-02T13:46:09+00:00" }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "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.30.4", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", - "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -2564,16 +2875,14 @@ "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 +2919,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 +2983,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 +3035,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 +3159,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,58 +3208,112 @@ "type": "github" } ], - "time": "2026-07-28T10:04:39+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "9.2.32", + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", - "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", "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" + "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" }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" - }, - "type": "library", + "type": "phpstan-extension", "extra": { - "branch-alias": { - "dev-main": "9.2.x-dev" + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] } }, "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "PHPStan\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "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": "12.5.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "876099a072646c7745f673d7aeab5382c4439691" + }, + "dist": { + "type": "zip", + "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": "^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": "^12.5.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" ], "authors": [ { @@ -2996,40 +3332,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 +3404,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 +3454,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3119,7 +3480,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 +3489,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 +3540,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 +3549,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 +3600,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 +3609,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 +3659,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -3332,7 +3691,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 +3699,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,153 +3747,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" - } - ], - "time": "2024-03-02T06:27:43+00:00" - }, - { - "name": "sebastian/code-unit", - "version": "1.0.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", - "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" - }, - "funding": [ + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-10-26T13:08:54+00:00" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "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://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.10", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", - "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "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": "4.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -3619,7 +3839,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { @@ -3639,33 +3860,33 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:22:56+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.3", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", - "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "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": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -3688,7 +3909,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 +3918,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 +3976,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 +3985,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 +4013,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -3809,7 +4032,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 +4040,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 +4130,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 +4151,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 +4198,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 +4225,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 +4274,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 +4283,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 +4332,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 +4341,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 +4388,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 +4397,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 +4452,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 +4473,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 +4513,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 +4583,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 +4596,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", @@ -4431,25 +4682,520 @@ }, "time": "2024-06-17T05:45:20+00:00" }, + { + "name": "symfony/console", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "shasum": "" + }, + "require": { + "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": "1.3.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -4471,7 +5217,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 +5225,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 +5375,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..eae224d3d7 100644 --- a/docs/add-new-adapter.md +++ b/docs/add-new-adapter.md @@ -1,53 +1,71 @@ # 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 three ways: + +- **Required methods.** `Utopia\Database\Adapter` implements the `Adapter\Feature\Attributes`, `Collections`, `Databases`, `Documents`, `Indexes` and `Transactions` interfaces and declares their methods abstract, so every adapter implements them. +- **Optional features.** A group of methods an adapter may or may not provide is an interface in `src/Database/Adapter/Feature/`: `ColumnTypes`, `ConnectionId`, `InternalCasting`, `QueryBuilder`, `RawQuery`, `Relationships`, `SchemaAttributes`, `SchemaIndexes`, `Spatial`, `Timeouts`, `Upserts` and `UTCCasting`. 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::Fulltext)`. The base adapter reports `Index`, `IndexArray` and `UniqueIndex`. + +Limits are the `getLimitFor*()`, `getMax*()` and `getDocumentSizeLimit()` methods of `Adapter`. ### 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 +│ │ └── SQL.php # Shared base of the SQL adapters +│ ├── Adapter.php # Parent class for individual adapters +│ ├── 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 +│ └── 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 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/Hook` (`PermissionFilter`, `TenantFilter`), MongoDB with `Hook\Mongo\PermissionFilter` and `Hook\Mongo\TenantFilter`. +- Compare types, index types and orders with the enums (`Utopia\Query\Schema\ColumnType`, `IndexType`, `Utopia\Query\OrderDirection`, ...), not strings. #### 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 write hooks. 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 supports a schemaless mode, reported through `Capability::DefinedAttributes` and `setSupportForAttributes()`. diff --git a/phpstan-bin.neon b/phpstan-bin.neon new file mode 100644 index 0000000000..337f61b1d7 --- /dev/null +++ b/phpstan-bin.neon @@ -0,0 +1,19 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + level: 5 + 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..c8e20bf4af --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,16 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + level: max + paths: + - src + - tests + 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 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..d29dd3e599 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -2,38 +2,73 @@ namespace Utopia\Database; +use DateTime; use Exception; +use Throwable; +use Utopia\Database\Adapter\Feature; 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\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; +use Utopia\Database\Hook\Transform; +use Utopia\Database\Hook\Write; +use Utopia\Database\Profiler\QueryProfiler; +use Utopia\Database\State\Value; use Utopia\Database\Validator\Authorization; +use Utopia\Query\CursorDirection; +use Utopia\Query\Method; -abstract class Adapter +/** + * Abstract base class for all database adapters, providing shared state management and a contract for database operations. + */ +abstract class Adapter implements Feature\Attributes, Feature\Collections, Feature\Databases, Feature\Documents, Feature\Indexes, Feature\Transactions { 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; + + /** @var Value|null */ + private ?Value $duplicateSkipping = null; + + protected int|string|null $tenant { + get => $this->scopedTenant()->get(); + set { + $this->scopedTenant()->set($value); + } + } protected bool $tenantPerDocument = false; protected int $timeout = 0; + /** + * @var array + */ + protected array $timeouts = []; + protected int $inTransaction = 0; protected bool $alterLocks = false; - protected bool $skipDuplicates = false; + protected bool $skipDuplicates { + get => $this->duplicateSkipping()->get(); + set { + $this->duplicateSkipping()->set($value); + } + } /** * @var array @@ -41,11 +76,9 @@ abstract class Adapter protected array $debug = []; /** - * @var array> + * @var array */ - protected array $transformations = [ - '*' => [], - ]; + protected array $queryTransforms = []; /** * @var array @@ -53,13 +86,60 @@ abstract class Adapter protected array $metadata = []; /** - * @var Authorization + * @var list */ + protected array $writeHooks = []; + + protected ?QueryProfiler $profiler = null; + protected Authorization $authorization; + /** @var array|null */ + protected ?array $capabilitySet = null; + + /** + * Check if this adapter supports a given capability. + * + * @param Capability $feature Capability enum case + */ + public function supports(Capability $feature): bool + { + if ($this->capabilitySet === null) { + $this->capabilitySet = []; + foreach ($this->capabilities() as $cap) { + $this->capabilitySet[$cap->name] = true; + } + } + return isset($this->capabilitySet[$feature->name]); + } + + /** + * @template T of object + * + * @param class-string $feature + * + * @phpstan-assert-if-true T $this + */ + 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::Index, + Capability::IndexArray, + Capability::UniqueIndex, + ]; + } + + /** * @return $this */ public function setAuthorization(Authorization $authorization): self @@ -69,39 +149,51 @@ 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(?QueryProfiler $profiler): static { - $this->debug[$key] = $value; + $this->profiler = $profiler; return $this; } + public function getProfiler(): ?QueryProfiler + { + return $this->profiler; + } + /** - * @return array + * Set Database. + * + * Set database to use for current scope + * + * + * @throws DatabaseException */ - public function getDebug(): array + public function setDatabase(string $name): bool { - return $this->debug; + $this->database = $this->filter($name); + + return true; } /** - * @return static + * Get Database. + * + * Get Database from current scope */ - public function resetDebug(): static + public function getDatabase(): string { - $this->debug = []; - - return $this; + return $this->database; } /** @@ -109,11 +201,10 @@ public function resetDebug(): static * * Set namespace to divide different scope of data sets * - * @param string $namespace * * @return $this - * @throws DatabaseException * + * @throws DatabaseException */ public function setNamespace(string $namespace): static { @@ -126,9 +217,6 @@ public function setNamespace(string $namespace): static * Get Namespace. * * Get namespace of current set scope - * - * @return string - * */ public function getNamespace(): string { @@ -138,7 +226,6 @@ public function getNamespace(): string /** * Set Hostname. * - * @param string $hostname * @return $this */ public function setHostname(string $hostname): static @@ -150,52 +237,16 @@ public function setHostname(string $hostname): static /** * Get Hostname. - * - * @return string */ public function getHostname(): string { return $this->hostname; } - /** - * Set Database. - * - * Set database to use for current scope - * - * @param string $name - * - * @return bool - * @throws DatabaseException - */ - public function setDatabase(string $name): bool - { - $this->database = $this->filter($name); - - return true; - } - - /** - * Get Database. - * - * Get Database from current scope - * - * @return string - * - */ - public function getDatabase(): string - { - return $this->database; - } - /** * Set Shared Tables. * * Set whether to share tables between tenants - * - * @param bool $sharedTables - * - * @return bool */ public function setSharedTables(bool $sharedTables): bool { @@ -208,8 +259,6 @@ public function setSharedTables(bool $sharedTables): bool * Get Share Tables. * * Get whether to share tables between tenants - * - * @return bool */ public function getSharedTables(): bool { @@ -220,14 +269,10 @@ public function getSharedTables(): bool * Set Tenant. * * Set tenant to use if tables are shared - * - * @param int|string|null $tenant - * - * @return bool */ public function setTenant(int|string|null $tenant): bool { - $this->tenant = $tenant; + $this->scopedTenant()->set($tenant); return true; } @@ -235,23 +280,65 @@ public function setTenant(int|string|null $tenant): bool /** * Get Tenant. * - * Get tenant to use for shared tables + * Get tenant to use for shared tables. * - * @return int|string|null + * `_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 getTenant(): int|string|null { - return $this->tenant; + $tenant = $this->currentTenant(); + if (\is_string($tenant) && \ctype_digit($tenant)) { + return (int) $tenant; + } + + return $tenant; } /** - * Set Tenant Per Document. + * Run the callback with the tenant set for the calling coroutine and the coroutines it starts. * - * Set whether to use a different tenant for each document + * @template T * - * @param bool $tenantPerDocument + * @param callable(): T $callback + * @return T + */ + public function withTenant(int|string|null $tenant, callable $callback): mixed + { + return $this->scopedTenant()->with($tenant, $callback); + } + + /** + * The tenant the calling coroutine's statements run as, exactly as it was set. The adapters read it through + * this method rather than the `$tenant` property hook: with the hook on their hot paths, PHP 8.5's tracing JIT + * (8.5.10 and 8.5.11) crashes the process. + */ + protected function currentTenant(): int|string|null + { + return $this->scopedTenant()->get(); + } + + /** + * @return Value + */ + private function scopedTenant(): Value + { + if ($this->scopedTenant === null) { + /** @var Value $scopedTenant */ + $scopedTenant = new Value(null); + $this->scopedTenant = $scopedTenant; + } + + return $this->scopedTenant; + } + + /** + * Set Tenant Per Document. * - * @return bool + * Set whether to use a different tenant for each document */ public function setTenantPerDocument(bool $tenantPerDocument): bool { @@ -264,34 +351,57 @@ public function setTenantPerDocument(bool $tenantPerDocument): bool * Get Tenant Per Document. * * Get whether to use a different tenant for each document - * - * @return bool */ public function getTenantPerDocument(): bool { return $this->tenantPerDocument; } + /** + * Set a debug key-value pair for diagnostic purposes. + * + * @param string $key The debug key. + * @param mixed $value The debug value. + * @return $this + */ + public function setDebug(string $key, mixed $value): static + { + $this->debug[$key] = $value; + + return $this; + } + + /** + * Get all collected debug data. + * + * @return array + */ + public function getDebug(): array + { + return $this->debug; + } + + /** + * Reset all debug data. + * + * @return $this + */ + public function resetDebug(): static + { + $this->debug = []; + + return $this; + } + /** * 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; } @@ -317,154 +427,302 @@ public function resetMetadata(): static 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 - * - * @throws Exception The provided timeout value must be greater than or equal to 0. - */ - abstract public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void; - - public function getTimeout(): int + protected function setTimeoutState(int $milliseconds, Event $event): void { - return $this->timeout; + $this->timeouts[$event->value] = $milliseconds; + + if ($event === Event::All) { + $this->timeout = $milliseconds; + } } /** - * Clears a global timeout for database queries. + * Get the current query timeout value. * - * @param string $event - * @return void + * @return int Timeout in milliseconds, or 0 if no timeout is set. */ - public function clearTimeout(string $event): void + public function getTimeout(Event $event = Event::All): int { - // Clear existing callback - $this->before($event, 'timeout'); + return $this->timeouts[$event->value] + ?? $this->timeouts[Event::All->value] + ?? $this->timeout; + } + + protected function clearTimeoutState(Event $event): void + { + if ($event === Event::All) { + $this->timeouts = []; + $this->timeout = 0; + + return; + } - // 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; + unset($this->timeouts[$event->value]); } /** - * Clears every timeout this adapter carries, for any event. - * - * 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. + * Enable or disable LOCK=SHARED during ALTER TABLE operations. * - * @return void + * @param bool $enable True to enable alter locks. + * @return $this */ - public function clearTimeouts(): void + public function enableAlterLocks(bool $enable): self { - foreach (\array_keys($this->transformations) as $event) { - $this->clearTimeout($event); - } + $this->alterLocks = $enable; - $this->timeout = 0; + return $this; + } + + public function getAlterLocks(): bool + { + return $this->alterLocks; } /** - * Start a new transaction. - * - * If a transaction is already active, this will only increment the transaction count and return true. - * - * @return bool - * @throws DatabaseException + * Set support for attributes */ - abstract public function startTransaction(): bool; + abstract public function setSupportForAttributes(bool $support): bool; /** - * Commit a transaction. - * - * If no transaction is active, this will be a no-op and will return false. - * 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. + * Register a write hook that intercepts document write operations. * - * @return bool - * @throws DatabaseException + * @param Write $hook The write hook to add. + * @return $this */ - abstract public function commitTransaction(): bool; + public function addWriteHook(Write $hook): static + { + $this->writeHooks[] = $hook; + + return $this; + } + + public function hasPermissionHook(): bool + { + foreach ($this->writeHooks as $hook) { + if ($hook instanceof Hook\Permissions) { + return true; + } + } + + return false; + } + + public function hasTenantHook(): bool + { + return $this->getTenantHook() !== null; + } + + public function getTenantHook(): ?Hook\Tenancy + { + foreach ($this->writeHooks as $hook) { + if ($hook instanceof Hook\Tenancy) { + return $hook; + } + } + + return null; + } /** - * Rollback a transaction. + * Remove a write hook by its class name. * - * 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 + * @param string $class The fully qualified class name of the hook to remove. + * @return $this */ - abstract public function rollbackTransaction(): bool; + public function removeWriteHook(string $class): static + { + $this->writeHooks = \array_values(\array_filter( + $this->writeHooks, + fn (Write $h) => ! ($h instanceof $class) + )); + + return $this; + } /** - * Check if a transaction is active. + * Get all registered write hooks. * - * @return bool + * @return list */ - public function inTransaction(): bool + public function getWriteHooks(): array { - return $this->inTransaction > 0; + return $this->writeHooks; } /** - * Run a callback with skipDuplicates enabled. - * Duplicate key errors during createDocuments() will be silently skipped - * instead of thrown. Nestable — saves and restores previous state. + * Register a named query transform hook that modifies queries before execution. * - * @template T - * @param callable(): T $callback - * @return T + * @param string $name Unique name for the transform. + * @param Transform $transform The query transform hook to add. + * @return $this */ - public function skipDuplicates(callable $callback): mixed + public function addTransform(string $name, Transform $transform): static { - $previous = $this->skipDuplicates; - $this->skipDuplicates = true; + $this->queryTransforms[$name] = $transform; - try { - return $callback(); - } finally { - $this->skipDuplicates = $previous; - } + return $this; } /** - * @template T - * @param callable(): T $callback + * Remove a query transform hook by name. + * + * @param string $name The name of the transform to remove. + * @return $this + */ + public function removeTransform(string $name): static + { + unset($this->queryTransforms[$name]); + + return $this; + } + + /** + * Remove all registered query transform hooks. + * + * @return $this + */ + public function resetTransforms(): static + { + $this->queryTransforms = []; + + return $this; + } + + /** + * Ping Database + */ + abstract public function ping(): bool; + + /** + * Reconnect Database + */ + abstract public function reconnect(): void; + + /** + * Clears every timeout this adapter carries, for any event. + * + * 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 + */ + public function clearTimeouts(): void + { + // Event::All empties the whole map rather than unsetting one entry, so + // this needs no knowledge of which events a previous holder set. + $this->clearTimeoutState(Event::All); + } + + /** + * Start a new transaction. + * + * If a transaction is already active, this will only increment the transaction count and return true. + * + * @throws DatabaseException + */ + abstract public function startTransaction(): bool; + + /** + * Commit a transaction. + * + * If no transaction is active, this will be a no-op and will return false. + * 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. + * + * @throws DatabaseException + */ + abstract public function commitTransaction(): bool; + + /** + * Rollback a transaction. + * + * 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. + * + * @throws DatabaseException + */ + abstract public function rollbackTransaction(): bool; + + /** + * Check if a transaction is active. + */ + public function inTransaction(): bool + { + return $this->inTransaction > 0; + } + + /** + * Run a callback with skipDuplicates enabled. + * Duplicate key errors during createDocuments() will be silently skipped + * instead of thrown. Nestable, and scoped to the calling coroutine and the coroutines it starts. + * + * @template T + * @param callable(): T $callback + * @return T + */ + public function skipDuplicates(callable $callback): mixed + { + return $this->duplicateSkipping()->with(true, $callback); + } + + /** + * @return Value + */ + private function duplicateSkipping(): Value + { + return $this->duplicateSkipping ??= new Value(false); + } + + /** + * Run the callback in a transaction, retrying a failed attempt up to twice. A nested call whose enclosing + * transaction is gone is never retried: it 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. + * + * @template T + * + * @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; for ($attempts = 0; $attempts <= $retries; $attempts++) { + $started = false; try { $this->startTransaction(); + $started = true; $result = $callback(); $this->commitTransaction(); + return $result; - } catch (\Throwable $action) { + } catch (Throwable $action) { $rollback = null; - 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; + $lost = $started && $this->inTransaction <= $depth; + if (! $lost) { + try { + $this->rollbackTransaction(); + } catch (Throwable $rollbackError) { + $rollback = $rollbackError; + $this->inTransaction = 0; + } + + $lost = $this->inTransaction < $depth; + } + + if ($lost) { + if (! $action instanceof ContentionException) { + throw new TransactionException('Failed to execute transaction: the transaction was lost before it could commit', previous: $action); + } + + if ($depth > 0) { + throw $action; + } } if ( @@ -481,6 +739,7 @@ public function withTransaction(callable $callback): mixed if ($attempts < $retries) { \usleep($sleep * ($attempts + 1)); + continue; } @@ -491,67 +750,8 @@ public function withTransaction(callable $callback): mixed throw new TransactionException('Failed to execute transaction'); } - /** - * 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 (\is_null($callback)) { - unset($this->transformations[$event][$name]); - } else { - $this->transformations[$event][$name] = $callback; - } - - return $this; - } - - 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); - } - - return $query; - } - - /** - * Quote a string - * - * @param string $string - * @return string - */ - abstract protected function quote(string $string): string; - - /** - * Ping Database - * - * @return bool - */ - abstract public function ping(): bool; - - /** - * Reconnect Database - */ - abstract public function reconnect(): void; - /** * Create Database - * - * @param string $name - * - * @return bool */ abstract public function create(string $name): bool; @@ -559,10 +759,8 @@ 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 - * - * @return bool + * @param string $database database name + * @param string|null $collection (optional) collection name */ abstract public function exists(string $database, ?string $collection = null): bool; @@ -575,61 +773,40 @@ 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 array $attributes (optional) + * @param array $indexes (optional) */ abstract public function createCollection(string $name, array $attributes = [], array $indexes = []): bool; /** * Delete Collection - * - * @param string $id - * - * @return bool */ abstract public function deleteCollection(string $id): 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 string $collection - * @param array> $attributes - * @return bool + * @param array $attributes + * * @throws TimeoutException * @throws DuplicateException */ @@ -637,160 +814,72 @@ abstract public function createAttributes(string $collection, array $attributes) /** * Update Attribute + */ + abstract public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool; + + /** + * Relax a column's null constraint when an attribute stops being required. * - * @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 + * 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 updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool; + public function relaxAttributeRequired(string $collection, string $id): bool + { + return true; + } /** * Delete Attribute - * - * @param string $collection - * @param string $id - * - * @return bool */ abstract public function deleteAttribute(string $collection, string $id): bool; /** * Rename Attribute - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool */ abstract public function renameAttribute(string $collection, string $old, string $new): bool; /** - * @param string $collection - * @param string $relatedCollection - * @param string $type - * @param bool $twoWay - * @param string $id - * @param string $twoWayKey - * @return bool - */ - abstract public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool; - - /** - * Update Relationship - * - * @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 + * @param array $indexAttributeTypes + * @param array $collation */ - 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; + abstract public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool; /** - * 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 + * Delete Index */ - abstract public function deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool; + abstract public function deleteIndex(string $collection, string $id): bool; /** * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool */ abstract public function renameIndex(string $collection, string $old, string $new): bool; /** - * Create Index - * - * @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 + * Create Document */ - 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 createDocument(Document $collection, Document $document): Document; /** - * Delete Index + * Create Documents in batches * - * @param string $collection - * @param string $id + * @param array $documents + * @return array The documents written; under skipDuplicates() the skipped ones are left out * - * @return bool + * @throws DatabaseException */ - abstract public function deleteIndex(string $collection, string $id): bool; + abstract public function createDocuments(Document $collection, array $documents): array; /** * 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; @@ -799,57 +888,37 @@ abstract public function updateDocument(Document $collection, string $id, Docume * * Updates all documents which match the given query. * - * @param Document $collection - * @param Document $updates - * @param array $documents - * - * @return int + * @param array $documents * * @throws DatabaseException */ abstract public function updateDocuments(Document $collection, Document $updates, array $documents): 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( - Document $collection, + abstract public function increaseDocumentAttribute( + string $collection, + string $id, string $attribute, - array $changes - ): array; - - /** - * @param string $collection - * @param array $documents - * @return array - */ - abstract public function getSequences(string $collection, array $documents): array; + int|float|string $value, + string $updatedAt, + int|float|string|null $min = null, + int|float|string|null $max = null + ): bool; /** * Delete Document - * - * @param string $collection - * @param string $id - * - * @return bool */ abstract public function deleteDocument(string $collection, string $id): bool; /** * Delete Documents * - * @param string $collection - * @param array $sequences - * @param array $permissionIds - * - * @return int + * @param array $sequences + * @param array $permissionIds */ abstract public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int; @@ -858,71 +927,41 @@ abstract public function deleteDocuments(string $collection, array $sequences, a * * 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<\Utopia\Query\OrderDirection> $orderTypes + * @param array $cursor * @return array */ - 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; - - /** - * Sum an attribute - * - * @param Document $collection - * @param string $attribute - * @param array $queries - * @param int|null $max - * - * @return int|float - */ - abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|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; /** * Count Documents * - * @param Document $collection - * @param array $queries - * @param int|null $max - * - * @return int + * @param array $queries */ abstract public function count(Document $collection, array $queries = [], ?int $max = null): int; /** - * Get Collection Size of the raw data + * Sum an attribute * - * @param string $collection - * @return int - * @throws DatabaseException + * @param array $queries */ - abstract public function getSizeOfCollection(string $collection): int; + abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int; /** - * Get Collection Size on the disk - * - * @param string $collection - * @return int - * @throws DatabaseException + * @param array $documents + * @return array */ - abstract public function getSizeOfCollectionOnDisk(string $collection): int; + abstract public function getSequences(string $collection, array $documents): array; /** * Get max STRING limit - * - * @return int */ abstract public function getLimitForString(): int; /** * Get max INT limit - * - * @return int */ abstract public function getLimitForInt(): int; @@ -935,435 +974,217 @@ 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 + * Get the maximum index key length in bytes. */ abstract public function getMaxIndexLength(): int; /** * Get the maximum VARCHAR length for this adapter - * - * @return int */ abstract public function getMaxVarcharLength(): int; /** * Get the maximum UID length for this adapter - * - * @return int */ abstract public function getMaxUIDLength(): int; /** * Get the minimum supported DateTime value - * - * @return \DateTime */ - abstract public function getMinDateTime(): \DateTime; - - /** - * Get the primitive type of the primary key type for this adapter - * - * @return string - */ - abstract public function getIdAttributeType(): string; + abstract public function getMinDateTime(): DateTime; /** * Get the maximum supported DateTime value - * - * @return \DateTime */ - public function getMaxDateTime(): \DateTime + public function getMaxDateTime(): DateTime { - return new \DateTime('9999-12-31 23:59:59'); + return new DateTime('9999-12-31 23:59:59'); } /** - * 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? - * - * @return bool - */ - abstract public function getSupportForCasting(): bool; - - /** - * Does the adapter handle array Contains? - * - * @return bool - */ - abstract public function getSupportForQueryContains(): bool; - - /** - * Are timeouts supported? - * - * @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 bool - */ - 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 + * Get the primitive type of the primary key type for this adapter */ - abstract public function getSupportForReconnection(): bool; + abstract public function getIdAttributeType(): string; /** - * Is hostname supported? + * Get Collection Size of the raw data * - * @return bool + * @throws DatabaseException */ - abstract public function getSupportForHostname(): bool; + abstract public function getSizeOfCollection(string $collection): int; /** - * Is creating multiple attributes in a single query supported? + * Get Collection Size on the disk * - * @return bool + * @throws DatabaseException */ - abstract public function getSupportForBatchCreateAttributes(): bool; + abstract public function getSizeOfCollectionOnDisk(string $collection): int; /** - * Is spatial attributes supported? - * - * @return bool + * Get maximum width, in bytes, allowed for a SQL row + * Return 0 when no restrictions apply */ - abstract public function getSupportForSpatialAttributes(): bool; + abstract public function getDocumentSizeLimit(): int; /** - * Are object (JSON) attributes supported? - * - * @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 */ - abstract public function getSupportForObject(): bool; + abstract public function getAttributeWidth(Document $collection): int; /** - * Are object (JSON) indexes supported? - * - * @return bool + * Get current attribute count from collection document */ - abstract public function getSupportForObjectIndexes(): bool; + abstract public function getCountOfAttributes(Document $collection): int; /** - * Does the adapter support null values in spatial indexes? - * - * @return bool + * Get current index count from collection document */ - abstract public function getSupportForSpatialIndexNull(): bool; + abstract public function getCountOfIndexes(Document $collection): int; /** - * Does the adapter support operators? - * - * @return bool + * Returns number of attributes used by default. */ - abstract public function getSupportForOperators(): bool; + abstract public function getCountOfDefaultAttributes(): int; /** - * Adapter supports optional spatial attributes with existing rows. - * - * @return bool + * Returns number of indexes used by default. */ - abstract public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool; + abstract public function getCountOfDefaultIndexes(): int; /** - * Does the adapter support order attribute in spatial indexes? + * Get list of keywords that cannot be used * - * @return bool + * @return array */ - abstract public function getSupportForSpatialIndexOrder(): bool; + abstract public function getKeywords(): array; /** - * Does the adapter support spatial axis order specification? + * Get List of internal index keys names * - * @return bool + * @return array */ - abstract public function getSupportForSpatialAxisOrder(): bool; + abstract public function getInternalIndexesKeys(): array; - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - abstract public function getSupportForBoundaryInclusiveContains(): bool; + protected function getInternalKeyForAttribute(string $attribute): string + { + return Storage::column($attribute); + } /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool + * Handle non utf characters supported? */ - abstract public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool; + public function getSupportNonUtfCharacters(): bool + { + return false; + } /** - * 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 = []; - /** - * Does the adapter support identical indexes? - * - * @return bool - */ - abstract public function getSupportForIdenticalIndexes(): bool; + private const FILTERED_KEY_CACHE_LIMIT = 4096; /** - * 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 + * Apply all write hooks' decorateRow to a row. * - * @return int + * @param array $row + * @param array $metadata + * @return array */ - abstract public function getDocumentSizeLimit(): int; + protected function decorateRow(array $row, array $metadata): array + { + foreach ($this->writeHooks as $hook) { + $row = $hook->decorateRow($row, $metadata); + } - /** - * 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; + return $row; + } /** - * Get list of keywords that cannot be used + * Run the callable once per registered write hook, in registration order. * - * @return array + * @param callable(Write): void $fn */ - abstract public function getKeywords(): array; + protected function runWriteHooks(callable $fn): void + { + foreach ($this->writeHooks as $hook) { + $fn($hook); + } + } /** - * Get an attribute projection given a list of selected attributes - * - * @param array $selections - * @param string $prefix - * @return mixed + * @return array */ - abstract protected function getAttributeProjection(array $selections, string $prefix): mixed; + protected function documentMetadata(Document $document): array + { + return ['id' => $document->getId(), 'tenant' => $document->getTenant()]; + } /** * 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 +1202,7 @@ protected function escapeWildcards(string $value): string ')', '{', '}', - '|' + '|', ]; foreach ($wildcards as $wildcard) { @@ -1392,266 +1213,12 @@ 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 + * Quote a string */ - abstract public function getTenantQuery(string $collection, string $alias = ''): string; + abstract protected function quote(string $string): 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 */ diff --git a/src/Database/Adapter/Feature/Attributes.php b/src/Database/Adapter/Feature/Attributes.php new file mode 100644 index 0000000000..9594f12634 --- /dev/null +++ b/src/Database/Adapter/Feature/Attributes.php @@ -0,0 +1,58 @@ + $attributes The attributes to create. + * @return bool True on success. + */ + public function createAttributes(string $collection, array $attributes): bool; + + /** + * Update an existing attribute in a collection. + * + * @param string $collection The collection identifier. + * @param Attribute $attribute The attribute with updated properties. + * @param string|null $newKey Optional new key to rename the attribute. + * @return bool True on success. + */ + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool; + + /** + * Delete an attribute from a collection. + * + * @param string $collection The collection identifier. + * @param string $id The attribute identifier to delete. + * @return bool True on success. + */ + public function deleteAttribute(string $collection, string $id): bool; + + /** + * Rename an attribute in a collection. + * + * @param string $collection The collection identifier. + * @param string $old The current attribute key. + * @param string $new The new attribute key. + * @return bool True on success. + */ + public function renameAttribute(string $collection, string $old, string $new): bool; +} diff --git a/src/Database/Adapter/Feature/Collections.php b/src/Database/Adapter/Feature/Collections.php new file mode 100644 index 0000000000..69d311fca0 --- /dev/null +++ b/src/Database/Adapter/Feature/Collections.php @@ -0,0 +1,54 @@ + $attributes Initial attributes for the collection. + * @param array $indexes Initial indexes for the collection. + * @return bool True on success. + */ + public function createCollection(string $name, array $attributes = [], array $indexes = []): bool; + + /** + * Delete a collection by its identifier. + * + * @param string $id The collection identifier. + * @return bool True on success. + */ + public function deleteCollection(string $id): bool; + + /** + * Analyze a collection to update index statistics. + * + * @param string $collection The collection identifier. + * @return bool True on success. + */ + public function analyzeCollection(string $collection): bool; + + /** + * Get the logical data size of a collection in bytes. + * + * @param string $collection The collection identifier. + * @return int Size in bytes. + */ + public function getSizeOfCollection(string $collection): int; + + /** + * Get the on-disk storage size of a collection in bytes. + * + * @param string $collection The collection identifier. + * @return int Size in bytes. + */ + public function getSizeOfCollectionOnDisk(string $collection): int; +} diff --git a/src/Database/Adapter/Feature/ColumnTypes.php b/src/Database/Adapter/Feature/ColumnTypes.php new file mode 100644 index 0000000000..1bdad576f7 --- /dev/null +++ b/src/Database/Adapter/Feature/ColumnTypes.php @@ -0,0 +1,21 @@ + Array of database documents. + */ + public function list(): array; + + /** + * Delete a database by name. + * + * @param string $name The database name. + * @return bool True on success. + */ + public function delete(string $name): bool; +} diff --git a/src/Database/Adapter/Feature/Documents.php b/src/Database/Adapter/Feature/Documents.php new file mode 100644 index 0000000000..ecee61bf4b --- /dev/null +++ b/src/Database/Adapter/Feature/Documents.php @@ -0,0 +1,152 @@ + $queries Optional queries for field selection. + * @param bool $forUpdate Whether to lock the document for update. + * @return Document The retrieved document. + */ + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document; + + /** + * Create a new document in a collection. + * + * @param Document $collection The collection document. + * @param Document $document The document to create. + * @return Document The created document. + */ + public function createDocument(Document $collection, Document $document): Document; + + /** + * Create multiple documents in a collection at once. + * + * @param Document $collection The collection document. + * @param array $documents The documents to create. + * @return array The created documents. + */ + public function createDocuments(Document $collection, array $documents): array; + + /** + * Update an existing document in a collection. + * + * @param Document $collection The collection document. + * @param string $id The document identifier. + * @param Document $document The document with updated data. + * @param bool $skipPermissions Whether to skip permission checks. + * @return Document The updated document. + */ + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document; + + /** + * Update multiple documents matching the given criteria. + * + * @param Document $collection The collection document. + * @param Document $updates The fields to update. + * @param array $documents The documents to update. + * @return int The number of documents updated. + */ + public function updateDocuments(Document $collection, Document $updates, array $documents): int; + + /** + * Delete a document from a collection. + * + * @param string $collection The collection identifier. + * @param string $id The document identifier. + * @return bool True on success. + */ + public function deleteDocument(string $collection, string $id): bool; + + /** + * Delete multiple documents from a collection. + * + * @param string $collection The collection identifier. + * @param array $sequences The document sequences to delete. + * @param array $permissionIds The permission identifiers to clean up. + * @return int The number of documents deleted. + */ + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int; + + /** + * Find documents in a collection matching the given queries and ordering. + * + * @param Document $collection The collection document. + * @param array $queries Filter queries. + * @param int|null $limit Maximum number of documents to return. + * @param int|null $offset Number of documents to skip. + * @param array $orderAttributes Attributes to order by. + * @param array $orderTypes Direction for each order attribute. + * @param array $cursor Cursor values for pagination. + * @param CursorDirection $cursorDirection Direction of cursor pagination. + * @param PermissionType $forPermission The permission type to check. + * @return array The matching documents. + */ + 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; + + /** + * Calculate the sum of an attribute's values across matching documents. + * + * @param Document $collection The collection document. + * @param string $attribute The attribute to sum. + * @param array $queries Optional filter queries. + * @param int|null $max Maximum number of documents to consider. + * @return float|int The sum result. + */ + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int; + + /** + * Count documents matching the given queries. + * + * @param Document $collection The collection document. + * @param array $queries Optional filter queries. + * @param int|null $max Maximum count to return. + * @return int The document count. + */ + public function count(Document $collection, array $queries = [], ?int $max = null): int; + + /** + * Increase or decrease a numeric attribute value on a document. + * + * @param string $collection The collection identifier. + * @param string $id The document identifier. + * @param string $attribute The numeric attribute to modify. + * @param int|float|string $value The value to add (negative to decrease). + * @param string $updatedAt The timestamp to set as the updated time. + * @param int|float|string|null $min Optional minimum bound for the resulting value. + * @param int|float|string|null $max Optional maximum bound for the resulting value. + * @return bool True on success. + */ + public function increaseDocumentAttribute( + string $collection, + string $id, + string $attribute, + int|float|string $value, + string $updatedAt, + int|float|string|null $min = null, + int|float|string|null $max = null + ): bool; + + /** + * Retrieve internal sequence values for the given documents. + * + * @param string $collection The collection identifier. + * @param array $documents The documents to retrieve sequences for. + * @return array The documents with populated sequence values. + */ + public function getSequences(string $collection, array $documents): array; +} diff --git a/src/Database/Adapter/Feature/Indexes.php b/src/Database/Adapter/Feature/Indexes.php new file mode 100644 index 0000000000..14e649331c --- /dev/null +++ b/src/Database/Adapter/Feature/Indexes.php @@ -0,0 +1,48 @@ + $indexAttributeTypes Mapping of attribute names to their types. + * @param array $collation Optional collation settings for the index. + * @return bool True on success. + */ + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool; + + /** + * Delete an index from a collection. + * + * @param string $collection The collection identifier. + * @param string $id The index identifier. + * @return bool True on success. + */ + public function deleteIndex(string $collection, string $id): bool; + + /** + * Rename an index in a collection. + * + * @param string $collection The collection identifier. + * @param string $old The current index name. + * @param string $new The new index name. + * @return bool True on success. + */ + public function renameIndex(string $collection, string $old, string $new): bool; + + /** + * Get the keys of all internal indexes used by the adapter. + * + * @return array The internal index keys. + */ + public function getInternalIndexesKeys(): array; +} diff --git a/src/Database/Adapter/Feature/InternalCasting.php b/src/Database/Adapter/Feature/InternalCasting.php new file mode 100644 index 0000000000..37a5685543 --- /dev/null +++ b/src/Database/Adapter/Feature/InternalCasting.php @@ -0,0 +1,29 @@ + $bindings Parameter bindings for prepared statements. + * @return array The query results as Document objects. + */ + public function rawQuery(string $query, array $bindings = []): array; + + /** + * Execute a raw mutation and return the number of affected rows. + * + * @param string $query The raw mutation string. + * @param array $bindings Parameter bindings for prepared statements. + * @return int The number of affected rows. + */ + 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..1fe5785a23 --- /dev/null +++ b/src/Database/Adapter/Feature/Relationships.php @@ -0,0 +1,37 @@ + The attribute documents describing the schema. + */ + public function getSchemaAttributes(string $collection): array; +} diff --git a/src/Database/Adapter/Feature/SchemaIndexes.php b/src/Database/Adapter/Feature/SchemaIndexes.php new file mode 100644 index 0000000000..632735097b --- /dev/null +++ b/src/Database/Adapter/Feature/SchemaIndexes.php @@ -0,0 +1,19 @@ + The index documents describing the schema. + */ + public function getSchemaIndexes(string $collection): array; +} diff --git a/src/Database/Adapter/Feature/Spatial.php b/src/Database/Adapter/Feature/Spatial.php new file mode 100644 index 0000000000..81c120bc95 --- /dev/null +++ b/src/Database/Adapter/Feature/Spatial.php @@ -0,0 +1,33 @@ + The point as [longitude, latitude]. + */ + public function decodePoint(string $wkb): array; + + /** + * Decode a WKB-encoded linestring into an array of coordinate pairs. + * + * @param string $wkb The Well-Known Binary representation. + * @return array> Array of [longitude, latitude] pairs. + */ + public function decodeLinestring(string $wkb): array; + + /** + * Decode a WKB-encoded polygon into an array of rings, each containing coordinate pairs. + * + * @param string $wkb The Well-Known Binary representation. + * @return array>> Array of rings, each an array of [longitude, latitude] pairs. + */ + public function decodePolygon(string $wkb): array; +} diff --git a/src/Database/Adapter/Feature/Timeouts.php b/src/Database/Adapter/Feature/Timeouts.php new file mode 100644 index 0000000000..c5694dcafc --- /dev/null +++ b/src/Database/Adapter/Feature/Timeouts.php @@ -0,0 +1,28 @@ + $changes The old/new document pairs to upsert. + * @return array The resulting documents after upsert. + */ + public function upsertDocuments(Document $collection, string $attribute, array $changes): array; +} diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 6d2aac8ef7..78e7fddeb9 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -3,11 +3,20 @@ 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\Attribute; +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\NotFound as NotFoundException; @@ -16,310 +25,239 @@ 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\OperatorType; +use Utopia\Database\PDO as DatabasePDO; +use Utopia\Database\PDOStatement as DatabasePDOStatement; use Utopia\Database\Query; - -class MariaDB extends SQL +use Utopia\Database\RelationSide; +use Utopia\Database\RelationType; +use Utopia\Database\Storage; +use Utopia\Query\Builder\MariaDB as MariaDBBuilder; +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; + +/** + * Database adapter for MariaDB, extending the base SQL adapter with MariaDB-specific features. + */ +class MariaDB extends SQL implements Feature\ConnectionId, Feature\SchemaAttributes, Feature\SchemaIndexes, Feature\Spatial, Feature\Timeouts { /** - * Create Database + * Get the list of capabilities supported by the MariaDB adapter. + * + * @return array + */ + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::IntegerBooleans, + Capability::NumericCasting, + Capability::AlterLock, + Capability::JSONOverlaps, + Capability::FulltextWildcard, + Capability::PCRE, + Capability::SpatialIndexOrder, + Capability::OptionalSpatial, + Capability::UpsertOnUniqueIndex, + Capability::UnsignedBigInt, + ]); + } + + /** + * Check whether the adapter supports storing non-UTF characters. * - * @param string $name * @return bool - * @throws Exception - * @throws PDOException */ - public function create(string $name): bool + public function getSupportNonUtfCharacters(): bool { - $name = $this->filter($name); + return true; + } - if ($this->exists($name)) { - return true; - } + /** + * Get the current database connection ID. + * + * @return string + */ + public function getConnectionId(): string + { + $result = $this->createBuilder()->fromNone()->selectRaw('CONNECTION_ID()')->build(); + $statement = $this->prepareStatement($result->query); - $sql = "CREATE DATABASE `{$name}` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;"; + if (! $statement->execute()) { + return ''; + } - $sql = $this->trigger(Database::EVENT_DATABASE_CREATE, $sql); + $column = $statement->fetchColumn(); - return $this->getPDO() - ->prepare($sql) - ->execute(); + return \is_scalar($column) ? (string) $column : ''; } /** - * Delete Database + * Create Database * - * @param string $name - * @return bool * @throws Exception * @throws PDOException */ - public function delete(string $name): bool + 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->createSchemaBuilder()->createDatabase($name); + $sql = $result->query; - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::DatabaseCreate); } /** * Create Collection * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool + * @param array $attributes + * @param array $indexes + * * @throws Exception * @throws PDOException */ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { $id = $this->filter($name); + $schema = $this->createSchemaBuilder(); + $sharedTables = $this->sharedTables; - /** @var array $attributeStrings */ - $attributeStrings = []; - - /** @var array $indexStrings */ - $indexStrings = []; - + // Pre-build attribute hash for array lookups during index construction $hash = []; - - foreach ($attributes as $key => $attribute) { - $attrId = $this->filter($attribute->getId()); + foreach ($attributes as $attribute) { + $attrId = $this->filter($attribute->key); $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) - ); + $table = $schema->table($this->getSQLTableRaw($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); + + foreach ($attributes as $attribute) { + $attrId = $this->filter($attribute->key); - // Ignore relationships with virtual attributes - if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) { - $options = $attribute->getAttribute('options', []); + if ($attribute->type === ColumnType::Relationship) { + $options = $attribute->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) + $relationType === RelationType::ManyToMany->value + || ($relationType === RelationType::OneToOne->value && ! $twoWay && $side === RelationSide::Child->value) + || ($relationType === RelationType::OneToMany->value && $side === RelationSide::Parent->value) + || ($relationType === RelationType::ManyToOne->value && $side === RelationSide::Child->value) ) { continue; } } - $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; + $attrType = $this->getSQLType( + $attribute->type, + $attribute->size, + $attribute->signed, + $attribute->array, + $attribute->required + ); + $table->rawColumn("`{$attrId}` {$attrType}"); } - foreach ($indexes as $key => $index) { - $indexId = $this->filter($index->getId()); - $indexType = $index->getAttribute('type'); + foreach ($indexes as $index) { + $indexId = $this->filter($index->key); + $indexType = $index->type; + $indexColumns = []; - $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); + foreach ($index->attributes as $nested => $attribute) { + $indexOrder = Index::direction($index->orders[$nested] ?? null); - if ($indexType === Database::INDEX_FULLTEXT) { - $indexOrder = ''; + if ($indexType === IndexType::Spatial && ! $this->supports(Capability::SpatialIndexOrder) && ! empty($indexOrder)) { + throw new DatabaseException('Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'); } - $indexAttributes[$nested] = "`{$indexAttribute}`{$indexLength} {$indexOrder}"; + $indexAttribute = $this->filter($this->getInternalKeyForAttribute($attribute)); - if (!empty($hash[$indexAttribute]['array']) && $this->getSupportForCastIndexArray()) { - $indexAttributes[$nested] = '(CAST(`' . $indexAttribute . '` AS char(' . Database::MAX_ARRAY_INDEX_LENGTH . ') ARRAY))'; - } + $indexColumns[] = $this->compileIndexColumn( + $indexAttribute, + ! empty($hash[$indexAttribute]->array), + (int) ($index->lengths[$nested] ?? 0), + $indexType === IndexType::Fulltext ? '' : $indexOrder, + ); } - $indexAttributes = \implode(", ", $indexAttributes); - - if ($this->sharedTables && $indexType !== Database::INDEX_FULLTEXT && $indexType !== Database::INDEX_SPATIAL) { - // Add tenant as first index column for best performance - $indexAttributes = "_tenant, {$indexAttributes}"; + if ($sharedTables && $indexType !== IndexType::Fulltext && $indexType !== IndexType::Spatial) { + \array_unshift($indexColumns, $this->quote(Storage::TENANT)); } - $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) - "; + $table->addIndex($indexId, [], $indexType, rawColumns: $indexColumns); } - $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 ($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); } - return true; - } + $collectionResult = $table->create(); + $collection = $collectionResult->query; - /** - * Get collection size on disk - * - * @param string $collection - * @return int - * @throws DatabaseException - */ - public function getSizeOfCollectionOnDisk(string $collection): int - { - $collection = $this->filter($collection); - $collection = $this->getNamespace() . '_' . $collection; - $database = $this->getDatabase(); - $name = $database . '/' . $collection; - $permissions = $database . '/' . $collection . '_perms'; + $permsTable = $schema->table($this->getSQLTableRaw(Storage::permissionsTable($id))); + $permsTable->id(Storage::SEQUENCE); + $permsTable->string(Storage::PERM_TYPE, 12); + $permsTable->string(Storage::PERM_PERMISSION, 255); + $permsTable->string(Storage::PERM_DOCUMENT, 255); - $collectionSize = $this->getPDO()->prepare(" - SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) - FROM INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES - WHERE NAME = :name - "); + if ($sharedTables) { + $permsTable->integer(Storage::TENANT)->unsigned()->nullable()->default(null); + $permsTable->uniqueIndex([Storage::PERM_DOCUMENT, Storage::TENANT, Storage::PERM_TYPE, Storage::PERM_PERMISSION], Storage::INDEX_1); + $permsTable->index([Storage::TENANT, Storage::PERM_PERMISSION, Storage::PERM_TYPE], Storage::PERM_PERMISSION); + } else { + $permsTable->uniqueIndex([Storage::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_PERMISSION], Storage::INDEX_1); + $permsTable->index([Storage::PERM_PERMISSION, Storage::PERM_TYPE], Storage::PERM_PERMISSION); + } - $permissionsSize = $this->getPDO()->prepare(" - SELECT SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE) - FROM INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES - WHERE NAME = :permissions - "); + $permsResult = $permsTable->create(); + $permissions = $permsResult->query; - $collectionSize->bindParam(':name', $name); - $permissionsSize->bindParam(':permissions', $permissions); + $created = false; try { - $collectionSize->execute(); - $permissionsSize->execute(); - $size = $collectionSize->fetchColumn() + $permissionsSize->fetchColumn(); + $this->executeStatement($collection, Event::CollectionCreate); + $created = true; + $this->executeStatement($permissions, Event::CollectionCreate); } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); - } - - return $size; - } + $error = $this->processException($e); - /** - * 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); + if ($created && ! $error instanceof DuplicateException) { + $this->discardCreatedCollection($id); + } - try { - $statement->execute(); - $size = $statement->fetchColumn(); - } catch (PDOException $e) { - throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); + throw $error; } - return (int) $size; + return true; } /** * Delete collection * - * @param string $id - * @return bool * @throws Exception * @throws PDOException */ @@ -327,468 +265,273 @@ public function deleteCollection(string $id): bool { $id = $this->filter($id); - $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')};"; - - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + $schema = $this->createSchemaBuilder(); + $main = $schema->table($this->getSQLTableRaw($id))->drop(); + $permissions = $schema->table($this->getSQLTableRaw(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; } } /** * 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); - $sql = "ANALYZE TABLE {$this->getSQLTable($name)}"; + $result = $this->createSchemaBuilder()->analyzeTable($this->getSQLTableRaw($name)); + $sql = $result->query; - $stmt = $this->getPDO()->prepare($sql); - return $stmt->execute(); + return $this->executeStatement($sql, Event::CollectionUpdate); } /** - * Get Schema Attributes + * Get collection size on disk * - * @param string $collection - * @return array * @throws DatabaseException */ - public function getSchemaAttributes(string $collection): array + public function getSizeOfCollectionOnDisk(string $collection): int { - $schema = $this->getDatabase(); - $collection = $this->getNamespace().'_'.$this->filter($collection); + $collection = $this->filter($collection); + $collection = $this->getNamespace().'_'.$collection; + $database = $this->getDatabase(); + $name = $database.'/'.$collection; + $permissions = $database.'/'.Storage::permissionsTable($collection); - 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(); + $builder = $this->createBuilder(); - foreach ($results as $index => $document) { - $document['$id'] = $document['_id']; - unset($document['_id']); + $collectionResult = $builder + ->from('INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES') + ->selectRaw('SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)') + ->filter([BaseQuery::equal('NAME', [$name])]) + ->build(); - $results[$index] = new Document($document); - } + $permissionsResult = $builder->reset() + ->from('INFORMATION_SCHEMA.INNODB_SYS_TABLESPACES') + ->selectRaw('SUM(FS_BLOCK_SIZE + ALLOCATED_SIZE)') + ->filter([BaseQuery::equal('NAME', [$permissions])]) + ->build(); - return $results; + $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 { + $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 schema attributes', $e->getCode(), $e); + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } + + return $size; } /** - * Update Attribute + * Get Collection Size of the raw data * - * @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 */ - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + public function getSizeOfCollection(string $collection): int { - $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};"; - } - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $collection = $this->filter($collection); + $collection = $this->getNamespace().'_'.$collection; + $database = $this->getDatabase(); + $permissions = Storage::permissionsTable($collection); + + $result = $this->createBuilder() + ->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); try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + $this->execute($statement); + $size = $statement->fetchColumn(); } catch (PDOException $e) { - throw $this->processException($e); + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); } + + return (int) (\is_numeric($size) ? $size : 0); } /** - * @param string $collection - * @param string $id - * @param string $type - * @param string $relatedCollection - * @param bool $twoWay - * @param string $twoWayKey - * @return bool - * @throws DatabaseException + * MariaDB has no column SRID attribute: MySQL's `SRID n` column syntax is a parse error there. */ - 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'); - } - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); - - return $this->getPDO() - ->prepare($sql) - ->execute(); + #[\Override] + protected function getSpatialColumnSrid(): ?int + { + return null; } /** - * @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 + * Update Attribute + * * @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 { + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = 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); - } - - $sql = ''; - - 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); - - $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + $id = $this->filter($attribute->key); + $newKey = empty($newKey) ? null : $this->filter($newKey); + $sqlType = $this->getSQLType($attribute->type, $attribute->size, $attribute->signed, $attribute->array, $attribute->required); + $schema = $this->createSchemaBuilder(); + $tableRaw = $this->getSQLTableRaw($name); - 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'); + if (! empty($newKey) && $this->isRenamed($collection, $id, $newKey)) { + $id = $newKey; + $newKey = null; } - if (empty($sql)) { - return true; + if (! empty($newKey)) { + $result = $schema->changeColumn($tableRaw, $id, $newKey, $sqlType); + } else { + $result = $schema->modifyColumn($tableRaw, $id, $sqlType); } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); - - return $this->getPDO() - ->prepare($sql) - ->execute(); - } - - /** - * @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'); - } + $sql = $result->query; - if (empty($sql)) { - return true; + try { + return $this->executeStatement($sql, Event::AttributeUpdate); + } catch (PDOException $e) { + throw $this->processException($e); } - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); - - return $this->getPDO() - ->prepare($sql) - ->execute(); - } - - /** - * Rename Index - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception - */ - public function renameIndex(string $collection, string $old, string $new): bool - { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); - - $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME INDEX `{$old}` TO `{$new}`;"; - - $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); - - return $this->getPDO() - ->prepare($sql) - ->execute(); } /** * Create Index * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - * @return bool + * @param array $indexAttributeTypes + * @param array $collation + * * @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 + 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); + $storedAttributes = $collection->getAttribute('attributes', []); + /** @var array> $collectionAttributes */ + $collectionAttributes = \is_string($storedAttributes) ? (\json_decode($storedAttributes, true) ?? []) : []; + $id = $this->filter($index->key); + $type = $index->type; + $attributes = $index->attributes; + $lengths = $index->lengths; + $orders = $index->orders; - $id = $this->filter($id); + $schema = $this->createSchemaBuilder(); + $tableName = $this->getSQLTableRaw($collection->getId()); - foreach ($attributes as $i => $attr) { + $columns = []; + foreach ($attributes as $i => $key) { $attribute = null; foreach ($collectionAttributes as $collectionAttribute) { - if (\strtolower($collectionAttribute['$id']) === \strtolower($attr)) { + $attributeId = $collectionAttribute[Document::ID] ?? ''; + if (\strtolower(\is_string($attributeId) ? $attributeId : '') === \strtolower($key)) { $attribute = $collectionAttribute; 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)), + ! empty($attribute['array']), + (int) ($lengths[$i] ?? 0), + $type === IndexType::Fulltext ? '' : Index::direction($orders[$i] ?? null), + ); + } - 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(); + return $this->executeStatement($sql, Event::IndexCreate); } catch (PDOException $e) { throw $this->processException($e); } } + /** + * 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::CastIndexArray)) { + 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; + } + /** * Delete Index * - * @param string $collection - * @param string $id - * @return bool * @throws Exception * @throws PDOException */ @@ -797,16 +540,15 @@ public function deleteIndex(string $collection, string $id): bool $name = $this->filter($collection); $id = $this->filter($id); - $sql = "ALTER TABLE {$this->getSQLTable($name)} DROP INDEX `{$id}`;"; + $schema = $this->createSchemaBuilder(); + $result = $schema->dropIndex($this->getSQLTableRaw($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; } @@ -815,143 +557,105 @@ public function deleteIndex(string $collection, string $id): bool } /** - * Create Document + * Rename Index * - * @param Document $collection - * @param Document $document - * @return Document * @throws Exception - * @throws PDOException - * @throws DuplicateException - * @throws \Throwable */ - public function createDocument(Document $collection, Document $document): Document + public function renameIndex(string $collection, string $old, string $new): bool { - try { - $spatialAttributes = $this->getSpatialAttributes($collection); - $collection = $collection->getId(); - $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); - - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } - - $name = $this->filter($collection); - $columns = ''; - $columnNames = ''; - - /** - * 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) . ", "; - } else { - $columnNames .= ':' . $bindKey . ', '; - } - $bindIndex++; - } + $collection = $this->filter($collection); + $old = $this->filter($old); + $new = $this->filter($new); - // Insert internal ID if set - if (!empty($document->getSequence())) { - $bindKey = '_id'; - $columns .= "_id, "; - $columnNames .= ':' . $bindKey . ', '; - } + $result = $this->createSchemaBuilder()->renameIndex($this->getSQLTableRaw($collection), $old, $new); + $sql = $result->query; - $sql = " - INSERT INTO {$this->getSQLTable($name)} ({$columns} _uid) - VALUES ({$columnNames} :_uid) - "; + return $this->executeStatement($sql, Event::IndexRename); + } - $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); + /** + * Create Document + * + * @throws Exception + * @throws PDOException + * @throws DuplicateException + * @throws \Throwable + */ + 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()); + $name = $this->filter($collection); - $stmt->bindValue(':_uid', $document->getId()); + // Build document INSERT using query builder + // Spatial columns use insertColumnExpression() for ST_GeomFromText() wrapping + $builder = $this->createBuilder()->into($this->getSQLTableRaw($name)); + $row = [Storage::UID => $document->getId()]; - if (!empty($document->getSequence())) { - $stmt->bindValue(':_id', $document->getSequence()); + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - $attributeIndex = 0; - foreach ($attributes as $value) { - if (\is_array($value)) { - $value = \json_encode($value); - } + $spatialMap = \array_fill_keys($spatialAttributes, true); - $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); - $value = (\is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; - } + foreach ($attributes as $attr => $value) { + $column = $this->filter($attr); - $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; + if (isset($spatialMap[$attr])) { + $value = $this->encodeSpatialWriteValue($value); + $value = (\is_bool($value)) ? (int) $value : $value; + $row[$column] = $value; + $builder->insertColumnExpression($column, $this->getSpatialGeomFromText('?')); + } else { + if (\is_array($value)) { + $value = \json_encode($value); + } + $value = (\is_bool($value)) ? (int) $value : $value; + $row[$column] = $value; } } - 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()); - } - } + $row = $this->decorateRow($row, $this->documentMetadata($document)); + $builder->set($row); + $result = $builder->insert(); + $stmt = $this->executeResult($result, Event::DocumentCreate); - $stmt->execute(); + $this->execute($stmt); - $document['$sequence'] = $this->pdo->lastInsertId(); + $document[Document::SEQUENCE] = $this->getPDO()->lastInsertId(); - if (empty($document['$sequence'])) { - throw new DatabaseException('Error creating document empty "$sequence"'); + if (empty($document[Document::SEQUENCE])) { + throw new DatabaseException('Error creating document empty "'.Document::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; - } + $ctx = $this->buildWriteContext($name); + try { + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $ctx)); + } 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; + } - // 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(); + // Clean up orphaned permissions from a previous failed delete, then retry + $cleanupBuilder = $this->newBuilder(Storage::permissionsTable($name)); + $cleanupBuilder->filter([BaseQuery::equal(Storage::PERM_DOCUMENT, [$document->getId()])]); + $cleanupResult = $cleanupBuilder->delete(); + $cleanupStmt = $this->executeResult($cleanupResult, Event::PermissionsDelete); + $this->execute($cleanupStmt); - $stmtPermissions->execute(); - } + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $ctx)); } } catch (PDOException $e) { throw $this->processException($e); @@ -963,11 +667,6 @@ public function createDocument(Document $collection, Document $document): Docume /** * Update Document * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * @return Document * @throws Exception * @throws PDOException * @throws DuplicateException @@ -976,142 +675,64 @@ public function createDocument(Document $collection, Document $document): Docume 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])) { + $op = $operators[$attribute]; + if ($op instanceof Operator) { + $opResult = $this->getOperatorBuilderExpression($column, $op); + $builder->setRaw($column, $opResult['expression'], $opResult['bindings']); + } + } elseif (isset($spatialMap[$attribute])) { + $value = $this->encodeSpatialWriteValue($value); + $value = (\is_bool($value)) ? (int) $value : $value; + $builder->setRaw($column, $this->getSpatialGeomFromText('?'), [$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)); - } - - $stmt->execute(); + $builder->set($regularRow); + $filters = [BaseQuery::equal(Storage::SEQUENCE, [$document->getSequence()])]; + $builder->filter($filters); + $result = $builder->update(); + $stmt = $this->executeResult($result, Event::DocumentUpdate); - if (isset($stmtRemovePermissions)) { - $stmtRemovePermissions->execute(); - } - if (isset($stmtAddPermissions)) { - $stmtAddPermissions->execute(); - } + $this->execute($stmt); + $ctx = $this->buildWriteContext($name, $id); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $document, $skipPermissions, $ctx)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1120,861 +741,466 @@ 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 + * Set max execution time + * * @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})"; - } - - return "{$attribute} = {$new}"; - }; - - $updateColumns = []; - $operatorBinds = []; + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); + } - 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); - } - } - } + if ($event === Event::All) { + $this->applyTimeout($milliseconds); } - $stmt = $this->getPDO()->prepare( - " - INSERT INTO {$this->getSQLTable($tableName)} {$columns} - VALUES " . \implode(', ', $batchKeys) . " - ON DUPLICATE KEY UPDATE - " . \implode(', ', $updateColumns) - ); + $this->setTimeoutState($milliseconds, $event); + } - foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + public function clearTimeout(Event $event = Event::All): void + { + if ($event === Event::All) { + $this->applyTimeout(0); } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $this->clearTimeoutState($event); + } - return $stmt; + /** + * Size of POINT spatial type + */ + protected function getMaxPointSize(): int + { + // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format + return 25; } /** - * 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)} - "; + #[\Override] + public 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); + // Unpack two doubles + $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 + #[\Override] + public 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 + #[\Override] + public 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) . ")"; + // 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_NOT_CROSSES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Crosses({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + $rings = explode('),(', $inside); - 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); + return array_map(function ($ring) { + $points = explode(',', $ring); - 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 ($point) { + $coords = explode(' ', trim($point)); - 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 [(float) $coords[0], (float) $coords[1]]; + }, $points); + }, $rings); + } - case Query::TYPE_OVERLAPS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + // 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'); + } + } - case Query::TYPE_NOT_OVERLAPS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Overlaps({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + if (strlen($wkb) < 21) { + throw new DatabaseException('WKB too short to be a POLYGON'); + } - case Query::TYPE_TOUCHES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + // MySQL SRID-aware WKB layout: 4 bytes SRID prefix + $offset = 4; - case Query::TYPE_NOT_TOUCHES: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Touches({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + $byteOrder = ord($wkb[$offset]); + if ($byteOrder !== 1) { + throw new DatabaseException('Only little-endian WKB supported'); + } + $offset += 1; - case Query::TYPE_EQUAL: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + $typeArr = unpack('V', substr($wkb, $offset, 4)); + if ($typeArr === false || ! isset($typeArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack geometry type'); + } - case Query::TYPE_NOT_EQUAL: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Equals({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + $type = \is_numeric($typeArr[1]) ? (int) $typeArr[1] : 0; + $hasSRID = ($type & 0x20000000) === 0x20000000; + $geomType = $type & 0xFF; + $offset += 4; - case Query::TYPE_CONTAINS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + if ($geomType !== 3) { // 3 = POLYGON + throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + } - case Query::TYPE_NOT_CONTAINS: - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($query->getValues()[0]); - return "NOT ST_Contains({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ")"; + // Skip SRID in type flag if present + if ($hasSRID) { + $offset += 4; + } - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: - return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; + $numRingsArr = unpack('V', substr($wkb, $offset, 4)); - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + if ($numRingsArr === false || ! isset($numRingsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of 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(); + $numRings = $numRingsArr[1]; + $offset += 4; - if ($query->isSpatialAttribute()) { - return $this->handleSpatialQueries($query, $binds, $attribute, $query->getAttributeType(), $alias, $placeholder); - } + $rings = []; - 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); - } + for ($r = 0; $r < $numRings; $r++) { + $numPointsArr = unpack('V', substr($wkb, $offset, 4)); - $method = strtoupper($query->getMethod()); + if ($numPointsArr === false || ! isset($numPointsArr[1])) { + throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + } - return empty($conditions) ? '' : ' '. $method .' (' . implode(' AND ', $conditions) . ')'; + $numPoints = $numPointsArr[1]; + $offset += 4; + $ring = []; - case Query::TYPE_SEARCH: - $fulltextValue = $this->getFulltextValue($query->getValue()); - if ($fulltextValue === '') { - return '0 = 1'; + 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.'); } - $binds[":{$placeholder}_0"] = $fulltextValue; - return "MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; + $x = (float) (is_numeric($xArr[1]) ? $xArr[1] : 0); - case Query::TYPE_NOT_SEARCH: - $fulltextValue = $this->getFulltextValue($query->getValue()); - if ($fulltextValue === '') { - return '1 = 1'; + $yArr = unpack('d', substr($wkb, $offset + 8, 8)); + if ($yArr === false) { + throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); } - $binds[":{$placeholder}_0"] = $fulltextValue; - return "NOT (MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE))"; + $y = (float) (is_numeric($yArr[1]) ? $yArr[1] : 0); - case Query::TYPE_BETWEEN: - $binds[":{$placeholder}_0"] = $query->getValues()[0]; - $binds[":{$placeholder}_1"] = $query->getValues()[1]; + $ring[] = [$x, $y]; + $offset += 16; + } - return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + $rings[] = $ring; + } - case Query::TYPE_NOT_BETWEEN: - $binds[":{$placeholder}_0"] = $query->getValues()[0]; - $binds[":{$placeholder}_1"] = $query->getValues()[1]; + return $rings; + } - return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + private const string TIMEOUT_SETTING = 'timeout'; - case Query::TYPE_IS_NULL: - case Query::TYPE_IS_NOT_NULL: + /** The session timeout last set, in milliseconds. */ + private int $appliedTimeout = 0; - 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)"; - } + /** + * 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. + */ + private int $appliedRound = 0; - // 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}"; + /** + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt + */ + protected function execute(mixed $stmt, ?Event $event = null): bool + { + $event ??= $this->getStatementEvent($stmt); + $baseline = $this->getTimeout(); + $timeout = $event === null ? $baseline : $this->getTimeout($event); + $this->applyTimeout($timeout); + + $exception = null; + try { + return parent::execute($stmt, $event); + } catch (Throwable $error) { + $exception = $error; + throw $error; + } finally { + if ($timeout !== $baseline) { + try { + $this->applyTimeout($baseline); + } catch (Throwable $error) { + if ($exception === null) { + throw $error; } } - - $separator = $isNotQuery ? ' AND ' : ' OR '; - return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + } } } - /** - * 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 + private function applyTimeout(int $milliseconds): void { - if (in_array($type, Database::SPATIAL_TYPES)) { - return $this->getSpatialSQLType($type, $required); - } - if ($array === true) { - return 'JSON'; + if ($milliseconds === 0 && $this->appliedTimeout === 0) { + return; } - switch ($type) { - case Database::VAR_ID: - return 'BIGINT UNSIGNED'; - - case Database::VAR_STRING: - // $size = $size * 4; // Convert utf8mb4 size to bytes - if ($size > Database::MAX_MEDIUMTEXT_BYTES) { - return 'LONGTEXT'; - } - - if ($size > Database::MAX_TEXT_BYTES) { - return 'MEDIUMTEXT'; - } - - if ($size > $this->getMaxVarcharLength()) { - return 'TEXT'; - } - - return "VARCHAR({$size})"; - - 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})"; - - case Database::VAR_TEXT: - return 'TEXT'; - - case Database::VAR_MEDIUMTEXT: - return 'MEDIUMTEXT'; - - case Database::VAR_LONGTEXT: - return 'LONGTEXT'; - - case Database::VAR_INTEGER: // We don't support zerofill: https://stackoverflow.com/a/5634147/2299554 - $signed = ($signed) ? '' : ' UNSIGNED'; - - if ($size >= 8) { // INT = 4 bytes, BIGINT = 8 bytes - return 'BIGINT' . $signed; - } - - return 'INT' . $signed; + $round = $this->getSessionRound(); + if ($round !== $this->appliedRound) { + $this->appliedTimeout = 0; + $this->appliedRound = $round; + } - case Database::VAR_BIGINT: - $signed = ($signed) ? '' : ' UNSIGNED'; - return 'BIGINT' . $signed; + if ($milliseconds === $this->appliedTimeout) { + return; + } - case Database::VAR_FLOAT: - $signed = ($signed) ? '' : ' UNSIGNED'; - return 'DOUBLE' . $signed; + $statement = $this->getTimeoutStatement($milliseconds); + $driver = $this->getDriver(); + if ($driver instanceof DatabasePDO) { + $driver->configure(self::TIMEOUT_SETTING, $statement); + } else { + $driver->exec($statement); + } - case Database::VAR_BOOLEAN: - return 'TINYINT(1)'; + $this->appliedTimeout = $milliseconds; + $this->appliedRound = $this->getSessionRound(); + } - case Database::VAR_RELATIONSHIP: - return 'VARCHAR(255)'; + protected function getTimeoutStatement(int $milliseconds): string + { + return 'SET max_statement_time = '.\sprintf('%.6F', $milliseconds / 1000.0); + } - case Database::VAR_DATETIME: - return 'DATETIME(3)'; + private function getSessionRound(): int + { + $driver = $this->getDriver(); - 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); - } + return $driver instanceof PDOProxy ? $driver->getRound() : 0; } /** - * Get PDO Type - * - * @param mixed $value - * @return int - * @throws Exception + * {@inheritDoc} */ - protected function getPDOType(mixed $value): int + protected function getConflictTenantExpression(string $column): string { - 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)), - }; + $quoted = $this->quote($this->filter($column)); + $tenant = Storage::TENANT; + + return "IF({$tenant} = VALUES({$tenant}), VALUES({$quoted}), {$quoted})"; } /** - * Get the SQL function for random ordering - * - * @return string + * {@inheritDoc} */ - protected function getRandomOrder(): string + protected function getConflictIncrementExpression(string $column): string { - return 'RAND()'; + $quoted = $this->quote($this->filter($column)); + + return "{$quoted} + VALUES({$quoted})"; } /** - * Size of POINT spatial type - * - * @return int - */ - protected function getMaxPointSize(): int + * {@inheritDoc} + */ + protected function getConflictTenantIncrementExpression(string $column): string { - // https://dev.mysql.com/doc/refman/8.4/en/gis-data-formats.html#gis-internal-format - return 25; + $quoted = $this->quote($this->filter($column)); + $tenant = Storage::TENANT; + + return "IF({$tenant} = VALUES({$tenant}), {$quoted} + VALUES({$quoted}), {$quoted})"; } - public function getMinDateTime(): \DateTime + protected function createBuilder(): SQLBuilder { - return new \DateTime('1000-01-01 00:00:00'); + return new MariaDBBuilder(); } - public function getMaxDateTime(): \DateTime + #[\Override] + protected function createSchemaBuilder(): MySQLSchema { - return new \DateTime('9999-12-31 23:59:59'); + return new MySQLSchema(); } /** - * Is fulltext Wildcard index supported? - * - * @return bool + * Get the SQL function for random ordering. */ - public function getSupportForFulltextWildcardIndex(): bool + protected function getRandomOrder(): string { - return true; + return 'RAND()'; } /** - * Does the adapter handle Query Array Overlaps? + * Get Schema Attributes * - * @return bool - */ - public function getSupportForJSONOverlaps(): bool - { - return true; - } - - public function getSupportForIntegerBooleans(): bool - { - return true; - } - - /** - * Are timeouts supported? + * @return array * - * @return bool + * @throws DatabaseException */ - public function getSupportForTimeouts(): bool - { - return true; - } - - 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 + public function getSchemaAttributes(string $collection): array { $schema = $this->getDatabase(); - $collection = $this->getNamespace() . '_' . $this->filter($collection); + $collection = $this->getNamespace().'_'.$this->filter($collection); try { - $stmt = $this->getPDO()->prepare(' + $stmt = $this->prepareStatement(' 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 + COLUMN_NAME as '.Storage::SEQUENCE.', + 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 - ORDER BY INDEX_NAME, SEQ_IN_INDEX - '); + ', Event::CollectionRead); $stmt->bindParam(':schema', $schema); $stmt->bindParam(':table', $collection); - $stmt->execute(); - $rows = $stmt->fetchAll(); + $this->execute($stmt); + $results = $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'); - } - - $this->timeout = $milliseconds; - - $seconds = $milliseconds / 1000; - - $this->before($event, 'timeout', function ($sql) use ($seconds) { - return "SET STATEMENT max_statement_time = {$seconds} FOR " . $sql; - }); - } - - /** - * @return string - */ - public function getConnectionId(): string - { - $stmt = $this->getPDO()->query("SELECT CONNECTION_ID();"); - return $stmt->fetchColumn(); - } - - public function getInternalIndexesKeys(): array - { - 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); - } + $docs = []; + foreach ($results as $document) { + /** @var array $document */ + $document[Document::ID] = $document[Storage::SEQUENCE]; + unset($document[Storage::SEQUENCE]); - // 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); - } - - // 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); + $docs[] = new Document($document); } - 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); - } - - // 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); - } + $results = $docs; - // 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); - } - - // Unknown database - if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { - return new NotFoundException('Database not found', $e->getCode(), $e); - } - - // Unknown collection - if ($e->getCode() === '42S02' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1049) { - return new NotFoundException('Collection not found', $e->getCode(), $e); - } - - // 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); - } + return $results; - // Unknown column - if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) { - return new NotFoundException('Attribute not found', $e->getCode(), $e); + } catch (PDOException $e) { + throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e); } - - return $e; } /** - * 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 array + * + * @throws DatabaseException */ - protected function getViolatedKey(string $message): ?string - { - if (\preg_match("/for key '(?:[^'.]*\.)?([^']+)'/", $message, $matches) === 1) { - return $matches[1]; - } - - return null; - } - - protected function quote(string $string): string + protected function getColumnNames(string $collection): array { - return "`{$string}`"; + return \array_map( + static fn (Document $column): string => $column->getId(), + $this->getSchemaAttributes($collection), + ); } /** * Get operator SQL * Override to handle MariaDB/MySQL-specific operators - * - * @param string $column - * @param Operator $operator - * @param array $binds - * @return ?string */ - protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string + protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex): ?string { $quotedColumn = $this->quote($column); $method = $operator->getMethod(); @@ -1982,161 +1208,171 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi 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 @@ -2146,8 +1382,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 @@ -2157,11 +1395,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 @@ -2179,170 +1418,188 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ), 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 + public function getSchemaIndexes(string $collection): array { - return true; - } + $schema = $this->getDatabase(); + $collection = $this->getNamespace() . '_' . $this->filter($collection); - public function getSupportForIndexArray(): bool - { - return true; - } + try { + $stmt = $this->prepareStatement(' + 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 + ', Event::CollectionRead); + $stmt->bindParam(':schema', $schema); + $stmt->bindParam(':table', $collection); + $this->execute($stmt); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + $stmt->closeCursor(); - public function getSupportForSpatialAttributes(): bool - { - return true; - } + $grouped = []; + foreach ($rows as $row) { + if (! \is_array($row)) { + continue; + } + $name = \is_string($row['indexName'] ?? null) ? $row['indexName'] : ''; + if ($name === '') { + continue; + } + if (!isset($grouped[$name])) { + $indexType = \is_string($row['indexType'] ?? null) ? $row['indexType'] : ''; + $nonUnique = \is_numeric($row['nonUnique'] ?? null) ? (int) $row['nonUnique'] : 0; + $grouped[$name] = [ + Document::ID => $name, + 'indexName' => $name, + 'indexType' => $indexType, + 'nonUnique' => $nonUnique, + 'columns' => [], + 'lengths' => [], + ]; + } + $grouped[$name]['columns'][] = \is_string($row['columnName'] ?? null) ? $row['columnName'] : ''; + $subPart = $row['subPart'] ?? null; + $grouped[$name]['lengths'][] = \is_numeric($subPart) ? (int) $subPart : null; + } - public function getSupportForObject(): bool - { - return false; + 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); + } } - public function getSupportForUnsignedBigInt(): bool + 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); + } - /** - * Are object (JSON) indexes supported? - * - * @return bool - */ - public function getSupportForObjectIndexes(): bool - { - return false; - } + // Timeout + if ($e->getCode() === '70100' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1969) { + return new TimeoutException('Query timed out', $e->getCode(), $e); + } - /** - * Get Support for Null Values in Spatial Indexes - * - * @return bool - */ - public function getSupportForSpatialIndexNull(): bool - { - return false; - } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ + // Duplicate table + if ($e->getCode() === '42S01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1050) { + return new DuplicateException('Collection already exists', $e->getCode(), $e); + } - public function getSupportForBoundaryInclusiveContains(): bool - { - return true; - } - /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool - { - return true; - } + // Duplicate column + if ($e->getCode() === '42S21' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1060) { + return new DuplicateException('Attribute already exists', $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; - } + // Duplicate index + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1061) { + return new DuplicateException('Index already exists', $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'; + // Duplicate row + 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"; + // 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); + } - case Database::VAR_LINESTRING: - return "LINESTRING($srid)$nullability"; + // 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); + } - case Database::VAR_POLYGON: - return "POLYGON($srid)$nullability"; + // 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); } - return ''; - } + // Unknown database + 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; - } + // Unknown column + 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 5e126a7177..0d9b39c89b 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,21 @@ 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\RelationSide; +use Utopia\Database\RelationType; +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 +43,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 +63,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,8 +104,6 @@ class Memory extends Adapter */ protected array $filterCache = []; - protected bool $supportForAttributes = true; - public function __construct() { // No external resources to initialise @@ -101,6 +114,33 @@ public function getDriver(): mixed return 'memory'; } + /** + * @return array + */ + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::AtomicTransactions, + Capability::Schemas, + Capability::Fulltext, + Capability::Casting, + Capability::QueryContains, + Capability::BatchOperations, + Capability::BatchCreateAttributes, + Capability::AttributeResizing, + Capability::Objects, + Capability::ObjectIndexes, + Capability::Operators, + Capability::OrderRandom, + Capability::DefinedAttributes, + Capability::NestedTransactions, + Capability::PCRE, + Capability::Regex, + Capability::BoundaryInclusive, + Capability::Caching, + ]); + } + protected function key(string $collection): string { // Schema scoping: prefix the storage key with the current database @@ -137,9 +177,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,11 +191,6 @@ 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; @@ -360,22 +397,22 @@ public function createCollection(string $name, array $attributes = [], array $in } foreach ($attributes as $attribute) { - $attrId = $this->filter($attribute->getId()); + $attrId = $this->filter($attribute->key); $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), + 'type' => $attribute->type->value, + 'size' => $attribute->size, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, ]; } foreach ($indexes as $index) { - $indexId = $this->filter($index->getId()); + $indexId = $this->filter($index->key); $this->data[$key]['indexes'][$indexId] = [ - 'type' => $index->getAttribute('type'), - 'attributes' => $index->getAttribute('attributes', []), - 'lengths' => $index->getAttribute('lengths', []), + 'type' => $index->type->value, + 'attributes' => $index->attributes, + 'lengths' => $index->lengths, 'orders' => $index->getAttribute('orders', []), ]; } @@ -388,7 +425,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]); } }); @@ -450,21 +487,21 @@ 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 + 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, + 'type' => $attribute->type->value, + 'size' => $attribute->size, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, ]; $this->journal(function () use ($key, $id, $previous): void { @@ -481,28 +518,20 @@ public function createAttribute(string $collection, string $id, string $type, in 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 + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): 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); if (! empty($newKey) && $newKey !== $id) { $this->renameAttribute($collection, $id, $newKey); $id = $this->filter($newKey); @@ -510,11 +539,11 @@ public function updateAttribute(string $collection, string $id, string $type, in $previous = $this->data[$key]['attributes'][$id] ?? null; $this->data[$key]['attributes'][$id] = [ - 'type' => $type, - 'size' => $size, - 'signed' => $signed, - 'array' => $array, - 'required' => $required, + 'type' => $attribute->type->value, + 'size' => $attribute->size, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, ]; $this->journal(function () use ($key, $id, $previous): void { @@ -554,29 +583,34 @@ public function deleteAttribute(string $collection, string $id): bool $previousIndexes = []; $previousUniqueHashes = []; - foreach ($this->data[$key]['indexes'] as $indexId => &$index) { - $attributes = $index['attributes'] ?? []; + foreach ($this->data[$key]['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 + if (($index['type'] ?? '') === IndexType::Unique->value && isset($this->uniqueIndexHashes[$key][$indexId])) { $previousUniqueHashes[$indexId] = $this->uniqueIndexHashes[$key][$indexId]; unset($this->uniqueIndexHashes[$key][$indexId]); @@ -585,21 +619,24 @@ public function deleteAttribute(string $collection, string $id): bool $index['attributes'] = $filtered; $index['lengths'] = $lengths; $index['orders'] = $orders; + $this->data[$key]['indexes'][$indexId] = $index; } - unset($index); $this->journal(function () use ($key, $id, $previousAttribute, $previousValues, $previousIndexes, $previousUniqueHashes): void { + if (! isset($this->data[$key])) { + return; + } $this->data[$key]['attributes'][$id] = $previousAttribute; foreach ($previousValues as $storageKey => $value) { if (isset($this->data[$key]['documents'][$storageKey])) { $this->data[$key]['documents'][$storageKey][$id] = $value; } } - foreach ($previousIndexes as $indexId => $value) { - $this->data[$key]['indexes'][$indexId] = $value; + foreach ($previousIndexes as $indexId => $previousIndex) { + $this->data[$key]['indexes'][$indexId] = $previousIndex; } - foreach ($previousUniqueHashes as $indexId => $value) { - $this->uniqueIndexHashes[$key][$indexId] = $value; + foreach ($previousUniqueHashes as $indexId => $hashes) { + $this->uniqueIndexHashes[$key][$indexId] = $hashes; } }); @@ -635,10 +672,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 +688,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(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,39 +730,48 @@ 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: + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $id = $relationship->key; + $twoWayKey = $relationship->twoWayKey; + $twoWay = $relationship->twoWay; + + switch ($relationship->type) { + case RelationType::OneToOne: $this->registerRelationshipField($collection, $id); if ($twoWay) { $this->registerRelationshipField($relatedCollection, $twoWayKey); } break; - case Database::RELATION_ONE_TO_MANY: + case RelationType::OneToMany: $this->registerRelationshipField($relatedCollection, $twoWayKey); break; - case Database::RELATION_MANY_TO_ONE: + case RelationType::ManyToOne: $this->registerRelationshipField($collection, $id); break; - case Database::RELATION_MANY_TO_MANY: + case RelationType::ManyToMany: // 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 + #[\Override] + public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): bool { - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); $newKey = $newKey !== null ? $this->filter($newKey) : null; $newTwoWayKey = $newTwoWayKey !== null ? $this->filter($newTwoWayKey) : null; + $side = $relationship->side; + $twoWay = $relationship->twoWay; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: + switch ($relationship->type) { + case RelationType::OneToOne: if ($newKey !== null && $newKey !== $key) { $this->renameDocumentField($collection, $key, $newKey); } @@ -725,8 +779,8 @@ public function updateRelationship(string $collection, string $relatedCollection $this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey); } break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { $this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey); } @@ -736,8 +790,8 @@ public function updateRelationship(string $collection, string $relatedCollection } } break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_CHILD) { + case RelationType::ManyToOne: + if ($side === RelationSide::Child) { if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { $this->renameDocumentField($relatedCollection, $twoWayKey, $newTwoWayKey); } @@ -747,7 +801,7 @@ public function updateRelationship(string $collection, string $relatedCollection } } break; - case Database::RELATION_MANY_TO_MANY: + case RelationType::ManyToMany: $junction = $this->resolveJunctionCollection($collection, $relatedCollection, $side); if ($junction !== null) { if ($newKey !== null && $newKey !== $key) { @@ -758,21 +812,24 @@ 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(Relationship $relationship): bool { - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $twoWay = $relationship->twoWay; + $side = $relationship->side; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + switch ($relationship->type) { + case RelationType::OneToOne: + if ($side === RelationSide::Parent) { $this->dropDocumentField($collection, $key); if ($twoWay) { $this->dropDocumentField($relatedCollection, $twoWayKey); @@ -784,25 +841,23 @@ public function deleteRelationship(string $collection, string $relatedCollection } } break; - case Database::RELATION_ONE_TO_MANY: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { $this->dropDocumentField($relatedCollection, $twoWayKey); } else { $this->dropDocumentField($collection, $key); } break; - case Database::RELATION_MANY_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + case RelationType::ManyToOne: + if ($side === RelationSide::Parent) { $this->dropDocumentField($collection, $key); } else { $this->dropDocumentField($relatedCollection, $twoWayKey); } break; - case Database::RELATION_MANY_TO_MANY: + case RelationType::ManyToMany: // Junction collection is dropped by the wrapper via cleanupCollection. break; - default: - throw new DatabaseException('Invalid relationship type'); } return true; @@ -822,7 +877,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, @@ -935,7 +990,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, RelationSide $side): ?string { $metadataKey = $this->key(Database::METADATA); if (! isset($this->data[$metadataKey])) { @@ -948,13 +1003,13 @@ 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 === RelationSide::Parent ? '_'.$collectionSequence.'_'.$relatedSequence : '_'.$relatedSequence.'_'.$collectionSequence; } @@ -970,7 +1025,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,15 +1050,21 @@ 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 + 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'); } + $id = $index->key; + $type = $index->type->value; + $attributes = $index->attributes; + $lengths = $index->lengths; + $orders = $index->getAttribute('orders', []); + $hashTable = []; - if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) { + if ($type === IndexType::Unique->value && ! empty($attributes)) { // MariaDB rejects CREATE UNIQUE INDEX with errno 1062 when existing // rows contain duplicates; Database::createIndex catches the resulting // DuplicateException and treats it as an "orphan index" (the metadata @@ -1022,7 +1083,7 @@ 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])) { @@ -1039,13 +1100,13 @@ public function createIndex(string $collection, string $id, string $type, array 'lengths' => $lengths, 'orders' => $orders, ]; - if ($type === Database::INDEX_UNIQUE && ! empty($attributes)) { + 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]); } }); @@ -1102,7 +1163,7 @@ public function getDocument(Document $collection, string $id, array $queries = [ $row = $this->projectRow($row, $selections); } - return new Document($row); + return Document::fromStorage($row); } /** @@ -1113,12 +1174,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; + } } } } @@ -1156,6 +1216,14 @@ private function projectRow(array $row, array $selections): array } public function createDocument(Document $collection, Document $document): Document + { + return $this->insertDocument($collection, $document) ?? $document; + } + + /** + * @return Document|null The stored document, or null when skipDuplicates() skipped it + */ + private function insertDocument(Document $collection, Document $document): ?Document { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { @@ -1168,9 +1236,10 @@ public function createDocument(Document $collection, Document $document): Docume // 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'); } @@ -1180,27 +1249,29 @@ public function createDocument(Document $collection, Document $document): Docume $this->checkUniqueSignatures($key, $signatures, $docKey); } catch (DuplicateException $e) { if ($this->skipDuplicates) { - return $document; + 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; @@ -1212,16 +1283,33 @@ public function createDocument(Document $collection, Document $document): Docume $this->writePermissions($key, $document); - $document['$sequence'] = (string) $sequence; + $document[Document::SEQUENCE] = (string) $sequence; return $document; } 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; @@ -1250,7 +1338,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $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'); } @@ -1269,38 +1357,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; } }); @@ -1309,16 +1404,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 @@ -1329,6 +1416,13 @@ public function updateDocument(Document $collection, string $id, Document $docum }); } } + $this->probeUniqueHash( + $key, + $indexId, + $newSignatures[$indexId] ?? null, + $oldSignatures[$indexId] ?? null, + $newKey, + ); } if (! $skipPermissions) { @@ -1380,7 +1474,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ $attrs = $updates->getAttributes(); $hasCreatedAt = ! empty($updates->getCreatedAt()); $hasUpdatedAt = ! empty($updates->getUpdatedAt()); - $hasPermissions = $updates->offsetExists('$permissions'); + $hasPermissions = $updates->offsetExists(Document::PERMISSIONS); if (empty($attrs) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { return 0; } @@ -1404,10 +1498,10 @@ public function updateDocuments(Document $collection, Document $updates, array $ $resolvedAttrs = $this->applyOperators($attrs, $existingRow); $merged = ! empty($resolvedAttrs) - ? new Document(\array_merge( + ? Document::fromStorage(\array_merge( $this->rowToDocument($existingRow), $resolvedAttrs, - ['$id' => $uid] + [Document::ID => $uid] )) : null; @@ -1444,11 +1538,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; } @@ -1468,13 +1562,13 @@ public function updateDocuments(Document $collection, Document $updates, array $ } 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); @@ -1484,9 +1578,9 @@ 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); } } } @@ -1507,11 +1601,6 @@ 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 { $key = $this->key($collection); @@ -1527,7 +1616,8 @@ 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 : ''); } } @@ -1567,8 +1657,8 @@ 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; } @@ -1591,11 +1681,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 { @@ -1633,14 +1725,14 @@ 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 + 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); @@ -1654,10 +1746,10 @@ 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); } @@ -1671,7 +1763,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 @@ -1689,7 +1781,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); @@ -1699,19 +1791,20 @@ 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 + public function increaseDocumentAttribute(string $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); $docKey = $this->documentKey($id); @@ -1721,9 +1814,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 @@ -1731,27 +1825,47 @@ 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; @@ -1817,223 +1931,26 @@ public function getMinDateTime(): \DateTime public function getIdAttributeType(): string { - return Database::VAR_INTEGER; - } - - public function getSupportForSchemas(): bool - { - return true; - } - - public function getSupportForAttributes(): bool - { - return $this->supportForAttributes; + return ColumnType::Integer->value; } 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(); + $attributes = $collection->getAttribute('attributes', []); + + return (\is_array($attributes) ? \count($attributes) : 0) + $this->getCountOfDefaultAttributes(); } public function getCountOfIndexes(Document $collection): int { - return \count($collection->getAttribute('indexes', [])) + $this->getCountOfDefaultIndexes(); + $indexes = $collection->getAttribute('indexes', []); + + return (\is_array($indexes) ? \count($indexes) : 0) + $this->getCountOfDefaultIndexes(); } public function getCountOfDefaultAttributes(): int @@ -2061,36 +1978,11 @@ 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; @@ -2101,31 +1993,6 @@ 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 * @@ -2136,31 +2003,6 @@ 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 @@ -2169,35 +2011,6 @@ public function getSupportNonUtfCharacters(): bool 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 */ @@ -2211,15 +2024,15 @@ protected function documentToRow(Document $document): array $row[$this->filter($attribute)] = $value; } - $row['_uid'] = $document->getId(); - $row['_createdAt'] = $document->getCreatedAt(); - $row['_updatedAt'] = $document->getUpdatedAt(); - $row['_permissions'] = $document->getPermissions(); + $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['_tenant'] = $document->getTenant() ?? $this->getTenant(); + $row[Storage::TENANT] = $document->getTenant() ?? $this->getTenant(); } return $row; @@ -2251,23 +2064,23 @@ protected function rowToDocument(array $row, ?array $selections = null, ?string $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])) { @@ -2281,7 +2094,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])) { @@ -2304,7 +2117,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; @@ -2320,9 +2133,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); } } } @@ -2341,20 +2154,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]); @@ -2374,17 +2182,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 @@ -2401,7 +2211,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']]); @@ -2409,20 +2219,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; } }); @@ -2430,6 +2235,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 @@ -2440,7 +2265,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; @@ -2480,15 +2305,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)) { @@ -2499,7 +2327,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); } @@ -2516,24 +2344,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); } @@ -2561,7 +2395,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; @@ -2571,20 +2405,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; } @@ -2612,7 +2449,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; @@ -2622,7 +2459,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; @@ -2642,8 +2479,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; } @@ -2651,12 +2497,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; } @@ -2664,28 +2517,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; @@ -2693,27 +2546,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 @@ -2740,16 +2593,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. @@ -2776,7 +2629,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; @@ -2796,18 +2649,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; @@ -2815,23 +2668,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); } /** @@ -2913,7 +2769,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; @@ -2952,7 +2812,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; } @@ -2964,7 +2824,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) { @@ -2978,8 +2838,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; } @@ -2991,7 +2851,7 @@ protected function matchesObject(mixed $value, Query $query): bool return false; - case Query::TYPE_CONTAINS_ALL: + case Method::ContainsAll: if ($haystack === null) { return false; } @@ -3003,7 +2863,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; @@ -3016,32 +2876,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; } /** @@ -3201,15 +3065,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)); } /** @@ -3220,7 +3076,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; @@ -3232,8 +3088,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])) { @@ -3259,30 +3114,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; } @@ -3302,11 +3157,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); @@ -3350,31 +3205,31 @@ 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; + $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; } $resolved[] = [ 'column' => $this->mapAttribute($attribute), - 'asc' => $direction === Database::ORDER_ASC, + 'asc' => $direction === OrderDirection::Asc, 'ref' => $cursor[$attribute] ?? null, ]; } @@ -3425,7 +3280,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); } } } @@ -3467,129 +3322,120 @@ 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 OperatorType::StringConcat: + $appendValue = $values[0] ?? ''; - case Operator::TYPE_STRING_CONCAT: - return ((string) ($current ?? '')).(string) ($values[0] ?? ''); + return $this->stringValue($current).$this->stringValue($appendValue); - case Operator::TYPE_STRING_REPLACE: - $search = (string) ($values[0] ?? ''); - $replace = (string) ($values[1] ?? ''); + 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; @@ -3601,65 +3447,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(); } + } - throw new OperatorException("Invalid operator: {$method}"); + /** + * 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; + } + + 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); } /** @@ -3696,20 +3586,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, }; } @@ -3724,10 +3614,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'); diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 760e9e79c7..4783f6743b 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2,34 +2,59 @@ 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 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\Limit as LimitException; +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\Type as TypeException; use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Hook\Mongo\PermissionFilter as MongoPermissionFilter; +use Utopia\Database\Hook\Mongo\TenantFilter as MongoTenantFilter; +use Utopia\Database\Hook\Read; +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\RelationSide; +use Utopia\Database\RelationType; +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\Query\CursorDirection; +use Utopia\Query\Method; +use Utopia\Query\OrderDirection; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; + +/** + * Database adapter for MongoDB, using the Utopia Mongo client for document-based storage. + */ +class Mongo extends Adapter implements Feature\InternalCasting, Feature\Relationships, Feature\Timeouts, Feature\Upserts, Feature\UTCCasting { /** * @var array @@ -53,30 +78,50 @@ class Mongo extends Adapter '$nor', '$exists', '$elemMatch', - '$exists' + '$all', ]; protected Client $client; + /** + * @var list + */ + protected array $readHooks = []; + /** * Default batch size for cursor operations */ private const 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]; + /** * Transaction/session state for MongoDB transactions - * @var array|null $session + * + * @var array|null */ private ?array $session = null; // Store session array from startSession + protected int $inTransaction = 0; + protected bool $supportForAttributes = true; + private const array PREFIX_SWAPPED_KEYS = ['permissions', 'createdAt', 'updatedAt', 'collection']; + + /** + * @var list|null + */ + private static ?array $internalAttributeArrays = null; + /** * Constructor. * * Set connection and settings * - * @param Client $client * @throws MongoException */ public function __construct(Client $client) @@ -99,126 +144,196 @@ public function getDriver(): mixed return $this->client; } - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void - { - if (!$this->getSupportForTimeouts()) { - return; - } + /** + * Get the list of capabilities supported by the MongoDB adapter. + * + * @return array + */ + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::Objects, + Capability::Fulltext, + Capability::TTLIndexes, + Capability::Regex, + Capability::QueryContains, + Capability::BatchCreateAttributes, + Capability::Caching, + Capability::Hostname, + Capability::PCRE, + Capability::Operators, + Capability::TransactionRetries, + ]); + } + /** + * Set the maximum execution time for queries. + * + * @param int $milliseconds Timeout in milliseconds + * @param Event $event The event scope for the timeout + * @return void + */ + #[\Override] + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { $this->timeout = $milliseconds; } - public function clearTimeout(string $event): void + /** + * Clear the query execution timeout. + * + * @param Event $event The event scope to clear + * @return void + */ + #[\Override] + public function clearTimeout(Event $event = Event::All): void { - parent::clearTimeout($event); - $this->timeout = 0; } /** - * @template T - * @param callable(): T $callback - * @return T - * @throws \Throwable + * Set whether the adapter supports schema-based attribute definitions. + * + * @param bool $support Whether to enable attribute support + * @return bool */ - public function withTransaction(callable $callback): mixed + public function setSupportForAttributes(bool $support): bool { - // If the database is not a replica set, we can't use transactions - if (!$this->client->isReplicaSet()) { - return $callback(); - } + $this->supportForAttributes = $support; + $this->capabilitySet = null; - // MongoDB doesn't support nested transactions/savepoints. - // If already in a transaction, just run the callback directly. - if ($this->inTransaction > 0) { - return $callback(); - } + return $this->supportForAttributes; + } - // upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation. - if ($this->skipDuplicates) { - return $callback(); + public function supports(Capability $feature): bool + { + if ($feature === Capability::DefinedAttributes) { + return $this->supportForAttributes; } - $sleep = 50_000; // 50 milliseconds - $retries = 2; + return parent::supports($feature); + } - 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; - } + protected function syncWriteHooks(): void + { + } - if ( - $action instanceof DuplicateException || - $action instanceof RestrictedException || - $action instanceof AuthorizationException || - $action instanceof RelationshipException || - $action instanceof ConflictException || - $action instanceof LimitException || - $action instanceof TimeoutException - ) { - throw $action; - } + protected function syncReadHooks(): void + { + $this->readHooks = [new MongoPermissionFilter($this->authorization)]; + } - if ($attempts < $retries) { - \usleep($sleep * ($attempts + 1)); - continue; - } + /** + * @param array $filters + * @return array + */ + protected function applyTenantFilter(array $filters, string $collection): array + { + $tenantFilter = new MongoTenantFilter( + $this->sharedTables, + fn (string $collection, array $tenants = []) => $this->getTenantFilters($collection, $tenants), + ); - throw $action; - } + return $tenantFilter->applyFilters($filters, $collection); + } + + /** + * @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); + } + + return $filters; + } + + /** + * Ping Database + * + * @throws Exception + * @throws MongoException + */ + public function ping(): bool + { + /** @var \stdClass|array|int $result */ + $result = $this->getClient()->query([ + 'ping' => 1, + 'skipReadConcern' => true, + ]); + + if ($result instanceof \stdClass && isset($result->ok)) { + return (bool) $result->ok; } - throw new TransactionException('Failed to execute transaction'); + return false; + } + + /** + * Reconnect to the MongoDB server. + * + * @return void + */ + public function reconnect(): void + { + $this->client->connect(); + } + + /** + * @throws Exception + */ + protected function getClient(): Client + { + return $this->client; } + /** + * Start a new database transaction or increment the nesting counter. + * + * @return bool + * + * @throws DatabaseException If the transaction cannot be started. + */ 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) { + if (! $this->session) { $this->session = $this->client->startSession(); // Get session array $this->client->startTransaction($this->session); // Start the transaction } } $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. + * + * @return bool + * + * @throws DatabaseException If the transaction cannot be committed. + */ 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; } @@ -228,7 +343,7 @@ public function commitTransaction(): bool } $this->inTransaction--; if ($this->inTransaction === 0) { - if (!$this->session) { + if (! $this->session) { return false; } try { @@ -241,10 +356,11 @@ public function commitTransaction(): bool $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) { + } catch (Throwable $e) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } finally { if ($this->session) { @@ -255,24 +371,34 @@ public function commitTransaction(): bool return true; } + return true; - } catch (\Throwable $e) { + } catch (Throwable $e) { // Ensure cleanup on any failure try { - $this->client->endSessions([$this->session]); - } catch (\Throwable $endSessionError) { + if ($this->session !== null) { + $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); + throw new DatabaseException('Failed to commit transaction: '.$e->getMessage(), $e->getCode(), $e); } } + /** + * Roll back the current database transaction or decrement the nesting counter. + * + * @return bool + * + * @throws DatabaseException If the rollback fails. + */ public function rollbackTransaction(): bool { // If the database is not a replica set, we can't use transactions - if (!$this->client->isReplicaSet()) { + if (! $this->client->isReplicaSet()) { return true; } @@ -282,13 +408,13 @@ public function rollbackTransaction(): bool } $this->inTransaction--; if ($this->inTransaction === 0) { - if (!$this->session) { + if (! $this->session) { return false; } try { $this->client->abortTransaction($this->session); - } catch (\Throwable $e) { + } catch (Throwable $e) { $e = $this->processException($e); if ($e instanceof TransactionException) { @@ -305,85 +431,103 @@ public function rollbackTransaction(): bool return true; } + return true; - } catch (\Throwable $e) { + } catch (Throwable $e) { try { - $this->client->endSessions([$this->session]); - } catch (\Throwable) { + if ($this->session !== null) { + $this->client->endSessions([$this->session]); + } + } catch (Throwable) { // Ignore errors when ending session during error cleanup } $this->session = null; $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); } } /** - * Helper to add transaction/session context to command options if in transaction - * Includes defensive check to ensure session is valid + * @template T * - * @param array $options - * @return array + * @param callable(): T $callback + * @return T + * + * @throws Throwable */ - private function getTransactionOptions(array $options = []): array + public function withTransaction(callable $callback): mixed { - if ($this->inTransaction > 0 && $this->session !== null) { - // Pass the session array directly - the client will handle the transaction state internally - $options['session'] = $this->session; + // If the database is not a replica set, we can't use transactions + if (! $this->client->isReplicaSet()) { + return $callback(); } - return $options; - } + // MongoDB doesn't support nested transactions/savepoints. + // If already in a transaction, just run the callback directly. + if ($this->inTransaction > 0) { + return $callback(); + } - /** - * Create a safe MongoDB regex pattern by escaping special characters - * - * @param string $value The user input to escape - * @param string $pattern The pattern template (e.g., ".*%s.*" for contains) - * @return Regex - * @throws DatabaseException - */ - private function createSafeRegex(string $value, string $pattern = '%s', string $flags = 'i'): Regex - { - $escaped = preg_quote($value, '/'); - - // 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'); + // upsert + $setOnInsert hits WriteConflict (E112) under txn snapshot isolation. + if ($this->skipDuplicates) { + return $callback(); } - $finalPattern = sprintf($pattern, $escaped); + $sleep = 50_000; + $retries = 2; - return new Regex($finalPattern, $flags); - } + for ($attempts = 0; $attempts <= $retries; $attempts++) { + try { + $this->startTransaction(); + $result = $callback(); + $this->commitTransaction(); - /** - * Ping Database - * - * @return bool - * @throws Exception - * @throws MongoException - */ - public function ping(): bool - { - return $this->getClient()->query([ - 'ping' => 1, - 'skipReadConcern' => true - ])->ok ?? false; - } + return $result; + } catch (Throwable $action) { + try { + $this->rollbackTransaction(); + } catch (Throwable) { + // Preserve the operation failure if cleanup fails. + } finally { + if ($this->session !== null) { + try { + $this->client->endSessions([$this->session]); + } catch (Throwable) { + // Cleanup is best-effort; preserve the operation failure. + } + } + $this->inTransaction = 0; + $this->session = null; + } - public function reconnect(): void - { - $this->client->connect(); + if ( + $action instanceof AuthorizationException + || $action instanceof ConflictException + || $action instanceof DuplicateException + || $action instanceof LimitException + || $action instanceof RelationshipException + || $action instanceof RestrictedException + || $action instanceof TimeoutException + ) { + throw $action; + } + + if ($attempts < $retries) { + \usleep($sleep * ($attempts + 1)); + + continue; + } + + throw $action; + } + } + + throw new TransactionException('Transaction retry loop exited unexpectedly'); } /** * Create Database - * - * @param string $name - * - * @return bool */ public function create(string $name): bool { @@ -394,25 +538,29 @@ 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 + * @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; + if (! \is_null($collection)) { + $collection = $this->getNamespace().'_'.$collection; try { // Use listCollections command with filter for O(1) lookup + /** @var \stdClass $result */ $result = $this->getClient()->query([ 'listCollections' => 1, - 'filter' => ['name' => $collection] + 'filter' => ['name' => $collection], ]); - return !empty($result->cursor->firstBatch); - } catch (\Exception $e) { + /** @var \stdClass $cursor */ + $cursor = $result->cursor; + /** @var array $firstBatch */ + $firstBatch = $cursor->firstBatch; + return ! empty($firstBatch); + } catch (Exception $e) { return false; } } @@ -424,13 +572,19 @@ public function exists(string $database, ?string $collection = null): bool * List Databases * * @return array + * * @throws Exception */ public function list(): array { + /** @var array $list */ $list = []; - foreach ((array)$this->getClient()->listDatabaseNames() as $value) { + /** @var \stdClass $databaseNames */ + $databaseNames = $this->getClient()->listDatabaseNames(); + /** @var array $databaseNamesArray */ + $databaseNamesArray = (array) $databaseNames; + foreach ($databaseNamesArray as $value) { $list[] = $value; } @@ -440,9 +594,7 @@ public function list(): array /** * Delete Database * - * @param string $name * - * @return bool * @throws Exception */ public function delete(string $name): bool @@ -455,20 +607,19 @@ public function delete(string $name): bool /** * Create Collection * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool + * @param array $attributes + * @param array $indexes + * * @throws Exception */ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { - $id = $this->getNamespace() . '_' . $this->filter($name); + $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->inTransaction && ($this->getSharedTables() || $name === Database::METADATA) && $this->exists($this->getNamespace(), $name)) { return true; } @@ -477,30 +628,20 @@ public function createCollection(string $name, array $attributes = [], array $in $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; - } - throw $e; + if (\str_contains($e->getMessage(), 'Collection Exists')) { + return true; } - // 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; - } - throw new DuplicateException('Collection already exists', $e->getCode(), $e); + $e = $this->processException($e); + if ($e instanceof DuplicateException && ($this->getSharedTables() || $name === Database::METADATA)) { + return true; } throw $e; } $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 +649,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,18 +672,18 @@ public function createCollection(string $name, array $attributes = [], array $in try { $options = $this->getTransactionOptions(); $indexesCreated = $this->client->createIndexes($id, $internalIndex, $options); - } catch (\Exception $e) { + } catch (Exception $e) { throw $this->processException($e); } - 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] */ @@ -555,32 +696,31 @@ public function createCollection(string $name, array $attributes = [], array $in $key = []; $unique = false; - $attributes = $index->getAttribute('attributes'); - $orders = $index->getAttribute('orders'); + $attributes = $index->attributes; + $orders = $index->orders; // 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) { - $attribute = $this->filter($this->getInternalKeyForAttribute($attribute)); + $attribute = $this->filter($this->getInternalKeyForAttribute((string) $attribute)); - switch ($index->getAttribute('type')) { - case Database::INDEX_KEY: - $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); + switch ($index->type) { + case IndexType::Key: + $order = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$j] ?? null)) ?? OrderDirection::Asc); break; - case Database::INDEX_FULLTEXT: + case IndexType::Fulltext: // MongoDB fulltext index is just 'text' - // Not using Database::INDEX_KEY for clarity $order = 'text'; break; - case Database::INDEX_UNIQUE: - $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); + case IndexType::Unique: + $order = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$j] ?? null)) ?? OrderDirection::Asc); $unique = true; break; - case Database::INDEX_TTL: - $order = $this->getOrder($this->filter($orders[$j] ?? Database::ORDER_ASC)); + case IndexType::Ttl: + $order = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$j] ?? null)) ?? OrderDirection::Asc); break; default: // index not supported @@ -592,60 +732,49 @@ public function createCollection(string $name, array $attributes = [], array $in $newIndexes[$i] = [ 'key' => $key, - 'name' => $this->filter($index->getId()), - 'unique' => $unique + 'name' => $this->filter($index->key), + 'unique' => $unique, ]; - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { + if ($index->type === IndexType::Fulltext) { $newIndexes[$i]['default_language'] = 'none'; } // Handle TTL indexes - if ($index->getAttribute('type') === Database::INDEX_TTL) { - $ttl = $index->getAttribute('ttl', 0); + if ($index->type === IndexType::Ttl) { + $ttl = $index->ttl; if ($ttl > 0) { $newIndexes[$i]['expireAfterSeconds'] = $ttl; } } - // Add partial filter for indexes to avoid indexing null values - if (in_array($index->getAttribute('type'), [ - Database::INDEX_UNIQUE, - Database::INDEX_KEY - ])) { - $partialFilter = []; + if (in_array($index->type, [IndexType::Unique, IndexType::Key])) { + $fields = []; foreach ($attributes as $attr) { - // Find the matching attribute in collectionAttributes to get its type - $attrType = 'string'; // Default fallback + $attributeType = ColumnType::String; foreach ($collectionAttributes as $collectionAttr) { - if ($collectionAttr->getId() === $attr) { - $attrType = $this->getMongoTypeCode($collectionAttr->getAttribute('type')); + if ($collectionAttr->key === $attr) { + $attributeType = $collectionAttr->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($attr))] = $attributeType; } - if (!empty($partialFilter)) { - $newIndexes[$i]['partialFilterExpression'] = $partialFilter; + if (! empty($fields)) { + $newIndexes[$i]['partialFilterExpression'] = $this->getPartialFilterExpression($index->type, $fields); } } } try { $options = $this->getTransactionOptions(); - $indexesCreated = $this->getClient()->createIndexes($id, $newIndexes, $options); - } catch (\Exception $e) { + $indexesCreated = $this->getClient()->createIndexes($id, \array_values($newIndexes), $options); + } catch (Exception $e) { throw $this->processException($e); } - if (!$indexesCreated) { + if (! $indexesCreated) { return false; } } @@ -657,79 +786,41 @@ public function createCollection(string $name, array $attributes = [], array $in * List Collections * * @return array + * * @throws Exception */ public 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; } return $list; } - /** - * Get Collection Size on disk - * @param string $collection - * @return int - * @throws DatabaseException - */ - public function getSizeOfCollectionOnDisk(string $collection): int - { - return $this->getSizeOfCollection($collection); - } - - /** - * Get Collection Size of raw data - * @param string $collection - * @return int - * @throws DatabaseException - */ - public function getSizeOfCollection(string $collection): int - { - $namespace = $this->getNamespace(); - $collection = $this->filter($collection); - $collection = $namespace . '_' . $collection; - - $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)); + $id = $this->getNamespace().'_'.$this->filter($id); + + return (bool) $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 { @@ -738,16 +829,8 @@ 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 */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + public function createAttribute(string $collection, Attribute $attribute): bool { return true; } @@ -755,9 +838,8 @@ public function createAttribute(string $collection, string $id, string $type, in /** * Create Attributes * - * @param string $collection - * @param array> $attributes - * @return bool + * @param array $attributes + * * @throws DatabaseException */ public function createAttributes(string $collection, array $attributes): bool @@ -765,24 +847,33 @@ public function createAttributes(string $collection, array $attributes): bool return true; } + /** + * Update Attribute. + */ + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool + { + if (! empty($newKey) && $newKey !== $attribute->key) { + return $this->renameAttribute($collection, $attribute->key, $newKey); + } + + return true; + } + /** * Delete Attribute * - * @param string $collection - * @param string $id * - * @return bool * @throws DatabaseException * @throws MongoException */ public function deleteAttribute(string $collection, string $id): bool { - $collection = $this->getNamespace() . '_' . $this->filter($collection); + $collection = $this->getNamespace().'_'.$this->filter($collection); $this->getClient()->update( $collection, [], - ['$unset' => [$id => '']], + ['$unset' => [$this->escapeMongoFieldName($this->getInternalKeyForAttribute($id)) => '']], multi: true ); @@ -792,19 +883,15 @@ public function deleteAttribute(string $collection, string $id): bool /** * Rename Attribute. * - * @param string $collection - * @param string $id - * @param string $name - * @return bool * @throws DatabaseException * @throws MongoException */ 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,181 +906,144 @@ 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 + * Create a relationship between collections. No-op for MongoDB since relationships are virtual. + * + * @param Relationship $relationship The relationship definition * @return bool */ - public function createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool + public function createRelationship(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, + Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null ): bool { - $collectionName = $this->getNamespace() . '_' . $this->filter($collection); - $relatedCollectionName = $this->getNamespace() . '_' . $this->filter($relatedCollection); + $collectionName = $this->getNamespace().'_'.$this->filter($relationship->collection); + $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->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; + $escapedKey = $this->escapeMongoFieldName($relationship->key); + $escapedNewKey = ! \is_null($newKey) ? $this->escapeMongoFieldName($newKey) : null; + $escapedTwoWayKey = $this->escapeMongoFieldName($relationship->twoWayKey); + $escapedNewTwoWayKey = ! \is_null($newTwoWayKey) ? $this->escapeMongoFieldName($newTwoWayKey) : null; $renameKey = [ '$rename' => [ $escapedKey => $escapedNewKey, - ] + ], ]; $renameTwoWayKey = [ '$rename' => [ $escapedTwoWayKey => $escapedNewTwoWayKey, - ] + ], ]; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if (!\is_null($newKey) && $key !== $newKey) { + switch ($relationship->type) { + case RelationType::OneToOne: + if (! \is_null($newKey) && $relationship->key !== $newKey) { $this->getClient()->update($collectionName, updates: $renameKey, multi: true); } - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + if ($relationship->twoWay && ! \is_null($newTwoWayKey) && $relationship->twoWayKey !== $newTwoWayKey) { $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); } break; - case Database::RELATION_ONE_TO_MANY: - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + case RelationType::OneToMany: + if ($relationship->twoWay && ! \is_null($newTwoWayKey) && $relationship->twoWayKey !== $newTwoWayKey) { $this->getClient()->update($relatedCollectionName, updates: $renameTwoWayKey, multi: true); } break; - case Database::RELATION_MANY_TO_ONE: - if (!\is_null($newKey) && $key !== $newKey) { + case RelationType::ManyToOne: + if (! \is_null($newKey) && $relationship->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); + case RelationType::ManyToMany: + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collectionDoc = $this->getDocument($metadataCollection, $relationship->collection); + $relatedCollectionDoc = $this->getDocument($metadataCollection, $relationship->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()); + $junction = $relationship->side === RelationSide::Parent + ? $this->getNamespace().'_'.$this->filter('_'.$collectionDoc->getSequence().'_'.$relatedCollectionDoc->getSequence()) + : $this->getNamespace().'_'.$this->filter('_'.$relatedCollectionDoc->getSequence().'_'.$collectionDoc->getSequence()); - if (!\is_null($newKey) && $key !== $newKey) { + if (! \is_null($newKey) && $relationship->key !== $newKey) { $this->getClient()->update($junction, updates: $renameKey, multi: true); } - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + if ($relationship->twoWay && ! \is_null($newTwoWayKey) && $relationship->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 + Relationship $relationship ): 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) { + $collectionName = $this->getNamespace().'_'.$this->filter($relationship->collection); + $relatedCollectionName = $this->getNamespace().'_'.$this->filter($relationship->relatedCollection); + $escapedKey = $this->escapeMongoFieldName($relationship->key); + $escapedTwoWayKey = $this->escapeMongoFieldName($relationship->twoWayKey); + + switch ($relationship->type) { + case RelationType::OneToOne: + if ($relationship->side === RelationSide::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) { + } elseif ($relationship->side === RelationSide::Child) { $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 RelationType::OneToMany: + if ($relationship->side === RelationSide::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 RelationType::ManyToOne: + if ($relationship->side === RelationSide::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 RelationType::ManyToMany: + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collectionDoc = $this->getDocument($metadataCollection, $relationship->collection); + $relatedCollectionDoc = $this->getDocument($metadataCollection, $relationship->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()); + $junction = $relationship->side === RelationSide::Parent + ? $this->getNamespace().'_'.$this->filter('_'.$collectionDoc->getSequence().'_'.$relatedCollectionDoc->getSequence()) + : $this->getNamespace().'_'.$this->filter('_'.$relatedCollectionDoc->getSequence().'_'.$collectionDoc->getSequence()); $this->getClient()->dropCollection($junction); break; - default: - throw new DatabaseException('Invalid relationship type'); } return true; @@ -1002,34 +1052,36 @@ public function deleteRelationship( /** * Create Index * - * @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 + * @param array $indexAttributeTypes + * @param array $collation + * * @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); + 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; + $attributes = $index->attributes; + $orders = $index->orders; + $ttl = $index->ttl; + /** @var array $indexes */ $indexes = []; $options = []; $indexes['name'] = $id; + /** @var array $indexKey */ + $indexKey = []; + // If sharedTables, always add _tenant as the first key if ($this->shouldAddTenantToIndex($type)) { - $indexes['key']['_tenant'] = $this->getOrder(Database::ORDER_ASC); + $indexKey[Storage::TENANT] = $this->getOrder(OrderDirection::Asc); } foreach ($attributes as $i => $attribute) { + $attribute = (string) $attribute; - if (isset($indexAttributeTypes[$attribute]) && \str_contains($attribute, '.') && $indexAttributeTypes[$attribute] === Database::VAR_OBJECT) { + 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); @@ -1037,33 +1089,35 @@ public function createIndex(string $collection, string $id, string $type, array $attributes[$i] = $this->filter($this->getInternalKeyForAttribute($attribute)); } - $orderType = $this->getOrder($this->filter($orders[$i] ?? Database::ORDER_ASC)); - $indexes['key'][$attributes[$i]] = $orderType; + $orderType = $this->getOrder(OrderDirection::tryFrom(Index::direction($orders[$i] ?? null)) ?? OrderDirection::Asc); + $indexKey[$attributes[$i]] = $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[$i]] = 'text'; break; - case Database::INDEX_UNIQUE: + case IndexType::Unique: $indexes['unique'] = true; break; - case Database::INDEX_TTL: + case IndexType::Ttl: break; default: return false; } } + $indexes['key'] = $indexKey; + /** * Collation * 1. Moved under $indexes. * 2. Updated format. * 3. Avoid adding collation to fulltext index */ - if (!empty($collation) && - $type !== Database::INDEX_FULLTEXT) { + if (! empty($collation) && + $type !== IndexType::Fulltext) { $indexes['collation'] = [ 'locale' => 'en', 'strength' => 1, @@ -1075,25 +1129,22 @@ 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 = []; + if (in_array($type, [IndexType::Unique, IndexType::Key])) { + $fields = []; foreach ($attributes as $i => $attr) { - $attrType = $indexAttributeTypes[$i] ?? Database::VAR_STRING; // Default to string if type not provided - $attrType = $this->getMongoTypeCode($attrType); - $partialFilter[$attr] = ['$exists' => true, '$type' => $attrType]; + $fields[$attr] = Attribute::tryNormalizeType($indexAttributeTypes[$index->attributes[$i]] ?? '') ?? ColumnType::String; } - if (!empty($partialFilter)) { - $indexes['partialFilterExpression'] = $partialFilter; + if (! empty($fields)) { + $indexes['partialFilterExpression'] = $this->getPartialFilterExpression($type, $fields); } } try { @@ -1102,7 +1153,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 @@ -1110,26 +1161,31 @@ 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 $e) { if ($retryCount >= $maxRetries - 1) { throw new DatabaseException( - 'Timeout waiting for index creation: ' . $e->getMessage(), + 'Timeout waiting for index creation: '.$e->getMessage(), $e->getCode(), $e ); @@ -1137,7 +1193,7 @@ public function createIndex(string $collection, string $id, string $type, array } $delay = \min($baseDelay * (2 ** $retryCount), $maxDelay); - \usleep((int)$delay); + \usleep((int) $delay); $retryCount++; } @@ -1145,33 +1201,50 @@ public function createIndex(string $collection, string $id, string $type, array } return $result; - } catch (\Exception $e) { + } catch (Exception $e) { throw $this->processException($e); } } + /** + * Delete Index + * + * + * @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; + } + /** * Rename Index. * - * @param string $collection - * @param string $old - * @param string $new * - * @return bool * @throws Exception */ 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); + $rawIndexes = $collectionDocument->getAttribute('indexes', '[]'); + /** @var array> $indexes */ + $indexes = json_decode((string) (is_string($rawIndexes) ? $rawIndexes : '[]'), true) ?? []; + /** @var array|null $index */ $index = null; foreach ($indexes as $node) { - if (($node['$id'] ?? $node['key'] ?? '') === $old) { + /** @var array $node */ + $nodeId = $node[Document::ID] ?? $node['key'] ?? ''; + $nodeIdStr = \is_string($nodeId) ? $nodeId : (\is_scalar($nodeId) ? (string) $nodeId : ''); + if ($nodeIdStr === $old) { $index = $node; break; } @@ -1179,14 +1252,22 @@ public function renameIndex(string $collection, string $old, string $new): bool // Extract attribute types from the collection document $indexAttributeTypes = []; - if (isset($collectionDocument['attributes'])) { - $attributes = json_decode($collectionDocument['attributes'], true); + $rawAttributes = $collectionDocument->getAttribute('attributes'); + if ($rawAttributes !== null) { + /** @var array> $attributes */ + $attributes = json_decode((string) (is_string($rawAttributes) ? $rawAttributes : '[]'), true) ?? []; if ($attributes && $index) { // Map index attributes to their types - foreach ($index['attributes'] as $attrName) { + /** @var array $indexAttrs */ + $indexAttrs = $index['attributes'] ?? []; + foreach ($indexAttrs as $attrName) { foreach ($attributes as $attr) { - if ($attr['key'] === $attrName) { - $indexAttributeTypes[$attrName] = $attr['type']; + /** @var array $attr */ + $attrKey = $attr['key'] ?? ''; + $attrKeyStr = \is_string($attrKey) ? $attrKey : (\is_scalar($attrKey) ? (string) $attrKey : ''); + if ($attrKeyStr === $attrName) { + $attrType = $attr['type'] ?? ''; + $indexAttributeTypes[$attrName] = \is_string($attrType) ? $attrType : (\is_scalar($attrType) ? (string) $attrType : ''); break; } } @@ -1195,12 +1276,27 @@ public function renameIndex(string $collection, string $old, string $new): bool } try { - if (!$index) { - throw new DatabaseException('Index not found: ' . $old); + if (! $index) { + 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) { + /** @var array $indexAttributes */ + $indexAttributes = $index['attributes'] ?? []; + /** @var array $indexLengths */ + $indexLengths = $index['lengths'] ?? []; + $rawIndexType = $index['type'] ?? 'key'; + $indexTypeStr = \is_string($rawIndexType) ? $rawIndexType : (\is_scalar($rawIndexType) ? (string) $rawIndexType : 'key'); + $rawIndexTtl = $index['ttl'] ?? 0; + $indexTtlInt = \is_int($rawIndexTtl) ? $rawIndexTtl : (\is_numeric($rawIndexTtl) ? (int) $rawIndexTtl : 0); + $createdindex = $this->createIndex($collection, Index::fromArray([ + 'key' => $new, + 'type' => $indexTypeStr, + 'attributes' => $indexAttributes, + 'lengths' => $indexLengths, + 'orders' => $index['orders'] ?? [], + 'ttl' => $indexTtlInt, + ]), $indexAttributeTypes); + } catch (Exception $e) { throw $this->processException($e); } @@ -1211,56 +1307,35 @@ public function renameIndex(string $collection, string $old, string $new): bool 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; - } - /** * Get Document * - * @param Document $collection - * @param string $id - * @param Query[] $queries - * @param bool $forUpdate - * @return Document + * @param Query[] $queries + * * @throws DatabaseException */ 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); } @@ -1269,13 +1344,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); + $result = $this->replaceChars('_', '$', $resultArray ?? []); + $document = Document::fromStorage($result); $document = $this->castingAfter($collection, $document); // Ensure missing relationship attributes are set to null (MongoDB doesn't store null fields) - if (!$hasProjection) { + if (! $hasProjection) { $this->ensureRelationshipDefaults($collection, $document); } @@ -1285,29 +1361,27 @@ public function getDocument(Document $collection, string $id, array $queries = [ /** * Create Document * - * @param Document $collection - * @param Document $document * - * @return Document * @throws Exception */ 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->replaceChars('$', '_', $documentArray); + $record = $this->decorateRow($record, $this->documentMetadata($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); @@ -1321,363 +1395,152 @@ public function createDocument(Document $collection, Document $document): Docume } /** - * Returns the document after casting from - * @param Document $collection - * @param Document $document - * @return Document + * Create Documents in batches + * + * @param array $documents + * @return array + * + * @throws DuplicateException + * @throws DatabaseException */ - public function castingAfter(Document $collection, Document $document): Document + 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->replaceChars('$', '_', $documentArr); + $record = $this->decorateRow($record, $this->documentMetadata($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->skipDuplicates) { + 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->replaceChars('_', '$', $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 + * Update Document + * + * @throws DuplicateException + * @throws DatabaseException */ - public function castingBefore(Document $collection, Document $document): Document + 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->replaceChars('$', '_', $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; } @@ -1687,28 +1550,22 @@ public function updateDocument(Document $collection, string $id, Document $docum * * Updates all documents which match the given query. * - * @param Document $collection - * @param Document $updates - * @param array $documents - * - * @return int + * @param array $documents * * @throws DatabaseException */ public function updateDocuments(Document $collection, Document $updates, array $documents): 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); @@ -1736,72 +1593,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. + * @param array $filters + * @param array> $pipeline + * @param array $options * - * 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 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'])) { @@ -1814,103 +1659,82 @@ private function updateWithPipeline(string $collection, array $filters, array $p } /** - * Execute a batch of upsert operations, supporting aggregation-pipeline updates. - * - * 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 array, update: array}> $operations + * @param array $options * - * @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 Operator::TYPE_POWER: - $base = ['$ifNull' => [$ref, 0]]; - $exponent = $values[0]; - $expr = ['$pow' => [$base, $exponent]]; + case OperatorType::Modulo: + return ['$mod' => [['$ifNull' => [$reference, 0]], $values[0]]]; + + 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]]; @@ -1918,61 +1742,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 OperatorType::StringConcat: + return ['$concat' => [['$ifNull' => [$reference, '']], ['$literal' => $values[0] ?? '']]]; - 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::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']], @@ -1981,54 +1802,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 @@ -2044,15 +1900,14 @@ 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 + * @param array $changes * @return array + * * @throws DatabaseException */ public function upsertDocuments(Document $collection, string $attribute, array $changes): array @@ -2061,8 +1916,10 @@ public function upsertDocuments(Document $collection, string $attribute, array $ return $changes; } + $this->syncWriteHooks(); + try { - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $name = $this->getNamespace().'_'.$this->filter($collection->getId()); $attribute = $this->filter($attribute); $operations = []; @@ -2070,35 +1927,34 @@ public function upsertDocuments(Document $collection, string $attribute, array $ foreach ($changes as $change) { $document = $change->getNew(); $oldDocument = $change->getOld(); + /** @var array $attributes */ $attributes = $document->getAttributes(); - $attributes['_uid'] = $document->getId(); - $attributes['_createdAt'] = $document['$createdAt']; - $attributes['_updatedAt'] = $document['$updatedAt']; - $attributes['_permissions'] = $document->getPermissions(); + $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 (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); + if (! empty($document->getSequence())) { + $attributes[Storage::SEQUENCE] = $document->getSequence(); } + $filters = [Storage::UID => $document->getId()]; + if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + $tenant = $document->getTenant() ?? $this->getTenant(); + $attributes[Storage::TENANT] = $tenant; + $filters[Storage::TENANT] = $this->getTenantFilters($collection->getId(), [$tenant]); } $record = $this->replaceChars('$', '_', $attributes); + $record = $this->decorateRow($record, $this->documentMetadata($document)); - // Build filter for upsert - $filters = ['_uid' => $document->getId()]; - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } - - unset($record['_id']); // Don't update _id + unset($record[Storage::SEQUENCE]); // Don't update _id // Get fields to unset for schemaless mode $unsetFields = $this->getUpsertAttributeRemovals($oldDocument, $document, $record); - if (!empty($attribute)) { + if (! empty($attribute)) { // Get the attribute value before removing it from $set $attributeValue = $record[$attribute] ?? 0; @@ -2112,46 +1968,37 @@ public function upsertDocuments(Document $collection, string $attribute, array $ // 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(), ]; } } @@ -2166,8 +2013,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( @@ -2184,293 +2029,118 @@ public function upsertDocuments(Document $collection, string $attribute, array $ } /** - * Get fields to unset for schemaless upsert operations + * Delete Document * - * @param Document $oldDocument - * @param Document $newDocument - * @param array $record - * @return array + * + * @throws Exception */ - private function getUpsertAttributeRemovals(Document $oldDocument, Document $newDocument, array $record): array + public function deleteDocument(string $collection, string $id): bool { - $unsetFields = []; - - if ($this->getSupportForAttributes() || $oldDocument->isEmpty()) { - return $unsetFields; - } - - $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; - } + $name = $this->getNamespace().'_'.$this->filter($collection); - $transformed = $this->replaceChars('$', '_', [$originalKey => $originalValue]); - $dbKey = array_key_first($transformed); + $filters = [Storage::UID => $id]; + $filters = $this->applyTenantFilter($filters, $collection); - 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 + * Delete Documents + * + * @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 + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int { - $documentIds = []; - $documentTenants = []; - foreach ($documents as $document) { - if (empty($document->getSequence())) { - $documentIds[] = $document->getId(); + $name = $this->getNamespace().'_'.$this->filter($collection); - if ($this->sharedTables) { - $documentTenants[] = $document->getTenant(); - } - } + foreach ($sequences as $index => $sequence) { + $sequences[$index] = $sequence; } - if (empty($documentIds)) { - return $documents; - } + /** @var array $filters */ + $filters = $this->buildFilters([new Query(Method::Equal, Storage::SEQUENCE, $sequences)]); + $filters = $this->applyTenantFilter($filters, $collection); - $sequences = []; - $name = $this->getNamespace() . '_' . $this->filter($collection); + $filters = $this->replaceInternalIdsKeys($filters, '$', '_', $this->operators); - $filters = ['_uid' => ['$in' => $documentIds]]; + $options = $this->getTransactionOptions(); - 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; - - // 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); - } + return $this->client->delete( + collection: $name, + filters: $filters, + limit: 0, + options: $options + ); } catch (MongoException $e) { throw $this->processException($e); } - - foreach ($documents as $document) { - if (isset($sequences[$document->getId()])) { - $document['$sequence'] = $sequences[$document->getId()]; - } - } - - 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); - } - - if ($max !== null || $min !== null) { - $filters[$attribute] = []; - if ($max !== null) { - $filters[$attribute]['$lte'] = $max; - } - if ($min !== null) { - $filters[$attribute]['$gte'] = $min; - } - } - - $options = $this->getTransactionOptions(); - try { - $this->client->update( - $this->getNamespace() . '_' . $this->filter($collection), - $filters, - [ - '$inc' => [$attribute => $value], - '$set' => ['_updatedAt' => $this->toMongoDatetime($updatedAt)], - ], - options: $options - ); - } catch (MongoException $e) { - throw $this->processException($e); - } - - return true; - } - - /** - * Delete Document - * - * @param string $collection - * @param string $id - * - * @return bool - * @throws Exception - */ - public function deleteDocument(string $collection, string $id): bool + public function increaseDocumentAttribute(string $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); + $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, $collection); - 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($collection), + $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; } /** @@ -2478,46 +2148,34 @@ private function permissionStrings(string $type): array * * 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 + 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; } @@ -2526,7 +2184,7 @@ 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); } @@ -2535,31 +2193,34 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $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++) { @@ -2567,17 +2228,17 @@ 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 ($originalAttribute === Document::SEQUENCE) { /** If there is only $sequence attribute in $orderAttributes skip Or And operators **/ if (count($orderAttributes) === 1) { $filters[$attribute] = [ - $operator => $tmp + $operator => $tmp, ]; break; } @@ -2585,24 +2246,26 @@ 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 { @@ -2610,31 +2273,63 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $options['batchSize'] = self::DEFAULT_BATCH_SIZE; $response = $this->client->find($name, $filters, $options); - $results = $response->cursor->firstBatch ?? []; + /** @var \stdClass $responseCursorFind */ + $responseCursorFind = $response->cursor; + /** @var array $results */ + $results = $responseCursorFind->firstBatch ?? []; // Process first batch foreach ($results as $result) { - $record = $this->replaceChars('_', '$', (array)$result); - $found[] = new Document($this->convertStdClassToArray($record)); + /** @var array $resultCast */ + $resultCast = (array) $result; + $record = $this->replaceChars('_', '$', $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->replaceChars('_', '$', $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); @@ -2644,20 +2339,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); } @@ -2666,84 +2361,16 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 return $found; } - - /** - * Converts Appwrite database type to MongoDB BSON type code. - * - * @param string $appwriteType - * @return string - */ - private function getMongoTypeCode(string $appwriteType): string - { - 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 => 'int', - Database::VAR_BIGINT => 'long', - Database::VAR_FLOAT => 'double', - Database::VAR_BOOLEAN => 'bool', - Database::VAR_DATETIME => 'date', - Database::VAR_ID => 'string', - Database::VAR_UUID7 => 'string', - 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 Document $collection - * @param array $queries - * @param int|null $max - * @return int + * @param array $queries + * * @throws Exception */ 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); @@ -2751,19 +2378,21 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $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; } + // Build filters from queries + /** @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 @@ -2772,7 +2401,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) { @@ -2782,29 +2410,29 @@ 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) { + if (! \is_null($max) && $max > 0) { // When limit is specified, use $group and $sum to count limited documents $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', ]; } @@ -2813,55 +2441,48 @@ 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 */ - 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/ @@ -2872,16 +2493,16 @@ public function sum(Document $collection, string $attribute, array $queries = [] // 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], ], ]; @@ -2892,1091 +2513,1268 @@ 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 + * Get sequences for documents that were created * - * @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 + public function getSequences(string $collection, array $documents): array { - if (\str_starts_with($name, '$')) { - $name = '_' . \substr($name, 1); + $documentIds = []; + $documentTenants = []; + foreach ($documents as $document) { + if (empty($document->getSequence())) { + $documentIds[] = $document->getId(); + + if ($this->sharedTables) { + $documentTenants[] = $document->getTenant() ?? $this->getTenant(); + } + } } - if (\str_contains($name, '.')) { - $name = \str_replace('.', '__dot__', $name); + + if (empty($documentIds)) { + return $documents; } - return $name; - } - /** - * 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); - } - } + $sequences = []; + $name = $this->getNamespace().'_'.$this->filter($collection); - if (empty($dotAttributes)) { - return; - } + $filters = [Storage::UID => ['$in' => \array_values(\array_unique($documentIds))]]; - foreach ($queries as $query) { - $attr = $query->getAttribute(); - if (isset($dotAttributes[$attr])) { - $query->setAttribute($dotAttributes[$attr]); - } + if ($this->sharedTables) { + $filters[Storage::TENANT] = $this->getTenantFilters($collection, \array_values(\array_unique($documentTenants))); } - } + try { + // Use cursor paging for large result sets + $options = [ + 'projection' => [Storage::UID => 1, Storage::SEQUENCE => 1, Storage::TENANT => 1], + 'batchSize' => self::DEFAULT_BATCH_SIZE, + ]; - /** - * 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'] ?? ''; + $options = $this->getTransactionOptions($options); + $response = $this->client->find($name, $filters, $options); + /** @var \stdClass $responseCursor */ + $responseCursor = $response->cursor; + /** @var array<\stdClass> $results */ + $results = $responseCursor->firstBatch ?? []; - // 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, - }; + $this->collectSequences($results, $sequences); - if ($storesData) { - $document->setAttribute($key, null); + // Get cursor ID for subsequent batches + /** @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; } } - } - } - - /** - * 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; - - // 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)); - } - - // 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); - } - - if ($newKey !== $k) { - $keysToRename[$k] = $newKey; - } - } - - foreach ($keysToRename as $oldKey => $newKey) { - $array[$newKey] = $array[$oldKey]; - unset($array[$oldKey]); - } - - // 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']); - } - } - 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']; + // Continue fetching with getMore + 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 ?? []; - 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; + if (empty($moreResults)) { + break; } - $operator = $this->getQueryOperator($query->getMethod()); - - $filters[$separator][] = $this->buildFilters($query->getValues(), $operator); - } else { - $filters[$separator][] = $this->buildFilter($query); - } - } + $this->collectSequences($moreResults, $sequences); - return $filters; - } - - /** - * @param Query $query - * @return array - * @throws Exception - */ - protected function buildFilter(Query $query): 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 + // Update cursor ID for next iteration + 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; } - } - } - $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())); - } - - $attribute = $query->getAttribute(); - $operator = $this->getQueryOperator($query->getMethod()); - - $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; - } - - 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.*')]; + $cursorId = null; } - } 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; + } catch (MongoException $e) { + throw $this->processException($e); } - return $filter; - } - - /** - * @param Query $query - * @param array $filter - * @return void - */ - private function handleObjectFilters(Query $query, array &$filter): 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; - } + 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]; } } - $logicalOperator = $isNot ? '$and' : '$or'; - if (count($conditions) && isset($filter[$logicalOperator])) { - $filter[$logicalOperator] = array_merge($filter[$logicalOperator], $conditions); - } else { - $filter[$logicalOperator] = $conditions; - } + return $documents; } /** - * Flatten a nested associative array into Mongo-style dot notation. + * 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 string $key - * @param mixed $value - * @param string $prefix - * @return array + * @param list $sequences + * @param array $options + * @return array + * + * @throws DatabaseException */ - private function flattenWithDotNotation(string $key, mixed $value, string $prefix = ''): array + private function storedSequences(string $name, array $sequences, array $options): array { - /** @var array $result */ - $result = []; + 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); + } - $stack = []; + /** @var \stdClass $cursor */ + $cursor = $response->cursor; + /** @var array<\stdClass> $rows */ + $rows = $cursor->firstBatch ?? []; - $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; - } + $stored = []; + foreach ($rows as $row) { + $stored[$this->stringifyIdentifier($row->{Storage::SEQUENCE} ?? null)] = true; } - return $result; + return $stored; } /** - * Get Query Operator - * - * @param string $operator - * - * @return string - * @throws Exception + * @param array<\stdClass> $rows + * @param array $sequences */ - 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), - }; - } - - protected function getQueryValue(string $method, mixed $value): mixed + private function collectSequences(array $rows, array &$sequences): void { - 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; + 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); } } /** - * Get Mongo Order - * - * @param string $order - * - * @return int - * @throws Exception + * `_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. */ - protected function getOrder(string $order): int + private function sequenceKey(mixed $tenant, string $id): string { - 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), - }; + $tenant = $tenant === null ? '' : $this->stringifyIdentifier($tenant); + + return $tenant."\0".$id; } /** - * Check if tenant should be added to index - * - * @param Document|string $indexOrType Index document or index type string - * @return bool + * Get max STRING limit */ - protected function shouldAddTenantToIndex(Document|string $indexOrType): bool + public function getLimitForString(): int { - if (!$this->sharedTables) { - return false; - } - - $indexType = $indexOrType instanceof Document - ? $indexOrType->getAttribute('type') - : $indexOrType; - - return $indexType !== Database::INDEX_TTL; + return 2147483647; } /** - * @param array $selections - * @param string $prefix - * @return mixed + * Get max INT limit */ - protected function getAttributeProjection(array $selections, string $prefix = ''): mixed + public function getLimitForInt(): int { - $projection = []; - - $internalKeys = \array_map( - fn ($attr) => $attr['$id'], - Database::INTERNAL_ATTRIBUTES - ); - - foreach ($selections as $selection) { - // Skip internal attributes since all are selected by default - if (\in_array($selection, $internalKeys)) { - continue; - } - - $projection[$selection] = 1; - } - - $projection['_uid'] = 1; - $projection['_id'] = 1; - $projection['_createdAt'] = 1; - $projection['_updatedAt'] = 1; - $projection['_permissions'] = 1; - - return $projection; + // Mongo does not handle integers directly, so using MariaDB limit for now + return 4294967295; } /** - * Get max STRING limit + * Get max BIGINT limit * * @return int */ - public function getLimitForString(): int + public function getLimitForBigInt(): int { - return 2147483647; + return Database::MAX_BIG_INT; } /** - * Get max VARCHAR limit - * MongoDB doesn't distinguish between string types, so using same as string limit - * - * @return int + * Get maximum column limit. + * Returns 0 to indicate no limit */ - public function getMaxVarcharLength(): int + public function getLimitForAttributes(): int { - return 2147483647; + return 0; } /** - * Get max INT limit - * - * @return int + * Get maximum index limit. + * https://docs.mongodb.com/manual/reference/limits/#mongodb-limit-Number-of-Indexes-per-Collection */ - public function getLimitForInt(): int + public function getLimitForIndexes(): int { - // Mongo does not handle integers directly, so using MariaDB limit for now - return 4294967295; + return 64; } /** - * Get max BIGINT limit + * Get the maximum combined index key length in bytes. * * @return int */ - public function getLimitForBigInt(): int + public function getMaxIndexLength(): int { - return Database::MAX_BIG_INT; + return 1024; } /** - * Get maximum column limit. - * Returns 0 to indicate no limit + * Get the maximum VARCHAR length. MongoDB has no distinction, so returns the same as string limit. * * @return int */ - public function getLimitForAttributes(): int + public function getMaxVarcharLength(): int { - return 0; + return 2147483647; } /** - * Get maximum index limit. - * https://docs.mongodb.com/manual/reference/limits/#mongodb-limit-Number-of-Indexes-per-Collection + * Get the maximum length for unique document IDs. * * @return int */ - public function getLimitForIndexes(): int + public function getMaxUIDLength(): int { - return 64; + return 255; } - public function getMinDateTime(): \DateTime + /** + * Get the minimum supported datetime value for MongoDB. + * + * @return NativeDateTime + */ + public function getMinDateTime(): NativeDateTime { - return new \DateTime('-9999-01-01 00:00:00'); + return new NativeDateTime('-9999-01-01 00:00:00'); } /** - * Is schemas supported? - * - * @return bool + * Get current attribute count from collection document */ - public function getSupportForSchemas(): bool + public function getCountOfAttributes(Document $collection): int { - return false; + $rawAttrCount = $collection->getAttribute('attributes'); + $attrArray = \is_array($rawAttrCount) ? $rawAttrCount : []; + $attributes = \count($attrArray); + + return $attributes + static::getCountOfDefaultAttributes(); } /** - * Is index supported? - * - * @return bool + * Get current index count from collection document */ - public function getSupportForIndex(): bool + public function getCountOfIndexes(Document $collection): int { - return true; + $rawIdxCount = $collection->getAttribute('indexes'); + $idxArray = \is_array($rawIdxCount) ? $rawIdxCount : []; + $indexes = \count($idxArray); + + return $indexes + static::getCountOfDefaultIndexes(); } - public function getSupportForIndexArray(): bool + /** + * Returns number of attributes used by default. + *p + */ + public function getCountOfDefaultAttributes(): int { - return true; + return \count(Database::internalAttributes()); } /** - * Is internal casting supported? - * - * @return bool + * Returns number of indexes used by default. */ - public function getSupportForInternalCasting(): bool + public function getCountOfDefaultIndexes(): int { - return true; + return \count(Database::INTERNAL_INDEXES); } - public function getSupportForUTCCasting(): bool + /** + * Get maximum width, in bytes, allowed for a SQL row + * Return 0 when no restrictions apply + */ + public function getDocumentSizeLimit(): int { - return true; + return 0; } - public function setUTCDatetime(string $value): mixed + /** + * 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 + */ + public function getAttributeWidth(Document $collection): int { - return new UTCDateTime(new \DateTime($value)); + return 0; } - /** - * Are attributes supported? + * Get reserved keywords that cannot be used as identifiers. MongoDB has none. * - * @return bool + * @return array */ - public function getSupportForAttributes(): bool + public function getKeywords(): array { - return $this->supportForAttributes; + return []; } - public function setSupportForAttributes(bool $support): bool + /** + * Get the keys of internally managed indexes. MongoDB has none exposed. + * + * @return array + */ + public function getInternalIndexesKeys(): array { - $this->supportForAttributes = $support; - return $this->supportForAttributes; + return []; } /** - * Is unique index supported? + * Get the internal ID attribute type used by MongoDB (UUID v7). * - * @return bool + * @return string */ - public function getSupportForUniqueIndex(): bool + public function getIdAttributeType(): string { - return true; + return ColumnType::Uuid7->value; } /** - * Is fulltext index supported? + * Get the query to check for tenant when in shared tables mode * - * @return bool + * @param string $collection The collection being queried + * @param string $alias The alias of the parent collection if in a subquery */ - public function getSupportForFulltextIndex(): bool + public function getTenantQuery(string $collection, string $alias = ''): string { - return true; + return ''; } /** - * Is fulltext Wildcard index supported? + * Check whether the adapter supports storing non-UTF characters. MongoDB does not. * * @return bool */ - public function getSupportForFulltextWildcardIndex(): bool + public function getSupportNonUtfCharacters(): bool { return false; } /** - * Does the adapter handle Query Array Contains? + * Get Collection Size of raw data * - * @return bool + * @throws DatabaseException */ - public function getSupportForQueryContains(): bool + public function getSizeOfCollection(string $collection): int { - return false; + $namespace = $this->getNamespace(); + $collection = $this->filter($collection); + $collection = $namespace.'_'.$collection; + + $command = [ + 'collStats' => $collection, + 'scale' => 1, + ]; + + 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'); + } + } catch (Exception $e) { + throw new DatabaseException('Failed to get collection size: '.$e->getMessage()); + } } /** - * Are timeouts supported? + * Get Collection Size on disk * - * @return bool + * @throws DatabaseException */ - public function getSupportForTimeouts(): bool + public function getSizeOfCollectionOnDisk(string $collection): int { - return true; + return $this->getSizeOfCollection($collection); } - public function getSupportForRelationships(): bool - { - return true; + /** + * @param array $tenants + * @return int|string|null|array> + */ + public function getTenantFilters( + string $collection, + array $tenants = [], + ): int|string|null|array { + if (! $this->sharedTables) { + return null; + } + + /** @var array $values */ + $values = []; + + if (\count($tenants) === 0) { + $tenant = $this->getTenant(); + if ($tenant !== null) { + $values[] = $tenant; + } + } else { + for ($index = 0; $index < \count($tenants); $index++) { + $values[] = $tenants[$index]; + } + } + + 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]]; + } + + if (empty($values)) { + return null; + } + + if (\count($values) === 1) { + return $values[0]; + } + + return ['$in' => $values]; } - public function getSupportForUpdateLock(): bool + /** + * Returns the document after casting to + * + * @throws Exception + */ + public function castingBefore(Document $collection, Document $document): Document { - return false; + if ($document->isEmpty()) { + return $document; + } + + $rawCbAttributes = $collection->getAttribute('attributes', []); + /** @var array> $cbAttributes */ + $cbAttributes = \is_array($rawCbAttributes) ? $rawCbAttributes : []; + + $internalCbAttributeArrays = self::getInternalAttributeArrays(); + + /** @var array> $attributes */ + $attributes = \array_merge($cbAttributes, $internalCbAttributeArrays); + + foreach ($attributes as $attribute) { + /** @var array $attribute */ + $rawCbId = $attribute[Document::ID] ?? null; + $key = \is_string($rawCbId) ? $rawCbId : ''; + $rawCbType = $attribute['type'] ?? null; + $type = $rawCbType instanceof ColumnType + ? $rawCbType + : (\is_string($rawCbType) ? Attribute::tryNormalizeType($rawCbType) : null); + $array = (bool) ($attribute['array'] ?? false); + + $value = $document->getAttribute($key); + if (is_null($value)) { + continue; + } + + if (Operator::isOperator($value)) { + if (Attribute::isIntegerType($type ?? ColumnType::String)) { + /** @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; + } + + 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]); + } + $rawIndexesAttr = $collection->getAttribute('indexes'); + /** @var array $indexes */ + $indexes = \is_array($rawIndexesAttr) ? $rawIndexesAttr : []; + /** @var array $ttlIndexes */ + $ttlIndexes = array_filter($indexes, function ($index) { + if ($index instanceof Document) { + return $index->getAttribute('type') === IndexType::Ttl->value; + } + return false; + }); + + 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) && (in_array($key, $ttlIndexes) || $this->isExtendedISODatetime($value))) { + try { + $newValue = new UTCDateTime(new NativeDateTime($value)); + $document->setAttribute($key, $newValue); + } catch (Throwable $th) { + // skip -> a valid string + } + } + } + } + + return $document; } - public function getSupportForAttributeResizing(): bool - { - return false; + /** + * Returns the document after casting from + */ + public function castingAfter(Document $collection, Document $document): Document + { + if ($document->isEmpty()) { + return $document; + } + + $rawCollectionAttributes = $collection->getAttribute('attributes', []); + /** @var array> $collectionAttributes */ + $collectionAttributes = \is_array($rawCollectionAttributes) ? $rawCollectionAttributes : []; + + $internalAttributeArrays = self::getInternalAttributeArrays(); + + /** @var array> $attributes */ + $attributes = \array_merge($collectionAttributes, $internalAttributeArrays); + + foreach ($attributes as $attribute) { + /** @var array $attribute */ + $rawId = $attribute[Document::ID] ?? null; + $key = \is_string($rawId) ? $rawId : ''; + $rawType = $attribute['type'] ?? null; + $type = $rawType instanceof ColumnType + ? $rawType + : (\is_string($rawType) ? Attribute::tryNormalizeType($rawType) : null); + $array = (bool) ($attribute['array'] ?? false); + $value = $document->getAttribute($key); + if (is_null($value)) { + continue; + } + + if (Operator::isOperator($value)) { + continue; + } + + 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::BigInteger: + case ColumnType::Integer: + $node = \is_int($node) + ? $node + : ($node instanceof Int64 + ? (int) (string) $node + : (\is_numeric($node) ? (int) $node : 0)); + break; + case ColumnType::String: + case ColumnType::Id: + $node = \is_string($node) ? $node : (\is_scalar($node) ? (string) $node : $node); + break; + case ColumnType::Float: + case ColumnType::Double: + $node = \is_float($node) ? $node : (\is_numeric($node) ? (float) $node : 0.0); + break; + case ColumnType::Boolean: + $node = \is_scalar($node) ? (bool) $node : $node; + break; + case ColumnType::Datetime: + $node = $this->convertUTCDateToString($node); + break; + case ColumnType::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; + } + $value[$index] = $node; + } + $document->setAttribute($key, ($array) ? $value : $value[0]); + } + + if (! $this->supports(Capability::DefinedAttributes)) { + 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 + * @return list */ - public function getSupportForBatchOperations(): bool + private static function getInternalAttributeArrays(): array { - return false; + return self::$internalAttributeArrays ??= \array_values(\array_map( + fn (Attribute $attribute): array => [Document::ID => $attribute->key, 'type' => $attribute->type, 'array' => $attribute->array], + Database::internalAttributes() + )); } /** - * Is get connection id supported? + * Convert a datetime string to a MongoDB UTCDateTime object. * - * @return bool + * @param string $value The datetime string + * @return mixed */ - public function getSupportForGetConnectionId(): bool + public function setUTCDatetime(string $value): mixed { - return false; + return new UTCDateTime(new NativeDateTime($value)); } /** - * Is PCRE regex supported? - * - * @return bool + * Escape a field name for MongoDB storage. + * MongoDB field names cannot start with $ or contain dots. */ - public function getSupportForPCRERegex(): bool + protected function escapeMongoFieldName(string $name): string { - return true; + if (\str_starts_with($name, '$')) { + $name = '_'.\substr($name, 1); + } + if (\str_contains($name, '.')) { + $name = \str_replace('.', '__dot__', $name); + } + + return $name; } /** - * Is POSIX 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 getSupportForPOSIXRegex(): bool + protected function escapeQueryAttributes(Document $collection, array $queries): void { - return false; + $dotAttributes = $this->getEscapedAttributes($collection); + + if (empty($dotAttributes)) { + return; + } + + foreach ($queries as $query) { + $attr = $query->getAttribute(); + if (isset($dotAttributes[$attr])) { + $query->setAttribute($dotAttributes[$attr]); + } + } } /** - * 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; - } + $rawAttrs = $collection->getAttribute('attributes', []); + /** @var array> $attributes */ + $attributes = \is_array($rawAttrs) ? $rawAttrs : []; + $dotAttributes = []; + foreach ($attributes as $attribute) { + /** @var array $attribute */ + $rawKey = $attribute[Document::ID] ?? null; + $key = \is_string($rawKey) ? $rawKey : (\is_scalar($rawKey) ? (string) $rawKey : ''); + 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; + $rawEnsureAttrs = $collection->getAttribute('attributes', []); + /** @var array> $attributes */ + $attributes = \is_array($rawEnsureAttrs) ? $rawEnsureAttrs : []; + foreach ($attributes as $attribute) { + /** @var array $attribute */ + $rawEnsureKey = $attribute[Document::ID] ?? null; + $key = \is_string($rawEnsureKey) ? $rawEnsureKey : (\is_scalar($rawEnsureKey) ? (string) $rawEnsureKey : ''); + $rawEnsureType = $attribute['type'] ?? null; + $type = \is_string($rawEnsureType) ? $rawEnsureType : (\is_scalar($rawEnsureType) ? (string) $rawEnsureType : ''); + if ($type === ColumnType::Relationship->value && ! $document->offsetExists($key)) { + $rawOptions = $attribute['options'] ?? []; + /** @var array $options */ + $options = \is_array($rawOptions) ? $rawOptions : []; + $twoWay = (bool) ($options['twoWay'] ?? false); + $rawSide = $options['side'] ?? null; + $side = \is_string($rawSide) ? $rawSide : (\is_scalar($rawSide) ? (string) $rawSide : ''); + $rawRelationType = $options['relationType'] ?? null; + $relationType = \is_string($rawRelationType) ? $rawRelationType : (\is_scalar($rawRelationType) ? (string) $rawRelationType : ''); + + // 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) { + RelationType::OneToOne->value => $side === RelationSide::Parent->value || $twoWay, + RelationType::OneToMany->value => $side === RelationSide::Child->value, + RelationType::ManyToOne->value => $side === RelationSide::Parent->value, + RelationType::ManyToMany->value => false, + default => false, + }; + + if ($storesData) { + $document->setAttribute($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 replaceChars(string $from, string $to, array $array): array { - return false; - } + // 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); + } - 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; - } + // Handle key replacement for filtered attributes + $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; - } + // Handle dot escaping in MongoDB field names + 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; + // Handle special attribute mappings + 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; + } - return $attributes + static::getCountOfDefaultAttributes(); + if (\is_scalar($value)) { + return (string) $value; + } + + if (\is_object($value) && \method_exists($value, '__toString')) { + return (string) $value; + } + + 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::groupForDatabase($queries)['filters']; - return $indexes + static::getCountOfDefaultIndexes(); - } + foreach ($queries as $query) { + /* @var $query 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()); - /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply - * - * @return int - */ - public function getDocumentSizeLimit(): int - { - return 0; + /** @var array $nestedValues */ + $nestedValues = $query->getValues(); + $filters[$separator][] = $this->buildFilters($nestedValues, $operator); + } else { + $filters[$separator][] = $this->buildFilter($query); + } + } + + return $filters; } /** - * 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 + * @return array * - * @param Document $collection - * @return int + * @throws Exception */ - public function getAttributeWidth(Document $collection): 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(), '$')) { + // Escape $ prefix and dots in user-defined $-prefixed attribute names for MongoDB + $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); + + 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()) { + // contains support array values + if (is_array($value)) { + $filter['$or'] = array_map(fn ($val) => [ + $attribute => [ + '$regex' => $this->createSafeRegex( + \is_string($val) ? $val : (\is_scalar($val) ? (string) $val : ''), + '.*%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 + * Get Query Operator * - * @return bool - */ - public function getSupportForSpatialIndexNull(): bool - { - return false; - } - - /** - * Does the adapter support operators? * - * @return bool + * @throws Exception */ - public function getSupportForOperators(): bool + protected function getQueryOperator(Method $operator): string { - return true; + 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 require booleans to be converted to integers (0/1)? - * - * @return bool - */ - public function getSupportForIntegerBooleans(): bool + protected function getQueryValue(Method $method, mixed $value): mixed { - return false; + 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 includes boundary during spatial contains? + * Get Mongo Order * - * @return bool + * + * @throws Exception */ - - public function getSupportForBoundaryInclusiveContains(): 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 support order attribute in spatial indexes? + * Check if tenant should be added to index * - * @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'); + $indexTypeVal = \is_string($rawIndexType) ? $rawIndexType : (\is_scalar($rawIndexType) ? (string) $rawIndexType : ''); + $indexType = IndexType::tryFrom($indexTypeVal) ?? 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_column(self::getInternalAttributeArrays(), Document::ID); - /** - * 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)) { + if (! is_array($list)) { // make sure the input is an array - return array($list); + return [$list]; } $newArray = []; @@ -3989,7 +3787,7 @@ protected function flattenArray(mixed $list): array } /** - * @param array|Document $target + * @param array|Document $target * @return array */ protected function removeNullKeys(array|Document $target): array @@ -4005,16 +3803,10 @@ 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) { @@ -4024,9 +3816,10 @@ protected function processException(\Throwable $e): \Throwable // 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); } @@ -4045,203 +3838,40 @@ protected function processException(\Throwable $e): \Throwable 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 []; - } + // Aborted transaction + if ($e->getCode() === 112) { + return new TransactionException('Transaction aborted', $e->getCode(), $e); + } - /** - * 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 ''; - } + // Invalid operation (MongoDB error code 14) + if ($e->getCode() === 14) { + return new TypeException('Invalid operation', $e->getCode(), $e); + } - public function getSupportForAlterLocks(): bool - { - return false; - } + if ($e->getCode() === 28764) { + return new LimitException('Value out of range', $e->getCode(), $e); + } - public function getSupportNonUtfCharacters(): bool - { - return false; + return $e; } - public function getSupportForTrigramIndex(): bool + protected function getViolatedIndex(string $message): ?string { - return false; - } + if (\preg_match('/index:\s*(\S+)\s+dup key/', $message, $matches) !== 1) { + return null; + } - public function getSupportForTTLIndexes(): bool - { - return true; + return $matches[1]; } - public function getSupportForTransactionRetries(): bool + protected function quote(string $string): string { - return false; + return ''; } - public function getSupportForNestedTransactions(): bool + protected function execute(mixed $stmt): bool { - return false; + return true; } protected function isExtendedISODatetime(string $val): bool @@ -4255,7 +3885,6 @@ 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); // absolute minimum @@ -4265,9 +3894,9 @@ protected function isExtendedISODatetime(string $val): bool // fixed datetime fingerprints if ( - !isset($val[19]) || - $val[4] !== '-' || - $val[7] !== '-' || + ! isset($val[19]) || + $val[4] !== '-' || + $val[7] !== '-' || $val[10] !== 'T' || $val[13] !== ':' || $val[16] !== ':' @@ -4284,7 +3913,7 @@ protected function isExtendedISODatetime(string $val): bool $val[$len - 3] === ':' ); - if (!$hasZ && !$hasOffset) { + if (! $hasZ && ! $hasOffset) { return false; } @@ -4297,12 +3926,12 @@ protected function isExtendedISODatetime(string $val): bool } $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; @@ -4325,7 +3954,7 @@ protected function isExtendedISODatetime(string $val): bool } foreach ($digitPositions as $i) { - if (!ctype_digit($val[$i])) { + if (! ctype_digit($val[$i])) { return false; } } @@ -4342,24 +3971,324 @@ protected function convertUTCDateToString(mixed $node): mixed // Handle Extended JSON format from (array) cast // Format: {"$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; } + + /** + * Helper to add transaction/session context to command options if in transaction + * Includes defensive check to ensure session is valid + * + * @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; + } + + /** + * Create a safe MongoDB regex pattern by escaping special characters + * + * @param string $value The user input to escape + * @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]]; + + 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, 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. + * + * @return string|list + */ + private function getMongoTypeCode(ColumnType $type): string|array + { + return match ($type) { + 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' + }; + } + + /** + * Converts timestamp to Mongo\BSON datetime format. + * + * @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 : array_map($this->convertStdClassToArray(...), $properties); + } + + if (is_array($value)) { + return array_map( + fn ($v) => $this->convertStdClassToArray($v), + $value + ); + } + + return $value; + } + + /** + * 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->replaceChars('$', '_', [$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..2a9374010f 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -2,151 +2,101 @@ namespace Utopia\Database\Adapter; +use Exception; use PDOException; +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\Hook\PermissionFilter; 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\MySQL as MySQLBuilder; +use Utopia\Query\Builder\SQL as SQLBuilder; +use Utopia\Query\Schema\ColumnType; + +/** + * Database adapter for MySQL, extending MariaDB with MySQL-specific behavior and overrides. + */ class MySQL extends MariaDB { /** - * Set max execution time - * @param int $milliseconds - * @param string $event - * @return void - * @throws DatabaseException + * Get the list of capabilities supported by the MySQL adapter. + * + * @return array */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + 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::BoundaryInclusive, + Capability::SpatialIndexOrder, + Capability::OptionalSpatial, + ]; + + return array_values(array_filter( + array_merge(parent::capabilities(), [ + Capability::SpatialAxisOrder, + Capability::MultiDimensionDistance, + Capability::CastIndexArray, + ]), + 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 */ 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 + 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); @@ -173,139 +123,134 @@ protected function processException(PDOException $e): \Exception return parent::processException($e); } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - public function getSupportForBoundaryInclusiveContains(): bool + + protected function createBuilder(): SQLBuilder { - 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): PermissionFilter + { + $hook = parent::newJoinPermissionHook($collection, $roles, $type, $documentColumn, $joins, $joinType); + + return $joins >= self::LARGE_JOIN || self::isOuterJoin($joinType) ? $hook->withoutSemiJoin() : $hook; + } + + private static function isOuterJoin(JoinType $joinType): bool { - return true; + return match ($joinType) { + JoinType::Left, JoinType::Right, JoinType::FullOuter => true, + default => false, + }; } /** - * Spatial type attribute - */ + * Get the MySQL SQL type definition for spatial column types with SRID support. + * + * @param string $type The spatial type (point, linestring, polygon) + * @param bool $required Whether the column is NOT NULL + * @return string + */ public 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::SpatialIndexNull)) { 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::SpatialIndexNull)) { 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::SpatialIndexNull)) { 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 { 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 + 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 ( @@ -316,11 +261,6 @@ protected function getOperatorSQL(string $column, \Utopia\Database\Operator $ope } // 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..d3459538d1 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -2,13 +2,31 @@ namespace Utopia\Database\Adapter; +use DateTime; +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\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\State\Value; use Utopia\Database\Validator\Authorization; use Utopia\Pools\Pool as UtopiaPool; - +use Utopia\Query\CursorDirection; + +/** + * Connection pool adapter that delegates database operations to pooled adapter instances. + * + * 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 { /** @@ -17,29 +35,31 @@ class Pool extends Adapter 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. + * Whether borrowed adapters should require attributes to be defined in metadata. + */ + protected ?bool $supportForAttributes = 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 $capabilities = null; + + /** + * @var \WeakMap, array>|null */ - private array $timeouts = []; + private static ?\WeakMap $features = 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) { @@ -51,840 +71,1172 @@ public function __construct(UtopiaPool $pool) * * Required because __call() can't be used to implement abstract methods. * - * @param string $method - * @param array $args - * @return mixed + * @param array $args + * * @throws DatabaseException */ public function delegate(string $method, array $args): mixed { - if ($this->pinnedAdapter !== null) { - if ($this->skipDuplicates) { - return $this->pinnedAdapter->skipDuplicates( - fn () => $this->pinnedAdapter->{$method}(...$args) - ); - } - return $this->pinnedAdapter->{$method}(...$args); + return $this->borrowAndInvoke($method, $args); + } + + /** + * @param class-string $feature + * @param array $args + */ + protected function delegateFeature(string $feature, string $method, array $args): mixed + { + return $this->borrowAndInvoke($method, $args, $feature); + } + + /** + * @param array $args + * @param class-string|null $feature + */ + protected function borrowAndInvoke(string $method, array $args, ?string $feature = null): mixed + { + $pinned = $this->pin(); + if ($pinned !== null) { + $this->syncBorrowedAdapter($pinned); + + return $pinned->withTenant( + $this->getTenant(), + fn (): mixed => $this->invokeDelegated($pinned, $method, $args, $feature), + ); } - 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); - } + return $this->pool->use(function (Adapter $adapter) use ($method, $args, $feature) { + try { + $this->syncBorrowedAdapter($adapter); - if ($this->skipDuplicates) { - return $adapter->skipDuplicates( - fn () => $adapter->{$method}(...$args) - ); + return $this->invokeDelegated($adapter, $method, $args, $feature); + } finally { + $this->releaseBorrowedAdapter($adapter); } - return $adapter->{$method}(...$args); }); } - public function getDriver(): mixed + /** + * @param array $args + * @param class-string|null $feature + */ + protected function invokeDelegated(Adapter $adapter, string $method, array $args, ?string $feature = null): mixed { - return $this->delegate(__FUNCTION__, \func_get_args()); + if ($feature !== null && ! $adapter instanceof $feature) { + throw new DatabaseException($this->unsupportedFeatureMessage($feature)); + } + + if ($this->skipDuplicates) { + return $adapter->skipDuplicates( + fn () => $adapter->{$method}(...$args) + ); + } + + return $adapter->{$method}(...$args); } - public function before(string $event, string $name = '', ?callable $callback = null): static - { - $this->delegate(__FUNCTION__, \func_get_args()); + /** + * @param class-string $feature + */ + 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\SchemaAttributes::class => 'Adapter does not support schema attributes', + Feature\SchemaIndexes::class => 'Adapter does not support schema indexes', + Feature\ColumnTypes::class => 'Adapter does not support column types', + Feature\Spatial::class => 'Adapter does not support spatial', + Feature\InternalCasting::class => 'Adapter does not support internal casting', + Feature\UTCCasting::class => 'Adapter does not support UTC casting', + Feature\ConnectionId::class => 'Adapter does not support connection id', + Feature\Relationships::class => 'Adapter does not support relationships', + Feature\Timeouts::class => 'Adapter does not support timeouts', + default => 'Adapter does not support '.$feature, + }; + } + + protected function syncBorrowedAdapter(Adapter $adapter): void + { + $adapter->setDatabase($this->getDatabase()); + $adapter->setNamespace($this->getNamespace()); + $adapter->setSharedTables($this->getSharedTables()); + $adapter->setTenant($this->getTenant()); + $adapter->setTenantPerDocument($this->getTenantPerDocument()); + $adapter->setAuthorization($this->authorization); + $adapter->enableAlterLocks($this->alterLocks); + + if ($this->supportForAttributes !== null) { + $adapter->setSupportForAttributes($this->supportForAttributes); + } - return $this; + $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); + } + $adapter->setProfiler($this->profiler); + $adapter->resetTransforms(); + foreach ($this->queryTransforms as $tName => $tTransform) { + $adapter->addTransform($tName, $tTransform); + } + $this->syncWriteHooks($adapter); + } + + /** + * Take back what syncBorrowedAdapter() 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 releaseBorrowedAdapter(Adapter $adapter): void + { + $adapter->setProfiler(null); } - protected function trigger(string $event, mixed $query): mixed + public function getDriver(): mixed { return $this->delegate(__FUNCTION__, \func_get_args()); } /** - * 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. + * Check if a specific capability is supported by the pooled adapter. + * + * Answered from the capabilities the pool's connections reported when first asked, except + * DefinedAttributes: it reflects the schema mode a connection is in, so it is asked every time. + * + * @param Capability $feature The capability to check + * @return bool */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + public function supports(Capability $feature): bool { - if ($milliseconds <= 0) { - $this->clearTimeout($event); + if ($feature === Capability::DefinedAttributes) { + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); - return; + return $result; + } + + return \in_array($feature, $this->capabilities(), true); + } + + /** + * Get all capabilities supported by the pooled adapter, as its connections reported them when first asked. + * + * @return array + */ + public function capabilities(): array + { + $remembered = self::$capabilities[$this->pool] ?? null; + if ($remembered !== null) { + return $remembered; } - $this->timeouts[$event] = $milliseconds; - $this->timeout = $this->timeouts[Database::EVENT_ALL] ?? 0; + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + self::$capabilities ??= new \WeakMap(); + self::$capabilities[$this->pool] = $result; - $this->syncPin(); + return $result; } /** - * 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 class-string $feature */ - public function clearTimeout(string $event): void + public function hasFeature(string $feature): bool { - unset($this->timeouts[$event]); - $this->timeout = $this->timeouts[Database::EVENT_ALL] ?? 0; + $known = self::$features[$this->pool][$feature] ?? null; + if ($known !== null) { + return $known; + } + + /** @var bool $result */ + $result = $this->delegate('hasFeature', [$feature]); - $this->syncPin(); + self::$features ??= new \WeakMap(); + $features = self::$features[$this->pool] ?? []; + $features[$feature] = $result; + self::$features[$this->pool] = $features; + + return $result; } /** - * 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. + * Register a named query transform hook on the pooled adapter. + * + * @param string $name The transform name + * @param Transform $transform The transform instance + * @return static */ - public function clearTimeouts(): void + public function addTransform(string $name, Transform $transform): static { - $this->timeouts = []; - $this->timeout = 0; + $this->queryTransforms[$name] = $transform; - $this->syncPin(); + return $this; } /** - * The connection this caller's open transaction is pinned to, if any. + * Remove a named query transform hook from the pooled adapter. * - * 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. + * @param string $name The transform name to remove + * @return static */ - protected function pin(): ?Adapter + public function removeTransform(string $name): static { - return $this->pinnedAdapter; + unset($this->queryTransforms[$name]); + + return $this; } /** - * 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. + * Set the maximum execution time for queries on the pooled adapter. + * + * @param int $milliseconds Timeout in milliseconds + * @param Event $event The event scope for the timeout + * @return void */ - private function syncPin(): void + public function setTimeout(int $milliseconds, Event $event = Event::All): void { - $pinned = $this->pin(); + // 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); - if ($pinned === null) { return; } - $this->syncTimeouts($pinned); + $this->setTimeoutState($milliseconds, $event); + $this->syncPinnedTimeouts(); + } + + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + $this->syncPinnedTimeouts(); } /** - * 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. + * 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 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. + * 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. */ - protected function syncTimeouts(Adapter $adapter): void + private function syncPinnedTimeouts(): void { - $adapter->clearTimeouts(); - - foreach ($this->timeouts as $event => $milliseconds) { - if ($event === Database::EVENT_ALL) { - continue; - } + $pinned = $this->pin(); - $adapter->setTimeout($milliseconds, $event); + if ($pinned !== null) { + $this->syncTimeouts($pinned); } + } + + /** + * 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->pinned?->get(); + } - if (isset($this->timeouts[Database::EVENT_ALL])) { - $adapter->setTimeout($this->timeouts[Database::EVENT_ALL]); + /** + * @return Value + */ + private function pinned(): Value + { + if ($this->pinned === null) { + /** @var Value $pinned */ + $pinned = new Value(null); + $this->pinned = $pinned; } + + return $this->pinned; } + /** + * Start a database transaction via the pooled adapter. + * + * @return bool + * + * @throws DatabaseException + */ public function startTransaction(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * Commit the current database transaction via the pooled adapter. + * + * @return bool + * + * @throws DatabaseException + */ public function commitTransaction(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * Roll back the current database transaction via the pooled adapter. + * + * @return bool + * + * @throws DatabaseException + */ 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 inTransaction(): bool + { + return $this->pin()?->inTransaction() ?? parent::inTransaction(); } public function getHostname(): string { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var string $result */ + $result = $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. + * 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 + * + * @param callable(): T $callback * @return T - * @throws \Throwable + * + * @throws Throwable */ public function withTransaction(callable $callback): mixed { - // 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); + $pinned = $this->pin(); + if ($pinned !== null) { + return $pinned->withTransaction($callback); } 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); + $this->syncBorrowedAdapter($adapter); + + return $this->pinned()->with($adapter, function () use ($adapter, $callback): mixed { + if ($this->skipDuplicates) { + return $adapter->skipDuplicates( + fn () => $adapter->withTransaction($callback) + ); + } + + return $adapter->withTransaction($callback); + }); } finally { - $this->pinnedAdapter = null; + $this->releaseBorrowedAdapter($adapter); } }); } protected function quote(string $string): string { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var string $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; + } + + protected function syncTimeouts(Adapter $adapter): void + { + if (! ($adapter instanceof Feature\Timeouts)) { + // 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)); + } + + return; + } + + if (empty($this->timeouts)) { + $adapter->clearTimeout(); + + return; + } + + if (count($this->timeouts) === 1 && isset($this->timeouts[Event::All->value])) { + $adapter->setTimeout($this->timeouts[Event::All->value]); + + return; + } + + // 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; + } + + $adapter->setTimeout($milliseconds, Event::from($event)); + } + + if (isset($this->timeouts[Event::All->value])) { + $adapter->setTimeout($this->timeouts[Event::All->value]); + } + } + + private function syncWriteHooks(Adapter $adapter): void + { + $current = $adapter->getWriteHooks(); + if ($current === $this->writeHooks) { + return; + } + + foreach ($current as $childHook) { + $adapter->removeWriteHook($childHook::class); + } + + foreach ($this->writeHooks as $hook) { + $adapter->addWriteHook($hook); + } } + /** + * {@inheritDoc} + */ public function ping(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function reconnect(): void { $this->delegate(__FUNCTION__, \func_get_args()); } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ public function exists(string $database, ?string $collection = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function list(): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function deleteCollection(string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ 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 createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + /** + * {@inheritDoc} + */ + 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; } + /** + * {@inheritDoc} + */ 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; + } + + /** + * {@inheritDoc} + */ + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool + { + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + 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()); + + return $result; } + /** + * {@inheritDoc} + */ public function deleteAttribute(string $collection, string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ 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 createRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay = false, string $id = '', string $twoWayKey = ''): bool + /** + * {@inheritDoc} + */ + public function createRelationship(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 updateRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side, ?string $newKey = null, ?string $newTwoWayKey = null): bool + /** + * {@inheritDoc} + */ + public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): 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 deleteRelationship(string $collection, string $relatedCollection, string $type, bool $twoWay, string $key, string $twoWayKey, string $side): bool + /** + * {@inheritDoc} + */ + public function deleteRelationship(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; } + /** + * {@inheritDoc} + */ 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 createIndex(string $collection, string $id, string $type, array $attributes, array $lengths, array $orders, array $indexAttributeTypes = [], array $collation = [], int $ttl = 1): bool + /** + * {@inheritDoc} + */ + 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; } + /** + * {@inheritDoc} + */ public function deleteIndex(string $collection, string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ public function updateDocuments(Document $collection, Document $updates, array $documents): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * @param array $changes + * @return array + */ public function upsertDocuments(Document $collection, string $attribute, array $changes): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\Upserts::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function deleteDocument(string $collection, string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function deleteDocuments(string $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 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 + /** + * {@inheritDoc} + */ + 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ public function getLimitForString(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getLimitForInt(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getLimitForBigInt(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForUnsignedBigInt(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getLimitForAttributes(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getLimitForIndexes(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getMaxIndexLength(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getMaxVarcharLength(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getMaxUIDLength(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getMinDateTime(): \DateTime - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSchemas(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForAttributes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSchemaAttributes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForIndexArray(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForCastIndexArray(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForUniqueIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForFulltextIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForFulltextWildcardIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForPCRERegex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForPOSIXRegex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForTrigramIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForCasting(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForQueryContains(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForTimeouts(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForRelationships(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForUpdateLock(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForBatchOperations(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForAttributeResizing(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForOperators(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForGetConnectionId(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForUpserts(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForUpsertOnUniqueIndex(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForVectors(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForCacheSkipOnFailure(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForCaching(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForReconnection(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForHostname(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForBatchCreateAttributes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSpatialAttributes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSpatialIndexNull(): bool + /** + * {@inheritDoc} + */ + public function getMinDateTime(): DateTime { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var DateTime $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ public function getCountOfDefaultAttributes(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getCountOfDefaultIndexes(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getDocumentSizeLimit(): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var int $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ 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; } + /** + * {@inheritDoc} + */ public function getKeywords(): array { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - protected function getAttributeProjection(array $selections, string $prefix): mixed - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $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 + /** + * {@inheritDoc} + */ + public function increaseDocumentAttribute(string $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; } + /** + * {@inheritDoc} + */ public function getConnectionId(): string { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var string $result */ + $result = $this->delegateFeature(Feature\ConnectionId::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getInternalIndexesKeys(): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * @return array + */ public function getSchemaAttributes(string $collection): array { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSchemaIndexes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\SchemaAttributes::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * @return array + */ public function getSchemaIndexes(string $collection): array { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getTenantQuery(string $collection, string $alias = ''): string - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\SchemaIndexes::class, __FUNCTION__, \func_get_args()); + return $result; } protected function execute(mixed $stmt): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getIdAttributeType(): string { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var string $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function getSequences(string $collection, array $documents): array { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForBoundaryInclusiveContains(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSpatialIndexOrder(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForSpatialAxisOrder(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForMultipleFulltextIndexes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForIdenticalIndexes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForOrderRandom(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * @return array + */ public function decodePoint(string $wkb): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array $result */ + $result = $this->delegateFeature(Feature\Spatial::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * @return array> + */ public function decodeLinestring(string $wkb): array { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array> $result */ + $result = $this->delegateFeature(Feature\Spatial::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * @return array>> + */ public function decodePolygon(string $wkb): array { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForObject(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForObjectIndexes(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var array>> $result */ + $result = $this->delegateFeature(Feature\Spatial::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function castingBefore(Document $collection, Document $document): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegateFeature(Feature\InternalCasting::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function castingAfter(Document $collection, Document $document): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForInternalCasting(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - - public function getSupportForUTCCasting(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var Document $result */ + $result = $this->delegateFeature(Feature\InternalCasting::class, __FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritDoc} + */ public function setUTCDatetime(string $value): mixed { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $this->delegateFeature(Feature\UTCCasting::class, __FUNCTION__, \func_get_args()); } + /** + * {@inheritDoc} + */ public function setSupportForAttributes(bool $support): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + $this->supportForAttributes = $support; - public function getSupportForIntegerBooleans(): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * Set the authorization instance used for permission checks. + * + * @param Authorization $authorization The authorization instance + * @return self + */ public function setAuthorization(Authorization $authorization): self { $this->authorization = $authorization; + return $this; } - public function getSupportForAlterLocks(): bool + /** + * {@inheritDoc} + */ + public function getSupportNonUtfCharacters(): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - 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 getBuilder(string $collection): \Utopia\Query\Builder { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var \Utopia\Query\Builder $result */ + $result = $this->delegateFeature(Feature\QueryBuilder::class, __FUNCTION__, \func_get_args()); + return $result; } - public function getSupportForNestedTransactions(): bool + public function getSchema(): \Utopia\Query\Schema { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var \Utopia\Query\Schema $result */ + $result = $this->delegateFeature(Feature\QueryBuilder::class, __FUNCTION__, \func_get_args()); + return $result; } + + public function getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string + { + /** @var string $result */ + $result = $this->delegateFeature(Feature\ColumnTypes::class, __FUNCTION__, \func_get_args()); + return $result; + } + } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3004e9780a..a04db40825 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2,23 +2,50 @@ namespace Utopia\Database\Adapter; +use DateTime; use Exception; use PDO; use PDOException; +use PDOStatement; +use Swoole\Database\PDOStatementProxy; +use Throwable; +use Utopia\Database\Attribute; +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\Hook\PermissionFilter; +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; +use Utopia\Database\RelationSide; +use Utopia\Database\RelationType; +use Utopia\Database\Storage; +use Utopia\Query\Builder\Condition; +use Utopia\Query\Builder\PostgreSQL as PostgreSQLBuilder; +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 PostgreSQLSchema; /** * Differences between MariaDB and Postgres @@ -28,89 +55,64 @@ * 3. DATETIME is TIMESTAMP * 4. Full-text search is different - to_tsvector() and to_tsquery() */ -class Postgres extends SQL +class Postgres extends SQL implements Feature\ConnectionId, Feature\Spatial, Feature\Timeouts { public const MAX_IDENTIFIER_NAME = 63; - /** - * @inheritDoc - */ - public function rollbackTransaction(): bool - { - if ($this->inTransaction === 0) { - return false; - } - - try { - if ($this->inTransaction > 1) { - $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); - $this->inTransaction--; - return true; - } - - $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 + /** + * Get the list of capabilities supported by the PostgreSQL adapter. + * + * @return array + */ + 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::SpatialIndexNull, + Capability::MultiDimensionDistance, + Capability::TrigramIndex, + Capability::POSIX, + Capability::ObjectIndexes, + ]); } - - /** - * Returns Max Execution Time - * @param int $milliseconds - * @param string $event - * @return void - * @throws DatabaseException + * Get the PostgreSQL backend process ID as the connection identifier. + * + * @return string */ - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void + public function getConnectionId(): string { - if (!$this->getSupportForTimeouts()) { - return; - } - if ($milliseconds <= 0) { - throw new DatabaseException('Timeout must be greater than 0'); + $result = $this->createBuilder()->fromNone()->selectRaw('pg_backend_pid()')->build(); + $statement = $this->prepareStatement($result->query); + if (! $statement->execute()) { + return ''; } + $column = $statement->fetchColumn(); - $this->timeout = $milliseconds; + return \is_scalar($column) ? (string) $column : ''; } /** * Create Database * - * @param string $name * - * @return bool * @throws DatabaseException */ public function create(string $name): bool @@ -121,207 +123,219 @@ public function create(string $name): bool return true; } - $sql = "CREATE SCHEMA \"{$name}\""; - $sql = $this->trigger(Database::EVENT_DATABASE_CREATE, $sql); + $schema = $this->createSchemaBuilder(); + $sql = $schema->createDatabase($name)->query; - $dbCreation = $this->getPDO() - ->prepare($sql) - ->execute(); + $dbCreation = $this->executeStatement($sql, Event::DatabaseCreate); + + // Enable extensions — wrap in try-catch to handle concurrent creation race conditions + 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 + } + } - // 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(); + 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 + } - $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; } /** - * Delete Database - * - * @param string $name - * @return bool - * @throws Exception - * @throws PDOException + * Override to use lowercase catalog names for Postgres case sensitivity. */ - public function delete(string $name): bool + #[\Override] + public function exists(string $database, ?string $collection = null): bool { - $name = $this->filter($name); + $database = $this->filter($database); + + if ($collection !== null) { + $sql = 'SELECT "table_name" FROM information_schema.tables WHERE "table_schema" = ? AND "table_name" = ?'; + $stmt = $this->prepareStatement($sql, Event::CollectionRead); + $stmt->bindValue(1, $database); + $stmt->bindValue(2, $this->getPhysicalTableName($collection)); + } else { + $sql = 'SELECT "schema_name" FROM information_schema.schemata WHERE "schema_name" = ?'; + $stmt = $this->prepareStatement($sql, Event::DatabaseList); + $stmt->bindValue(1, $database); + } - $sql = "DROP SCHEMA IF EXISTS \"{$name}\" CASCADE"; - $sql = $this->trigger(Database::EVENT_DATABASE_DELETE, $sql); + try { + $this->execute($stmt); + $document = $stmt->fetchAll(); + $stmt->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); + } - return $this->getPDO()->prepare($sql)->execute(); + return ! empty($document); } /** * Create Collection * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool + * @param array $attributes + * @param array $indexes + * * @throws DuplicateException */ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { $namespace = $this->getNamespace(); $id = $this->filter($name); + $tableRaw = $this->getSQLTableRaw($id); + $permsTableRaw = $this->getSQLTableRaw(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->createSchemaBuilder(); + + $table = $schema->table($tableRaw); + $table->id(Storage::SEQUENCE); + $table->string(Storage::UID, 255); + + if ($this->sharedTables) { + $table->integer(Storage::TENANT)->nullable()->default(null); + } - // Ignore relationships with virtual attributes - if ($attribute->getAttribute('type') === Database::VAR_RELATIONSHIP) { - $options = $attribute->getAttribute('options', []); + $table->datetime(Storage::CREATED_AT, 3)->nullable()->default(null); + $table->datetime(Storage::UPDATED_AT, 3)->nullable()->default(null); + + foreach ($attributes as $attribute) { + if ($attribute->type === ColumnType::Relationship) { + $options = $attribute->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) + $relationType === RelationType::ManyToMany->value + || ($relationType === RelationType::OneToOne->value && ! $twoWay && $side === RelationSide::Child->value) + || ($relationType === RelationType::OneToMany->value && $side === RelationSide::Parent->value) + || ($relationType === RelationType::ManyToOne->value && $side === RelationSide::Child->value) ) { continue; } } - $attributeStrings[] = "\"{$attrId}\" {$attrType}, "; + $this->addTableColumn( + $table, + $attribute->key, + $attribute->type, + $attribute->size, + $attribute->signed, + $attribute->array, + $attribute->required + ); } - $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->json(Storage::PERMISSIONS)->nullable()->default(null); + $collectionResult = $table->create(); + + // Build default indexes using schema builder + $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); + + $permsTable = $schema->table($permsTableRaw); + $permsTable->id(Storage::SEQUENCE); + $permsTable->integer(Storage::TENANT)->nullable()->default(null); + $permsTable->string(Storage::PERM_TYPE, 12); + $permsTable->string(Storage::PERM_PERMISSION, 255); + $permsTable->string(Storage::PERM_DOCUMENT, 255); + $permsResult = $permsTable->create(); + + // Build permission indexes using schema builder + $permsIndexStatements = []; 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"); + $permsIndexStatements[] = $schema->createIndex($permsTableRaw, $uniquePermissionIndex, [Storage::TENANT, Storage::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_PERMISSION], unique: true, method: 'btree')->query; + $permsIndexStatements[] = $schema->createIndex($permsTableRaw, $permissionIndex, [Storage::TENANT, Storage::PERM_PERMISSION, Storage::PERM_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); - "; + $permsIndexStatements[] = $schema->createIndex($permsTableRaw, $uniquePermissionIndex, [Storage::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_PERMISSION], unique: true, method: 'btree', collations: [Storage::PERM_DOCUMENT => 'utf8_ci_ai'])->query; + $permsIndexStatements[] = $schema->createIndex($permsTableRaw, $permissionIndex, [Storage::PERM_PERMISSION, Storage::PERM_TYPE], method: 'btree')->query; } - $permissions = $this->trigger(Database::EVENT_COLLECTION_CREATE, $permissions); + $permsSql = $permsResult->query.'; '.implode('; ', $permsIndexStatements); - try { - $this->getPDO()->prepare($collection)->execute(); + $created = false; - $this->getPDO()->prepare($permissions)->execute(); + try { + $this->executeStatement($collectionSql, Event::CollectionCreate); + $created = true; + $this->executeStatement($permsSql, Event::CollectionCreate); foreach ($indexes as $index) { - $indexId = $this->filter($index->getId()); - $indexType = $index->getAttribute('type'); - $indexAttributes = $index->getAttribute('attributes', []); + $indexId = $this->filter($index->key); + $indexType = $index->type; + $indexAttributes = $index->attributes; $indexAttributesWithType = []; foreach ($indexAttributes 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)) { + $indexOrders = $index->orders; + $indexTtl = $index->ttl; + if ($indexType === IndexType::Spatial && count($indexOrders)) { 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, + new Index( + key: $indexId, + type: $indexType, + attributes: $indexAttributes, + orders: $indexOrders, + ttl: $indexTtl, + ), $indexAttributesWithType, - [], - $indexTtl + event: Event::CollectionCreate, ); } - } catch (PDOException $e) { - $e = $this->processException($e); + } catch (Throwable $e) { + if ($e instanceof PDOException) { + $e = $this->processException($e); + } - if (!($e instanceof DuplicateException)) { - $this->execute($this->getPDO() - ->prepare("DROP TABLE IF EXISTS {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')};")); + if ($created && ! ($e instanceof DuplicateException)) { + $this->discardCreatedCollection($id); } throw $e; @@ -331,756 +345,632 @@ public function createCollection(string $name, array $attributes = [], array $in } /** - * 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->createSchemaBuilder(); - $collectionSize->bindParam(':name', $name); - $permissionsSize->bindParam(':permissions', $permissions); + $main = $schema->analyzeTable($this->getSQLTableRaw($name)); + $permissions = $schema->analyzeTable($this->getSQLTableRaw(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 + * Get Collection Size on disk * + * @throws DatabaseException */ - public function getSizeOfCollection(string $collection): int + public function getSizeOfCollectionOnDisk(string $collection): int { $collection = $this->filter($collection); $name = $this->getSQLTable($collection); - $permissions = $this->getSQLTable($collection . '_perms'); + $permissions = $this->getSQLTable(Storage::permissionsTable($collection)); + + $builder = $this->createBuilder(); - $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 + * Get Collection Size of raw data * - * @param string $id - * @return bool + * @throws DatabaseException */ - public function deleteCollection(string $id): bool + public function getSizeOfCollection(string $collection): int { - $id = $this->filter($id); + $collection = $this->filter($collection); + $name = $this->getSQLTable($collection); + $permissions = $this->getSQLTable(Storage::permissionsTable($collection)); - $sql = "DROP TABLE {$this->getSQLTable($id)}, {$this->getSQLTable($id . '_perms')}"; - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); + $builder = $this->createBuilder(); + + $collectionResult = $builder->fromNone()->selectRaw('pg_relation_size(?)', [$name])->build(); + $permissionsResult = $builder->reset()->fromNone()->selectRaw('pg_relation_size(?)', [$permissions])->build(); + + $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 + public function createAttribute(string $collection, Attribute $attribute): bool { // Ensure pgvector extension is installed for vector types - if ($type === Database::VAR_VECTOR) { - if ($size <= 0) { + if ($attribute->type === ColumnType::Vector) { + if ($attribute->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); + if ($attribute->size > Database::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed '.Database::MAX_VECTOR_DIMENSIONS); } } - $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->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($collection)); + $this->addTableColumn($table, $attribute->key, $attribute->type, $attribute->size, $attribute->signed, $attribute->array, $attribute->required); + $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)); + return $this->executeStatement($sql, Event::AttributeCreate); } catch (PDOException $e) { throw $this->processException($e); } } /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * @param bool $array + * @param array $attributes * - * @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); + $this->refuseSharedColumnsOfAnotherType($collection, $attributes); - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - DROP COLUMN \"{$id}\"; - "; + return parent::createAttributes($collection, $attributes); + } + + /** + * @param array $attributes + * + * @throws MismatchException + * @throws DatabaseException + */ + private function refuseSharedColumnsOfAnotherType(string $collection, array $attributes): void + { + if (! $this->sharedTables) { + return; + } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); + $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->getSQLTable($this->filter($collection))); try { - return $this->execute($this->getPDO() - ->prepare($sql)); + $this->execute($statement); + /** @var array $columns */ + $columns = $statement->fetchAll(PDO::FETCH_KEY_PAIR); + $statement->closeCursor(); } catch (PDOException $e) { - if ($e->getCode() === "42703" && $e->errorInfo[1] === 7) { - return true; + throw $this->processException($e); + } + + foreach ($attributes as $attribute) { + $existing = $columns[$this->filter($attribute->key)] ?? null; + if ($existing === null) { + continue; } - throw $e; + $requested = $this->getSQLType($attribute->type, $attribute->size, $attribute->signed, $attribute->array, $attribute->required); + if (self::canonicalColumnType($existing) !== self::canonicalColumnType($requested)) { + throw new MismatchException('Attribute exists in the shared table with another type'); + } } } - /** - * Rename Attribute - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception - * @throws PDOException - */ - public function renameAttribute(string $collection, string $old, string $new): bool + private static function canonicalColumnType(string $type): string { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); - - $sql = " - ALTER TABLE {$this->getSQLTable($collection)} - RENAME COLUMN \"{$old}\" TO \"{$new}\" - "; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); - - return $this->execute($this->getPDO() - ->prepare($sql)); + return \strtr(\strtoupper($type), self::CATALOG_TYPE_SPELLINGS); } /** * 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 + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool { $name = $this->filter($collection); - $id = $this->filter($id); + $id = $this->filter($attribute->key); $newKey = empty($newKey) ? null : $this->filter($newKey); - if ($type === Database::VAR_VECTOR) { - if ($size <= 0) { + if ($attribute->type === ColumnType::Vector) { + if ($attribute->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); + if ($attribute->size > Database::MAX_VECTOR_DIMENSIONS) { + throw new DatabaseException('Vector dimensions cannot exceed '.Database::MAX_VECTOR_DIMENSIONS); } } - $type = $this->getSQLType( - $type, - $size, - $signed, - $array, - $required, - ); + $schema = $this->createSchemaBuilder(); - 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) { + // Rename column first if needed + if (! empty($newKey) && $id !== $newKey) { $newKey = $this->filter($newKey); - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - RENAME COLUMN \"{$id}\" TO \"{$newKey}\" - "; + $renameTable = $schema->table($this->getSQLTableRaw($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 $e) { + throw $this->processException($e); + } - if (!$result) { + if (! $result) { return false; } $id = $newKey; } - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - ALTER COLUMN \"{$id}\" TYPE {$type} - "; + // Modify column type using schema builder's alterColumnType + $sqlType = $this->getSQLType($attribute->type, $attribute->size, $attribute->signed, $attribute->array, $attribute->required); + $tableRaw = $this->getSQLTableRaw($name); + + if ($sqlType == 'TIMESTAMP(3)') { + $result = $schema->alterColumnType($tableRaw, $id, 'TIMESTAMP(3)', $this->quote($id).'::TIMESTAMP(3)'); + } else { + $result = $schema->alterColumnType($tableRaw, $id, $sqlType); + } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $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; + return $ok; + } catch (PDOException $e) { + throw $this->processException($e); + } + } + + public function relaxAttributeRequired(string $collection, string $id): bool + { + $schema = $this->createSchemaBuilder(); + $statement = $schema->alterColumnNullable( + $this->getSQLTableRaw($this->filter($collection)), + $this->filter($id), + true, + ); + + try { + return $this->executeStatement($statement->query, Event::AttributeUpdate); } catch (PDOException $e) { throw $this->processException($e); } } /** - * @param string $collection - * @param string $id - * @param string $type - * @param string $relatedCollection - * @param bool $twoWay - * @param string $twoWayKey - * @return bool - * @throws Exception + * Delete Attribute + * + * + * @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); + public function deleteAttribute(string $collection, string $id): bool + { + $schema = $this->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($collection)); + $table->dropColumn($this->filter($id)); + $result = $table->alter(); - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - $sql = "ALTER TABLE {$table} ADD COLUMN \"{$id}\" {$sqlType} DEFAULT NULL;"; + $sql = $result->query; - 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: + try { + return $this->executeStatement($sql, Event::AttributeDelete); + } catch (PDOException $e) { + if ($e->getCode() === '42703' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { return true; - default: - throw new DatabaseException('Invalid relationship type'); - } - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + } - return $this->execute($this->getPDO() - ->prepare($sql)); + throw $e; + } } /** - * @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 + * Rename Attribute + * + * @throws Exception + * @throws PDOException */ - 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); + public function renameAttribute(string $collection, string $old, string $new): bool + { + if ($this->isRenamed($collection, $old, $new)) { + return true; } - $sql = ''; - - 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); + $schema = $this->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($collection)); + $table->renameColumn($this->filter($old), $this->filter($new)); + $result = $table->alter(); - $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + $sql = $result->query; - 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'); + try { + return $this->executeStatement($sql, Event::AttributeUpdate); + } catch (PDOException $e) { + throw $this->processException($e); } + } - if (empty($sql)) { - return true; - } + /** + * @return array + * + * @throws DatabaseException + */ + 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->getSQLTable($collection)); - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + try { + $this->execute($statement); + /** @var array $columns */ + $columns = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); + } - return $this->execute($this->getPDO() - ->prepare($sql)); + return $columns; } /** - * @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 + * Create Index + * + * @param array $indexAttributeTypes + * @param array $collation */ - public function deleteRelationship( + public function createIndex( string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side + Index $index, + array $indexAttributeTypes = [], + array $collation = [], + Event $event = Event::IndexCreate, ): 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); + $collection = $this->filter($collection); + $id = $this->filter($index->key); + $type = $index->type; + $attributes = $index->attributes; + $orders = $index->orders; + + // Validate 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), + }; - $junction = $side === Database::RELATION_SIDE_PARENT - ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()) - : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence()); + $keyName = $this->getIndexName($collection, $id, $this->currentTenant()); + $tableRaw = $this->getSQLTableRaw($collection); + $schema = $this->createSchemaBuilder(); - $perms = $side === Database::RELATION_SIDE_PARENT - ? $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() . '_perms') - : $this->getSQLTable('_' . $relatedCollection->getSequence() . '_' . $collection->getSequence() . '_perms'); + $operatorClass = match ($type) { + IndexType::HnswEuclidean => 'vector_l2_ops', + IndexType::HnswCosine => 'vector_cosine_ops', + IndexType::HnswDot => 'vector_ip_ops', + IndexType::Trigram => 'gin_trgm_ops', + default => '', + }; - $sql = "DROP TABLE {$junction}; DROP TABLE {$perms}"; - break; - default: - throw new DatabaseException('Invalid relationship type'); - } + $columns = []; + foreach ($attributes as $i => $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::direction($orders[$i] ?? null); - if (empty($sql)) { - return true; + $columns[] = $column + .($operatorClass !== '' ? ' '.$operatorClass : '') + .($order !== '' ? ' '.$order : ''); } - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); - - return $this->execute($this->getPDO() - ->prepare($sql)); - } - - /** - * Create Index - * - * @param string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - - * @return bool - */ - 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}"; - } + if ($this->sharedTables && \in_array($type, [IndexType::Key, IndexType::Unique])) { + \array_unshift($columns, $this->quote(Storage::TENANT)); } - $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), - }; + $unique = $type === IndexType::Unique; - $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})", + $method = match ($type) { + IndexType::Spatial => 'gist', + IndexType::Object => 'gin', + IndexType::Trigram => 'gin', + IndexType::HnswEuclidean, + IndexType::HnswCosine, + IndexType::HnswDot => 'hnsw', + default => '', }; - $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); + $sql = $schema->createIndex( + $tableRaw, + $keyName, + [], + unique: $unique, + method: $method, + rawColumns: $columns, + )->query; try { - return $this->getPDO()->prepare($sql)->execute(); + return $this->executeStatement($sql, $event); } catch (PDOException $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}"); + $keyName = $this->getIndexName($collection, $id, $this->currentTenant()); + $schemaQualifiedName = $this->getDatabase().'.'.$keyName; - $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".\"{$keyName}\""; - $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); + $schema = $this->createSchemaBuilder(); + $sql = $schema->dropIndex($this->getSQLTableRaw($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->execute($this->getPDO() - ->prepare($sql)); + return $this->executeStatement($sql, Event::IndexDelete); } /** * Rename Index * - * @param string $collection - * @param string $old - * @param string $new - * @return bool + * 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. + * * @throws Exception * @throws PDOException */ public function renameIndex(string $collection, string $old, string $new): bool { - $collection = $this->filter($collection); - $namespace = $this->getNamespace(); + $name = $this->filter($collection); $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}"); + $oldIndexName = $this->getIndexName($name, $old, $this->currentTenant()); + $newIndexName = $this->getIndexName($name, $new, $this->currentTenant()); + + $schemaBuilder = $this->createSchemaBuilder(); + $sql = $schemaBuilder->renameIndex($this->getSQLTableRaw($name), $this->getDatabase().'.'.$oldIndexName, $newIndexName)->query; + $sql = \str_replace('ALTER INDEX', 'ALTER INDEX IF EXISTS', $sql); + + $this->executeStatement($sql, Event::IndexRename); + + $names = [$newIndexName]; + if ($this->sharedTables) { + foreach ($this->getCollectionTenants($collection) as $tenant) { + \array_push($names, $this->getIndexName($name, $old, $tenant), $this->getIndexName($name, $new, $tenant)); + } + } - $sql = "ALTER INDEX \"{$schema}\".\"{$oldIndexName}\" RENAME TO \"{$newIndexName}\""; - $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); + return $this->anyIndexExists($names); + } - return $this->execute($this->getPDO() - ->prepare($sql)); + private function getIndexName(string $collection, string $id, int|string|null $tenant): string + { + return $this->getShortKey("{$this->getNamespace()}_{$tenant}_{$collection}_{$id}"); } /** - * Create Document + * @return list * - * @param Document $collection - * @param Document $document - * - * @return Document + * @throws DatabaseException */ - public function createDocument(Document $collection, Document $document): Document + private function getCollectionTenants(string $collection): array { - $collection = $collection->getId(); - $attributes = $document->getAttributes(); - $attributes['_createdAt'] = $document->getCreatedAt(); - $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = \json_encode($document->getPermissions()); + $statement = $this->prepareStatement( + 'SELECT DISTINCT '.$this->quote(Storage::TENANT).' FROM '.$this->getSQLTable(Database::METADATA).' WHERE '.$this->quote(Storage::UID).' = ?', + Event::IndexRename, + ); + $statement->bindValue(1, $collection); - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); + try { + $this->execute($statement); + /** @var list $tenants */ + $tenants = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); } - $name = $this->filter($collection); - $columns = ''; - $columnNames = ''; + return $tenants; + } - // Insert internal id if set - if (!empty($document->getSequence())) { - $bindKey = '_id'; - $columns .= "\"_id\", "; - $columnNames .= ':' . $bindKey . ', '; + /** + * @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); } - $bindIndex = 0; - foreach ($attributes as $attribute => $value) { - $column = $this->filter($attribute); - $bindKey = 'key_' . $bindIndex; - $columns .= "\"{$column}\", "; - $columnNames .= ':' . $bindKey . ', '; - $bindIndex++; + try { + $this->execute($statement); + $found = $statement->fetchAll(PDO::FETCH_COLUMN); + $statement->closeCursor(); + } catch (PDOException $e) { + throw $this->processException($e); } - $sql = " - INSERT INTO {$this->getSQLTable($name)} ({$columns} \"_uid\") - VALUES ({$columnNames} :_uid) - "; + return $found !== []; + } - $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); + /** + * Create Document + */ + 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(':_uid', $document->getId(), PDO::PARAM_STR); + $name = $this->filter($collection); - if (!empty($document->getSequence())) { - $stmt->bindValue(':_id', $document->getSequence(), PDO::PARAM_STR); - } + $builder = $this->createBuilder()->into($this->getSQLTableRaw($name)); - $attributeIndex = 0; - foreach ($attributes as $value) { - if (\is_array($value)) { - $value = \json_encode($value); + $row = [Storage::UID => $document->getId()]; + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - $bindKey = 'key_' . $attributeIndex; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; - } - - $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})"; + foreach ($spatialAttributes as $spatialCol) { + $builder->insertColumnExpression($spatialCol, $this->getSpatialGeomFromText('?')); } - } - - if (!empty($permissions)) { - $permissions = \implode(', ', $permissions); - $sqlTenant = $this->sharedTables ? ', _tenant' : ''; + $spatialMap = \array_fill_keys($spatialAttributes, true); - $queryPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$sqlTenant}) - VALUES {$permissions} - "; + foreach ($attributes as $attr => $value) { + $column = $this->filter($attr); - $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()); + if (isset($spatialMap[$attr])) { + $row[$column] = $this->encodeSpatialWriteValue($value); + $builder->insertColumnExpression($column, $this->getSpatialGeomFromText('?')); + } else { + if (\is_array($value)) { + $value = \json_encode($value); + } + $row[$column] = $value; + } } - } - try { + $row = $this->decorateRow($row, $this->documentMetadata($document)); + $builder->set($row); + $result = $builder->insert(); + $stmt = $this->executeResult($result, Event::DocumentCreate); + $this->execute($stmt); $lastInsertedId = $this->getPDO()->lastInsertId(); - // Sequence can be manually set as well - $document['$sequence'] ??= $lastInsertedId; + $document[Document::SEQUENCE] ??= $lastInsertedId; - if (isset($stmtPermissions)) { - $this->execute($stmtPermissions); - } + $ctx = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $ctx)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1092,150 +982,67 @@ public function createDocument(Document $collection, Document $document): Docume * Update Document * * - * @param Document $collection - * @param string $id - * @param Document $document - * @param bool $skipPermissions - * @return Document * @throws DatabaseException * @throws DuplicateException */ 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(); - - $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) { - $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; - $binds[":_add_{$type}_{$i}"] = $permission; - } - } - - if (!empty($values)) { - $sqlTenant = $this->sharedTables ? ', _tenant' : ''; - - $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$sqlTenant}) - 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 (\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); + $row = []; + if ($document->getId() !== $id) { + $row[Storage::UID] = $document->getId(); } - } - - $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()); + $spatialMap = \array_fill_keys($spatialAttributes, true); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - $keyIndex = 0; - foreach ($attributes as $attribute => $value) { - // Handle operators separately - if (Operator::isOperator($value)) { - continue; - } + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); - // 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])) { + $op = $operators[$attribute]; + if ($op instanceof Operator) { + $opResult = $this->getOperatorBuilderExpression($column, $op); + $builder->setRaw($column, $opResult['expression'], $opResult['bindings']); + } + } elseif (isset($spatialMap[$attribute])) { + $builder->setRaw($column, $this->getSpatialGeomFromText('?'), [$this->encodeSpatialWriteValue($value)]); + } else { + if (\is_array($value)) { + $value = \json_encode($value); + } + $row[$column] = $value; } - } elseif (is_array($value)) { - $value = json_encode($value); } - $bindKey = 'key_' . $keyIndex; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $keyIndex++; - } - - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $builder->set($row); + $filters = [BaseQuery::equal(Storage::SEQUENCE, [$document->getSequence()])]; + $builder->filter($filters); + $result = $builder->update(); + $stmt = $this->executeResult($result, Event::DocumentUpdate); - try { $this->execute($stmt); - if (isset($stmtRemovePermissions)) { - $this->execute($stmtRemovePermissions); - } - if (isset($stmtAddPermissions)) { - $this->execute($stmtAddPermissions); - } + + $ctx = $this->buildWriteContext($name, $id); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $document, $skipPermissions, $ctx)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1244,1372 +1051,938 @@ 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 + * Returns Max Execution Time + * + * @throws DatabaseException */ - 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}"; - } - - if ($this->sharedTables) { - return "{$attribute} = CASE WHEN target._tenant = EXCLUDED._tenant THEN {$new} ELSE target.{$attribute} END"; - } - - return "{$attribute} = {$new}"; - }; - - $operatorBinds = []; - - 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; - } - } else { - if (!in_array($attr, ['_uid', '_id', '_createdAt', '_tenant'])) { - $updateColumns[] = $getUpdateClause($filteredAttr); - } - } - } - } - - $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) - ); - - foreach ($bindValues as $key => $binding) { - $stmt->bindValue($key, $binding, $this->getPDOType($binding)); + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + if ($milliseconds <= 0) { + throw new DatabaseException('Timeout must be greater than 0'); } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $this->setTimeoutState($milliseconds, $event); + } - return $stmt; + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); } /** - * Increase or decrease an attribute value + * Get the minimum supported datetime value for PostgreSQL. * - * @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 + * @return DateTime */ - 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 + public function getMinDateTime(): DateTime { - $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; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); - - $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':_uid', $id); - $stmt->bindValue(':val', $value); - $stmt->bindValue(':updatedAt', $updatedAt); - - if ($max !== null) { - $stmt->bindValue(':max', $max); - } - if ($min !== null) { - $stmt->bindValue(':min', $min); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - $this->execute($stmt) || throw new DatabaseException('Failed to update attribute'); - return true; + return new DateTime('-4713-01-01 00:00:00'); } /** - * 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 + #[\Override] + public 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()); + // X coordinate + $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; - return $deleted; - } + // Y coordinate + $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 string - */ - public function getConnectionId(): string - { - $stmt = $this->getPDO()->query("SELECT pg_backend_pid();"); - return $stmt->fetchColumn(); + return [$x, $y]; } /** - * Handle distance spatial queries + * Decode a WKB or WKT LINESTRING into an array of coordinate pairs. * - * @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 + * @param mixed $wkb The WKB binary or WKT string + * @return array> + * + * @throws DatabaseException If the input is invalid. + */ + #[\Override] + public function decodeLinestring(mixed $wkb): array { - $distanceParams = $query->getValues()[0]; - $binds[":{$placeholder}_0"] = $this->convertArrayToWKT($distanceParams[0]); - $binds[":{$placeholder}_1"] = $distanceParams[1]; + $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); - $meters = isset($distanceParams[2]) && $distanceParams[2] === true; + $points = explode(',', $inside); - 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()); - } + return array_map(function ($point) { + $coords = explode(' ', trim($point)); - if ($meters) { - $attr = "({$alias}.{$attribute}::geography)"; - $geom = "ST_SetSRID(" . $this->getSpatialGeomFromText(":{$placeholder}_0", null) . ", " . Database::DEFAULT_SRID . ")::geography"; - return "ST_Distance({$attr}, {$geom}) {$operator} :{$placeholder}_1"; + return [(float) $coords[0], (float) $coords[1]]; + }, $points); } - // Without meters, use the original SRID (e.g., 4326) - return "ST_Distance({$alias}.{$attribute}, " . $this->getSpatialGeomFromText(":{$placeholder}_0") . ") {$operator} :{$placeholder}_1"; - } + if (ctype_xdigit($wkb)) { + $wkb = hex2bin($wkb); + if ($wkb === false) { + throw new DatabaseException('Failed to convert hex WKB to binary.'); + } + } + if (strlen($wkb) < 9) { + throw new DatabaseException('WKB too short to be a valid geometry'); + } - /** - * 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())}"; + $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'); + } - default: - throw new DatabaseException('Unknown spatial query method: ' . $query->getMethod()); + // Type + SRID flag + $typeField = unpack('V', substr($wkb, 1, 4)); + if ($typeField === false) { + throw new DatabaseException('Failed to unpack the type field from WKB.'); } - } - /** - * 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) . ')'; - } + $typeField = \is_numeric($typeField[1]) ? (int) $typeField[1] : 0; + $geomType = $typeField & 0xFF; + $hasSRID = ($typeField & 0x20000000) !== 0; - 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) . ')'; - } - - default: - throw new DatabaseException('Query method ' . $query->getMethod() . ' not supported for object attributes'); + if ($geomType !== 2) { // 2 = LINESTRING + throw new DatabaseException("Not a LINESTRING geometry type, got {$geomType}"); } - } - /** - * 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); + $offset = 5; + if ($hasSRID) { + $offset += 4; } - $alias = $this->quote(Query::DEFAULT_ALIAS); - $placeholder = ID::unique(); - - $operator = null; - - if ($query->isSpatialAttribute()) { - return $this->handleSpatialQueries($query, $binds, $attribute, $alias, $placeholder); + $numPoints = unpack('V', substr($wkb, $offset, 4)); + if ($numPoints === false) { + throw new DatabaseException("Failed to unpack number of points at offset {$offset}."); } - if ($query->isObjectAttribute() && !$isNestedObjectAttribute) { - return $this->handleObjectQueries($query, $binds, $attribute, $alias, $placeholder); - } + $numPoints = \is_numeric($numPoints[1]) ? (int) $numPoints[1] : 0; + $offset += 4; - 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); - } + $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}."); + } - $method = strtoupper($query->getMethod()); - return empty($conditions) ? '' : ' ' . $method . ' (' . implode(' AND ', $conditions) . ')'; + $x = \is_numeric($x[1]) ? (float) $x[1] : 0.0; - 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)"; + $offset += 8; - 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 = '@>'; - } + $y = unpack('e', substr($wkb, $offset, 8)); + if ($y === false) { + throw new DatabaseException("Failed to unpack Y coordinate at offset {$offset}."); + } - // 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}"; - } - } + $y = \is_numeric($y[1]) ? (float) $y[1] : 0.0; - $separator = $isNotQuery ? ' AND ' : ' OR '; - return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; + $offset += 8; + $points[] = [$x, $y]; } - } - - /** - * 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 - * @throws DatabaseException - */ - protected function getSQLVectorDistance(Query $query, array &$binds, string $alias): ?string - { - $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - - $attribute = $this->filter($query->getAttribute()); - $attribute = $this->quote($attribute); - $alias = $this->quote($alias); - $placeholder = ID::unique(); - - $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, - }; - } - /** - * @param string $distance - * @return string - */ - protected function getSQLReadableDistance(string $distance): string - { - return "{$distance}::text"; + return $points; } /** - * Match the permission against the copy carried on the row rather than joining the - * permissions table. + * Decode a WKB or WKT POLYGON into an array of rings, each containing coordinate pairs. * - * 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 $wkb The WKB hex or WKT string + * @return array>> * - * @param string $collection - * @param array $roles - * @param string $alias - * @param string $type - * @return string - * @throws DatabaseException + * @throws DatabaseException If the input is invalid. */ - 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 - ); + #[\Override] + public function decodePolygon(string $wkb): 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); - if ($permissions === []) { - return 'FALSE'; - } + $rings = explode('),(', $inside); - return '(' . \implode(' OR ', $permissions) . ')'; - } + return array_map(function ($ring) { + $points = explode(',', $ring); - /** - * @param string $value - * @return string - */ - protected function getFulltextValue(string $value): string - { - $exact = str_ends_with($value, '"') && str_starts_with($value, '"'); + return array_map(function ($point) { + $coords = explode(' ', trim($point)); - /** 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); + return [(float) $coords[0], (float) $coords[1]]; + }, $points); + }, $rings); + } - if (empty($value)) { - return ''; + // 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 (!$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})"; + // Number of rings + $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)'; + $x = \is_numeric($x[1]) ? (float) $x[1] : 0.0; - case Database::VAR_DATETIME: - return 'TIMESTAMP(3)'; - - 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; // array of rings, each ring is array of [x,y] } /** - * 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; + + 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 + 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 + 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 $stmt + */ + protected function execute(mixed $stmt, ?Event $event = null): bool { - // https://stackoverflow.com/questions/30455025/size-of-data-type-geographypoint-4326-in-postgis - return 32; - } + $event ??= $this->getStatementEvent($stmt); + $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($stmt); } - } - /** - * 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($stmt); } - return '{' . implode(",", $value) . '}'; - } + $pdo = $this->getPDO(); + $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($stmt); + } 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->getPDO()->exec($milliseconds === 0 + ? 'SET LOCAL statement_timeout = DEFAULT' + : "SET LOCAL statement_timeout = '{$milliseconds}ms'"); + + $this->localTimeout = $milliseconds; } /** - * Are timeouts supported? - * - * @return bool + * {@inheritDoc} */ - public function getSupportForTimeouts(): bool + protected function insertRequiresAlias(): bool { return true; } /** - * Does the adapter handle Query Array Overlaps? - * - * @return bool + * {@inheritDoc} */ - public function getSupportForJSONOverlaps(): bool + 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 + * {@inheritDoc} */ - public function getSupportForSchemaAttributes(): bool + 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 + /** + * {@inheritDoc} + */ + 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? + * Get a builder-compatible operator expression for upsert conflict resolution. * - * @return bool + * 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. + * + * @param string $column The unquoted, filtered column name + * @param Operator $operator The operator to convert + * @return array{expression: string, bindings: list} */ - public function getSupportForVectors(): bool + protected function getOperatorUpsertExpression(string $column, Operator $operator): array { - 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; - } + // Strip the "quotedColumn = " prefix to get just the RHS expression + $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; - } + // Collect the named binding keys and their values in order + /** @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; - // 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::ArrayUnique: + // No bindings + 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::ArrayInsert: + $namedBindings["op_{$idx}"] = $values[0] ?? 0; + $idx++; + $namedBindings["op_{$idx}"] = json_encode($values[1] ?? null); + $idx++; + break; - return $e; - } + case OperatorType::ArrayIntersect: + case OperatorType::ArrayDiff: + $namedBindings["op_{$idx}"] = json_encode($values); + $idx++; + break; - /** - * 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; + 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; } - $columns = \array_map( - fn (string $column) => \trim($column, " \t\"'"), - \explode(',', $matches[1]) - ); + // Replace each named binding occurrence with ? and collect positional bindings + $positionalBindings = []; + $keys = array_keys($namedBindings); + usort($keys, fn ($a, $b) => strlen($b) - strlen($a)); - \sort($columns); + $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); + } + } - return $columns; - } + usort($replacements, fn ($a, $b) => $a['pos'] - $b['pos']); - /** - * @param string $string - * @return string - */ - protected function quote(string $string): string - { - return "\"{$string}\""; - } + $result = $expression; + for ($i = count($replacements) - 1; $i >= 0; $i--) { + $r = $replacements[$i]; + $result = substr_replace($result, '?', $r['pos'], $r['len']); + } - /** - * Is spatial attributes supported? - * - * @return bool - */ - public function getSupportForSpatialAttributes(): bool - { - return true; - } + foreach ($replacements as $r) { + $positionalBindings[] = $namedBindings[$r['key']]; + } - /** - * Are object (JSONB) attributes supported? - * - * @return bool - */ - public function getSupportForObject(): bool - { - return true; + return ['expression' => $result, 'bindings' => $positionalBindings]; } /** - * Are object (JSONB) indexes supported? - * - * @return bool + * Get SQL Type */ - public function getSupportForObjectIndexes(): bool + protected function createBuilder(): SQLBuilder { - return true; + return new PostgreSQLBuilder(); } - /** - * Does the adapter support null values in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexNull(): bool + #[\Override] + protected function createSchemaBuilder(): PostgreSQLSchema { - return true; + return new PostgreSQLSchema(); } - /** - * Does the adapter includes boundary during spatial contains? - * - * @return bool - */ - public function getSupportForBoundaryInclusiveContains(): bool + protected function getSQLType(ColumnType $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string { - return true; - } + if ($array === true) { + return 'JSONB'; + } - /** - * Does the adapter support order attribute in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool - { - return false; + return match ($type) { + ColumnType::Id => 'BIGINT', + ColumnType::String => $size <= 0 || $size > $this->getMaxVarcharLength() ? '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), + }; } /** - * Does the adapter support calculating distance(in meters) between multidimension geometry(line, polygon,etc)? - * - * @return bool + * Get SQL schema */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + protected function getSQLSchema(): string { - return true; - } + if (! $this->supports(Capability::Schemas)) { + return ''; + } - /** - * Does the adapter support spatial axis order specification? - * - * @return bool - */ - public function getSupportForSpatialAxisOrder(): bool - { - return false; + return "\"{$this->getDatabase()}\"."; } /** - * Adapter supports optional spatial attributes with existing rows. + * Get PDO Type * - * @return bool + * + * @throws DatabaseException */ - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool + protected function getPDOType(mixed $value): int { - return false; + 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)), + }; } - protected function getInsertKeyword(): string + protected function getNullOrder(): OrderDirection { - return 'INSERT INTO'; + return OrderDirection::Desc; } - protected function getInsertSuffix(string $table): string + /** + * {@inheritDoc} + */ + protected function getVectorOrderRaw(Query $query, string $alias): ?array { - if (!$this->skipDuplicates) { - return ''; - } + $query->setAttribute($this->getInternalKeyForAttribute($query->getAttribute())); - $conflictTarget = $this->sharedTables ? '("_uid", "_tenant")' : '("_uid")'; + $attribute = $this->filter($query->getAttribute()); + $attribute = $this->quote($attribute); + $quotedAlias = $this->quote($alias); - return "ON CONFLICT {$conflictTarget} DO NOTHING"; - } + $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, + }; - protected function getInsertPermissionsSuffix(): string - { - if (!$this->skipDuplicates) { - return ''; + if ($expression === null) { + return null; } - $conflictTarget = $this->sharedTables - ? '("_type", "_permission", "_document", "_tenant")' - : '("_type", "_permission", "_document")'; - - return "ON CONFLICT {$conflictTarget} DO NOTHING"; + return ['expression' => $expression, 'bindings' => [$vector]]; } - public function decodePoint(string $wkb): array + #[\Override] + protected function getSQLReadableDistance(string $distance): string { - 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]]; - } - - $bin = hex2bin($wkb); - if ($bin === false) { - throw new DatabaseException('Invalid hex WKB string'); - } - - if (strlen($bin) < 13) { // 1 byte endian + 4 bytes type + 8 bytes for X - throw new DatabaseException('WKB too short'); - } - - $isLE = ord($bin[0]) === 1; - - // Type (4 bytes) - $typeBytes = substr($bin, 1, 4); - if (strlen($typeBytes) !== 4) { - throw new DatabaseException('Failed to extract type bytes from WKB'); - } - - $typeArr = unpack($isLE ? 'V' : 'N', $typeBytes); - if ($typeArr === false || !isset($typeArr[1])) { - throw new DatabaseException('Failed to unpack type from WKB'); - } - $type = $typeArr[1]; - - // Offset to coordinates (skip SRID if present) - $offset = 5 + (($type & 0x20000000) ? 4 : 0); + return "{$distance}::text"; + } - if (strlen($bin) < $offset + 16) { // 16 bytes for X,Y - throw new DatabaseException('WKB too short for coordinates'); - } + /** + * 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. + * + * @param array $roles + */ + #[\Override] + protected function newPermissionHook(string $collection, array $roles, string $type = PermissionType::Read->value, string $documentColumn = Storage::UID): PermissionFilter + { + return new class (\array_values($roles), $type, $documentColumn) extends PermissionFilter { + /** + * @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, + quoteChar: '"', + ); + } - $fmt = $isLE ? 'e' : 'E'; // little vs big endian double + #[\Override] + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } - // X coordinate - $xArr = unpack($fmt, substr($bin, $offset, 8)); - if ($xArr === false || !isset($xArr[1])) { - throw new DatabaseException('Failed to unpack X coordinate'); - } - $x = (float)$xArr[1]; + $parts = \explode('.', $this->documentColumn); + $parts[\array_key_last($parts)] = Storage::PERMISSIONS; + $column = \implode('.', \array_map( + static fn (string $part): string => '"'.\str_replace('"', '""', $part).'"', + $parts + )); - // Y coordinate - $yArr = unpack($fmt, substr($bin, $offset + 8, 8)); - if ($yArr === false || !isset($yArr[1])) { - throw new DatabaseException('Failed to unpack Y coordinate'); - } - $y = (float)$yArr[1]; + $conditions = []; + $bindings = []; + foreach ($this->roles as $role) { + $conditions[] = "{$column} @> ?::jsonb"; + $bindings[] = \json_encode(["{$this->type}(\"{$role}\")"]) ?: '[]'; + } - return [$x, $y]; + return new Condition('('.\implode(' OR ', $conditions).')', $bindings); + } + }; } - public function decodeLinestring(mixed $wkb): array + /** + * Size of POINT spatial type + */ + protected function getMaxPointSize(): int { - if (str_starts_with(strtoupper($wkb), 'LINESTRING(')) { - $start = strpos($wkb, '(') + 1; - $end = strrpos($wkb, ')'); - $inside = substr($wkb, $start, $end - $start); + // https://stackoverflow.com/questions/30455025/size-of-data-type-geographypoint-4326-in-postgis + return 32; + } - $points = explode(',', $inside); - return array_map(function ($point) { - $coords = explode(' ', trim($point)); - return [(float)$coords[0], (float)$coords[1]]; - }, $points); + 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); } - if (ctype_xdigit($wkb)) { - $wkb = hex2bin($wkb); - if ($wkb === false) { - throw new DatabaseException("Failed to convert hex WKB to binary."); - } + // Duplicate table + if ($e->getCode() === '42P07' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new DuplicateException('Collection already exists', $e->getCode(), $e); } - if (strlen($wkb) < 9) { - throw new DatabaseException("WKB too short to be a valid geometry"); + // Duplicate column + if ($e->getCode() === '42701' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new DuplicateException('Attribute already exists', $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"); - } + // Duplicate row + 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 + SRID flag - $typeField = unpack('V', substr($wkb, 1, 4)); - if ($typeField === false) { - throw new DatabaseException('Failed to unpack the type field from WKB.'); + return new DuplicateException('Document already exists', $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}"); + // 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); } - $offset = 5; - if ($hasSRID) { - $offset += 4; + // 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); } - $numPoints = unpack('V', substr($wkb, $offset, 4)); - if ($numPoints === false) { - throw new DatabaseException("Failed to unpack number of points at offset {$offset}."); + // Invalid argument for power function + if ($e->getCode() === '2201F' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Invalid argument for power function', $e->getCode(), $e); } - $numPoints = $numPoints[1]; - $offset += 4; + // Datetime field overflow + if ($e->getCode() === '22008' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new LimitException('Datetime field overflow', $e->getCode(), $e); + } - $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}."); + 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); } - $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}."); - } + return new NotFoundException('Collection not found', $e->getCode(), $e); + } - $y = (float) $y[1]; + // Unknown column + if ($e->getCode() === '42703' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); + } - $offset += 8; - $points[] = [$x, $y]; + 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); } - return $points; - } + if ($e->getCode() === '40P01' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new ContentionException('Deadlock detected', $e->getCode(), $e); + } - public function decodePolygon(string $wkb): 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); + 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); + } - $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); + if ($e->getCode() === '55P03' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new ContentionException('Lock not available', $e->getCode(), $e); } - // 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() === '22021' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new CharacterException('Invalid character', $e->getCode(), $e); } - if (strlen($wkb) < 9) { - throw new DatabaseException("WKB too short"); + 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); } - $uInt32 = 'V'; // little-endian 32-bit unsigned - $uDouble = 'd'; // little-endian double + return $e; + } - $typeInt = unpack($uInt32, substr($wkb, 1, 4)); - if ($typeInt === false) { - throw new DatabaseException('Failed to unpack type field from WKB.'); + #[\Override] + protected function processSelectException(PDOException $e, Statement $statement): Exception + { + 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); } - $typeInt = (int) $typeInt[1]; - $hasSrid = ($typeInt & 0x20000000) !== 0; - $geomType = $typeInt & 0xFF; + return parent::processSelectException($e, $statement); + } - if ($geomType !== 3) { // 3 = POLYGON - throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); + /** + * 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; } - $offset = 5; - if ($hasSrid) { - $offset += 4; - } + $name = $matches[1]; + $separator = \strrpos($name, '.'); + $relation = $separator === false ? $name : \substr($name, $separator + 1); - // Number of rings - $numRings = unpack($uInt32, substr($wkb, $offset, 4)); - if ($numRings === false) { - throw new DatabaseException('Failed to unpack number of rings from WKB.'); + if (! \str_starts_with($relation, $this->getNamespace().'_') && \preg_match(self::HASHED_IDENTIFIER, $relation) !== 1) { + return true; } - $numRings = (int) $numRings[1]; - $offset += 4; + return $separator === false && \str_contains($message, "\n"); + } - $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.'); - } + /** + * 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; + } - $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.'); - } + $columns = \array_map( + static fn (string $column): string => \trim($column, " \t\"'"), + \explode(',', $matches[1]) + ); - $x = (float) $x[1]; + \sort($columns); - $y = unpack($uDouble, substr($wkb, $offset + 8, 8)); - if ($y === false) { - throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); - } + return $columns; + } - $y = (float) $y[1]; + protected function quote(string $string): string + { + return "\"{$string}\""; + } - $points[] = [$x, $y]; - $offset += 16; - } - $rings[] = $points; - } + protected function getIdentifierQuoteChar(): string + { + return '"'; + } - return $rings; // array of rings, each ring is array of [x,y] + /** + * 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. + */ + #[\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); } /** * Get SQL expression for operator - * - * @param string $column - * @param Operator $operator - * @param array $binds - * @param bool $useTargetPrefix - * @return ?string */ - protected function getOperatorSQL(string $column, Operator $operator, array &$binds, bool $useTargetPrefix = false): ?string + protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex, bool $useTargetPrefix = false): ?string { $quotedColumn = $this->quote($column); $columnRef = $useTargetPrefix ? "target.{$quotedColumn}" : $quotedColumn; @@ -2618,138 +1991,160 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi 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 ( @@ -2765,27 +2160,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 @@ -2803,32 +2203,119 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ), '[]'::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'); + } + } + + /** + * Bind operator parameters to statement + * Override to handle PostgreSQL-specific JSON binding + */ + protected function bindOperatorParams(PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt, Operator $operator, int &$bindIndex): void + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $arrayValue, PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::ArrayRemove: + $value = $values[0] ?? null; + $bindKey = "op_{$bindIndex}"; + // Always JSON encode for PostgreSQL jsonb comparison + $stmt->bindValue(':'.$bindKey, json_encode($value), PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::ArrayIntersect: + case OperatorType::ArrayDiff: + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $arrayValue, PDO::PARAM_STR); + $bindIndex++; + break; + + default: + // Use parent implementation for other operators + parent::bindOperatorParams($stmt, $operator, $bindIndex); + break; } } - public function getSupportNonUtfCharacters(): bool + protected function getOperatorBuilderExpression(string $column, Operator $operator): array { - return false; + if ($operator->getMethod() === OperatorType::ArrayRemove) { + $result = parent::getOperatorBuilderExpression($column, $operator); + $values = $operator->getValues(); + $value = $values[0] ?? null; + if (! is_array($value)) { + $result['bindings'] = [json_encode($value)]; + } + + return $result; + } + + return parent::getOperatorBuilderExpression($column, $operator); } /** - * Ensure index key length stays within PostgreSQL's 63 character limit. + * Encode array * - * @param string $key - * @return string + * + * @return array + */ + protected function encodeArray(string $value): array + { + $string = substr($value, 1, -1); + if (empty($string)) { + return []; + } else { + return explode(',', $string); + } + } + + /** + * Decode array + * + * @param array $value + */ + protected function decodeArray(array $value): string + { + if (empty($value)) { + return '{}'; + } + + foreach ($value as $index => $item) { + $value[$index] = '"'.str_replace(['"', '(', ')'], ['\"', '\(', '\)'], $item).'"'; + } + + return '{'.implode(',', $value).'}'; + } + + /** + * Ensure index key length stays within PostgreSQL's 63 character limit. */ protected function getShortKey(string $key): string { @@ -2854,29 +2341,35 @@ protected function getShortKey(string $key): string return substr($hash, 0, self::MAX_IDENTIFIER_NAME); } - protected function getSQLTable(string $name): string + protected function getPhysicalTableName(string $name): string { - $table = "{$this->getNamespace()}_{$this->filter($name)}"; - $table = $this->getShortKey($table); + return $this->getShortKey("{$this->getNamespace()}_{$this->filter($name)}"); + } - return "{$this->quote($this->getDatabase())}.{$this->quote($table)}"; + #[\Override] + protected function getSQLTable(string $name): string + { + return "{$this->quote($this->getDatabase())}.{$this->quote($this->getPhysicalTableName($name))}"; } - public function getSupportForTTLIndexes(): bool + #[\Override] + protected function getSQLTableRaw(string $name): string { - return false; + return $this->getDatabase().'.'.$this->getPhysicalTableName($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('/^[a-zA-Z0-9_\-]+$/', $part)) { + 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/ReadWritePool.php b/src/Database/Adapter/ReadWritePool.php new file mode 100644 index 0000000000..3c5d04e25d --- /dev/null +++ b/src/Database/Adapter/ReadWritePool.php @@ -0,0 +1,263 @@ + + */ + 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; + } + + public function delegate(string $method, array $args): mixed + { + return $this->borrowAndInvoke($method, $args); + } + + #[\Override] + public function withTransaction(callable $callback): mixed + { + try { + return parent::withTransaction($callback); + } finally { + $this->stick(); + } + } + + #[\Override] + public function getHostname(): string + { + return $this->writePoolHostname ??= parent::getHostname(); + } + + /** + * 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; + } + + /** + * @param array $args + * @param class-string|null $feature + */ + #[\Override] + protected function borrowAndInvoke(string $method, array $args, ?string $feature = null): mixed + { + if ($this->isWrite($method, $args)) { + $this->recordRead($method, false); + try { + return parent::borrowAndInvoke($method, $args, $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, $args, $feature); + } + + $this->recordRead($method, true); + + return $this->readPool->use(function (Adapter $adapter) use ($method, $args, $feature) { + try { + $this->syncBorrowedAdapter($adapter); + + return $this->invokeDelegated($adapter, $method, $args, $feature); + } finally { + $this->releaseBorrowedAdapter($adapter); + } + }); + } + + /** + * @param array $args + */ + private function isWrite(string $method, array $args): bool + { + if ($this->decidesWrite($method, $args)) { + 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 $args + */ + private function decidesWrite(string $method, array $args): bool + { + return match ($method) { + 'getDocument' => ($args[3] ?? $args['forUpdate'] ?? false) === true, + 'find' => ($args[8] ?? $args['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; + } + + private function getCoroutineContext(): ?Context + { + 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 81f3350634..90f0d1ec61 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -6,6 +6,9 @@ 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\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -18,64 +21,48 @@ 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\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\RelationSide; +use Utopia\Database\RelationType; +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\ConnectionId { 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 +77,2620 @@ 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 + public function getDriver(): mixed { - return \implode(self::SEP, $parts); + return 'redis'; } /** - * 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()); + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::Schemas, + Capability::Fulltext, + Capability::Casting, + Capability::QueryContains, + Capability::BatchOperations, + Capability::BatchCreateAttributes, + Capability::AttributeResizing, + Capability::Objects, + Capability::Operators, + Capability::OrderRandom, + Capability::DefinedAttributes, + Capability::NestedTransactions, + Capability::PCRE, + Capability::Regex, + ]); } - /** - * 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 + 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 + 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 + public function startTransaction(): bool { - $id = \strtolower($id); - if (! $this->getSharedTables()) { - return $this->key($this->ns(), 'doc', $collection, $id); - } - - $bucket = $this->bucketFor($tenant); + $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 + 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); - } - - /** - * 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 - { - if (! $this->getSharedTables()) { - return $this->key($this->ns(), 'seq', $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 $this->key($this->ns(), 'seq', 't', $this->bucketFor($tenant), $collection); + return true; } - /** - * 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 + public function rollbackTransaction(): bool { - if ($tenant === null) { - $tenant = $this->getTenant(); + if ($this->inTransaction === 0) { + return false; } - return $tenant === null ? '_' : (string) $tenant; - } - - private function encode(Document $document): string - { - return \json_encode( - $document->getArrayCopy(), - JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION - ); - } - - 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->rollbackJournal(); + $this->inTransaction--; + } catch (\Throwable $error) { + $this->inTransaction = 0; + $this->journalStack = []; + + throw $error; } - 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. - * - * @param callable(RedisClient): mixed $fn - */ - protected function tx(callable $fn): mixed + public function create(string $name): bool { - try { - return $fn($this->client); - } catch (\RedisException $exception) { - throw new TransactionException('tx failed: ' . $exception->getMessage(), 0, $exception); - } + $name = $this->filter($name); + $dbsKey = $this->key($this->nsBase(), 'dbs'); + + $this->tx(fn (RedisClient $client) => $client->sAdd($dbsKey, $name)); + + return true; } - /** - * 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 + public function exists(string $database, ?string $collection = null): bool { - // 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); + $database = $this->filter($database); + $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, $database) === false) { + return false; } - if ($byRole === []) { - return; + if ($collection === null) { + return true; } - $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]; - } - } + $collection = $this->filter($collection); + $namespace = $this->getNamespace(); + $colsKey = $this->key($this->nsFor($namespace, $database), 'cols'); - // 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. - try { - $this->client->discard(); - } catch (\Throwable) { - // ignore - } - throw $e; + return (bool) $this->client->sIsMember($colsKey, $collection); + } + + public function list(): array + { + $dbsKey = $this->key($this->nsBase(), 'dbs'); + /** @var array|false $names */ + $names = $this->client->sMembers($dbsKey); + if ($names === false) { + $names = []; } - // 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, - ]); + $databases = []; + foreach ($names as $name) { + $databases[] = new Document(['name' => $name]); } + + return $databases; } - /** - * 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 + public function delete(string $name): bool { - // 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; - } + $name = $this->filter($name); + $namespace = $this->getNamespace(); + $dbsKey = $this->key($this->nsBase(), 'dbs'); + $colsKey = $this->key($this->nsFor($namespace, $name), 'cols'); - $removals = []; - foreach ($hash as $role => $letterCsv) { - if ($letterCsv === '') { - continue; - } - foreach (\explode(',', $letterCsv) as $letter) { - $removals[] = [$role, $letter]; + $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); + } } - } - // 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 $e; - } + $client->del($colsKey); + $client->sRem($dbsKey, $name); + }); - // 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 - */ - private function applyPermissionFilter(string $collection, array $ids, string $action): array + public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { - if ($ids === []) { - return $ids; - } - if ($this->authorization->getStatus() === false) { - return $ids; + $id = $this->filter($name); + $colsKey = $this->key($this->ns(), 'cols'); + $metaKey = $this->key($this->ns(), 'meta', $id); + $idxKey = $this->idxKey($id); + + if ((bool) $this->client->exists($metaKey)) { + throw new DuplicateException('Collection already exists'); } - $roles = $this->authorization->getRoles(); - if ($roles === []) { - return []; + $attributePayload = []; + foreach ($attributes as $attribute) { + $attributePayload[] = [ + Document::ID => $attribute->key, + 'key' => $attribute->key, + 'type' => Attribute::persistedType($attribute->type), + 'size' => $attribute->size, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, + ]; } - $letter = self::actionLetter($action); - $keys = []; - foreach ($roles as $role) { - $keys[] = $this->permKey($collection, $letter, $role); + $indexPayload = []; + foreach ($indexes as $index) { + $indexPayload[] = [ + Document::ID => $index->key, + 'key' => $index->key, + 'type' => $index->type->value, + 'attributes' => $index->attributes, + 'lengths' => $index->lengths, + 'orders' => $index->getAttribute('orders', []), + ]; } - 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 []; - } + $schema = new Document([ + Document::ID => $id, + 'name' => $name, + 'attributes' => $attributePayload, + 'indexes' => $indexPayload, + ]); - $allowedSet = \array_flip($allowed); + $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', + ]); + $client->del($idxKey); + $client->sAdd($colsKey, $id); + }); - return \array_values(\array_filter($ids, static fn (string $id): bool => isset($allowedSet[$id]))); + return true; } - /** - * 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 + public function deleteCollection(string $id): 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), - }; - } + $id = $this->filter($id); + $namespace = $this->getNamespace(); + $database = $this->getDatabase(); + $colsKey = $this->key($this->ns(), 'cols'); - /** - * 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 - { - if (! $this->getSharedTables()) { - return null; - } - $tenant = $this->getTenant(); + $this->tx(function (RedisClient $client) use ($id, $namespace, $database, $colsKey): void { + $this->purgeCollectionKeys($client, $namespace, $database, $id); + $client->sRem($colsKey, $id); + }); - return $tenant === null ? '_' : (string) $tenant; + return true; } - /** - * 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 + public function analyzeCollection(string $collection): bool { - $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; - } - - return $this->ns() . self::SEP . 'perm' . self::SEP . $collection . self::SEP . $letter . self::SEP . $role; + return false; } - /** - * 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 + public function getSizeOfCollection(string $collection): int { - $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->computeCollectionSize($collection); + } - return $this->ns() . self::SEP . 'perm' . self::SEP . 'doc' . self::SEP . $collection . self::SEP . $id; + public function getSizeOfCollectionOnDisk(string $collection): int + { + return $this->computeCollectionSize($collection); } - /** - * 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 + public function createAttribute(string $collection, Attribute $attribute): bool { - if ($this->inTransaction === 0) { - return; + $collection = $this->filter($collection); + $id = $this->filter($attribute->key); + $metaKey = $this->key($this->ns(), 'meta', $collection); + + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); } - $this->journalStack[\count($this->journalStack) - 1][] = [ - 'op' => $op, - 'payload' => $payload, + + $record = [ + Document::ID => $id, + 'key' => $id, + 'type' => Attribute::persistedType($attribute->type), + 'size' => $attribute->size, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, ]; + + $this->tx(function (RedisClient $client) use ($metaKey, $record): void { + $attrs = $this->readAttributesField($client, $metaKey); + $attrs = $this->upsertAttributeRecord($attrs, $record); + $client->hSet($metaKey, 'attrs', \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); + + return true; } - /** - * 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 + public function createAttributes(string $collection, array $attributes): bool { - $frame = \array_pop($this->journalStack); - if ($frame === null) { - return; + foreach ($attributes as $attribute) { + $this->createAttribute($collection, $attribute); } - for ($i = \count($frame) - 1; $i >= 0; $i--) { - $entry = $frame[$i]; - $op = $entry['op']; - $payload = $entry['payload']; + return true; + } - 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; + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool + { + $collection = $this->filter($collection); + $id = $this->filter($attribute->key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - 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; + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - 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; + if (! empty($newKey) && $newKey !== $id) { + $this->renameAttribute($collection, $id, $newKey); + $id = $this->filter($newKey); + } - 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; + $record = [ + Document::ID => $id, + 'key' => $id, + 'type' => Attribute::persistedType($attribute->type), + 'size' => $attribute->size, + 'signed' => $attribute->signed, + 'array' => $attribute->array, + 'required' => $attribute->required, + ]; - 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; + $this->tx(function (RedisClient $client) use ($metaKey, $record): void { + $attrs = $this->readAttributesField($client, $metaKey); + $attrs = $this->upsertAttributeRecord($attrs, $record); + $client->hSet($metaKey, 'attrs', \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); - default: - throw new TransactionException('Unknown journal op: ' . $op); - } - } + return true; } - /** - * 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 + public function deleteAttribute(string $collection, string $id): bool { - $frame = \array_pop($this->journalStack); - if ($frame === null) { - return; - } + $collection = $this->filter($collection); + $id = $this->filter($id); + $metaKey = $this->key($this->ns(), 'meta', $collection); - if ($frame !== [] && $this->journalStack !== []) { - $outerIndex = \count($this->journalStack) - 1; - \array_push($this->journalStack[$outerIndex], ...$frame); + if ((bool) $this->client->exists($metaKey) === false) { + return true; } - } - 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)); - } + $this->tx(function (RedisClient $client) use ($metaKey, $id): void { + $attrs = $this->readAttributesField($client, $metaKey); + $filtered = []; + foreach ($attrs 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)); + }); - 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); + $this->dropDocumentField($collection, $id); + + return true; } - /** - * @param array $queries - * @param array $orderAttributes - * @param array $orderTypes - * @param array $cursor - * @return array - */ - protected function evaluateQueries(string $collection, array $queries, ?int $limit, ?int $offset, array $orderAttributes, array $orderTypes, array $cursor, string $cursorDirection): array + public function renameAttribute(string $collection, string $old, string $new): bool { - $collectionId = $this->filter($collection); - $metaKey = $this->key($this->ns(), 'meta', $collectionId); + $collection = $this->filter($collection); + $old = $this->filter($old); + $new = $this->filter($new); + $metaKey = $this->key($this->ns(), 'meta', $collection); if ((bool) $this->client->exists($metaKey) === false) { throw new NotFoundException('Collection not found'); } - 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); - - if (! \is_null($offset)) { - $documents = \array_slice($documents, $offset); - } - if (! \is_null($limit)) { - $documents = \array_slice($documents, 0, $limit); + $this->tx(function (RedisClient $client) use ($metaKey, $old, $new): void { + $attrs = $this->readAttributesField($client, $metaKey); + $touched = false; + foreach ($attrs as $i => $attribute) { + $existingId = $this->recordIdentifier($attribute); + if ($this->filter($existingId) !== $old) { + continue; + } + $attribute[Document::ID] = $new; + $attribute['key'] = $new; + $attrs[$i] = $attribute; + $touched = true; } - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $documents = \array_reverse($documents); + if (! $touched) { + return; } - - return $documents; + $client->hSet($metaKey, 'attrs', \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); }); - } - - public function getDriver(): mixed - { - return 'redis'; - } - public function setTimeout(int $milliseconds, string $event = Database::EVENT_ALL): void - { - } + $this->renameDocumentField($collection, $old, $new); - public function ping(): bool - { - return (bool) $this->client->ping(); + return true; } - public function reconnect(): void + #[\Override] + public function createRelationship(Relationship $relationship): bool { - } + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $id = $relationship->key; + $twoWayKey = $relationship->twoWayKey; + $twoWay = $relationship->twoWay; - protected function quote(string $string): string - { - return '"' . $string . '"'; - } + switch ($relationship->type) { + case RelationType::OneToOne: + $this->registerRelationshipField($collection, $id); + if ($twoWay) { + $this->registerRelationshipField($relatedCollection, $twoWayKey); + } + break; + case RelationType::OneToMany: + $this->registerRelationshipField($relatedCollection, $twoWayKey); + break; + case RelationType::ManyToOne: + $this->registerRelationshipField($collection, $id); + break; + case RelationType::ManyToMany: + break; + default: + throw new DatabaseException('Invalid relationship type'); + } - public function getLimitForString(): int - { - return 4294967295; + return true; } - public function getLimitForInt(): int + #[\Override] + public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): bool { - return 4294967295; - } + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $newKey = $newKey !== null ? $this->filter($newKey) : null; + $newTwoWayKey = $newTwoWayKey !== null ? $this->filter($newTwoWayKey) : null; + $side = $relationship->side; + $twoWay = $relationship->twoWay; - public function getLimitForAttributes(): int - { - return 1017; - } + switch ($relationship->type) { + case RelationType::OneToOne: + if ($newKey !== null && $newKey !== $key) { + $this->renameAttribute($collection, $key, $newKey); + } + if ($twoWay && $newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); + } + break; + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { + if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); + } + } else { + if ($newKey !== null && $newKey !== $key) { + $this->renameAttribute($collection, $key, $newKey); + } + } + break; + case RelationType::ManyToOne: + if ($side === RelationSide::Child) { + if ($newTwoWayKey !== null && $newTwoWayKey !== $twoWayKey) { + $this->renameAttribute($relatedCollection, $twoWayKey, $newTwoWayKey); + } + } else { + if ($newKey !== null && $newKey !== $key) { + $this->renameAttribute($collection, $key, $newKey); + } + } + break; + case RelationType::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; + default: + throw new DatabaseException('Invalid relationship type'); + } - public function getLimitForIndexes(): int - { - return 64; + return true; } - public function getMaxIndexLength(): int + #[\Override] + public function deleteRelationship(Relationship $relationship): bool { - return 1024; - } + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $twoWay = $relationship->twoWay; + $side = $relationship->side; - public function getMaxVarcharLength(): int - { - return 16381; - } + switch ($relationship->type) { + case RelationType::OneToOne: + if ($side === RelationSide::Parent) { + $this->deleteAttribute($collection, $key); + if ($twoWay) { + $this->deleteAttribute($relatedCollection, $twoWayKey); + } + } else { + $this->deleteAttribute($relatedCollection, $twoWayKey); + if ($twoWay) { + $this->deleteAttribute($collection, $key); + } + } + break; + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { + $this->deleteAttribute($relatedCollection, $twoWayKey); + } else { + $this->deleteAttribute($collection, $key); + } + break; + case RelationType::ManyToOne: + if ($side === RelationSide::Parent) { + $this->deleteAttribute($collection, $key); + } else { + $this->deleteAttribute($relatedCollection, $twoWayKey); + } + break; + case RelationType::ManyToMany: + break; + default: + throw new DatabaseException('Invalid relationship type'); + } - public function getMaxUIDLength(): int - { - return 255; + return true; } - public function getMinDateTime(): \DateTime + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool { - return new \DateTime('0001-01-01 00:00:00'); - } + $collection = $this->filter($collection); + $id = $this->filter($index->key); + $metaKey = $this->key($this->ns(), 'meta', $collection); - 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; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - public function getSupportForSchemas(): bool - { - return true; - } + $type = $index->type->value; + $attributes = $index->attributes; + $lengths = $index->lengths; + $orders = $index->getAttribute('orders', []); - public function getSupportForAttributes(): bool - { - return true; - } + $this->tx(function (RedisClient $client) use ($metaKey, $collection, $id, $type, $attributes, $lengths, $orders): void { + $indexes = $this->readIndexesField($client, $metaKey); - public function setSupportForAttributes(bool $support): bool - { - return true; - } + foreach ($indexes as $existing) { + if (($existing[Document::ID] ?? $existing['key'] ?? null) === $id) { + throw new DuplicateException('Index already exists'); + } + } - public function getSupportForSchemaAttributes(): 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->getSharedTables(); + $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 DuplicateException('Cannot create unique index: existing rows already contain duplicate values'); + } + $seen[$hash] = true; + } + } + } - public function getSupportForSchemaIndexes(): bool - { - return false; - } + $indexes[] = [ + Document::ID => $id, + 'key' => $id, + 'type' => $type, + 'attributes' => \array_values($attributes), + 'lengths' => \array_values($lengths), + 'orders' => \array_values(\is_array($orders) ? $orders : []), + ]; + + $client->hSet($metaKey, 'indexes', \json_encode($indexes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); + }); - public function getSupportForIndex(): bool - { return true; } - public function getSupportForIndexArray(): bool + public function deleteIndex(string $collection, string $id): bool { - return false; - } + $collection = $this->filter($collection); + $id = $this->filter($id); + $metaKey = $this->key($this->ns(), 'meta', $collection); - public function getSupportForCastIndexArray(): bool - { - return false; - } + if ((bool) $this->client->exists($metaKey) === false) { + return true; + } - public function getSupportForUniqueIndex(): bool - { - return true; - } + $this->tx(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 getSupportForFulltextIndex(): bool - { return true; } - public function getSupportForFulltextWildcardIndex(): bool + public function renameIndex(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 getSupportForCasting(): bool - { - return true; - } + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); + } - public function getSupportForQueryContains(): bool - { - return true; - } + return $this->tx(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 getSupportForTimeouts(): bool - { - return false; + return true; + }) === true; } - public function getSupportForRelationships(): bool + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document { - return true; - } + $col = $this->filter($collection->getId()); + $payload = $this->client->get($this->docKey($col, $id)); - public function getSupportForUpdateLock(): bool - { - return false; - } + if ((! \is_string($payload) || $payload === '') && $this->getSharedTables() && $col === Database::METADATA) { + $payload = $this->client->get($this->docKey($col, $id, '_')); + } - public function getSupportForBatchOperations(): bool - { - return true; - } + if (! \is_string($payload) || $payload === '') { + return new Document([]); + } - public function getSupportForAttributeResizing(): bool - { - return true; - } + $document = $this->decode($payload); - public function getSupportForGetConnectionId(): bool - { - return false; + if ($this->getSharedTables()) { + $rowTenant = $document->getAttribute(Document::TENANT); + $tenant = $this->getTenant(); + $allowNullTenant = $col === Database::METADATA && $rowTenant === null; + if (! $allowNullTenant && $rowTenant !== $tenant) { + return new Document([]); + } + } + + if ($col !== Database::METADATA) { + $document = $this->surfaceRelationshipAttributes($col, $document); + } + + $selections = $this->extractSelections($queries); + if (! empty($selections) && ! \in_array('*', $selections, true)) { + $document = $this->projectDocument($document, $selections); + } + + return $document; } - public function getSupportForUpserts(): bool + public function createDocument(Document $collection, Document $document): Document { - return false; + return $this->insertDocument($collection, $document) ?? $document; } - public function getSupportForUpsertOnUniqueIndex(): bool - { - return false; - } - - public function getSupportForVectors(): bool + /** + * @return Document|null The stored document, or null when skipDuplicates() skipped it + */ + private function insertDocument(Document $collection, Document $document): ?Document { - return false; - } + $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); - public function getSupportForCacheSkipOnFailure(): bool - { - return false; - } + return $this->tx(function (RedisClient $redis) use ($col, $id, $document, $docKey, $idxKey, $seqKey, $permDocKey): ?Document { + if ((bool) $redis->exists($docKey)) { + if ($this->skipDuplicates) { + $existingPayload = $redis->get($docKey); + if (\is_string($existingPayload) && $existingPayload !== '') { + $existing = $this->decode($existingPayload); + $document->setAttribute(Document::SEQUENCE, $existing->getSequence() ?? ''); + } - 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 null; + } + throw new DuplicateException('Document already exists'); + } - public function getSupportForReconnection(): bool - { - return false; - } + try { + $this->enforceUniqueIndexes($redis, $col, $document); + } catch (DuplicateException $e) { + if ($this->skipDuplicates) { + return null; + } + throw $e; + } - public function getSupportForHostname(): bool - { - return false; - } + $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 getSupportForBatchCreateAttributes(): bool - { - return true; - } + $redis->set($docKey, $this->encode($document)); + $redis->sAdd($idxKey, \strtolower($id)); - public function getSupportForSpatialAttributes(): bool - { - return false; - } + $this->writePermissions($col, $id, $document); + $this->journal('createDoc', [ + 'collection' => $col, + 'id' => $id, + 'docKey' => $docKey, + 'idxKey' => $idxKey, + 'permDocKey' => $permDocKey, + ]); - public function getSupportForObject(): bool - { - return true; + return $document; + }); } - public function getSupportForObjectIndexes(): bool + public function createDocuments(Document $collection, array $documents): array { - return false; - } + $created = []; + foreach ($documents as $document) { + $inserted = $this->insertDocument($collection, $document); + if ($inserted !== null) { + $created[] = $inserted; + } + } - public function getSupportForSpatialIndexNull(): bool - { - return false; + return $created; } - public function getSupportForOperators(): bool + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { - return true; - } + $col = $this->filter($collection->getId()); + $oldKey = $this->docKey($col, $id); + $idxKey = $this->idxKey($col); - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return false; - } + $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; + } + } - public function getSupportForSpatialIndexOrder(): bool - { - return false; - } + 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'); + } - public function getSupportForSpatialAxisOrder(): bool - { - return false; - } + $existing = $this->decode($existingPayload); + if ($col !== Database::METADATA) { + $existing = $this->surfaceRelationshipAttributes($col, $existing); + } + $newId = $document->getId() !== '' ? $document->getId() : $id; + $newKey = $useNullTenant ? $this->docKey($col, $newId, '_') : $this->docKey($col, $newId); + $effectiveIdxKey = $useNullTenant ? $this->idxKey($col, '_') : $idxKey; - public function getSupportForBoundaryInclusiveContains(): bool - { - return false; - } + if ($newKey !== $oldKey && (bool) $redis->exists($newKey)) { + throw new DuplicateException('Document already exists'); + } - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool - { - return false; - } + $resolved = $this->applyOperators($document->getArrayCopy(), $existing->getArrayCopy()); + $merged = \array_merge($existing->getArrayCopy(), $resolved); + $merged[Document::ID] = $newId; + $mergedDocument = new Document($merged); - public function getSupportForMultipleFulltextIndexes(): bool - { - return false; - } + $this->enforceUniqueIndexes($redis, $col, $mergedDocument, $id); - public function getSupportForIdenticalIndexes(): bool - { - return false; - } + $payload = $this->encode($mergedDocument); - public function getSupportForOrderRandom(): bool - { - return true; - } + if ($newId !== $id) { + $redis->del($oldKey); + $redis->sRem($effectiveIdxKey, \strtolower($id)); + } + $redis->set($newKey, $payload); + $redis->sAdd($effectiveIdxKey, \strtolower($newId)); - /** - * Get max BIGINT limit - * - * @return int - */ - public function getLimitForBigInt(): int - { - return Database::MAX_BIG_INT; - } + $this->journal('updateDoc', [ + 'collection' => $col, + 'id' => $id, + 'newId' => $newId, + 'payload' => $existingPayload, + 'docKey' => $oldKey, + 'newDocKey' => $newKey, + 'idxKey' => $effectiveIdxKey, + ]); - public function getSupportForInternalCasting(): bool - { - return false; - } + if (! $skipPermissions) { + $this->clearPermissions($col, $id); + if ($newId !== $id) { + $this->clearPermissions($col, $newId); + } + $this->writePermissions($col, $newId, $mergedDocument); + } - public function getSupportForUTCCasting(): bool - { - return false; + return $mergedDocument; + }); } - public function getSupportForIntegerBooleans(): bool + public function updateDocuments(Document $collection, Document $updates, array $documents): int { - return false; - } + if (empty($documents)) { + return 0; + } - public function getSupportForAlterLocks(): bool - { - return false; - } + $attrs = $updates->getAttributes(); + $hasCreatedAt = ! empty($updates->getCreatedAt()); + $hasUpdatedAt = ! empty($updates->getUpdatedAt()); + $hasPermissions = $updates->offsetExists(Document::PERMISSIONS); + if (empty($attrs) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { + return 0; + } - public function getSupportNonUtfCharacters(): bool - { - return false; - } + $col = $this->filter($collection->getId()); + $documents = \array_values($documents); - public function getSupportForTrigramIndex(): bool - { - return false; - } + return $this->tx(function (RedisClient $redis) use ($col, $documents, $updates, $attrs, $hasCreatedAt, $hasUpdatedAt, $hasPermissions): int { + $docKeys = []; + foreach ($documents as $doc) { + $docKeys[] = $this->docKey($col, $doc->getId()); + } - public function getSupportForPCRERegex(): bool - { - return true; - } + $redis->multi(\Redis::PIPELINE); + foreach ($docKeys as $docKey) { + $redis->get($docKey); + } + $existingPayloads = $redis->exec(); + if (! \is_array($existingPayloads)) { + $existingPayloads = []; + } - public function getSupportForPOSIXRegex(): bool - { - return false; - } + $relationshipKeys = []; + if ($col !== Database::METADATA) { + $metaKey = $this->key($this->ns(), 'meta', $col); + $attributes = $this->readAttributesField($redis, $metaKey); + $relationshipKeys = $this->extractRelationshipKeys($attributes); + } - 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; - } + $writes = []; + foreach ($documents as $i => $doc) { + $existingPayload = $existingPayloads[$i] ?? false; + if (! \is_string($existingPayload) || $existingPayload === '') { + continue; + } - public function getSupportForNestedTransactions(): bool - { - return true; - } + $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[Document::CREATED_AT] = $updates->getCreatedAt(); + } + if ($hasUpdatedAt) { + $merged[Document::UPDATED_AT] = $updates->getUpdatedAt(); + } + if ($hasPermissions) { + $merged[Document::PERMISSIONS] = $updates->getPermissions(); + } - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } + $writes[] = new Write($doc->getId(), $docKeys[$i], $existingPayload, new Document($merged)); + } - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); - } + if ($attrs !== []) { + $this->enforceUniqueIndexesForDocuments( + $redis, + $col, + \array_map(static fn (Write $write): Document => $write->document, $writes), + \array_map(static fn (Write $write): string => $write->id, $writes), + ); + } - public function getDocumentSizeLimit(): int - { - return 0; - } + foreach ($writes as $write) { + $redis->set($write->key, $this->encode($write->document)); - public function getAttributeWidth(Document $collection): int - { - return 0; - } + $this->journal('updateDoc', [ + 'collection' => $col, + 'id' => $write->id, + 'newId' => $write->id, + 'payload' => $write->payload, + 'docKey' => $write->key, + ]); - public function getKeywords(): array - { - return []; - } + if ($hasPermissions) { + $this->clearPermissions($col, $write->id); + $this->writePermissions($col, $write->id, $write->document); + } + } - /** - * @param array $selections - */ - protected function getAttributeProjection(array $selections, string $prefix): mixed - { - return $selections; + return \count($writes); + }); } - public function getConnectionId(): string + #[\Override] + public function upsertDocuments(Document $collection, string $attribute, array $changes): array { - return '0'; - } + if (empty($changes)) { + return $changes; + } - public function getInternalIndexesKeys(): array - { - return []; - } + $col = $this->filter($collection->getId()); - public function getTenantQuery(string $collection, string $alias = ''): string - { - return ''; - } + return $this->tx(function (RedisClient $redis) use ($col, $attribute, $changes): array { + $results = []; - protected function execute(mixed $stmt): bool - { - return true; - } + $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 = []; + } - public function decodePoint(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Redis adapter'); - } + $relationshipKeys = []; + if ($col !== Database::METADATA) { + $metaKey = $this->key($this->ns(), 'meta', $col); + $attributes = $this->readAttributesField($redis, $metaKey); + $relationshipKeys = $this->extractRelationshipKeys($attributes); + } - public function decodeLinestring(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Redis adapter'); - } + $writes = []; + foreach ($changes as $i => $change) { + $document = $change->getNew(); + $id = $document->getId(); + $existingPayload = $existingPayloads[$i] ?? false; - public function decodePolygon(string $wkb): array - { - throw new DatabaseException('Spatial types are not implemented in the Redis adapter'); - } + if (! \is_string($existingPayload) || $existingPayload === '') { + $writes[] = new Write($id, $this->docKey($col, $id, $document->getTenant()), null, $document, $document->getTenant()); - public function castingBefore(Document $collection, Document $document): Document - { - return $document; - } + continue; + } - public function castingAfter(Document $collection, Document $document): Document - { - return $document; - } + $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; - public function setUTCDatetime(string $value): mixed - { - return $value; - } + 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; + } - /** - * 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 - { - if ($collection === Database::METADATA) { - return $document; - } + $writes[] = new Write($id, $this->docKey($col, $id, $document->getTenant()), $existingPayload, new Document($merged), $document->getTenant()); + } - $metaKey = $this->key($this->ns(), 'meta', $this->filter($collection)); - $attributes = $this->readAttributesField($this->client, $metaKey); - $relationshipKeys = $this->extractRelationshipKeys($attributes); - if ($relationshipKeys === []) { - return $document; - } + $this->enforceUniqueIndexesInOrder($redis, $col, $writes); - return $this->surfaceRelationshipAttributesUsing($relationshipKeys, $document); - } + foreach ($writes as $write) { + $id = $write->id; + $document = $write->document; + $tenant = $write->tenant; - /** - * 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 - { - if ($relationshipKeys === []) { - return $document; - } + if ($write->payload !== null) { + $redis->set($write->key, $this->encode($document)); - $payload = $document->getArrayCopy(); - foreach ($relationshipKeys as $key) { - if (! \array_key_exists($key, $payload)) { - $document->setAttribute($key, null); - } - } + $this->journal('updateDoc', [ + 'collection' => $col, + 'id' => $id, + 'newId' => $id, + 'payload' => $write->payload, + 'docKey' => $write->key, + ]); - return $document; - } + $this->clearPermissions($col, $id, $tenant); + $this->writePermissions($col, $id, $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; - } - $key = (string) ($attribute['$id'] ?? $attribute['key'] ?? ''); - if ($key === '') { - continue; + $results[] = $document; + + continue; + } + + $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); + + $resolved = $this->applyOperators($document->getArrayCopy(), []); + foreach ($resolved as $attr => $value) { + $document->setAttribute($attr, $value); + } + + $redis->set($write->key, $this->encode($document)); + $redis->sAdd($idxKey, \strtolower($id)); + + $this->writePermissions($col, $id, $document); + $this->journal('createDoc', [ + 'collection' => $col, + 'id' => $id, + 'docKey' => $write->key, + 'idxKey' => $idxKey, + 'permDocKey' => $this->permDocKey($col, $id, $tenant), + ]); + + $results[] = $document; } - $keys[] = $key; - } - return $keys; + return $results; + }); } - /** - * 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 + public function getSequences(string $collection, array $documents): array { - $collection = $this->filter($collection); - $oldKey = $this->filter($oldKey); - $newKey = $this->filter($newKey); - - if ($oldKey === $newKey) { - return; + if (empty($documents)) { + return $documents; } - $idxKey = $this->idxKey($collection); - - $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; - } + $col = $this->filter($collection); - foreach ($docIds as $docId) { - $docKey = $this->docKey($collection, $docId); - $payload = $client->get($docKey); - if (! \is_string($payload) || $payload === '') { + $this->client->multi(\Redis::PIPELINE); + try { + $indexes = []; + foreach ($documents as $index => $doc) { + if (! empty($doc->getSequence())) { continue; } - - /** @var array $decoded */ - $decoded = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - if (! \array_key_exists($oldKey, $decoded)) { - 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. } - $decoded[$newKey] = $decoded[$oldKey]; - unset($decoded[$oldKey]); + 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; + } - $client->set( - $docKey, - \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), - ); + 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; } - /** - * 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 + public function deleteDocument(string $collection, string $id): bool { $collection = $this->filter($collection); - $field = $this->filter($field); + $docKey = $this->docKey($collection, $id); $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; + return $this->tx(function (RedisClient $redis) use ($collection, $id, $docKey, $idxKey): bool { + $payload = $redis->get($docKey); + if (! \is_string($payload) || $payload === '') { + return false; } - 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; - } + $this->journal('deleteDoc', [ + 'collection' => $collection, + 'id' => $id, + 'payload' => $payload, + 'docKey' => $docKey, + 'idxKey' => $idxKey, + ]); - unset($decoded[$field]); + $this->clearPermissions($collection, $id); + $redis->del($docKey); + $redis->sRem($idxKey, \strtolower($id)); - $client->set( - $docKey, - \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), - ); - } + return true; }); } - /** - * 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 + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int { - $collectionDoc = $this->loadMetadataDocument($collection); - $relatedDoc = $this->loadMetadataDocument($relatedCollection); - if ($collectionDoc === null || $relatedDoc === null) { - return null; + if (empty($sequences) && empty($permissionIds)) { + return 0; } - $collectionSequence = $collectionDoc->getSequence(); - $relatedSequence = $relatedDoc->getSequence(); - if ($collectionSequence === null || $relatedSequence === null || $collectionSequence === '' || $relatedSequence === '') { - return null; - } + $collection = $this->filter($collection); + $idxKey = $this->idxKey($collection); - return $side === Database::RELATION_SIDE_PARENT - ? '_' . $collectionSequence . '_' . $relatedSequence - : '_' . $relatedSequence . '_' . $collectionSequence; - } + return $this->tx(function (RedisClient $redis) use ($collection, $sequences, $permissionIds, $idxKey): int { + $sequenceSet = []; + foreach ($sequences as $sequence) { + $sequenceSet[(string) $sequence] = true; + } - /** - * 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; - } + $allIds = $redis->sMembers($idxKey); + if (! \is_array($allIds)) { + $allIds = []; + } - return $this->decode($payload); - } + $docKeys = []; + $redis->multi(\Redis::PIPELINE); + foreach ($allIds as $id) { + $docKey = $this->docKey($collection, (string) $id); + $docKeys[(string) $id] = $docKey; + $redis->get($docKey); + } + $payloads = $redis->exec(); + if (! \is_array($payloads)) { + $payloads = []; + } - // === @architect:T20 owns: schema + collection + attribute ops === + $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]]; + } + } - public function create(string $name): bool - { - $name = $this->filter($name); - $dbsKey = $this->key($this->nsBase(), 'dbs'); + 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)); + } - $this->tx(fn (RedisClient $client) => $client->sAdd($dbsKey, $name)); + foreach ($permissionIds as $permissionId) { + $documentId = (string) $permissionId; + if (isset($deleted[$documentId])) { + continue; + } + $this->clearPermissions($collection, $documentId); + } - return true; + return \count($deleted); + }); } - public function exists(string $database, ?string $collection = null): bool + 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 { - $database = $this->filter($database); - $dbsKey = $this->key($this->nsBase(), 'dbs'); + $collectionId = $this->filter($collection->getId()); + $metaKey = $this->key($this->ns(), 'meta', $collectionId); - if ((bool) $this->client->sIsMember($dbsKey, $database) === false) { - return false; + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); } - if ($collection === null) { - return true; - } + 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); - $collection = $this->filter($collection); - $namespace = $this->getNamespace(); - $colsKey = $this->key($this->nsFor($namespace, $database), 'cols'); + if (! \is_null($offset)) { + $documents = \array_slice($documents, $offset); + } + if (! \is_null($limit)) { + $documents = \array_slice($documents, 0, $limit); + } - return (bool) $this->client->sIsMember($colsKey, $collection); + $selections = $this->extractSelections($queries); + if (! empty($selections)) { + $projected = []; + foreach ($documents as $document) { + $projected[] = $this->projectDocument($document, $selections); + } + $documents = $projected; + } + + if ($cursorDirection === CursorDirection::Before) { + $documents = \array_reverse($documents); + } + + return $documents; + }); } - public function list(): array + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int { - $dbsKey = $this->key($this->nsBase(), 'dbs'); - /** @var array|false $names */ - $names = $this->client->sMembers($dbsKey); - if ($names === false) { - $names = []; - } + $collectionId = $this->filter($collection->getId()); + $metaKey = $this->key($this->ns(), 'meta', $collectionId); - $databases = []; - foreach ($names as $name) { - $databases[] = new Document(['name' => $name]); + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); } - return $databases; - } + return $this->tx(function (RedisClient $client) use ($collectionId, $attribute, $queries, $max): float|int { + $documents = $this->loadCollectionDocuments($client, $collectionId, PermissionType::Read); + $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); - 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'); + if (! \is_null($max)) { + $documents = \array_slice($documents, 0, $max); + } - $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); + $sum = 0; + $isFloat = false; + foreach ($documents as $document) { + $value = $this->resolveDocumentAttribute($document, $attribute); + if ($value === null) { + continue; + } + if (\is_float($value)) { + $isFloat = true; + } + if (\is_numeric($value)) { + $sum += $value; } } - $client->del($colsKey); - $client->sRem($dbsKey, $name); + return $isFloat ? (float) $sum : (int) $sum; }); - - return true; } - public function createCollection(string $name, array $attributes = [], array $indexes = []): bool + public function count(Document $collection, array $queries = [], ?int $max = null): int { - $id = $this->filter($name); - $colsKey = $this->key($this->ns(), 'cols'); - $metaKey = $this->key($this->ns(), 'meta', $id); - $idxKey = $this->idxKey($id); + $collectionId = $this->filter($collection->getId()); + $metaKey = $this->key($this->ns(), 'meta', $collectionId); - if ((bool) $this->client->exists($metaKey)) { - throw new DuplicateException('Collection already exists'); + if ((bool) $this->client->exists($metaKey) === false) { + throw new NotFoundException('Collection not found'); } - $attributePayload = []; - foreach ($attributes as $attribute) { - $attributePayload[] = $attribute->getArrayCopy(); - } - $indexPayload = []; - foreach ($indexes as $index) { - $indexPayload[] = $index->getArrayCopy(); + 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); + } } - $schema = new Document([ - '$id' => $id, - 'name' => $name, - 'attributes' => $attributePayload, - 'indexes' => $indexPayload, - ]); + return $this->tx(function (RedisClient $client) use ($collectionId, $queries, $max): int { + $documents = $this->loadCollectionDocuments($client, $collectionId, PermissionType::Read); + $documents = $this->filterDocumentsByQueries($collectionId, $documents, $queries); - $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); - }); + if (! \is_null($max)) { + $documents = \array_slice($documents, 0, $max); + } - return true; + return \count($documents); + }); } - public function deleteCollection(string $id): bool + public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool { - $id = $this->filter($id); - $namespace = $this->getNamespace(); - $database = $this->getDatabase(); - $colsKey = $this->key($this->ns(), 'cols'); + $collection = $this->filter($collection); + $docKey = $this->docKey($collection, $id); - $this->tx(function (RedisClient $client) use ($id, $namespace, $database, $colsKey): void { - $this->purgeCollectionKeys($client, $namespace, $database, $id); - $client->sRem($colsKey, $id); + return $this->tx(function (RedisClient $redis) use ($collection, $id, $attribute, $value, $updatedAt, $min, $max, $docKey): bool { + $payload = $redis->get($docKey); + if (! \is_string($payload) || $payload === '') { + throw new NotFoundException('Document not found'); + } + + $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 (! \is_null($max) && BigInt::compare($current, $max) > 0) { + return true; + } + $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 (! \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' => $collection, + 'id' => $id, + 'newId' => $id, + 'payload' => $payload, + 'docKey' => $docKey, + ]); + + return true; }); + } - return true; + public function getLimitForString(): int + { + return 4294967295; } - public function analyzeCollection(string $collection): bool + public function getLimitForInt(): int { - // Redis maintains no internal table statistics; mirrors Memory's - // behavior for adapters without a stats subsystem. - return false; + return 4294967295; } - public function getSizeOfCollection(string $collection): int + public function getLimitForBigInt(): int { - return $this->computeCollectionSize($collection); + return Database::MAX_BIG_INT; } - public function getSizeOfCollectionOnDisk(string $collection): int + public function getLimitForAttributes(): 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); + return 1017; } - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): bool + public function getLimitForIndexes(): int { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); + return 64; + } - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); - } + public function getMaxIndexLength(): int + { + return 1024; + } - $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), - ); - }); + public function getMaxVarcharLength(): int + { + return 16381; + } - return true; + public function getMaxUIDLength(): int + { + return 255; } - public function createAttributes(string $collection, array $attributes): bool + public function getMinDateTime(): \DateTime { - 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 new \DateTime('0001-01-01 00:00:00'); + } - return true; + public function getIdAttributeType(): string + { + return ColumnType::Integer->value; } - public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null, bool $required = false): bool + public function getCountOfAttributes(Document $collection): int { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); + $attributes = $collection->getAttribute('attributes', []); - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); - } + return (\is_array($attributes) ? \count($attributes) : 0) + $this->getCountOfDefaultAttributes(); + } - if (! empty($newKey) && $newKey !== $id) { - $this->renameAttribute($collection, $id, $newKey); - $id = $this->filter($newKey); - } + public function getCountOfIndexes(Document $collection): int + { + $indexes = $collection->getAttribute('indexes', []); - $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), - ); - }); + return (\is_array($indexes) ? \count($indexes) : 0) + $this->getCountOfDefaultIndexes(); + } - return true; + public function getCountOfDefaultAttributes(): int + { + return \count(Database::INTERNAL_ATTRIBUTES); } - public function deleteAttribute(string $collection, string $id): bool + public function getCountOfDefaultIndexes(): int { - $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); + return \count(Database::INTERNAL_INDEXES); + } - if ((bool) $this->client->exists($metaKey) === false) { - return true; - } + public function getDocumentSizeLimit(): int + { + 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) { - continue; - } - $filtered[] = $attribute; - } - $client->hSet( - $metaKey, - 'attrs', - \json_encode($filtered, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - }); + public function getAttributeWidth(Document $collection): int + { + return 0; + } - $this->dropDocumentField($collection, $id); + public function getKeywords(): array + { + return []; + } + + public function getInternalIndexesKeys(): array + { + return []; + } + public function setSupportForAttributes(bool $support): bool + { return true; } - public function renameAttribute(string $collection, string $old, string $new): bool + #[\Override] + public function getConnectionId(): string { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); - $metaKey = $this->key($this->ns(), 'meta', $collection); + return '0'; + } - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); - } + protected function execute(mixed $stmt): bool + { + return true; + } - $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; - } - if (! $touched) { - return; - } - $client->hSet( - $metaKey, - 'attrs', - \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - }); + protected function quote(string $string): string + { + return '"'.$string.'"'; + } - $this->renameDocumentField($collection, $old, $new); + private function key(string ...$parts): string + { + return \implode(self::SEP, $parts); + } - return true; + private function ns(): string + { + return $this->nsFor($this->getNamespace(), $this->getDatabase()); } - public function getSchemaAttributes(string $collection): array + private function nsFor(string $namespace, string $database): string { - return []; + return self::KEY_PREFIX.self::SEP.$namespace.self::SEP.$database; } - public function getCountOfAttributes(Document $collection): int + private function nsBase(): string { - return \count($collection->getAttribute('attributes', [])) + $this->getCountOfDefaultAttributes(); + return self::KEY_PREFIX.self::SEP.$this->getNamespace(); } - /** - * 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 + private function docKey(string $collection, string $id, int|string|null $tenant = null): string { - $raw = $client->hGet($metaKey, 'attrs'); - if (! \is_string($raw) || $raw === '') { - return []; + $id = \strtolower($id); + if (! $this->getSharedTables()) { + return $this->key($this->ns(), 'doc', $collection, $id); } - /** @var array> $decoded */ - $decoded = \json_decode($raw, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); - return \array_values($decoded); + $bucket = $this->bucketFor($tenant); + + return $this->key($this->ns(), 'doc', 't', $bucket, $collection, $id); } - /** - * 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 + private function idxKey(string $collection, int|string|null $tenant = null): string { - $metaKey = $this->key($this->ns(), 'meta', $collection); - $indexes = $this->readIndexesField($client, $metaKey); - - $uniqueIndexes = []; - foreach ($indexes as $index) { - if (($index['type'] ?? '') !== Database::INDEX_UNIQUE) { - continue; - } - $attributes = $index['attributes'] ?? []; - if (empty($attributes)) { - continue; - } - $uniqueIndexes[] = $attributes; + if (! $this->getSharedTables()) { + return $this->key($this->ns(), 'idx', $collection); } - if ($uniqueIndexes === []) { - return; - } + return $this->key($this->ns(), 'idx', 't', $this->bucketFor($tenant), $collection); + } - // 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); - if ($value === null) { - $hasNull = true; - break; - } - $signature[] = $this->normalizeIndexValue($value); - } - if ($hasNull) { - continue; - } - if ($sharedTables) { - \array_unshift($signature, $tenant); - } - $newSignatures[$i] = \serialize($signature); - } - - if ($newSignatures === []) { - return; + private function seqKey(string $collection, int|string|null $tenant = null): string + { + if (! $this->getSharedTables()) { + return $this->key($this->ns(), 'seq', $collection); } - $idxKey = $this->idxKey($collection); - /** @var array $docIds */ - $docIds = $client->sMembers($idxKey); - if (empty($docIds)) { - return; - } + return $this->key($this->ns(), 'seq', 't', $this->bucketFor($tenant), $collection); + } - $excludeKey = $excludeId !== null ? \strtolower($excludeId) : null; - $docKeys = []; - foreach ($docIds as $docId) { - if ($excludeKey !== null && \strtolower((string) $docId) === $excludeKey) { - continue; - } - $docKeys[(string) $docId] = $this->docKey($collection, (string) $docId); - } - if ($docKeys === []) { - return; + private function bucketFor(int|string|null $tenant): string + { + if ($tenant === null) { + $tenant = $this->getTenant(); } - /** @var array $payloads */ - $payloads = $client->mGet(\array_values($docKeys)); - $position = 0; - foreach ($docKeys as $docId => $_) { - $payload = $payloads[$position++] ?? null; - if (! \is_string($payload) || $payload === '') { - continue; - } - $existing = $this->decode($payload); - if ($sharedTables) { - $rowTenant = $existing->getAttribute('$tenant'); - if ($rowTenant !== $tenant) { - continue; - } - } - 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); - } - if ($hasNull) { - continue; - } - if ($sharedTables) { - \array_unshift($signature, $tenant); - } - if (\serialize($signature) === $newHash) { - throw new UniqueException('Unique index violation'); - } - } - } + return $tenant === null ? '_' : (string) $tenant; } - /** - * 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 + private function tenantBucket(int|string|null $tenant = null): ?string { - $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; + if (! $this->getSharedTables()) { + return null; } - return \array_values($attrs); + return $this->bucketFor($tenant); } - /** - * 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 + private function permKey(string $collection, string $letter, string $role, int|string|null $tenant = null): string { - $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); - - // 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); - } + $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; } - // 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); + return $this->ns().self::SEP.'perm'.self::SEP.$collection.self::SEP.$letter.self::SEP.$role; + } - // 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 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; + } - $client->del($metaKey, $idxKey, $seqKey); + return $this->ns().self::SEP.'perm'.self::SEP.'doc'.self::SEP.$collection.self::SEP.$id; } - /** - * 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 encode(Document $document): 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 \json_encode( + $document->getArrayCopy(), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION + ); } - /** - * 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 decode(string $payload): Document { - $collection = $this->filter($collection); - $metaKey = $this->key($this->ns(), 'meta', $collection); - - if ((bool) $this->client->exists($metaKey) === false) { - return 0; - } - - $total = $this->measureKey($metaKey); - - $idxKey = $this->idxKey($collection); - $total += $this->measureKey($idxKey); - - /** @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)); - } - } - - // 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 . '*'; + 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); } - $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 Document::fromStorage($data); } /** - * 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. + * @template T + * @param callable(RedisClient): T $fn + * @return T */ - private function measureKey(string $key): int + protected function tx(callable $fn): mixed { 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. - } - - $type = $this->client->type($key); - switch ($type) { - case RedisClient::REDIS_STRING: - $value = $this->client->get($key); - - 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); - } - } - - 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 $bytes; - default: - return 0; + return $fn($this->client); + } catch (\RedisException $exception) { + throw new TransactionException('tx failed: '.$exception->getMessage(), 0, $exception); } } - // === @architect:T20 end === - - - - - - // === @architect:T30 owns: document CRUD + bulk + increase === - - public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + private function writePermissions(string $collection, string $id, Document $document): void { - $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, '_')); - } - - if (! \is_string($payload) || $payload === '') { - return new Document([]); - } - - $document = $this->decode($payload); + $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); + if ($byRole === []) { + return; } - $selections = []; - foreach ($queries as $query) { - if ($query instanceof Query && $query->getMethod() === Query::TYPE_SELECT) { - foreach ($query->getValues() as $value) { - $selections[] = (string) $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)]; } } - 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; - } + $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->client->discard(); + } catch (\Throwable) { + // ignore } - $document = new Document($projected); + throw $e; } - return $document; + foreach ($writes as [$role, $letter, $setKey]) { + $this->journal('createPerm', [ + 'collection' => $collection, + 'id' => $id, + 'role' => $role, + 'letter' => $letter, + 'permKey' => $setKey, + 'permDocKey' => $hashKey, + ]); + } } - public function createDocument(Document $collection, Document $document): Document + private function clearPermissions(string $collection, string $id, int|string|null $tenant = null): void { - $col = $this->filter($collection->getId()); - $id = $document->getId(); - if ($id === '') { - $id = ID::unique(); - $document->setAttribute('$id', $id); + $id = \strtolower($id); + $hashKey = $this->permDocKey($collection, $id, $tenant); + /** @var array|false $hash */ + $hash = $this->client->hGetAll($hashKey); + if ($hash === false || $hash === []) { + return; } - $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'); + $removals = []; + foreach ($hash as $role => $letterCsv) { + if ($letterCsv === '') { + continue; } - - try { - $this->enforceUniqueIndexes($redis, $col, $document); - } catch (DuplicateException $e) { - if ($this->skipDuplicates) { - return $document; - } - throw $e; + foreach (\explode(',', $letterCsv) as $letter) { + $removals[] = [$role, $letter, $this->permKey($collection, $letter, $role, $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); - } + $this->client->multi(\Redis::PIPELINE); + try { + foreach ($removals as [, , $setKey]) { + $this->client->sRem($setKey, $id); } - $document->setAttribute('$sequence', $sequence); - - $redis->set($docKey, $this->encode($document)); - $redis->sAdd($idxKey, \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 + } + throw $e; + } - $this->writePermissions($col, $id, $document); - $this->journal('createDoc', [ - 'collection' => $col, + foreach ($removals as [$role, $letter, $setKey]) { + $this->journal('deletePerm', [ + 'collection' => $collection, 'id' => $id, - 'docKey' => $docKey, - 'idxKey' => $idxKey, - 'permDocKey' => $permDocKey, + 'role' => $role, + 'letter' => $letter, + 'previous' => $hash[$role] ?? '', + '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 + /** + * @param array $ids + * @return array + */ + private function applyPermissionFilter(string $collection, array $ids, PermissionType $action): array { - $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; - } + if ($ids === []) { + return $ids; + } + if ($this->authorization->getStatus() === false) { + return $ids; } - 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'); - } - - $existing = $this->decode($existingPayload); - if ($col !== Database::METADATA) { - $existing = $this->surfaceRelationshipAttributes($col, $existing); - } - $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'); - } - - $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); + $roles = $this->authorization->getRoles(); + if ($roles === []) { + return []; + } - if ($newId !== $id) { - $redis->del($oldKey); - $redis->sRem($effectiveIdxKey, \strtolower($id)); - } - $redis->set($newKey, $payload); - $redis->sAdd($effectiveIdxKey, \strtolower($newId)); + $letter = $this->actionLetter($action); + $keys = []; + foreach ($roles as $role) { + $keys[] = $this->permKey($collection, $letter, $role); + } - $this->journal('updateDoc', [ - 'collection' => $col, - 'id' => $id, - 'newId' => $newId, - 'payload' => $existingPayload, - 'docKey' => $oldKey, - 'newDocKey' => $newKey, - 'idxKey' => $effectiveIdxKey, - ]); + 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 []; + } - if (! $skipPermissions) { - $this->clearPermissions($col, $id); - if ($newId !== $id) { - $this->clearPermissions($col, $newId); - } - $this->writePermissions($col, $newId, $mergedDocument); - } + $allowedSet = \array_flip($allowed); - return $mergedDocument; - }); + return \array_values(\array_filter($ids, static fn (string $id): bool => isset($allowedSet[$id]))); } - public function updateDocuments(Document $collection, Document $updates, array $documents): int + private function actionLetter(PermissionType $action): string { - if (empty($documents)) { - return 0; - } + return match ($action) { + PermissionType::Read => 'r', + PermissionType::Create => 'c', + PermissionType::Update => 'u', + PermissionType::Delete => 'd', + PermissionType::Write => 'w', + }; + } - $attrs = $updates->getAttributes(); - $hasCreatedAt = ! empty($updates->getCreatedAt()); - $hasUpdatedAt = ! empty($updates->getUpdatedAt()); - $hasPermissions = $updates->offsetExists('$permissions'); - if (empty($attrs) && ! $hasCreatedAt && ! $hasUpdatedAt && ! $hasPermissions) { - return 0; + /** + * @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, + ]; + } - $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); - - 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()); - } - - $redis->multi(\Redis::PIPELINE); - foreach ($docKeys as $docKey) { - $redis->get($docKey); - } - $existingPayloads = $redis->exec(); - if (! \is_array($existingPayloads)) { - $existingPayloads = []; - } + /** + * @param array $payload + */ + private function payloadString(array $payload, string $key): ?string + { + $value = $payload[$key] ?? null; - // 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); - } + return \is_string($value) ? $value : null; + } - $count = 0; - foreach ($documents as $i => $doc) { - $uid = $doc->getId(); - $docKey = $docKeys[$i]; - $existingPayload = $existingPayloads[$i] ?? false; - if (! \is_string($existingPayload) || $existingPayload === '') { - continue; - } + /** + * @param array $payload + */ + private function payloadStringOr(array $payload, string $key, string $default): string + { + $value = $payload[$key] ?? null; - $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(); - } + return \is_string($value) ? $value : $default; + } - $mergedDocument = new Document($merged); - $redis->set($docKey, $this->encode($mergedDocument)); + private function stringOrEmpty(mixed $value): string + { + return \is_string($value) ? $value : ''; + } - $this->journal('updateDoc', [ - 'collection' => $col, - 'id' => $uid, - 'newId' => $uid, - 'payload' => $existingPayload, - 'docKey' => $docKey, - ]); + private function numericOr(mixed $value, int|float $default): int|float + { + return \is_numeric($value) ? $value + 0 : $default; + } - if ($hasPermissions) { - $this->clearPermissions($col, $uid); - $this->writePermissions($col, $uid, $mergedDocument); - } + private function intOr(mixed $value, int $default): int + { + return \is_numeric($value) ? (int) $value : $default; + } - $count++; - } + /** + * @param array $record + */ + private function recordIdentifier(array $record): string + { + $id = $record[Document::ID] ?? null; + if (\is_string($id)) { + return $id; + } + $key = $record['key'] ?? null; - return $count; - }); + return \is_string($key) ? $key : ''; } - public function upsertDocuments( - Document $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; + protected function rollbackJournal(): void + { + $frame = \array_pop($this->journalStack); + if ($frame === null) { + return; } - $col = $this->filter($collection->getId()); + for ($i = \count($frame) - 1; $i >= 0; $i--) { + $entry = $frame[$i]; + $op = $entry['op']; + $payload = $entry['payload']; - return $this->tx(function (RedisClient $redis) use ($col, $attribute, $changes): array { - $results = []; + switch ($op) { + 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; - // 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 = []; - } + 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; - // 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); - } + 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)); + } + break; - 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; + 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; - 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; + 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; - $mergedDocument = new Document($merged); - $redis->set($docKey, $this->encode($mergedDocument)); + default: + throw new TransactionException('Unknown journal op: '.$op); + } + } + } - $this->journal('updateDoc', [ - 'collection' => $col, - 'id' => $id, - 'newId' => $id, - 'payload' => $existingPayload, - 'docKey' => $docKey, - ]); + 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)); + } - $this->clearPermissions($col, $id); - $this->writePermissions($col, $id, $mergedDocument); + 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); + } - $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); - } - } - $document->setAttribute('$sequence', $sequence); + /** + * @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); - $resolved = $this->applyOperators($document->getArrayCopy(), []); - foreach ($resolved as $attr => $value) { - $document->setAttribute($attr, $value); - } + return \array_values($decoded); + } - $redis->set($docKey, $this->encode($document)); - $redis->sAdd($idxKey, \strtolower($id)); + /** + * @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 []; + } - $this->writePermissions($col, $id, $document); - $this->journal('createDoc', [ - 'collection' => $col, - 'id' => $id, - 'docKey' => $docKey, - 'idxKey' => $idxKey, - 'permDocKey' => $this->permDocKey($col, $id), - ]); + /** @var array> $decoded */ + return $decoded; + } - $results[] = $document; - } + /** + * @param array> $attrs + * @param array $record + * @return array> + */ + private function upsertAttributeRecord(array $attrs, array $record): array + { + $targetId = $this->stringOrEmpty($record[Document::ID] ?? ''); + $replaced = false; + foreach ($attrs as $i => $existing) { + $existingId = $this->recordIdentifier($existing); + if ($existingId !== $targetId) { + continue; } + $attrs[$i] = $record; + $replaced = true; + break; + } + if (! $replaced) { + $attrs[] = $record; + } - return $results; - }); + return \array_values($attrs); } - public function getSequences(string $collection, array $documents): array + private function enforceUniqueIndexes(RedisClient $client, string $collection, Document $document, ?string $excludeId = null): void { - if (empty($documents)) { - return $documents; - } + $this->enforceUniqueIndexesForDocuments($client, $collection, [$document], $excludeId === null ? [] : [$excludeId]); + } - $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; + /** + * 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 + { + $uniqueIndexes = $this->uniqueIndexAttributes($client, $collection); + if ($uniqueIndexes === []) { + return; } - foreach ($indexes as $position => $index) { - $payload = $payloads[$position] ?? false; - if (! \is_string($payload) || $payload === '') { - continue; + $sharedTables = $this->getSharedTables(); + $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; } - $existing = $this->decode($payload); - $sequence = $existing->getSequence(); - if (! empty($sequence)) { - $documents[$index]->setAttribute('$sequence', (string) $sequence); + 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; } } - return $documents; + 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); + } + } + } } - public function deleteDocument(string $collection, string $id): bool + /** + * 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 { - $collection = $this->filter($collection); - $docKey = $this->docKey($collection, $id); - $idxKey = $this->idxKey($collection); + $uniqueIndexes = $this->uniqueIndexAttributes($client, $collection); + if ($uniqueIndexes === []) { + return; + } - return $this->tx(function (RedisClient $redis) use ($collection, $id, $docKey, $idxKey): bool { - $payload = $redis->get($docKey); - if (! \is_string($payload) || $payload === '') { - return false; + $sharedTables = $this->getSharedTables(); + $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); } - $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)); + $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); + } + } - return true; - }); + 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 deleteDocuments(string $collection, array $sequences, array $permissionIds): int + /** + * 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 { - if (empty($sequences) && empty($permissionIds)) { - return 0; + /** @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 [[], []]; } - $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; + $owners = []; + $held = []; + /** @var array|false $payloads */ + $payloads = $client->mGet($docKeys); + foreach (\is_array($payloads) ? $payloads : [] as $position => $payload) { + if (! \is_string($payload) || $payload === '') { + continue; } - - $allIds = $redis->sMembers($idxKey); - if (! \is_array($allIds)) { - $allIds = []; + $existing = $this->decode($payload); + if ($this->getSharedTables() && $existing->getTenant() !== $tenant) { + continue; + } + $id = $ids[$position]; + $held[$id] = $this->uniqueSignatures($existing, $uniqueIndexes, $tenant); + foreach ($held[$id] as $index => $signature) { + $owners[$index][$signature] = $id; } + } - $docKeys = []; - $redis->multi(\Redis::PIPELINE); - foreach ($allIds as $id) { - $docKey = $this->docKey($collection, (string) $id); - $docKeys[(string) $id] = $docKey; - $redis->get($docKey); + return [$owners, $held]; + } + + /** + * @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; } - $payloads = $redis->exec(); - if (! \is_array($payloads)) { - $payloads = []; + $attributes = $index['attributes'] ?? []; + if (empty($attributes) || ! \is_array($attributes)) { + continue; } - - $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]]; + $names = []; + foreach ($attributes as $attribute) { + if (\is_string($attribute) && $attribute !== '') { + $names[] = $attribute; } } - - 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)); + if ($names !== []) { + $uniqueIndexes[] = $names; } + } - // 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; + return $uniqueIndexes; + } + + /** + * 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; } - $this->clearPermissions($collection, $documentId); + $signature[] = $this->normalizeIndexValue($value); + } + if ($this->getSharedTables()) { + \array_unshift($signature, $tenant); } + $signatures[$index] = \serialize($signature); + } - return \count($deleted); - }); + return $signatures; } - 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 { + private function purgeCollectionKeys(RedisClient $client, string $namespace, string $database, string $collection): void + { $collection = $this->filter($collection); - $docKey = $this->docKey($collection, $id); + $prefix = $this->nsFor($namespace, $database); + $grantsKey = $this->grantsKey($prefix, $collection); - return $this->tx(function (RedisClient $redis) use ($collection, $id, $attribute, $value, $updatedAt, $min, $max, $docKey): bool { - $payload = $redis->get($docKey); - if (! \is_string($payload) || $payload === '') { - throw new NotFoundException('Document not found'); - } + /** @var array|false $registered */ + $registered = $client->sMembers($grantsKey); + $keys = \is_array($registered) ? $registered : []; - $document = $this->decode($payload); - $current = $document->getAttribute($attribute); - $current = \is_numeric($current) ? $current + 0 : 0; + $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); - // 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; + /** @var array|false $docIds */ + $docIds = $client->sMembers($idxKey); + if (! \is_array($docIds) || $docIds === []) { + continue; } - if (! \is_null($max) && $current > $max) { - return true; + + $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)); + } - $document->setAttribute($attribute, $current + $value); - $document->setAttribute('$updatedAt', $updatedAt); + $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); + } + } - $redis->set($docKey, $this->encode($document)); + private function grantsKey(string $prefix, string $collection): string + { + return $this->key($prefix, 'grants', $collection); + } - $this->journal('updateDoc', [ - 'collection' => $collection, - 'id' => $id, - 'newId' => $id, - 'payload' => $payload, - 'docKey' => $docKey, - ]); + /** + * @param array $permDocKeys + * @return array + */ + private function roleSetKeys(RedisClient $client, string $prefix, ?string $bucket, string $collection, array $permDocKeys): array + { + if ($permDocKeys === []) { + return []; + } - return true; - }); - } + $client->multi(\Redis::PIPELINE); + foreach ($permDocKeys as $permDocKey) { + $client->hGetAll($permDocKey); + } + $grantsByDocument = $client->exec(); - // === @architect:T30 end === + $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 : []; + if ($bucket === null) { + return $keys; + } + $scope = $this->scopedKey($prefix, 'perm', $bucket).self::SEP; - // === @architect:T40 owns: indexes + queries + counts === + return \array_values(\array_filter($keys, static fn (string $key): bool => \str_starts_with($key, $scope))); + } - 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 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); - - if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); - } + $scope = $bucket === null ? [] : ['t', $bucket]; - return $this->tx(function (RedisClient $client) use ($metaKey, $collection, $id, $type, $attributes, $lengths, $orders): bool { - $indexes = $this->readIndexesField($client, $metaKey); + return $this->key($prefix, $family, ...$scope, ...$parts); + } - foreach ($indexes as $existing) { - if (($existing['$id'] ?? $existing['key'] ?? null) === $id) { - throw new DuplicateException('Index already exists'); + /** + * 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; } - } - - // 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 DuplicateException('Cannot create unique index: existing rows already contain duplicate values'); - } - $seen[$hash] = true; - } + $bucket = \substr($key, \strlen($head), -\strlen($tail)); + if ($bucket !== '' && ! \str_contains($bucket, self::SEP)) { + $buckets[] = $bucket; } } + } while ($cursor !== 0 && $cursor !== null); - $indexes[] = [ - '$id' => $id, - 'key' => $id, - 'type' => $type, - 'attributes' => \array_values($attributes), - 'lengths' => \array_values($lengths), - 'orders' => \array_values($orders), - ]; - - $client->hSet( - $metaKey, - 'indexes', - \json_encode($indexes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); - - return true; - }); + return $buckets; } - public function deleteIndex(string $collection, string $id): bool + private function computeCollectionSize(string $collection): int { $collection = $this->filter($collection); - $id = $this->filter($id); - $metaKey = $this->key($this->ns(), 'meta', $collection); + $prefix = $this->ns(); + $metaKey = $this->key($prefix, 'meta', $collection); if ((bool) $this->client->exists($metaKey) === false) { - return true; + return 0; } - 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) { - continue; - } - $filtered[] = $index; + $bucket = $this->tenantBucket(); + $idxKey = $this->idxKey($collection); + $keys = [$metaKey, $idxKey]; + + /** @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), + ); + + $total = 0; + foreach (\array_unique($keys) as $key) { + $total += $this->measureKey($key); + } + + return $total; + } + + private function measureKey(string $key): int + { + 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. + } - $client->hSet( - $metaKey, - 'indexes', - \json_encode(\array_values($filtered), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); + $type = $this->client->type($key); + switch ($type) { + case RedisClient::REDIS_STRING: + $value = $this->client->get($key); - return true; - }); + 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); + } + } + + 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 $bytes; + default: + return 0; + } } - public function renameIndex(string $collection, string $old, string $new): bool + private function registerRelationshipField(string $collection, string $field): void { $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); + $field = $this->filter($field); $metaKey = $this->key($this->ns(), 'meta', $collection); if ((bool) $this->client->exists($metaKey) === false) { - throw new NotFoundException('Collection not found'); + return; } - 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; - } - } - - if (! $changed) { - return true; - } - - $client->hSet( - $metaKey, - 'indexes', - \json_encode(\array_values($indexes), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE), - ); + $record = [ + Document::ID => $field, + 'key' => $field, + 'type' => ColumnType::Relationship->value, + 'size' => 0, + 'signed' => true, + 'array' => false, + 'required' => false, + ]; - return true; + $this->tx(function (RedisClient $client) use ($metaKey, $record): void { + $attrs = $this->readAttributesField($client, $metaKey); + $attrs = $this->upsertAttributeRecord($attrs, $record); + $client->hSet($metaKey, 'attrs', \json_encode($attrs, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); }); } - 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 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, $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); + $idxKey = $this->idxKey($collection); - if (! \is_null($offset)) { - $documents = \array_slice($documents, $offset); - } - if (! \is_null($limit)) { - $documents = \array_slice($documents, 0, $limit); + $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; } - $selections = $this->extractSelectionsFromQueries($queries); - if (! empty($selections)) { - $projected = []; - foreach ($documents as $document) { - $projected[] = $this->projectDocument($document, $selections); + foreach ($docIds as $docId) { + $docKey = $this->docKey($collection, $docId); + $payload = $client->get($docKey); + if (! \is_string($payload) || $payload === '') { + continue; } - $documents = $projected; - } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $documents = \array_reverse($documents); - } + /** @var array $decoded */ + $decoded = \json_decode($payload, true, self::JSON_DECODE_DEPTH, JSON_THROW_ON_ERROR); + if (! \array_key_exists($oldKey, $decoded)) { + continue; + } - return $documents; + $decoded[$newKey] = $decoded[$oldKey]; + unset($decoded[$oldKey]); + + $client->set( + $docKey, + \json_encode($decoded, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + ); + } }); } - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + private function dropDocumentField(string $collection, string $field): void { - $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'); - } - - 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); + $collection = $this->filter($collection); + $field = $this->filter($field); + $idxKey = $this->idxKey($collection); - if (! \is_null($max)) { - $documents = \array_slice($documents, 0, $max); + $this->tx(function (RedisClient $client) use ($collection, $field, $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($field, $decoded)) { + continue; } - } - return $isFloat ? (float) $sum : (int) $sum; + 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, RelationSide $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 === RelationSide::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->getSharedTables()) { + $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; + } - return \count($documents); - }); + $metaKey = $this->key($this->ns(), 'meta', $this->filter($collection)); + $attributes = $this->readAttributesField($this->client, $metaKey); + $relationshipKeys = $this->extractRelationshipKeys($attributes); + if ($relationshipKeys === []) { + return $document; + } + + 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)) { @@ -3008,9 +2709,6 @@ private function loadCollectionDocuments(RedisClient $client, string $collection $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 +2724,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 +2743,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 +2757,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 +2792,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 +2806,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 +2825,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 +2840,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 +2855,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 +2928,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 +2955,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 +3000,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 +3012,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 +3024,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 +3037,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 +3049,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 +3061,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) { @@ -3414,11 +3108,11 @@ private function orderDocuments(array $documents, array $orderAttributes, array $directions = []; foreach ($orderAttributes as $i => $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; } \usort($documents, function (Document $a, Document $b) use ($orderAttributes, $directions): int { @@ -3446,35 +3140,33 @@ 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; + $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; } $resolved[] = [ 'attribute' => $attribute, - 'asc' => $direction === Database::ORDER_ASC, + 'asc' => $direction === OrderDirection::Asc, 'ref' => $cursor[$attribute] ?? null, ]; } @@ -3512,16 +3204,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 +3234,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 +3252,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 +3267,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 +3280,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 +3296,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 +3317,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 +3366,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 +3380,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 +3431,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 +3452,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 +3462,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,208 +3475,9 @@ 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 $attrs + * @param array $existing * @return array */ protected function applyOperators(array $attrs, array $existing): array @@ -4046,138 +3496,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 +3623,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 +3721,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 +3738,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; + + /** + * @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 __construct(object $pdo) + { + $this->pdo = $pdo; + } + + /** + * Get the list of capabilities supported by SQL adapters. + * + * @return array + */ + public function capabilities(): array + { + return array_merge(parent::capabilities(), [ + Capability::AtomicTransactions, + Capability::Schemas, + Capability::BoundaryInclusive, + Capability::Caching, + Capability::Fulltext, + Capability::MultipleFulltextIndexes, + Capability::Regex, + Capability::Casting, + Capability::UpdateLock, + Capability::BatchOperations, + Capability::BatchCreateAttributes, + Capability::TransactionRetries, + Capability::NestedTransactions, + Capability::QueryContains, + Capability::Operators, + Capability::OrderRandom, + Capability::IdenticalIndexes, + Capability::Reconnection, + Capability::CacheSkipOnFailure, + Capability::Hostname, + Capability::AttributeResizing, + Capability::DefinedAttributes, + Capability::Joins, + Capability::Aggregations, + Capability::StatisticalAggregates, + Capability::BitwiseAggregates, + ]); + } + + /** + * Returns the current PDO object + * + * @deprecated Use getDriver() instead + */ + protected function getPDO(): DatabasePDO|PDOProxy|PDO + { + 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'); + } + + /** + * Returns the current PDO object + */ + public function getDriver(): DatabasePDO|PDOProxy|PDO + { + return $this->getPDO(); + } + + /** + * Returns default PDO configuration + * + * @return array + */ + public static function getPDOAttributes(): 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 + ]; + } + /** * Configure float precision for parameter binding/logging. */ @@ -42,35 +234,91 @@ public function setFloatPrecision(int $precision): void */ protected function getFloatPrecision(float $value): string { - return sprintf('%.'. $this->floatPrecision . 'F', $value); + return sprintf('%.'.$this->floatPrecision.'F', $value); + } + + /** + * Get the hostname of the database connection. + * + * @return string + */ + public function getHostname(): string + { + try { + if ($this->pdo instanceof DatabasePDO) { + return $this->pdo->getHostname(); + } + + return $this->hostname; + } catch (Throwable) { + return ''; + } } /** - * Build conditions threading `$name` to per-query builders so adapter - * overrides (SQLite FTS5 routing) can resolve auxiliary tables. + * Get the internal ID attribute type used by SQL adapters. * - * @param array $queries - * @param array $binds + * @return string */ - protected function getSQLConditionsForCollection(string $name, array $queries, array &$binds, string $separator = 'AND'): string + public function getIdAttributeType(): string { - return $this->getSQLConditions($queries, $binds, $separator, $name); + return ColumnType::Integer->value; } /** - * Constructor. + * Set whether the adapter supports attribute definitions. Always true for SQL. * - * Set connection and settings + * @param bool $support Whether to enable attribute support + * @return bool + */ + public function setSupportForAttributes(bool $support): bool + { + return true; + } + + /** + * Get the ALTER TABLE lock type clause for concurrent DDL operations. * - * @param mixed $pdo + * @return string */ - public function __construct(mixed $pdo) + public function getLockType(): string { - $this->pdo = $pdo; + if ($this->supports(Capability::AlterLock) && $this->alterLocks) { + return ',LOCK=SHARED'; + } + + return ''; } /** - * @inheritDoc + * Ping Database + * + * @throws Exception + * @throws PDOException + */ + public function ping(): bool + { + $result = $this->createBuilder()->fromNone()->selectRaw('1')->build(); + + return $this->prepareStatement($result->query)->execute(); + } + + /** + * Reconnect to the database and reset the transaction counter. + * + * @return void + */ + public function reconnect(): void + { + $pdo = $this->getPDO(); + if ($pdo instanceof DatabasePDO) { + $pdo->reconnect(); + } + $this->inTransaction = 0; + } + + /** + * {@inheritDoc} */ public function startTransaction(): bool { @@ -81,7 +329,7 @@ public function startTransaction(): bool $this->getPDO()->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 +338,18 @@ public function startTransaction(): bool // effort; swallow it and begin a fresh transaction below. } - $this->getPDO()->beginTransaction(); + $result = $this->getPDO()->beginTransaction(); } else { - $this->getPDO()->exec('SAVEPOINT transaction' . $this->inTransaction); + $this->getPDO()->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++; @@ -105,7 +358,7 @@ public function startTransaction(): bool } /** - * @inheritDoc + * {@inheritDoc} */ public function commitTransaction(): bool { @@ -113,13 +366,15 @@ public function commitTransaction(): bool return false; } - if (!$this->getPDO()->inTransaction()) { + if (! $this->getPDO()->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; } @@ -127,10 +382,10 @@ public function commitTransaction(): bool $result = $this->getPDO()->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'); } @@ -138,7 +393,7 @@ public function commitTransaction(): bool } /** - * @inheritDoc + * {@inheritDoc} */ public function rollbackTransaction(): bool { @@ -148,74 +403,59 @@ public function rollbackTransaction(): bool try { if ($this->inTransaction > 1) { - $this->getPDO()->exec('ROLLBACK TO transaction' . ($this->inTransaction - 1)); + $this->getPDO()->exec('ROLLBACK TO transaction'.($this->inTransaction - 1)); $this->inTransaction--; + $result = true; } else { - $this->getPDO()->rollBack(); + $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); + throw new DatabaseException('Failed to rollback transaction: '.$e->getMessage(), $e->getCode(), $e); } - return true; - } - - /** - * Ping Database - * - * @return bool - * @throws Exception - * @throws PDOException - */ - public function ping(): bool - { - return $this->getPDO() - ->prepare("SELECT 1;") - ->execute(); - } + if ($result !== true) { + throw new TransactionException('Failed to rollback transaction'); + } - public function reconnect(): void - { - $this->getPDO()->reconnect(); - $this->inTransaction = 0; + return true; } /** * Check if Database exists * Optionally check if collection exists in Database * - * @param string $database - * @param string|null $collection - * @return bool * @throws DatabaseException */ public function exists(string $database, ?string $collection = null): bool { $database = $this->filter($database); - if (!\is_null($collection)) { + 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); + $builder = $this->createBuilder(); + $result = $builder + ->from('INFORMATION_SCHEMA.TABLES') + ->selectRaw('TABLE_NAME') + ->filter([ + BaseQuery::equal('TABLE_SCHEMA', [$database]), + BaseQuery::equal('TABLE_NAME', ["{$this->getNamespace()}_{$collection}"]), + ]) + ->build(); + $stmt = $this->executeResult($result, Event::CollectionRead); } else { - $stmt = $this->getPDO()->prepare(" - SELECT SCHEMA_NAME FROM - INFORMATION_SCHEMA.SCHEMATA - WHERE SCHEMA_NAME = :schema - "); - $stmt->bindValue(':schema', $database, \PDO::PARAM_STR); + $builder = $this->createBuilder(); + $result = $builder + ->from('INFORMATION_SCHEMA.SCHEMATA') + ->selectRaw('SCHEMA_NAME') + ->filter([BaseQuery::equal('SCHEMA_NAME', [$database])]) + ->build(); + $stmt = $this->executeResult($result, Event::DatabaseList); } try { - $stmt->execute(); + $this->execute($stmt); $document = $stmt->fetchAll(); $stmt->closeCursor(); } catch (PDOException $e) { @@ -248,27 +488,29 @@ public function list(): array /** * 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 + 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->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($collection)); + $this->addTableColumn($table, $attribute->key, $attribute->type, $attribute->size, $attribute->signed, $attribute->array, $attribute->required); + $result = $table->alter(); + + $sql = $result->query; + $lockType = $this->getLockType(); + if (! empty($lockType)) { + $sql = rtrim($sql, ';').' '.$lockType; + } try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, $event); } catch (PDOException $e) { throw $this->processException($e); } @@ -277,1219 +519,2291 @@ public function createAttribute(string $collection, string $id, string $type, in /** * Create Attributes * - * @param string $collection - * @param array> $attributes - * @return bool + * @param array $attributes + * * @throws DatabaseException */ public function createAttributes(string $collection, array $attributes): bool { - $parts = []; + $schema = $this->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($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, + $this->addTableColumn( + $table, + $attribute->key, + $attribute->type, + $attribute->size, + $attribute->signed, + $attribute->array, + $attribute->required, ); - $parts[] = "{$id} {$type}"; } + $result = $table->alter(); - $columns = \implode(', ADD COLUMN ', $parts); - - $sql = "ALTER TABLE {$this->getSQLTable($collection)} ADD COLUMN {$columns} {$this->getLockType()};"; - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + $sql = $result->query; + $lockType = $this->getLockType(); + if (! empty($lockType)) { + $sql = rtrim($sql, ';').' '.$lockType; + } try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::AttributesCreate); } catch (PDOException $e) { throw $this->processException($e); } } /** - * Rename Attribute + * Delete Attribute * - * @param string $collection - * @param string $old - * @param string $new - * @return bool * @throws Exception * @throws PDOException */ - public function renameAttribute(string $collection, string $old, string $new): bool + public function deleteAttribute(string $collection, string $id): bool { - $collection = $this->filter($collection); - $old = $this->quote($this->filter($old)); - $new = $this->quote($this->filter($new)); + $schema = $this->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($collection)); + $table->dropColumn($this->filter($id)); + $result = $table->alter(); - $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME COLUMN {$old} TO {$new};"; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + $sql = $result->query; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::AttributeDelete); } catch (PDOException $e) { throw $this->processException($e); } } /** - * Delete Attribute + * Rename 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 + public function renameAttribute(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); + if ($this->isRenamed($collection, $old, $new)) { + return true; + } + + $schema = $this->createSchemaBuilder(); + $table = $schema->table($this->getSQLTableRaw($collection)); + $table->renameColumn($this->filter($old), $this->filter($new)); + $result = $table->alter(); + + $sql = $result->query; try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->executeStatement($sql, Event::AttributeUpdate); } catch (PDOException $e) { throw $this->processException($e); } } + /** + * 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. + * + * @throws DatabaseException + */ + protected function isRenamed(string $collection, string $old, string $new): bool + { + $old = $this->filter($old); + $new = $this->filter($new); + + if ($old === $new) { + return false; + } + + $columns = $this->getColumnNames($collection); + + return ! \in_array($old, $columns, true) && \in_array($new, $columns, true); + } + + /** + * 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; + /** * Get Document * - * @param Document $collection - * @param string $id - * @param Query[] $queries - * @param bool $forUpdate - * @return Document + * @param Query[] $queries + * * @throws DatabaseException */ 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; - $forUpdate = $forUpdate ? 'FOR UPDATE' : ''; + // Fast path: single-row lookup by primary key with no projection, + // no shared-tenant filter, no joins, and no row lock. This is by far + // the most common shape (metadata fetch, primary cache miss, etc.); + // skip the builder pipeline and go directly to a parameterised SELECT. + if ( + empty($selections) + && ! $forUpdate + && ! $this->sharedTables + && ! $this->queriesHaveJoins($queries) + ) { + $tableExpr = $this->getSQLTable($name); + $aliasQuoted = $this->quote($alias); + $uidQuoted = $this->quote(Storage::UID); + $sql = "SELECT * FROM {$tableExpr} AS {$aliasQuoted} WHERE {$this->collateDocumentId($uidQuoted)} = " . ':'.Storage::UID; + $stmt = null; + $row = false; + $exception = null; + + try { + $stmt = $this->prepareStatement($sql, Event::DocumentRead); + $stmt->bindValue(':'.Storage::UID, $id, PDO::PARAM_STR); + $this->describeStatement($stmt, [':'.Storage::UID => $id], $name); + $this->execute($stmt); + /** @var array|false $row */ + $row = $stmt->fetch(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($stmt !== null) { + try { + $stmt->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; + } + } + } - $alias = Query::DEFAULT_ALIAS; + if ($exception !== null) { + throw $this->processException($exception); + } + + 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)); + $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 (! empty($selections) && ! \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. + * Create Documents in batches * - * @param Document $collection - * @param Document $updates - * @param array $documents + * Under skipDuplicates() only the documents written are returned and handed to the write + * hooks, so a skipped document writes no permission rows for a stored one. * - * @return int + * @param array $documents + * @return array * - * @throws DatabaseException + * @throws DuplicateException + * @throws Throwable */ - public function updateDocuments(Document $collection, Document $updates, array $documents): int + 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 (!empty($updates->getUpdatedAt())) { - $attributes['_updatedAt'] = $updates->getUpdatedAt(); - } + if ($this->skipDuplicates) { + $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->getCreatedAt())) { - $attributes['_createdAt'] = $updates->getCreatedAt(); + if (! empty($documents)) { + $context = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, $documents, $context)); + } + } catch (PDOException $e) { + throw $this->processException($e); } - if ($updates->offsetExists('$permissions')) { - $attributes['_permissions'] = json_encode($updates->getPermissions()); - } + return $documents; + } - if (empty($attributes)) { - return 0; - } + protected function supportsInsertReturning(): bool + { + return true; + } - $keyIndex = 0; - $operatorBinds = []; - $columns = ''; + /** + * 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'); + } - 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, ','); + return $hasSequence ?? false; + } - if (empty($columns)) { - return 0; + /** + * 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; } - $name = $this->filter($collection); - $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); - - $sql = " - UPDATE {$this->getSQLTable($name)} - SET {$columns} - WHERE _id IN (" . \implode(', ', \array_map(fn ($index) => ":_id_{$index}", \array_keys($sequences))) . ") - {$this->getTenantQuery($collection)} - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENTS_UPDATE, $sql); - $stmt = $this->getPDO()->prepare($sql); - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } + return $firstCopies; + } - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); + /** + * @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; } - $keyIndex = 0; - foreach ($attributes as $attributeName => $value) { - // Skip operators as they don't need value binding - if (Operator::isOperator($value)) { - continue; - } - - // 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); - } - - $bindKey = 'key_' . $keyIndex; - if ($this->getSupportForIntegerBooleans()) { - $value = (\is_bool($value)) ? (int)$value : $value; + foreach ($documents as $document) { + foreach ($document->getAttributes() as $key => $value) { + $attributeKeySet[$key] = true; } - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $keyIndex++; } - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $attributeKeys = \array_keys($attributeKeySet); - try { - $stmt->execute(); - } catch (PDOException $e) { - throw $this->processException($e); + if ($hasSequence) { + $attributeKeys[] = Storage::SEQUENCE; } - $affected = $stmt->rowCount(); - - // Permissions logic - if ($updates->offsetExists('$permissions')) { - $removeQueries = []; - $removeBindValues = []; + $builder = $this->createBuilder()->into($this->getSQLTableRaw($name)); - $addQuery = ''; - $addBindValues = []; + $spatialMap = \array_fill_keys($spatialAttributes, true); - foreach ($documents as $index => $document) { - if ($document->getAttribute('$skipPermissionsUpdate', false)) { - continue; - } + foreach ($spatialAttributes as $spatialColumn) { + $builder->insertColumnExpression($spatialColumn, $this->getSpatialGeomFromText('?')); + } - $sql = " - SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} - WHERE _document = :_uid - {$this->getTenantQuery($collection)} - "; + $intBools = $this->supports(Capability::IntegerBooleans); - $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + foreach ($documents as $document) { + $row = $this->buildDocumentRow($document, $attributeKeys, $spatialMap, $intBools); + $row = $this->decorateRow($row, $this->documentMetadata($document)); + $builder->set($row); + } - $permissionsStmt = $this->getPDO()->prepare($sql); - $permissionsStmt->bindValue(':_uid', $document->getId()); + return $builder; + } - if ($this->sharedTables) { - $permissionsStmt->bindValue(':_tenant', $this->tenant); - } + /** + * @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(); - $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(); - $permissionsStmt->closeCursor(); + 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); + } - $initial = []; - foreach (Database::PERMISSIONS as $type) { - $initial[$type] = []; - } + $statement = $this->executeResult($insert, Event::DocumentsCreate); + $this->execute($statement); + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_NUM); + $statement->closeCursor(); - $permissions = \array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; - return $carry; - }, $initial); + $written = $this->rowKeys($rows); - // Get removed Permissions - $removals = []; - foreach (Database::PERMISSIONS as $type) { - $diff = array_diff($permissions[$type], $updates->getPermissionsByType($type)); - if (!empty($diff)) { - $removals[$type] = $diff; - } - } + return \array_values(\array_filter( + $documents, + fn (Document $document): bool => $this->hasKey($written, $document), + )); + } - // 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))) . - ") - )"; - } - } + /** + * 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 []; + } - // Get added Permissions - $additions = []; - foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($updates->getPermissionsByType($type), $permissions[$type]); - if (!empty($diff)) { - $additions[$type] = $diff; - } - } + $statement = $this->executeResult( + $this->insertOrIgnore($this->buildDocumentsInsert($name, $candidates, $spatialAttributes, $hasSequence)), + Event::DocumentsCreate, + ); + $this->execute($statement); + $written = $statement->rowCount(); + $statement->closeCursor(); - // 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(); + if ($written === \count($candidates)) { + return $candidates; + } - $bindKey = 'add_' . $type . '_' . $index . '_' . $i; - $addBindValues[$bindKey] = $permission; + $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; + } - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + return \array_values(\array_filter( + $candidates, + function (Document $document) use ($permissions): bool { + [$tenant, $id] = $this->documentKey($document); - if ($this->sharedTables) { - $addQuery .= ", :_tenant)"; - } else { - $addQuery .= ")"; - } + return \array_key_exists($id, $permissions[$tenant] ?? []) + && $permissions[$tenant][$id] === $document->getPermissions(); + }, + )); + } - if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { - $addQuery .= ', '; - } - } - } - if ($index !== \array_key_last($documents)) { - $addQuery .= ', '; - } - } + /** + * @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; } + } - if (!empty($removeQueries)) { - $removeQuery = \implode(' OR ', $removeQueries); + $builder = $this->newBuilder($name, tenants: $tenants); + $builder->select($columns); + $builder->filter([BaseQuery::equal(Storage::UID, \array_values(\array_unique($ids)))]); - $stmtRemovePermissions = $this->getPDO()->prepare(" - DELETE - FROM {$this->getSQLTable($name . '_perms')} - WHERE ({$removeQuery}) - "); + $statement = $this->executeResult($builder->build(), Event::DocumentRead); + $this->execute($statement); + /** @var list> $rows */ + $rows = $statement->fetchAll(PDO::FETCH_NUM); + $statement->closeCursor(); - foreach ($removeBindValues as $key => $value) { - $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); - } + return $rows; + } - if ($this->sharedTables) { - $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); - } - $stmtRemovePermissions->execute(); - } + /** + * @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; + } - if (!empty($addQuery)) { - $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission - "; + return $keys; + } - if ($this->sharedTables) { - $sqlAddPermissions .= ', _tenant)'; - } else { - $sqlAddPermissions .= ')'; - } + /** + * @param list $row + */ + private function rowId(array $row): string + { + $id = $row[0] ?? null; - $sqlAddPermissions .= " VALUES {$addQuery}"; + return \is_scalar($id) ? (string) $id : ''; + } - $stmtAddPermissions = $this->getPDO()->prepare($sqlAddPermissions); + /** + * @param list $row + */ + private function rowTenant(array $row): string + { + $tenant = $this->sharedTables ? ($row[1] ?? null) : null; - foreach ($addBindValues as $key => $value) { - $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); - } + return \is_scalar($tenant) ? (string) $tenant : ''; + } - if ($this->sharedTables) { - $stmtAddPermissions->bindValue(':_tenant', $this->tenant); - } + /** + * @param array> $keys + */ + private function hasKey(array $keys, Document $document): bool + { + [$tenant, $id] = $this->documentKey($document); - $stmtAddPermissions->execute(); - } - } + return isset($keys[$tenant][$id]); + } - return $affected; + /** + * @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; + } /** - * Delete Documents + * Update documents + * + * Updates all documents which match the given query. * - * @param string $collection - * @param array $sequences - * @param array $permissionIds + * @param array $documents * - * @return int * @throws DatabaseException */ - public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int + public function updateDocuments(Document $collection, Document $updates, array $documents): int { - if (empty($sequences)) { + if (empty($documents)) { return 0; } - 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)} - "; + $spatialAttributes = $this->getSpatialAttributes($collection); + $collection = $collection->getId(); - $sql = $this->trigger(Database::EVENT_DOCUMENTS_DELETE, $sql); + $attributes = $updates->getAttributes(); - $stmt = $this->getPDO()->prepare($sql); + if (! empty($updates->getUpdatedAt())) { + $attributes[Storage::UPDATED_AT] = $updates->getUpdatedAt(); + } - foreach ($sequences as $id => $value) { - $stmt->bindValue(":_id_{$id}", $value); - } + if (! empty($updates->getCreatedAt())) { + $attributes[Storage::CREATED_AT] = $updates->getCreatedAt(); + } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } + if ($updates->offsetExists(Document::PERMISSIONS)) { + $attributes[Storage::PERMISSIONS] = json_encode($updates->getPermissions()); + } - if (!$stmt->execute()) { - throw new DatabaseException('Failed to delete documents'); - } + if (empty($attributes)) { + return 0; + } - 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)} - "; + $name = $this->filter($collection); - $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); + // Build the UPDATE using the query builder + $builder = $this->newBuilder($name); - $stmtPermissions = $this->getPDO()->prepare($sql); + // 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); - foreach ($permissionIds as $id => $value) { - $stmtPermissions->bindValue(":_id_{$id}", $value); - } + $regularRow = []; + $spatialRows = []; + $operators = []; - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); - } + foreach ($attributes as $attribute => $value) { + if (Operator::isOperator($value)) { + $operators[$attribute] = $value; - if (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to delete permissions'); - } + continue; } - } catch (\Throwable $e) { - throw new DatabaseException($e->getMessage(), $e->getCode(), $e); - } - - return $stmt->rowCount(); - } - /** - * Assign internal IDs for the given documents - * - * @param string $collection - * @param array $documents - * @return array - * @throws DatabaseException - */ - public function getSequences(string $collection, array $documents): array - { - $documentIds = []; - $keys = []; - $binds = []; - $tenants = []; + if (isset($spatialMap[$attribute])) { + $spatialRows[$this->filter($attribute)] = $this->encodeSpatialWriteValue($value); - foreach ($documents as $i => $document) { - if (empty($document->getSequence())) { - $documentIds[] = $document->getId(); + continue; + } - $key = ":uid_{$i}"; + $column = $this->filter($attribute); - $binds[$key] = $document->getId(); - $keys[] = $key; + if (\is_array($value)) { + $value = \json_encode($value); + } + if ($intBools && \is_bool($value)) { + $value = (int) $value; + } - if ($this->sharedTables) { - $tenant = $document->getTenant(); + $regularRow[$column] = $value; + } - // One placeholder per distinct tenant - if (!\in_array($tenant, $tenants, true)) { - $binds[':_tenant_'.\count($tenants)] = $tenant; - $tenants[] = $tenant; - } - } - } + if (! empty($regularRow)) { + $builder->set($regularRow); } - if (empty($documentIds)) { - return $documents; + // Spatial attributes use setRaw with ST_GeomFromText(?) + foreach ($spatialRows as $column => $value) { + $builder->setRaw($column, $this->getSpatialGeomFromText('?'), [$value]); } - $placeholders = implode(',', array_values($keys)); + // Operator attributes use setRaw with converted expressions + foreach ($operators as $attribute => $operator) { + $column = $this->filter($attribute); + /** @var Operator $operator */ + $opResult = $this->getOperatorBuilderExpression($column, $operator); + $builder->setRaw($column, $opResult['expression'], $opResult['bindings']); + } - $sql = " - SELECT _uid, _id - FROM {$this->getSQLTable($collection)} - WHERE {$this->quote('_uid')} IN ({$placeholders}) - {$this->getTenantQuery($collection, tenantCount: \count($tenants))} - "; + // WHERE _id IN (sequence values) + $sequences = \array_map(fn ($document) => $document->getSequence(), $documents); + $builder->filter([BaseQuery::equal(Storage::SEQUENCE, \array_values($sequences))]); - $stmt = $this->getPDO()->prepare($sql); + $result = $builder->update(); + $stmt = $this->executeResult($result, Event::DocumentsUpdate); - foreach ($binds as $key => $value) { - $stmt->bindValue($key, $value); + try { + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); } - $stmt->execute(); - $sequences = $stmt->fetchAll(\PDO::FETCH_KEY_PAIR); // Fetch as [documentId => sequence] - $stmt->closeCursor(); + $affected = $stmt->rowCount(); - foreach ($documents as $document) { - if (isset($sequences[$document->getId()])) { - $document['$sequence'] = $sequences[$document->getId()]; - } - } + $ctx = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentBatchUpdate($name, $updates, $documents, $ctx)); - return $documents; + return $affected; } /** - * Get max STRING limit + * @param array $changes + * @return array * - * @return int + * @throws DatabaseException */ - public function getLimitForString(): int - { - return 4294967295; - } + public function upsertDocuments( + Document $collection, + string $attribute, + array $changes + ): array { + if (empty($changes)) { + return $changes; + } - /** - * Get max INT limit - * - * @return int - */ - public function getLimitForInt(): int - { - return 4294967295; - } + $this->syncWriteHooks(); - /** - * Get max BIGINT limit - * - * @return int - */ - public function getLimitForBigInt(): int - { - return Database::MAX_BIG_INT; - } + try { + $spatialAttributes = $this->getSpatialAttributes($collection); - /** - * 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; - } + /** @var array $attributeDefaults */ + $attributeDefaults = []; + /** @var array $collAttrs */ + $collAttrs = $collection->getAttribute('attributes', []); + foreach ($collAttrs as $attr) { + /** @var array $attr */ + $attrIdRaw = $attr[Document::ID] ?? ''; + $attrId = \is_scalar($attrIdRaw) ? (string) $attrIdRaw : ''; + $attributeDefaults[$attrId] = $attr['default'] ?? null; + } - /** - * Get maximum index limit. - * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema - * - * @return int - */ - public function getLimitForIndexes(): int - { - return 64; - } + $collection = $collection->getId(); + $name = $this->filter($collection); - /** - * Is schemas supported? - * - * @return bool - */ - public function getSupportForSchemas(): bool - { - return true; - } + $hasOperators = false; + $firstChange = $changes[0]; + $firstDoc = $firstChange->getNew(); + $firstExtracted = Operator::extractOperators($firstDoc->getAttributes()); - /** - * Is index supported? - * - * @return bool - */ - public function getSupportForIndex(): bool - { - return true; - } + 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; + } + } + } - /** - * Are attributes supported? - * - * @return bool - */ - public function getSupportForAttributes(): bool - { - return true; - } + if (! $hasOperators) { + $this->executeUpsertBatch($name, $changes, $spatialAttributes, $attribute, [], $attributeDefaults, false); + } else { + $groups = []; - /** - * Is unique index supported? - * - * @return bool - */ - public function getSupportForUniqueIndex(): bool - { - return true; - } + foreach ($changes as $change) { + $document = $change->getNew(); + $extracted = Operator::extractOperators($document->getAttributes()); + $operators = $extracted['operators']; - /** - * Is fulltext index supported? - * - * @return bool - */ - public function getSupportForFulltextIndex(): bool - { - return true; - } + if (empty($operators)) { + $signature = 'no_ops'; + } else { + $parts = []; + foreach ($operators as $attr => $op) { + $parts[] = $attr.':'.$op->getMethod()->value.':'.json_encode($op->getValues()); + } + sort($parts); + $signature = implode('|', $parts); + } - /** - * Are FOR UPDATE locks supported? - * - * @return bool - */ - public function getSupportForUpdateLock(): bool - { - return true; - } + if (! isset($groups[$signature])) { + $groups[$signature] = [ + 'documents' => [], + 'operators' => $operators, + ]; + } - /** - * Is Attribute Resizing Supported? - * - * @return bool - */ - public function getSupportForAttributeResizing(): bool - { - return true; - } + $groups[$signature]['documents'][] = $change; + } - /** - * Are batch operations supported? - * - * @return bool - */ - public function getSupportForBatchOperations(): bool - { - return true; + foreach ($groups as $group) { + $this->executeUpsertBatch($name, $group['documents'], $spatialAttributes, '', $group['operators'], $attributeDefaults, true); + } + } + + $ctx = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpsert($name, $changes, $ctx)); + } catch (PDOException $e) { + throw $this->processException($e); + } + + return \array_map(fn ($change) => $change->getNew(), $changes); } /** - * Is get connection id supported? + * Delete Documents * - * @return bool + * @param array $sequences + * @param array $permissionIds + * + * @throws DatabaseException */ - public function getSupportForGetConnectionId(): bool + public function deleteDocuments(string $collection, array $sequences, array $permissionIds): int { - return true; + if (empty($sequences)) { + return 0; + } + + $this->syncWriteHooks(); + + try { + $name = $this->filter($collection); + + // Delete documents + $builder = $this->newBuilder($name); + $builder->filter([BaseQuery::equal(Storage::SEQUENCE, \array_values($sequences))]); + $result = $builder->delete(); + $stmt = $this->executeResult($result, Event::DocumentsDelete); + + if (! $this->execute($stmt)) { + throw new DatabaseException('Failed to delete documents'); + } + + $ctx = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentDelete($name, \array_values($permissionIds), $ctx)); + } catch (Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + return $stmt->rowCount(); } /** - * Is cache fallback supported? + * Assign internal IDs for the given documents * - * @return bool + * @param array $documents + * @return array + * + * @throws DatabaseException */ - public function getSupportForCacheSkipOnFailure(): bool + public function getSequences(string $collection, array $documents): array { - return true; - } + $documentIds = []; + $tenants = []; + $keyedByTenant = $this->sharedTables && $this->tenantPerDocument; - public function getSupportForCaching(): bool - { - return true; + foreach ($documents as $document) { + if (empty($document->getSequence())) { + $documentIds[] = $document->getId(); + + if ($keyedByTenant) { + $tenant = $document->getTenant(); + if (! \in_array($tenant, $tenants, true)) { + $tenants[] = $tenant; + } + } + } + } + + if (empty($documentIds)) { + return $documents; + } + + $builder = $this->newBuilder($collection, tenants: $tenants); + $builder->select($keyedByTenant + ? [Storage::UID, Storage::SEQUENCE, Storage::TENANT] + : [Storage::UID, Storage::SEQUENCE]); + $builder->filter([BaseQuery::equal(Storage::UID, $documentIds)]); + + $result = $builder->build(); + $stmt = $this->executeResult($result, Event::DocumentRead); + $this->execute($stmt); + + $sequenceKey = static fn (mixed $tenant, mixed $id): string => (\is_scalar($tenant) ? (string) $tenant : '')."\0".(\is_scalar($id) ? (string) $id : ''); + + if ($keyedByTenant) { + $sequences = []; + /** @var array $row */ + foreach ($stmt->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 = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + } + $stmt->closeCursor(); + + foreach ($documents as $document) { + $key = $keyedByTenant ? $sequenceKey($document->getTenant(), $document->getId()) : $document->getId(); + if (isset($sequences[$key])) { + $document[Document::SEQUENCE] = $sequences[$key]; + } + } + + return $documents; } - /** - * Is hostname supported? - * - * @return bool - */ - public function getSupportForHostname(): bool - { + public function increaseDocumentAttribute( + string $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); + $attribute = $this->filter($attribute); + + $builder = $this->newBuilder($name); + $builder->setRaw($attribute, 'COALESCE('.$this->quote($attribute).', 0) + ?', [$value]); + $builder->set([Storage::UPDATED_AT => $updatedAt]); + + $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); + + $result = $builder->update(); + $event = $value < 0 ? Event::DocumentDecrease : Event::DocumentIncrease; + $stmt = $this->executeResult($result, $event); + + try { + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } + return true; } - /** - * Returns the INSERT keyword, optionally with IGNORE for duplicate handling. - * Override in adapter subclasses for DB-specific syntax. - */ - protected function getInsertKeyword(): string + public function deleteDocument(string $collection, string $id): bool { - return $this->skipDuplicates ? 'INSERT IGNORE INTO' : 'INSERT INTO'; - } + try { + $this->syncWriteHooks(); - /** - * 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 ''; - } + $name = $this->filter($collection); - /** - * Returns a suffix for the permissions INSERT statement when ignoring duplicates. - * Override in adapter subclasses for DB-specific syntax. - */ - protected function getInsertPermissionsSuffix(): string - { - return ''; - } + $builder = $this->newBuilder($name); + $filters = [BaseQuery::equal(Storage::UID, [$id])]; + $builder->filter($filters); + $result = $builder->delete(); + $stmt = $this->executeResult($result, Event::DocumentDelete); - /** - * Get current attribute count from collection document - * - * @param Document $collection - * @return int - */ - public function getCountOfAttributes(Document $collection): int - { - $attributes = \count($collection->getAttribute('attributes') ?? []); + if (! $this->execute($stmt)) { + throw new DatabaseException('Failed to delete document'); + } - return $attributes + $this->getCountOfDefaultAttributes(); + $deleted = $stmt->rowCount(); + + $ctx = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentDelete($name, [$id], $ctx)); + } catch (\Throwable $e) { + throw new DatabaseException($e->getMessage(), $e->getCode(), $e); + } + + return $deleted > 0; } /** - * Get current index count from collection document + * Find Documents * - * @param Document $collection - * @return int + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + * @return array + * + * @throws DatabaseException + * @throws TimeoutException + * @throws Exception */ - public function getCountOfIndexes(Document $collection): int + 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 { - $indexes = \count($collection->getAttribute('indexes') ?? []); - return $indexes + $this->getCountOfDefaultIndexes(); - } + $collectionDoc = $collection; + $collection = $collection->getId(); + $name = $this->filter($collection); + $roles = $this->authorization->getRoles(); + $alias = Query::DEFAULT_ALIAS; - /** - * Returns number of attributes used by default. - * - * @return int - */ - public function getCountOfDefaultAttributes(): int - { - return \count(Database::INTERNAL_ATTRIBUTES); - } + // 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->getSQLTable($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}"; + $stmt = null; + $rows = []; + $exception = null; + + try { + $stmt = $this->prepareStatement($sql, Event::DocumentFind); + $this->describeStatement($stmt, [], $name); + $this->execute($stmt); + /** @var array> $rows */ + $rows = $stmt->fetchAll(); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($stmt !== null) { + try { + $stmt->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; + } + } + } - /** - * Returns number of indexes used by default. - * - * @return int - */ - public function getCountOfDefaultIndexes(): int - { - return \count(Database::INTERNAL_INDEXES); + if ($exception !== null) { + throw $this->processException($exception); + } + + $documents = []; + foreach ($rows as $row) { + $this->remapRow($row); + $documents[] = Document::fromRow($row); + } + + return $documents; + } + + // 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; + + foreach ($queries as $query) { + $method = $query->getMethod(); + + if ($method->isVector()) { + $vectorQueries[] = $query; + + continue; + } + + if ($this->isAdapterFilterQuery($query)) { + $adapterFilterQueries[] = $query; + + continue; + } + + $otherQueries[] = $query; + + if ($method->isAggregate() || $method === Method::GroupBy) { + $hasAggregation = true; + } + if ($method->isJoin()) { + $hasJoins = true; + } + if ($method === Method::Distinct) { + $hasDistinct = true; + } + } + + $queries = $otherQueries; + + if ($hasJoins) { + $queries = \array_map(static fn ($query) => clone $query, $queries); + } + + $joinTablePrefixes = []; + if ($hasJoins) { + $joinTablePrefixes = $this->remapJoinQueries($queries); + } + + $hasPreservingOuterJoin = false; + if ($hasJoins) { + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::RightJoin || $method === Method::FullOuterJoin) { + $hasPreservingOuterJoin = true; + break; + } + } + } + + if ($joinTablePrefixes !== []) { + [$orderAttributes, $cursor] = $this->qualifyJoinedOrders($orderAttributes, $cursor, $collectionDoc, $joinTablePrefixes); + } + + $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->createBuilder(), $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); + } + + [$leftQueries, $rightQueries] = $this->emulateFullOuterJoin($queries, $alias); + $leftPreserving = $this->keepsUnmatchedRows($leftQueries); + + $left = $this->newBuilder($name, $alias, $leftPreserving); + $leftProjected = $this->configureFindBuilder( + $left, + $collectionDoc, + $leftQueries, + $joinTablePrefixes, + $hasAggregation, + $hasDistinct, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + ); + $this->applyFullOuterJoinOrderProjection( + $left, + $collectionDoc, + $alias, + $orderAttributes, + $orderTypes, + $leftProjected, + $joinTablePrefixes, + ); + $this->applyFindCursor( + $left, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $resolveInternalKey, + nullable: true, + ); + + $right = $this->newBuilder($name, $alias, true); + $rightProjected = $this->configureFindBuilder( + $right, + $collectionDoc, + $rightQueries, + $joinTablePrefixes, + $hasAggregation, + $hasDistinct, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + ); + $this->applyFullOuterJoinOrderProjection( + $right, + $collectionDoc, + $alias, + $orderAttributes, + $orderTypes, + $rightProjected, + $joinTablePrefixes, + ); + $this->applyFindCursor( + $right, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $resolveInternalKey, + nullable: true, + ); + + 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 { + $builder = $this->newBuilder($name, $alias, $hasPreservingOuterJoin); + $hasSelectionProjection = $this->configureFindBuilder( + $builder, + $collectionDoc, + $queries, + $joinTablePrefixes, + $hasAggregation, + $hasDistinct, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + ); + + $vectorDistance = null; + $vectorQuery = $vectorQueries[0] ?? null; + if ($vectorQuery !== null) { + $vectorDistance = $this->getVectorOrderRaw($vectorQuery, $alias); + } + + if ($vectorDistance !== null && $vectorQuery !== null) { + $vectorAttribute = $this->quote($this->filter($vectorQuery->getAttribute())); + $builder->whereRaw($this->quote($alias).".{$vectorAttribute} IS NOT NULL"); + } + + // Cursor pagination - build nested Query objects for complex multi-attribute cursor conditions + 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'); + } + + $vectorCursor = $this->getVectorCursorCondition( + $vectorDistance, + (float) $distance, + \array_values($orderAttributes), + \array_values($orderTypes), + $cursor, + $cursorDirection, + $alias, + $resolveInternalKey, + nullable: $hasJoins, + ); + $builder->whereRaw($vectorCursor['expression'], $vectorCursor['bindings']); + } + + if ($vectorDistance === null || $hasDistinct) { + $this->applyFindCursor( + $builder, + $orderAttributes, + $orderTypes, + $cursor, + $cursorDirection, + $resolveInternalKey, + nullable: $hasJoins, + ); + } + + // Vector ordering (comes first for similarity search) + if ($vectorDistance !== null && ! $hasAggregation && ! $hasDistinct) { + $vectorOrder = $vectorDistance['expression']; + if (! empty($cursor) && $cursorDirection === CursorDirection::Before) { + $vectorOrder .= ' DESC'; + } + $builder->orderByRaw($vectorOrder, $vectorDistance['bindings']); + + if (! $hasSelectionProjection) { + $builder->select(['*']); + } + $builder->selectRaw( + $this->getSQLReadableDistance($vectorDistance['expression']).' AS '.$this->quote(Storage::DISTANCE), + $vectorDistance['bindings'] + ); + } + + $this->applyFindPage($builder, $orderAttributes, $orderTypes, $limit, $offset, $cursorDirection, joinAliases: $joinAliases); + $results = $this->executeSelect($builder, Event::DocumentFind, $name); + } + + $documents = []; + + if ($hasAggregation) { + $inputs = $this->bitwiseInputs($queries); + foreach ($results as $row) { + /** @var array $row */ + $documents[] = Document::fromRow($this->bitwiseResults($row, $inputs)); + } + + return $documents; + } + + foreach ($results as $row) { + /** @var array $row */ + $this->remapRow($row); + $documents[] = Document::fromRow($row); + } + + if ($cursorDirection === CursorDirection::Before) { + $documents = \array_reverse($documents); + } + + return $documents; } /** - * Get maximum width, in bytes, allowed for a SQL row - * Return 0 when no restrictions apply + * @param array $bindings + * @return array * - * @return int + * @throws DatabaseException */ - public function getDocumentSizeLimit(): int + public function rawQuery(string $query, array $bindings = []): array { - return 65535; + try { + $stmt = $this->prepareStatement($query); + foreach ($bindings as $i => $value) { + $stmt->bindValue($i + 1, $value, $this->getPDOType($value)); + } + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } + + $results = $stmt->fetchAll(); + $stmt->closeCursor(); + + $documents = []; + foreach ($results as $row) { + /** @var array $row */ + $documents[] = Document::fromRow($row); + } + + return $documents; } /** - * 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. + * Count Documents * - * @param Document $collection - * @return int - * @throws DatabaseException + * @param array $queries + * + * @throws Exception + * @throws PDOException */ - public function getAttributeWidth(Document $collection): int + public function count(Document $collection, array $queries = [], ?int $max = null): 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 - */ + $collectionDoc = $collection; + $collection = $collection->getId(); + $name = $this->filter($collection); + $roles = $this->authorization->getRoles(); + $alias = Query::DEFAULT_ALIAS; - $total = 1067; + $otherQueries = []; + $hasJoins = false; + foreach ($queries as $query) { + if ($query->getMethod()->isVector()) { + continue; + } + $otherQueries[] = $query; + if ($query->getMethod()->isJoin()) { + $hasJoins = true; + } + } - $attributes = $collection->getAttributes()['attributes'] ?? []; + if ($hasJoins) { + $innerBuilder = $this->configureCountBuilder( + $collectionDoc, + $otherQueries, + $name, + $alias, + $roles, + $max, + ); - foreach ($attributes as $attribute) { - /** - * Json / Longtext - * only the pointer contributes 20 bytes - * data is stored externally - */ + return $this->executeWrappedCount($innerBuilder, $name); + } - if ($attribute['array'] ?? false) { - $total += 20; - continue; + if ( + empty($otherQueries) + && $max === null + && ! $this->authorization->getStatus() + && ! $this->sharedTables + ) { + $sql = "SELECT COUNT(1) AS {$this->quote('sum')} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)}"; + + try { + $stmt = $this->prepareStatement($sql, Event::DocumentCount); + $this->describeStatement($stmt, [], $name); + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); } - switch ($attribute['type']) { - case Database::VAR_ID: - $total += 8; // BIGINT 8 bytes - break; + /** @var array|false $row */ + $row = $stmt->fetch(); + $stmt->closeCursor(); - case Database::VAR_STRING: - /** - * Text / Mediumtext / Longtext - * only the pointer contributes 20 bytes to the row size - * data is stored externally - */ + return $this->countOf(\is_array($row) ? $row : []); + } - $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 - }; + $builder = $this->newBuilder($name, $alias); + $this->applyFilters($builder, $otherQueries, $name, $alias); - break; + if ($this->authorization->getStatus() && $this->filtersPerDocument($collectionDoc)) { + $builder->addHook($this->newPermissionHook($name, $roles)); + } - 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 ($max === null && $this->onlyNarrowsRows($otherQueries)) { + $builder->count('1', 'sum'); - case Database::VAR_TEXT: - case Database::VAR_MEDIUMTEXT: - case Database::VAR_LONGTEXT: - $total += 20; // Pointer storage for TEXT types - break; + return $this->countOf($this->fetchAggregateRow($builder, Event::DocumentCount, $name)); + } - case Database::VAR_INTEGER: - if ($attribute['size'] >= 8) { - $total += 8; // BIGINT 8 bytes - } else { - $total += 4; // INT 4 bytes - } - break; + $builder->selectRaw('1'); + if (! \is_null($max)) { + $builder->limit($max); + } - case Database::VAR_BIGINT: - $total += 8; // BIGINT 8 bytes - break; + return $this->executeWrappedCount($builder, $name); + } - case Database::VAR_FLOAT: - $total += 8; // DOUBLE 8 bytes - break; + /** + * Sum an Attribute + * + * @param array $queries + * + * @throws Exception + * @throws PDOException + */ + 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; - case Database::VAR_BOOLEAN: - $total += 1; // TINYINT(1) 1 bytes - break; + $otherQueries = []; + $hasJoins = false; + foreach ($queries as $query) { + if ($query->getMethod()->isVector()) { + continue; + } + $otherQueries[] = $query; + if ($query->getMethod()->isJoin()) { + $hasJoins = true; + } + } - case Database::VAR_RELATIONSHIP: - $total += Database::LENGTH_KEY * 4 + 1; // VARCHAR(<=255) - break; + if ($hasJoins) { + $innerBuilder = $this->configureCountBuilder( + $collectionDoc, + $otherQueries, + $name, + $alias, + $roles, + $max, + $attribute, + ); - 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; + return $this->executeWrappedSum($innerBuilder, 'sum_attr', $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; + $attribute = $this->filter($attribute); - case Database::VAR_POINT: - $total += $this->getMaxPointSize(); - break; - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: - $total += 20; - break; + if ( + empty($otherQueries) + && $max === null + && ! $this->authorization->getStatus() + && ! $this->sharedTables + ) { + $sql = "SELECT SUM({$this->quote($attribute)}) AS {$this->quote('sum')} FROM {$this->getSQLTable($name)} AS {$this->quote($alias)}"; + + try { + $stmt = $this->prepareStatement($sql, Event::DocumentSum); + $this->describeStatement($stmt, [], $name); + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } - case Database::VAR_VECTOR: - // Each dimension is typically 4 bytes (float32) - $total += ($attribute['size'] ?? 0) * 4; - break; + /** @var array|false $row */ + $row = $stmt->fetch(); + $stmt->closeCursor(); - default: - throw new DatabaseException('Unknown type: ' . $attribute['type']); - } + return $this->sumOf(\is_array($row) ? $row : []); } - return $total; + $builder = $this->newBuilder($name, $alias); + $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); } /** - * Get list of keywords that cannot be used - * Refference: https://mariadb.com/kb/en/reserved-words/ + * @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); + $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->createBuilder(), $queries)) { + [$leftQueries, $rightQueries] = $this->emulateFullOuterJoin($queries, $alias); + $leftPreserving = $this->keepsUnmatchedRows($leftQueries); + + $left = $this->newBuilder($name, $alias, $leftPreserving); + $left->selectRaw($selectRaw); + $this->applyFindFilters( + $left, + $collection, + $leftQueries, + $joinTablePrefixes, + $adapterFilterQueries, + $name, + $alias, + $roles, + PermissionType::Read, + ); + + $right = $this->newBuilder($name, $alias, true); + $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); + $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 + { + $quote = $this->getIdentifierQuoteChar(); + $aliasSet = \array_fill_keys(\array_column($joinTablePrefixes, 'alias'), true); + $aliasSet[Query::DEFAULT_ALIAS] = true; + $mainAttributes = []; + /** @var array $collectionAttrs */ + $collectionAttrs = $collection->getAttribute('attributes', []); + foreach ($collectionAttrs as $attr) { + $mainAttributes[$attr->getId()] = 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 $quote.$prefix.$quote.'.'.$quote.$name.$quote; + } + + private function executeWrappedCount(SQLBuilder $innerBuilder, string $collection): int + { + $outerBuilder = $this->createBuilder(); + $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->createBuilder(); + $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; + } + + /** + * 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. * - * @return array + * @param array $queries */ - public function getKeywords(): array + private function onlyNarrowsRows(array $queries): bool { - 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', + 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 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->getValue(''); + if (\is_string($alias) && $alias !== '') { + return \array_key_exists($alias, $row) ? $alias : null; + } + + $function = ($aggregate->getMethod()->sqlFunction() ?? '').'('; + $quote = $this->getIdentifierQuoteChar(); + $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; + } + + /** + * Get max STRING limit + */ + public function getLimitForString(): int + { + return 4294967295; + } + + /** + * Get max INT limit + */ + public function getLimitForInt(): int + { + return 4294967295; + } + + /** + * Get max BIGINT limit + */ + public function getLimitForBigInt(): int + { + return Database::MAX_BIG_INT; + } + + /** + * 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 + */ + public function getLimitForAttributes(): int + { + return 1017; + } + + /** + * Get maximum index limit. + * https://mariadb.com/kb/en/innodb-limitations/#limitations-on-schema + */ + public function getLimitForIndexes(): int + { + return 64; + } + + /** + * Get current attribute count from collection document + */ + public function getCountOfAttributes(Document $collection): int + { + /** @var array $attrs */ + $attrs = $collection->getAttribute('attributes') ?? []; + $attributes = \count($attrs); + + return $attributes + $this->getCountOfDefaultAttributes(); + } + + /** + * Get current index count from collection document + */ + public function getCountOfIndexes(Document $collection): int + { + /** @var array $idxs */ + $idxs = $collection->getAttribute('indexes') ?? []; + $indexes = \count($idxs); + + return $indexes + $this->getCountOfDefaultIndexes(); + } + + /** + * Returns number of attributes used by default. + */ + public function getCountOfDefaultAttributes(): int + { + return \count(Database::internalAttributes()); + } + + /** + * Returns number of indexes used by default. + */ + public function getCountOfDefaultIndexes(): int + { + return \count(Database::INTERNAL_INDEXES); + } + + /** + * Get maximum width, in bytes, allowed for a SQL row + * Return 0 when no restrictions apply + */ + public function getDocumentSizeLimit(): int + { + return 65535; + } + + /** + * 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 + */ + 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; + + /** @var array> $attributes */ + $attributes = $collection->getAttributes()['attributes'] ?? []; + + foreach ($attributes as $attribute) { + if ($attribute instanceof Attribute) { + $isArray = $attribute->array; + $attrSize = $attribute->size; + $attrType = $attribute->type->value; + } elseif ($attribute instanceof Document) { + $isArray = (bool) $attribute->getAttribute('array', false); + $size = $attribute->getAttribute('size', 0); + $attrSize = \is_numeric($size) ? (int) $size : 0; + $rawType = $attribute->getAttribute('type', ''); + if ($rawType instanceof ColumnType) { + $rawType = $rawType->value; + } + $rawType = \is_scalar($rawType) ? (string) $rawType : ''; + $normalizedType = Attribute::tryNormalizeType($rawType); + $attrType = $normalizedType instanceof ColumnType ? $normalizedType->value : $rawType; + } else { + $isArray = (bool) ($attribute['array'] ?? false); + $attrSize = (int) (is_scalar($attribute['size'] ?? 0) ? ($attribute['size'] ?? 0) : 0); + $rawType = $attribute['type'] ?? ''; + if ($rawType instanceof ColumnType) { + $rawType = $rawType->value; + } + $rawType = \is_scalar($rawType) ? (string) $rawType : ''; + $normalizedType = Attribute::tryNormalizeType($rawType); + $attrType = $normalizedType instanceof ColumnType ? $normalizedType->value : $rawType; + } + + /** + * Json / Longtext + * only the pointer contributes 20 bytes + * data is stored externally + */ + if ($isArray) { + $total += 20; + + continue; + } + + switch ($attrType) { + case ColumnType::Id->value: + $total += 8; // BIGINT 8 bytes + break; + + case ColumnType::String->value: + /** + * Text / Mediumtext / Longtext + * only the pointer contributes 20 bytes to the row size + * data is stored externally + */ + $total += match (true) { + $attrSize > $this->getMaxVarcharLength() => 20, + $attrSize > 255 => $attrSize * 4 + 2, // VARCHAR(>255) + 2 length + default => $attrSize * 4 + 1, // VARCHAR(<=255) + 1 length + }; + + break; + + case ColumnType::Varchar->value: + $total += match (true) { + $attrSize > 255 => $attrSize * 4 + 2, // VARCHAR(>255) + 2 length + default => $attrSize * 4 + 1, // VARCHAR(<=255) + 1 length + }; + break; + + case ColumnType::Text->value: + case ColumnType::MediumText->value: + case ColumnType::LongText->value: + $total += 20; // Pointer storage for TEXT types + break; + + case ColumnType::Integer->value: + if ($attrSize >= 8) { + $total += 8; // BIGINT 8 bytes + } else { + $total += 4; // INT 4 bytes + } + break; + + case ColumnType::BigInteger->value: + $total += 8; + break; + + case ColumnType::Float->value: + case ColumnType::Double->value: + $total += 8; // DOUBLE 8 bytes + break; + + case ColumnType::Boolean->value: + $total += 1; // TINYINT(1) 1 bytes + break; + + case ColumnType::Relationship->value: + $total += Database::LENGTH_KEY * 4 + 1; // VARCHAR(<=255) + break; + + case ColumnType::Datetime->value: + /** + * 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->value: + /** + * JSONB/JSON type + * Only the pointer contributes 20 bytes to the row size + * Data is stored externally + */ + $total += 20; + break; + + case ColumnType::Point->value: + $total += $this->getMaxPointSize(); + break; + case ColumnType::Linestring->value: + case ColumnType::Polygon->value: + $total += 20; + break; + + case ColumnType::Vector->value: + // Each dimension is typically 4 bytes (float32) + $total += $attrSize * 4; + break; + + default: + throw new DatabaseException('Unknown type: ' . $attrType); + } + } + + return $total; + } + + /** + * Get the maximum VARCHAR column length supported across SQL engines. + * + * @return int + */ + public function getMaxVarcharLength(): int + { + return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 + } + + /** + * Size of POINT spatial type + */ + abstract protected function getMaxPointSize(): int; + + /** + * Get the maximum combined index key length in bytes. + * + * @return int + */ + public function getMaxIndexLength(): int + { + /** + * $tenant int = 1 + */ + return $this->sharedTables ? 767 : 768; + } + + /** + * Get the maximum length for unique document IDs. + * + * @return int + */ + public function getMaxUIDLength(): int + { + return 36; + } + + /** + * Get list of keywords that cannot be used + * Refference: https://mariadb.com/kb/en/reserved-words/ + * + * @return array + */ + public 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', @@ -1585,2027 +2899,3885 @@ public function getKeywords(): array 'SYSTEM', 'SYSTEM_TIME', 'VERSIONING', - 'WITHOUT' + 'WITHOUT', ]; } /** - * Does the adapter handle casting? + * Get the keys of internally managed indexes. + * + * @return array + */ + public function getInternalIndexesKeys(): array + { + return [Storage::INDEX_PRIMARY, Storage::INDEX_CREATED_AT, Storage::INDEX_UPDATED_AT, Storage::INDEX_TENANT_ID]; + } + + /** + * Get the minimum supported datetime value. + * + * @return \DateTime + */ + public function getMinDateTime(): \DateTime + { + return new \DateTime('1000-01-01 00:00:00'); + } + + /** + * Analyze a collection, updating its metadata on the database engine. + * + * @throws DatabaseException + */ + public function analyzeCollection(string $collection): bool + { + return false; + } + + /** + * Delete a database schema. + * + * @throws Exception + * @throws PDOException + */ + public function delete(string $name): bool + { + $name = $this->filter($name); + + $result = $this->createSchemaBuilder()->dropDatabase($name); + $sql = $result->query; + + return $this->executeStatement($sql, Event::DatabaseDelete); + } + + /** + * Delete a collection and its permissions table. + * + * @throws DatabaseException + */ + public function deleteCollection(string $id): bool + { + $id = $this->filter($id); + + $schema = $this->createSchemaBuilder(); + $main = $schema->table($this->getSQLTableRaw($id))->drop(); + $permissions = $schema->table($this->getSQLTableRaw(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->createSchemaBuilder(); + $main = $schema->table($this->getSQLTableRaw($id))->dropIfExists(); + $permissions = $schema->table($this->getSQLTableRaw(Storage::permissionsTable($id)))->dropIfExists(); + + $this->executeStatement($main->query.'; '.$permissions->query, Event::CollectionCreate); + } + + /** + * Create a relationship between collections by adding foreign key columns. + * + * @throws DatabaseException + */ + public function createRelationship(Relationship $relationship): bool + { + $name = $this->filter($relationship->collection); + $relatedName = $this->filter($relationship->relatedCollection); + $id = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $type = $relationship->type; + $twoWay = $relationship->twoWay; + + $schema = $this->createSchemaBuilder(); + $addRelColumn = function (string $tableName, string $columnId) use ($schema): string { + $table = $schema->table($this->getSQLTableRaw($tableName)); + $table->string($columnId, 255)->nullable()->default(null); + $result = $table->alter(); + + return $result->query; + }; + + $sql = match ($type) { + RelationType::OneToOne => $addRelColumn($name, $id) . ';' . ($twoWay ? $addRelColumn($relatedName, $twoWayKey) . ';' : ''), + RelationType::OneToMany => $addRelColumn($relatedName, $twoWayKey) . ';', + RelationType::ManyToOne => $addRelColumn($name, $id) . ';', + RelationType::ManyToMany => null, + }; + + if ($sql === null) { + return true; + } + + return $this->executeStatement($sql, Event::AttributeCreate); + } + + /** + * Update a relationship, optionally renaming its keys. + * + * @throws DatabaseException + */ + public function updateRelationship( + Relationship $relationship, + ?string $newKey = null, + ?string $newTwoWayKey = null, + ): bool { + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $name = $this->filter($collection); + $relatedName = $this->filter($relatedCollection); + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $type = $relationship->type; + $twoWay = $relationship->twoWay; + $side = $relationship->side; + + if ($newKey !== null) { + $newKey = $this->filter($newKey); + } + if ($newTwoWayKey !== null) { + $newTwoWayKey = $this->filter($newTwoWayKey); + } + + $schema = $this->createSchemaBuilder(); + $renameCol = function (string $tableName, string $from, string $to) use ($schema): string { + $table = $schema->table($this->getSQLTableRaw($tableName)); + $table->renameColumn($from, $to); + $result = $table->alter(); + + return $result->query; + }; + + $sql = ''; + + switch ($type) { + case RelationType::OneToOne: + if ($key !== $newKey && \is_string($newKey)) { + $sql = $renameCol($name, $key, $newKey) . ';'; + } + if ($twoWay && $twoWayKey !== $newTwoWayKey && \is_string($newTwoWayKey)) { + $sql .= $renameCol($relatedName, $twoWayKey, $newTwoWayKey) . ';'; + } + break; + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { + if ($twoWayKey !== $newTwoWayKey && \is_string($newTwoWayKey)) { + $sql = $renameCol($relatedName, $twoWayKey, $newTwoWayKey) . ';'; + } + } else { + if ($key !== $newKey && \is_string($newKey)) { + $sql = $renameCol($name, $key, $newKey) . ';'; + } + } + break; + case RelationType::ManyToOne: + if ($side === RelationSide::Child) { + if ($twoWayKey !== $newTwoWayKey && \is_string($newTwoWayKey)) { + $sql = $renameCol($relatedName, $twoWayKey, $newTwoWayKey) . ';'; + } + } else { + if ($key !== $newKey && \is_string($newKey)) { + $sql = $renameCol($name, $key, $newKey) . ';'; + } + } + break; + case RelationType::ManyToMany: + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); + + $junctionName = '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence(); + + if ($newKey !== null) { + $sql = $renameCol($junctionName, $key, $newKey) . ';'; + } + if ($twoWay && $newTwoWayKey !== null) { + $sql .= $renameCol($junctionName, $twoWayKey, $newTwoWayKey) . ';'; + } + break; + default: + throw new DatabaseException('Invalid relationship type'); + } + + if ($sql === '') { + return true; + } + + return $this->executeStatement($sql, Event::AttributeUpdate); + } + + /** + * Delete a relationship between collections. + * + * @throws DatabaseException + */ + public function deleteRelationship(Relationship $relationship): bool + { + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; + $name = $this->filter($collection); + $relatedName = $this->filter($relatedCollection); + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $type = $relationship->type; + $twoWay = $relationship->twoWay; + $side = $relationship->side; + + $schema = $this->createSchemaBuilder(); + $dropCol = function (string $tableName, string $columnId) use ($schema): string { + $table = $schema->table($this->getSQLTableRaw($tableName)); + $table->dropColumn($columnId); + $result = $table->alter(); + + return $result->query; + }; + + $sql = ''; + + switch ($type) { + case RelationType::OneToOne: + if ($side === RelationSide::Parent) { + $sql = $dropCol($name, $key) . ';'; + if ($twoWay) { + $sql .= $dropCol($relatedName, $twoWayKey) . ';'; + } + } elseif ($side === RelationSide::Child) { + $sql = $dropCol($relatedName, $twoWayKey) . ';'; + if ($twoWay) { + $sql .= $dropCol($name, $key) . ';'; + } + } + break; + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { + $sql = $dropCol($relatedName, $twoWayKey) . ';'; + } else { + $sql = $dropCol($name, $key) . ';'; + } + break; + case RelationType::ManyToOne: + if ($side === RelationSide::Parent) { + $sql = $dropCol($name, $key) . ';'; + } else { + $sql = $dropCol($relatedName, $twoWayKey) . ';'; + } + break; + case RelationType::ManyToMany: + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collection = $this->getDocument($metadataCollection, $collection); + $relatedCollection = $this->getDocument($metadataCollection, $relatedCollection); + + $junctionName = $side === RelationSide::Parent + ? '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() + : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); + + $junctionResult = $schema->table($this->getSQLTableRaw($junctionName))->drop(); + $permsResult = $schema->table($this->getSQLTableRaw(Storage::permissionsTable($junctionName)))->drop(); + + $sql = $junctionResult->query . '; ' . $permsResult->query; + break; + default: + throw new DatabaseException('Invalid relationship type'); + } + + return $this->executeStatement($sql, Event::AttributeDelete); + } + + /** + * Convert a type string and size to the corresponding SQL column type definition. + * + * @param string $type The column type value + * @param int $size The column size + * @param bool $signed Whether the column is signed + * @param bool $array Whether the column stores an array + * @param bool $required Whether the column is required + * @return string + * + * @throws DatabaseException For unknown type values. + */ + public function getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string + { + $columnType = Attribute::tryNormalizeType($type); + if ($columnType === null) { + throw new DatabaseException('Unknown column type: '.$type); + } + + return $this->getSQLType($columnType, $size, $signed, $array, $required); + } + + 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->getMaxVarcharLength()) { + 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), + }; + } + + /** + * Get the SQL type definition for spatial column types. + * + * @param string $type The spatial type (point, linestring, polygon) + * @param bool $required Whether the column is NOT NULL + * @return string + */ + protected function getSpatialSQLType(string $type, bool $required): string + { + $srid = $this->getSpatialColumnSrid(); + $modifier = $srid === null ? '' : "({$srid})"; + $nullability = ''; + + if (! $this->supports(Capability::SpatialIndexNull)) { + 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 => '', + }; + } + + /** + * Get SQL Index Type + * + * @throws Exception + */ + protected function getSQLIndexType(IndexType $type): string + { + return match ($type) { + IndexType::Key => 'INDEX', + IndexType::Unique => 'UNIQUE INDEX', + IndexType::Fulltext => 'FULLTEXT INDEX', + default => throw new DatabaseException('Unknown index type: '.$type->value.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value), + }; + } + + /** + * Extract the spatial geometry type name from a WKT string. + * + * @param string $wkt The Well-Known Text representation + * @return string The lowercase type name (e.g. "point", "polygon") + * + * @throws DatabaseException If the WKT is invalid. + */ + public function getSpatialTypeFromWKT(string $wkt): string + { + $wkt = trim($wkt); + $pos = strpos($wkt, '('); + if ($pos === false) { + throw new DatabaseException('Invalid spatial type'); + } + + return strtolower(trim(substr($wkt, 0, $pos))); + } + + /** + * Generate ST_GeomFromText call with proper SRID and axis order support + */ + protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string + { + $srid = $srid ?? Database::DEFAULT_SRID; + $geomFromText = "ST_GeomFromText({$wktPlaceholder}, {$srid}"; + + if ($this->supports(Capability::SpatialAxisOrder)) { + $geomFromText .= ', '.$this->getSpatialAxisOrderSpec(); + } + + $geomFromText .= ')'; + + return $geomFromText; + } + + /** + * Get the spatial axis order specification string + */ + protected function getSpatialAxisOrderSpec(): 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'); + } + + /** + * Get SQL table + * + * @throws DatabaseException + */ + protected function getSQLTable(string $name): string + { + return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace().'_'.$this->filter($name))}"; + } + + /** + * Get an unquoted qualified table name (the builder handles quoting). + * + * @throws DatabaseException + */ + protected function getSQLTableRaw(string $name): string + { + return $this->getDatabase().'.'.$this->getNamespace().'_'.$this->filter($name); + } + + /** + * Create a new query builder instance for this adapter's SQL dialect. + */ + abstract protected function createBuilder(): SQLBuilder; + + /** + * Create a new schema builder instance for this adapter's SQL dialect. + */ + protected function createSchemaBuilder(): MySQLSchema|PostgreSQLSchema + { + return new MySQLSchema(); + } + + /** + * Create and configure a new query builder for a given table. + * + * 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 + * + * @throws DatabaseException + */ + protected function newBuilder(string $table, string $alias = '', bool $allowNullTenant = false, array $tenants = []): SQLBuilder + { + $builder = $this->createBuilder()->from($this->getSQLTableRaw($table), $alias); + + // AttributeMap is a readonly stateless config object — share one + // instance across builders to avoid allocating it on every read. + $this->attributeMap ??= new AttributeMap(Storage::attributeMap()); + $builder->addHook($this->attributeMap); + if ($this->sharedTables) { + $source = $alias !== '' ? $alias : $table; + $allowNullColumn = ''; + if ($allowNullTenant) { + $allowNullColumn = $source.'.'.Storage::UID; + } + $tenantFilter = new TenantFilter( + $tenants === [] ? $this->currentTenant() : $tenants, + Database::METADATA, + $table, + $allowNullColumn, + $this->getIdentifierQuoteChar(), + ); + $builder->addHook($tenantFilter); + $builder->addHook(new OuterJoinTenantFilter($tenantFilter, $source)); + } + + return $builder; + } + + public function rawMutation(string $query, array $bindings = []): int + { + try { + $stmt = $this->prepareStatement($query); + foreach ($bindings as $i => $value) { + $stmt->bindValue($i + 1, $value, $this->getPDOType($value)); + } + $this->execute($stmt); + } catch (PDOException $e) { + throw $this->processException($e); + } + + $count = $stmt->rowCount(); + $stmt->closeCursor(); + + return $count; + } + + /** + * A builder over the collection's table for Database::from(): it maps document attributes to + * columns and applies no permissions. + * + * Under shared tables it keeps every statement to the selected tenant: the main table and every + * table joined through the builder's join methods (RawTenantFilter). It does not use + * newBuilder()'s tenant hooks, which need a read's joins up front; the caller adds these later. + * Not kept to the tenant: SQL the caller writes, builders that did not come from Database::from() + * (subqueries, unions, lateral joins) and a dialect's multi-table updates and deletes. + */ + public function getBuilder(string $collection): SQLBuilder + { + $name = $this->filter($collection); + if (! $this->sharedTables) { + return $this->newBuilder($name); + } + + $table = $this->getSQLTableRaw($name); + $tenants = new RawTenantFilter( + $this->currentTenant(), + $table, + $name === Database::METADATA || $name === Storage::permissionsTable(Database::METADATA), + $this->getIdentifierQuoteChar(), + ); + $this->attributeMap ??= new AttributeMap(Storage::attributeMap()); + + return $this->createBuilder() + ->from($table) + ->addHook($this->attributeMap) + ->addHook($tenants) + ->addHook(new RawOuterJoinTenantFilter($tenants)) + ->beforeBuild($tenants->reset(...)); + } + + public function getSchema(): Schema + { + return $this->createSchemaBuilder(); + } + + protected function getIdentifierQuoteChar(): 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): PermissionFilter + { + return new PermissionFilter( + roles: \array_values($roles), + permissionsTable: fn (string $table) => $this->getSQLTableRaw(Storage::permissionsTable($collection)), + type: $type, + documentColumn: $documentColumn, + permDocumentColumn: Storage::PERM_DOCUMENT, + permRoleColumn: Storage::PERM_PERMISSION, + permTypeColumn: Storage::PERM_TYPE, + subqueryFilter: $this->sharedTables + ? new TenantFilter( + $this->currentTenant(), + Database::METADATA, + Storage::permissionsTable($collection), + quoteChar: $this->getIdentifierQuoteChar(), + ) + : null, + quoteChar: $this->getIdentifierQuoteChar(), + ); + } + + /** + * @param array $roles + */ + protected function newJoinPermissionHook(string $collection, array $roles, string $type, string $documentColumn, int $joins, JoinType $joinType): PermissionFilter + { + return $this->newPermissionHook($collection, $roles, $type, $documentColumn); + } + + /** + * Re-register the write hooks this adapter owns. + * + * Only Tenancy, and only while shared tables are active. It takes each + * row's tenant from the document being written and falls back to the + * ambient tenant, so it is needed in per-document mode too, where there is + * no ambient 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 + { + $this->removeWriteHook(Tenancy::class); + if ($this->sharedTables) { + $this->addWriteHook(new Tenancy($this->currentTenant())); + } + } + + /** + * Build a WriteContext that delegates to this adapter's query infrastructure. * - * @return bool + * @param string $collection The filtered collection name + * @param string|null $lookupId The document id used to load/update this write */ - public function getSupportForCasting(): bool + protected function buildWriteContext(string $collection, ?string $lookupId = null): WriteContext { - return true; + $name = $this->filter($collection); + + return new WriteContext( + newBuilder: fn (string $table, string $alias = '') => $this->newBuilder($table, $alias), + executeResult: fn (Statement $result, ?Event $event = null) => $this->executeResult($result, $event), + execute: fn (mixed $stmt) => $this->execute($stmt), + decorateRow: fn (array $row, array $metadata) => $this->decorateRow($row, $metadata), + createBuilder: fn () => $this->createBuilder(), + getTableRaw: fn (string $table) => $this->getSQLTableRaw($table), + skipDuplicates: $this->skipDuplicates, + lookupId: $lookupId, + ); } - public function getSupportForNumericCasting(): bool + /** + * 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 Event|null $event Optional event to run through transformation system + * @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 = ''): PDOStatement|DatabasePDOStatement|PDOStatementProxy { - return false; + $stmt = $this->prepareStatement($result->query, $event); + $this->describeStatement($stmt, $result->bindings, $collection); + foreach ($result->bindings as $i => $value) { + if (\is_bool($value) && $this->supports(Capability::IntegerBooleans)) { + $value = (int) $value; + } + if (\is_float($value)) { + $stmt->bindValue($i + 1, $this->getFloatPrecision($value), PDO::PARAM_STR); + } else { + $stmt->bindValue($i + 1, $value, $this->getPDOType($value)); + } + } + + return $stmt; } + /** + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt + */ + protected function execute(mixed $stmt, ?Event $event = null): bool + { + return $this->executeAndProfile($stmt); + } /** - * Does the adapter handle Query Array Contains? + * Run a prepared statement and hand it to the profiler when one is attached. * - * @return bool + * Subclasses that wrap execute() with engine-specific timeout handling call + * this instead of $stmt->execute(), so the statement is still counted. + * + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt */ - public function getSupportForQueryContains(): bool + protected function executeAndProfile(mixed $stmt): bool { - return true; + if ($this->profiler === null || ! $this->profiler->isEnabled()) { + return $stmt->execute(); + } + + $start = \microtime(true); + $result = $stmt->execute(); + $this->profiler->log( + $stmt->queryString ?? '', + $this->statementBindings[$stmt] ?? [], + (\microtime(true) - $start) * 1000, + $this->statementCollections[$stmt] ?? '', + $this->getStatementEvent($stmt)->value ?? '', + ); + + return $result; } /** - * Does the adapter handle array Overlaps? + * Keep the values bound to a statement and the collection it runs on for the profiler, while + * one is recording. * - * @return bool + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt + * @param array $bindings */ - abstract public function getSupportForJSONOverlaps(): bool; - - public function getSupportForIndexArray(): bool + protected function describeStatement(PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt, array $bindings, string $collection): void { - return true; + if ($this->profiler === null || ! $this->profiler->isEnabled()) { + return; + } + + $this->statementBindings ??= new \WeakMap(); + $this->statementBindings[$stmt] = $bindings; + $this->statementCollections ??= new \WeakMap(); + $this->statementCollections[$stmt] = $collection; } - public function getSupportForCastIndexArray(): bool + /** + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt + */ + protected function getStatementEvent(PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt): ?Event { - return false; + if ($this->statementEvents === null) { + return null; + } + + return $this->statementEvents[$stmt] ?? null; } - public function getSupportForRelationships(): bool + protected function prepareStatement(string $sql, ?Event $event = null): DatabasePDOStatement|PDOStatementProxy|PDOStatement { - return true; + $sql = $this->comments().$sql; + + if ($event !== null) { + $sql = $this->transformQuery($event, $sql); + } + + $statement = $this->getPDO()->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; } - public function getSupportForReconnection(): bool + private function comments(): string { - return true; + $comments = ''; + foreach ($this->metadata as $key => $value) { + $comments .= '/* '.$this->commentText($key).': '.$this->commentText($value).' */'."\n"; + } + + return $comments; } - public function getSupportForBatchCreateAttributes(): bool + private function commentText(mixed $value): string { - return true; + $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); } - /** - * Are spatial attributes supported? - * - * @return bool - */ - public function getSupportForSpatialAttributes(): bool + protected function executeStatement(string $sql, Event $event): bool { - return false; + return $this->execute($this->prepareStatement($sql, $event)); } - /** - * Does the adapter support null values in spatial indexes? - * - * @return bool - */ - public function getSupportForSpatialIndexNull(): bool + private function transformQuery(Event $event, string $sql): string { - return false; + foreach ($this->queryTransforms as $transform) { + $sql = $transform->transform($event, $sql); + } + + return $sql; } /** - * Does the adapter support operators? + * Execute a single upsert batch using the query builder. * - * @return bool + * 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 + * @param bool $hasOperators Whether this batch contains operator expressions + * + * @throws DatabaseException */ - public function getSupportForOperators(): bool - { - return true; + protected function executeUpsertBatch( + string $name, + array $changes, + array $spatialAttributes, + string $attribute, + array $operators, + array $attributeDefaults, + bool $hasOperators + ): void { + $builder = $this->createBuilder()->into($this->getSQLTableRaw($name)); + + foreach ($spatialAttributes as $spatialCol) { + $builder->insertColumnExpression($spatialCol, $this->getSpatialGeomFromText('?')); + } + + // Postgres requires an alias on the INSERT target for conflict resolution + if ($this->insertRequiresAlias()) { + $builder->insertAs('target'); + } + + // Collect all column names and build rows + $allColumnNames = []; + $documentsData = []; + + foreach ($changes as $change) { + $document = $change->getNew(); + + if ($hasOperators) { + $extracted = Operator::extractOperators($document->getAttributes()); + $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[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, $this->documentMetadata($document)); + + foreach (\array_keys($currentRegularAttributes) as $colName) { + $allColumnNames[$colName] = true; + } + + $documentsData[] = $currentRegularAttributes; + } + + // Include operator column names in the column set + foreach (\array_keys($operators) as $colName) { + $allColumnNames[$colName] = true; + } + + $allColumnNames = \array_keys($allColumnNames); + \sort($allColumnNames); + + // Hoist hot-loop guards: spatial set lookup is O(1) via array_flip, and + // IntegerBooleans support is a constant for the lifetime of the adapter. + $spatialMap = \array_fill_keys($spatialAttributes, true); + $intBools = $this->supports(Capability::IntegerBooleans); + + // Build rows for the builder, applying JSON/boolean/spatial conversions + foreach ($documentsData as $docAttrs) { + $row = []; + foreach ($allColumnNames as $key) { + $value = $docAttrs[$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; + } + $builder->set($row); + } + + // Determine conflict keys + $conflictKeys = $this->sharedTables ? [Storage::UID, Storage::TENANT] : [Storage::UID]; + + // Determine which columns to update on conflict + $skipColumns = [Storage::UID, Storage::SEQUENCE, Storage::CREATED_AT, Storage::TENANT]; + + if (! empty($attribute)) { + // Increment mode: only update the increment column and _updatedAt + $updateColumns = [$this->filter($attribute), Storage::UPDATED_AT]; + } else { + // Normal mode: update all columns except the skip set + $updateColumns = \array_values(\array_filter( + $allColumnNames, + fn ($c) => ! \in_array($c, $skipColumns) + )); + } + + $builder->onConflict($conflictKeys, $updateColumns); + + // Apply conflict-resolution expressions + // Column names passed to conflictSetRaw() must match the names in onConflict(). + // The expression-generating methods handle their own quoting/filtering internally. + if (! empty($attribute)) { + // Increment attribute + $filteredAttr = $this->filter($attribute); + if ($this->sharedTables) { + $builder->conflictSetRaw($filteredAttr, $this->getConflictTenantIncrementExpression($filteredAttr)); + $builder->conflictSetRaw(Storage::UPDATED_AT, $this->getConflictTenantExpression(Storage::UPDATED_AT)); + } else { + $builder->conflictSetRaw($filteredAttr, $this->getConflictIncrementExpression($filteredAttr)); + } + } elseif (! empty($operators)) { + // Operator columns + foreach ($allColumnNames as $colName) { + if (\in_array($colName, $skipColumns)) { + continue; + } + if (isset($operators[$colName])) { + $filteredCol = $this->filter($colName); + $opResult = $this->getOperatorUpsertExpression($filteredCol, $operators[$colName]); + $builder->conflictSetRaw($colName, $opResult['expression'], $opResult['bindings']); + } elseif ($this->sharedTables) { + $builder->conflictSetRaw($colName, $this->getConflictTenantExpression($colName)); + } + } + } elseif ($this->sharedTables) { + // Shared tables without operators or increment: tenant-guard all update columns + foreach ($updateColumns as $col) { + $builder->conflictSetRaw($col, $this->getConflictTenantExpression($col)); + } + } + + if (! $builder instanceof UpsertFeature) { + throw new DatabaseException('Upserts are not supported on this dialect'); + } + + $result = $builder->upsert(); + $stmt = $this->executeResult($result, Event::DocumentsUpsert); + $this->execute($stmt); + $stmt->closeCursor(); } /** - * Does the adapter support order attribute in spatial indexes? + * Map attribute selections to database column names. * - * @return bool - */ - public function getSupportForSpatialIndexOrder(): bool - { - return false; + * 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 columns the join returns without a select, from $joinSelections. + * + * @param array $selections + * @param array $joinAliases + * @param array> $joinSelections The selections a read without a select 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); + } } /** - * Is internal casting supported? - * - * @return bool - */ - public function getSupportForInternalCasting(): bool + * @param array $queries + * @param list $joinTablePrefixes + */ + private function remapDottedQueryAttributes(array $queries, array $joinTablePrefixes, Document $collection): void { - return false; + $aliasSet = \array_fill_keys(\array_column($joinTablePrefixes, 'alias'), true); + $aliasSet[Query::DEFAULT_ALIAS] = true; + $mainAttributes = []; + /** @var array $collectionAttrs */ + $collectionAttrs = $collection->getAttribute('attributes', []); + foreach ($collectionAttrs as $attribute) { + $mainAttributes[$attribute->getId()] = true; + } + + foreach ($queries as $query) { + $this->remapDottedQuery($query, $aliasSet, $mainAttributes); + } } /** - * Does the adapter support multiple fulltext indexes? - * - * @return bool + * @param array $aliasSet + * @param array $mainAttributes */ - public function getSupportForMultipleFulltextIndexes(): bool + private function remapDottedQuery(BaseQuery $query, array $aliasSet, array $mainAttributes): void { - return true; + $method = $query->getMethod(); + if ($method === Method::Select) { + return; + } + + if ($method->isJoin()) { + if ($query->isNestedJoin()) { + foreach ($query->getJoinOnQueries() as $onQuery) { + if ($onQuery->getMethod() === Method::On) { + continue; + } + $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 ($method === Method::GroupBy) { + $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)); } /** - * Does the adapter support identical indexes? - * - * @return bool + * @param array $aliasSet + * @param array $mainAttributes */ - public function getSupportForIdenticalIndexes(): bool + private function qualifyDottedAttribute(string $attribute, array $aliasSet, array $mainAttributes): string { - return true; + 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; } /** - * Does the adapter support random order for queries? - * - * @return bool + * @param array $selections + * @param array $joinAliases + * @return array */ - public function getSupportForOrderRandom(): bool + protected function mapSelectionsToColumns(array $selections, bool $includeInternal = true, array $joinAliases = []): array { - return true; - } + $internalKeys = [ + Document::ID, + Document::SEQUENCE, + Document::PERMISSIONS, + Document::CREATED_AT, + Document::UPDATED_AT, + ]; - public function getSupportForUTCCasting(): bool - { - return false; - } + $explicitInternals = []; + foreach ($selections as $selection) { + if (\in_array($selection, $internalKeys, true)) { + $explicitInternals[] = $selection; + } + } - public function setUTCDatetime(string $value): mixed - { - return $value; - } + $selections = \array_values(\array_diff($selections, [...$internalKeys, Document::COLLECTION])); - public function castingBefore(Document $collection, Document $document): Document - { - return $document; - } + if ($includeInternal) { + foreach ($internalKeys as $internalKey) { + $selections[] = $this->getInternalKeyForAttribute($internalKey); + } + } else { + foreach (\array_values(\array_unique($explicitInternals)) as $internalKey) { + $selections[] = $this->getInternalKeyForAttribute($internalKey); + } + } - public function castingAfter(Document $collection, Document $document): Document - { - return $document; + $aliasSet = \array_fill_keys($joinAliases, true); + $quote = $this->getIdentifierQuoteChar(); + $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 support spatial axis order specification? + * The projection of a join without a select: 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. * - * @return bool + * @param list $joinTablePrefixes */ - public function getSupportForSpatialAxisOrder(): bool + private function applyJoinProjection(SQLBuilder $builder, Document $collection, array $joinTablePrefixes, string $alias): void { - return false; + $builder->select([$this->filter($alias).'.*']); + + $this->applySelectionProjection( + $builder, + \array_merge(...\array_values($this->joinSelections($collection, $joinTablePrefixes))), + includeInternal: false, + joinAliases: \array_column($joinTablePrefixes, 'alias'), + ); } /** - * Is vector type supported? + * 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. * - * @return bool + * @param list $joinTablePrefixes + * @return array> */ - public function getSupportForVectors(): bool + private function joinSelections(Document $collection, array $joinTablePrefixes): array { - return false; + $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; } /** - * Generate ST_GeomFromText call with proper SRID and axis order support + * Map Database type constants to Schema Table column definitions. * - * @param string $wktPlaceholder - * @param int|null $srid - * @return string + * @throws DatabaseException */ - protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string - { - $srid = $srid ?? Database::DEFAULT_SRID; - $geomFromText = "ST_GeomFromText({$wktPlaceholder}, {$srid}"; + 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 ($this->getSupportForSpatialAxisOrder()) { - $geomFromText .= ", " . $this->getSpatialAxisOrderSpec(); + if (\in_array($type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true)) { + $column = $this->addSpatialColumn($table, $filteredId, $type); + if (! $required || $this->supports(Capability::SpatialIndexNull)) { + $column->nullable(); + } + + return $column; } - $geomFromText .= ")"; + if ($array) { + return $table->json($filteredId)->nullable(); + } - return $geomFromText; + 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->getMaxVarcharLength() => $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(); + + return $column; } /** - * Get the spatial axis order specification string - * - * @return string + * @throws DatabaseException */ - protected function getSpatialAxisOrderSpec(): string + protected function assertVarcharSize(int $size): void { - return "'axis-order=long-lat'"; + 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.'); + } } /** - * @param string $tableName - * @param string $columns - * @param array $batchKeys - * @param array $bindValues - * @param array $attributes - * @param string $attribute - * @param array $operators - * @return mixed + * @throws DatabaseException */ - abstract protected function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - array $operators = [] - ): mixed; + private function addSpatialColumn(Table $table, string $name, ColumnType $type): Column + { + $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), + }; + } /** - * 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 + * SRID written into spatial column definitions, or null for a dialect that cannot declare one on a column. */ - protected function getSQLVectorDistance(Query $query, array &$binds, string $alias): ?string + protected function getSpatialColumnSrid(): ?int + { + return Database::DEFAULT_SRID; + } + + private function addVectorColumn(Table $table, string $name, int $size): Column { - return null; + if (! $table instanceof PostgreSQLTable) { + throw new DatabaseException('Vector columns are only supported on PostgreSQL'); + } + + return $table->vector($name, $size); } /** - * Render a vector distance expression in a form safe to read back into PHP - * - * 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 string $distance - * @return string + * @param array $queries */ - protected function getSQLReadableDistance(string $distance): string + private function queriesHaveJoins(array $queries): bool { - return $distance; + foreach ($queries as $query) { + if ($query->getMethod()->isJoin()) { + return true; + } + } + + return false; } /** - * @param string $value - * @return string + * @param array $queries + * @return list + * + * @throws QueryException */ - protected function getFulltextValue(string $value): string + private function remapJoinQueries(array &$queries): array { - $exact = str_ends_with($value, '"') && str_starts_with($value, '"'); + $joinTablePrefixes = []; + $joinIndex = 0; + $alias = Query::DEFAULT_ALIAS; + $takenAliases = $this->declaredJoinAliases($queries); - /** 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); + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } - if (empty($value)) { - return ''; - } + $joinTable = $query->getAttribute(); + $resolvedTable = $this->getSQLTableRaw($this->filter($joinTable)); + $query->setAttribute($resolvedTable); - if ($exact) { - $value = '"' . $value . '"'; - } else { - /** Prepend wildcard by default on the back. */ - $value .= '*'; + $method = $query->getMethod(); + $joinAlias = $query->getJoinAlias(); + if ($joinAlias === '') { + $joinAlias = Storage::joinAlias($joinIndex, $takenAliases); + } + $joinIndex++; + + if ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + $query->setValues([$joinAlias]); + } elseif ($query->isNestedJoin()) { + $query->setValues($this->remapNestedJoinValues($query, $alias, $joinAlias)); + } else { + $values = $query->getValues(); + if (\count($values) >= 3) { + $left = $values[0] ?? null; + $right = $values[2] ?? null; + if (! \is_string($left) || ! \is_string($right)) { + throw new QueryException('Join columns must be strings'); + } + $values[0] = $this->qualifyJoinColumn($left, $alias); + $values[2] = $this->qualifyJoinColumn($right, $joinAlias); + $values[3] = $joinAlias; + $query->setValues($values); + } + } + + $joinTablePrefixes[] = ['table' => $joinTable, 'alias' => $joinAlias]; } - return $value; + return $joinTablePrefixes; } /** - * Get SQL Operator + * @param array $queries + * @return array Every alias the joins declare, lower-cased * - * @param string $method - * @return string - * @throws Exception + * @throws QueryException */ - protected function getSQLOperator(string $method): string + private function declaredJoinAliases(array $queries): array { - 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); + $declared = []; + foreach ($queries as $query) { + if (! $query->getMethod()->isJoin()) { + continue; + } + + $alias = $query->getJoinAlias(); + if ($alias === '') { + continue; + } + + $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; } - } - abstract protected function getSQLType( - string $type, - int $size, - bool $signed = true, - bool $array = false, - bool $required = false - ): string; + return $declared; + } /** - * @throws DatabaseException For unknown type values. + * @return list */ - public function getColumnType(string $type, int $size, bool $signed = true, bool $array = false, bool $required = false): string + private function remapNestedJoinValues(BaseQuery $query, string $mainAlias, string $joinAlias): array { - return $this->getSQLType($type, $size, $signed, $array, $required); + $values = [$joinAlias]; + foreach ($query->getJoinOnQueries() as $onQuery) { + $values[] = $this->remapNestedJoinOnQuery($onQuery, $mainAlias, $joinAlias); + } + + return $values; } - /** - * Get SQL Index Type - * - * @param string $type - * @return string - * @throws Exception - */ - protected function getSQLIndexType(string $type): string + private function remapNestedJoinOnQuery(BaseQuery $onQuery, string $mainAlias, string $joinAlias): BaseQuery { - 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), - }; - } + if ($onQuery->getMethod() !== Method::On) { + return $onQuery; + } - /** - * 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); + $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'); } - $roles = \array_map(fn ($role) => $this->getPDO()->quote($role), $roles); - $roles = \implode(', ', $roles); + $values[0] = $this->qualifyJoinColumn($left, $mainAlias); + $values[2] = $this->qualifyJoinColumn($right, $joinAlias); + $onQuery->setValues($values); - 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 $onQuery; } /** - * Get SQL table - * - * @param string $name - * @return string - * @throws DatabaseException + * @param array $queries */ - protected function getSQLTable(string $name): string + private function needsFullOuterJoinEmulation(SQLBuilder $builder, array $queries): bool { - return "{$this->quote($this->getDatabase())}.{$this->quote($this->getNamespace() . '_' .$this->filter($name))}"; - } + if ($builder instanceof FullOuterJoinsFeature) { + return false; + } - /** - * 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 - */ - abstract protected function getOperatorSQL(string $column, Operator $operator, array &$binds): ?string; + foreach ($queries as $query) { + if ($query->getMethod() === Method::FullOuterJoin) { + return true; + } + } - /** - * 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 - */ - protected function registerOperatorBind(array &$binds, mixed $value): string - { - $key = ID::unique(); - $binds[":{$key}"] = $value; - return $key; + 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. + * 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. * - * @param Operator $operator - * @param mixed $value The current value (typically the attribute default) - * @return mixed The result after applying the operator + * 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. + * + * @param array $queries With the join columns remapJoinQueries() qualified + * @return array{0: array, 1: array} + * + * @throws QueryException */ - protected function applyOperatorToValue(Operator $operator, mixed $value): mixed + private function emulateFullOuterJoin(array $queries, string $alias): array { - $method = $operator->getMethod(); - $values = $operator->getValues(); - - switch ($method) { - // Numeric operators - case Operator::TYPE_INCREMENT: - return ($value ?? 0) + ($values[0] ?? 1); + $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 = []; - case Operator::TYPE_DECREMENT: - return ($value ?? 0) - ($values[0] ?? 1); + foreach ($queries as $query) { + $method = $query->getMethod(); + if (! $method->isJoin()) { + continue; + } - case Operator::TYPE_MULTIPLY: - return ($value ?? 0) * ($values[0] ?? 1); + $joinAlias = $query->getJoinAlias(); - case Operator::TYPE_DIVIDE: - $divisor = $values[0] ?? 1; - return (float)$divisor !== 0.0 ? ($value ?? 0) / $divisor : ($value ?? 0); + if ($method === Method::FullOuterJoin) { + if ($fullJoinAlias !== null) { + throw new QueryException('A query can hold only one full outer join on this database'); + } - case Operator::TYPE_MODULO: - $divisor = $values[0] ?? 1; - return (float)$divisor !== 0.0 ? ($value ?? 0) % $divisor : ($value ?? 0); + $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; - case Operator::TYPE_POWER: - return pow($value ?? 0, $values[0] ?? 1); + continue; + } - // Array operators - case Operator::TYPE_ARRAY_APPEND: - return array_merge($value ?? [], $values); + if ($fullJoinAlias === null) { + $joinedAliases[] = $joinAlias; - case Operator::TYPE_ARRAY_PREPEND: - return array_merge($values, $value ?? []); + continue; + } - case Operator::TYPE_ARRAY_INSERT: - $arr = $value ?? []; - $index = $values[0] ?? 0; - $item = $values[1] ?? null; - array_splice($arr, $index, 0, [$item]); - return $arr; + $rows = $presentRows; + foreach ($this->joinConditionAliases($query) as $conditionAlias) { + $rows &= $reach[$conditionAlias] ?? $presentRows; + } - case Operator::TYPE_ARRAY_REMOVE: - $arr = $value ?? []; - $toRemove = $values[0] ?? null; - if (is_array($toRemove)) { - return array_values(array_diff($arr, $toRemove)); + 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'); } - return array_values(array_diff($arr, [$toRemove])); - case Operator::TYPE_ARRAY_UNIQUE: - return array_values(array_unique($value ?? [])); + $rows |= $unmatchedRows; + $presentRows |= $unmatchedRows; + } elseif ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + $rows = $presentRows; + } - case Operator::TYPE_ARRAY_INTERSECT: - return array_values(array_intersect($value ?? [], $values)); + $reach[$joinAlias] = $rows; + $joinedAliases[] = $joinAlias; + } - case Operator::TYPE_ARRAY_DIFF: - return array_values(array_diff($value ?? [], $values)); + if ($fullJoinAlias === null) { + throw new DatabaseException('The query holds no full outer join to emulate'); + } - case Operator::TYPE_ARRAY_FILTER: - return $value ?? []; + $firstHalf = $this->rewriteFullOuterJoins($queries, Method::LeftJoin); + \array_push($firstHalf, ...$firstHalfExclusions); - // String operators - case Operator::TYPE_STRING_CONCAT: - return ($value ?? '') . ($values[0] ?? ''); + $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, + ]); - case Operator::TYPE_STRING_REPLACE: - $search = $values[0] ?? ''; - $replace = $values[1] ?? ''; - return str_replace($search, $replace, $value ?? ''); + return [$firstHalf, $secondHalf]; + } - // Boolean operators - case Operator::TYPE_TOGGLE: - return !($value ?? false); + /** + * The aliases whose columns a join's ON compares, other than the join's own. + * + * @return list + */ + private function joinConditionAliases(BaseQuery $join): array + { + $method = $join->getMethod(); + if ($method === Method::CrossJoin || $method === Method::NaturalJoin) { + return []; + } - // Date operators - case Operator::TYPE_DATE_ADD_DAYS: - case Operator::TYPE_DATE_SUB_DAYS: - // For NULL dates, operators return NULL - return $value; + $columns = []; + if ($join->isNestedJoin()) { + foreach ($join->getJoinOnQueries() as $condition) { + if ($condition->getMethod() === Method::On) { + $values = $condition->getValues(); + $columns[] = $values[0] ?? null; + $columns[] = $values[2] ?? null; + } + } + } else { + $values = $join->getValues(); + $columns[] = $values[0] ?? null; + $columns[] = $values[2] ?? null; + } + + $joinAlias = $join->getJoinAlias(); + $aliases = []; + foreach ($columns as $column) { + if (! \is_string($column)) { + continue; + } - case Operator::TYPE_DATE_SET_NOW: - return DateTime::now(); + $dot = \strpos($column, '.'); + if ($dot === false) { + continue; + } - default: - return $value; + $conditionAlias = \substr($column, 0, $dot); + if ($conditionAlias !== $joinAlias) { + $aliases[] = $conditionAlias; + } } + + return $aliases; } /** - * Returns the current PDO object - * @return mixed - * @deprecated Use getDriver() instead + * @param non-empty-list $conditions */ - protected function getPDO(): mixed + private function anyOf(array $conditions): BaseQuery { - return $this->pdo; + return \count($conditions) === 1 ? $conditions[0] : BaseQuery::or($conditions); } /** - * Returns the current PDO object - * @return mixed + * @param non-empty-list $conditions */ - public function getDriver(): mixed + private function allOf(array $conditions): BaseQuery { - return $this->pdo; + return \count($conditions) === 1 ? $conditions[0] : BaseQuery::and($conditions); } /** - * Get PDO Type - * - * @param mixed $value - * @return int - * @throws Exception + * @param array $queries */ - abstract protected function getPDOType(mixed $value): int; + private function keepsUnmatchedRows(array $queries): bool + { + foreach ($queries as $query) { + $method = $query->getMethod(); + if ($method === Method::RightJoin || $method === Method::FullOuterJoin) { + return true; + } + } - /** - * Get the SQL function for random ordering - * - * @return string - */ - abstract protected function getRandomOrder(): string; + return false; + } /** - * Returns default PDO configuration - * - * @return array + * @param array $queries + * @return array */ - public static function getPDOAttributes(): array + private function rewriteFullOuterJoins(array $queries, Method $replacement): 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 - ]; - } - - public function getHostname(): string - { - try { - return $this->pdo->getHostname(); - } catch (\Throwable) { - return ''; + $rewritten = []; + foreach ($queries as $query) { + $clone = clone $query; + if ($clone->getMethod() === Method::FullOuterJoin) { + $clone->setMethod($replacement); + } + $rewritten[] = $clone; } + + return $rewritten; } /** - * @return int + * @param array $queries + * @param list $joinTablePrefixes + * @param array $adapterFilterQueries + * @param array $roles */ - public function getMaxVarcharLength(): int - { - return 16381; // Floor value for Postgres:16383 | MySQL:16381 | MariaDB:16382 + 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, + ): 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->joinSelections($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); + $hasSelectionProjection = true; + } + } + + if ($hasAggregation && ! empty($joinTablePrefixes)) { + $mainAttributes = []; + foreach (Database::internalAttributes() as $attribute) { + $mainAttributes[$attribute->key] = true; + } + /** @var array $collectionAttributes */ + $collectionAttributes = $collection->getAttribute('attributes', []); + foreach ($collectionAttributes as $attribute) { + $mainAttributes[$attribute->getId()] = true; + } + + $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; + } + + $qualify = function (string $attribute) use ($mainAttributes, $declared): string { + if ( + $attribute === '*' + || $attribute === '' + || \is_numeric($attribute) + || \str_contains($attribute, '.') + || isset($mainAttributes[$attribute]) + ) { + return $attribute; + } + + $aliases = []; + foreach ($declared as $alias => $attributes) { + if ($attributes === null || isset($attributes[$attribute])) { + $aliases[] = $alias; + } + } + + if (\count($aliases) > 1) { + throw new QueryException('Attribute "'.$attribute.'" is ambiguous across joins; qualify it with a join alias'); + } + + if ($aliases === []) { + throw new QueryException('Attribute not found in schema: '.$attribute); + } + + 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(), + )); + } + } + } + + 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($table).'.'.$this->quote($column).' AS '.$this->quote($group)); + } + } + } + } + + $this->applyFindFilters( + $builder, + $collection, + $queries, + $joinTablePrefixes, + $adapterFilterQueries, + $name, + $alias, + $roles, + $forPermission, + ); + + return $hasSelectionProjection; } /** - * Size of POINT spatial type - * - * @return int - */ - abstract protected function getMaxPointSize(): int; - /** - * @return string + * @param array $queries */ - public function getIdAttributeType(): string + private function applyFilters(SQLBuilder $builder, array $queries, string $name, string $alias): void { - return Database::VAR_INTEGER; + $builderQueries = []; + $adapterFilters = []; + foreach ($queries as $query) { + if ($this->isAdapterFilterQuery($query)) { + $adapterFilters[] = $this->compileAdapterFilter($query, $name, $alias); + + continue; + } + $builderQueries[] = $query; + } + + $builder->filter($builderQueries); + + foreach ($adapterFilters as $filter) { + if ($filter !== null) { + $builder->whereRaw($filter['expression'], $filter['bindings']); + } + } } /** - * @return int + * @param array $queries + * @param list $joinTablePrefixes + * @param array $adapterFilterQueries + * @param array $roles */ - public function getMaxIndexLength(): int - { - /** - * $tenant int = 1 - */ - return $this->sharedTables ? 767 : 768; - } + 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); + $this->remapDottedQueryAttributes($queries, $joinTablePrefixes, $collection); + $builder->filter($queries); + + foreach ($adapterFilterQueries as $query) { + $compiled = $this->compileAdapterFilter($query, $name, $alias, $joinTablePrefixes); + if ($compiled !== null) { + $builder->whereRaw($compiled['expression'], $compiled['bindings']); + } + } + + $chain = JoinChain::fromQueries($queries); + $preserving = $chain->hasPreservingOuterJoin(); + + if ($this->sharedTables && $preserving) { + $tenantFilter = new TenantFilter($this->currentTenant(), quoteChar: $this->getIdentifierQuoteChar()); + $tenantConditions = []; + foreach ($joinTablePrefixes as $join) { + $tenantConditions[$join['alias']] = $tenantFilter->joined($join['alias']); + } + $builder->addHook(new OuterJoinChainFilter($chain, $tenantConditions, $this->getIdentifierQuoteChar())); + } + + 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 PermissionAllowNullUid( + $permissionHook, + $docCol, + $this->getIdentifierQuoteChar(), + ); + } + $builder->addHook($permissionHook); + } - /** - * @return int - */ - public function getMaxUIDLength(): int - { - return 36; - } + $joinDocumentSecurity = $collection->getAttribute(Database::JOIN_DOCUMENT_SECURITY, []); + /** @var array $joinDocumentSecurity */ + $joinDocumentSecurity = \is_array($joinDocumentSecurity) ? $joinDocumentSecurity : []; - /** - * @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; + foreach ($joinTablePrefixes as $join) { + if ($this->joinDocumentSecurityEnabled($joinDocumentSecurity, $join['table']) === false) { + continue; + } - /** - * @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) { - continue; + $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 PermissionJoinFilter( + $permissionHook, + $join['alias'], + $this->getIdentifierQuoteChar(), + $preserving, + )); } - if ($query->isNested()) { - $conditions[] = $this->getSQLConditions($query->getValues(), $binds, $query->getMethod(), $forCollection); - } else { - $conditions[] = $this->getSQLCondition($query, $binds, $forCollection); + if ($permissionConditions !== []) { + $builder->addHook(new OuterJoinPermissionFilter($alias, $permissionConditions, $this->getIdentifierQuoteChar())); + $builder->addHook(new OuterJoinChainFilter($chain, $permissionConditions, $this->getIdentifierQuoteChar())); } } - - $tmp = implode(' ' . $separator . ' ', $conditions); - return empty($tmp) ? '' : '(' . $tmp . ')'; } /** - * @return string + * Rewrite the two ambiguous statistical aggregates to their explicit + * population forms, and count the inputs of every bitwise aggregate. + * + * 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. + * + * 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 function getLikeOperator(): string + private function populationStatistics(array $queries): array { - return 'LIKE'; + 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); + } + } + + foreach ($this->bitwiseInputs($queries) as $count => $aggregate) { + $queries[] = Query::count($aggregate->getAttribute(), $count); + } + + return $queries; } /** - * @return string + * 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. + * + * @param array $queries + * @return array */ - public function getRegexOperator(): string - { - return 'REGEXP'; - } - - public function getInternalIndexesKeys(): array + private function bitwiseInputs(array $queries): array { - return []; - } + $inputs = []; + foreach ($queries as $query) { + if (\in_array($query->getMethod(), self::BITWISE_AGGREGATES, true)) { + $inputs[self::BITWISE_INPUTS.\count($inputs)] = $query; + } + } - public function getSchemaAttributes(string $collection): array - { - return []; + return $inputs; } - public function getSchemaIndexes(string $collection): array + private function filtersPerDocument(Document $collection): bool { - return []; + return (bool) $collection->getAttribute('documentSecurity', false) + || $collection->getId() === Database::METADATA; } - public function getSupportForSchemaIndexes(): bool + /** + * @param array $joinDocumentSecurity + */ + private function joinDocumentSecurityEnabled(array $joinDocumentSecurity, string $table): bool { - return false; - } - - public function getTenantQuery( - string $collection, - string $alias = '', - int $tenantCount = 0, - string $condition = 'AND' - ): string { - if (!$this->sharedTables) { - return ''; + foreach ($this->joinDocumentSecurityLookupKeys($table) as $key) { + if (\array_key_exists($key, $joinDocumentSecurity)) { + return (bool) $joinDocumentSecurity[$key]; + } } - $dot = ''; - if ($alias !== '') { - $dot = '.'; - $alias = $this->quote($alias); + if ($joinDocumentSecurity === []) { + return true; } - $bindings = []; - if ($tenantCount === 0) { - $bindings[] = ':_tenant'; - } else { - for ($index = 0; $index < $tenantCount; $index++) { - $bindings[] = ":_tenant_{$index}"; + $candidates = $this->joinDocumentSecurityLookupKeys($table); + foreach ($joinDocumentSecurity as $key => $enabled) { + $key = (string) $key; + if ($key === '') { + continue; } - } - $bindings = \implode(',', $bindings); - $orIsNull = ''; - if ($collection === Database::METADATA) { - $orIsNull = " OR {$alias}{$dot}_tenant IS NULL"; + if (\array_intersect($candidates, $this->joinDocumentSecurityLookupKeys($key)) !== []) { + return (bool) $enabled; + } } - return "{$condition} ({$alias}{$dot}_tenant IN ({$bindings}) {$orIsNull})"; + return true; } /** - * Get the SQL projection given the selected attributes - * - * @param array $selections - * @param string $prefix - * @return mixed - * @throws Exception + * @return list */ - protected function getAttributeProjection(array $selections, string $prefix): mixed + private function joinDocumentSecurityLookupKeys(string $table): array { - if (empty($selections) || \in_array('*', $selections)) { - return "{$this->quote($prefix)}.*"; + $filtered = $this->filter($table); + $keys = [$table, $filtered]; + $qualified = $this->getSQLTableRaw($filtered); + $keys[] = $qualified; + + $dot = \strrpos($qualified, '.'); + if ($dot !== false) { + $keys[] = \substr($qualified, $dot + 1); } - // Handle specific selections with spatial conversion where needed - $internalKeys = [ - '$id', - '$sequence', - '$permissions', - '$createdAt', - '$updatedAt', - ]; - - $hasDeletedAt = \in_array('$deletedAt', $selections); - - $selections = \array_diff($selections, [...$internalKeys, '$deletedAt', '$collection']); + return \array_values(\array_unique($keys)); + } - foreach ($internalKeys as $internalKey) { - $selections[] = $this->getInternalKeyForAttribute($internalKey); + /** + * @param array $orderAttributes + * @param array $orderTypes + * @param list $joinTablePrefixes + */ + 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; + } } - if ($hasDeletedAt) { - $selections[] = $this->getInternalKeyForAttribute('$deletedAt'); + if (! $hasOrderColumns) { + return; } - $projections = []; - foreach ($selections as $selection) { - $filteredSelection = $this->filter($selection); - $quotedSelection = $this->quote($filteredSelection); - $projections[] = "{$this->quote($prefix)}.{$quotedSelection}"; + if (! $hasSelectionProjection) { + if (empty($joinTablePrefixes)) { + $builder->select(['*']); + } else { + $this->applyJoinProjection($builder, $collection, $joinTablePrefixes, $alias); + } } - return \implode(',', $projections); - } - - 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 - }; - } - - protected function escapeWildcards(string $value): string - { - $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + foreach ($orderAttributes as $i => $attribute) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; + if ($orderType === OrderDirection::Random) { + continue; + } - foreach ($wildcards as $wildcard) { - $value = \str_replace($wildcard, "\\$wildcard", $value); + $expression = $this->quoteOrderColumn($this->qualifyOrderAttribute($attribute, $joinAliases), $alias); + $builder->selectRaw($expression.' AS '.$this->quote(self::FOJ_ORDER_ALIAS_PREFIX.$i)); } - - return $value; - } - - protected function processException(PDOException $e): \Exception - { - return $e; } /** - * @param mixed $stmt - * @return bool + * 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 execute(mixed $stmt): bool + private function quoteOrderColumn(string $key, string $alias): string { - return $stmt->execute(); + $dot = \strpos($key, '.'); + if ($dot === false) { + return $this->quote($alias).'.'.$this->quote($key); + } + + return $this->quote(\substr($key, 0, $dot)).'.'.$this->quote(\substr($key, $dot + 1)); } /** - * Create Documents in batches + * 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 Document $collection - * @param array $documents - * - * @return array + * @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 DuplicateException - * @throws \Throwable + * @throws DatabaseException */ - public function createDocuments(Document $collection, array $documents): array - { - if (empty($documents)) { - return $documents; - } - - $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)]; + 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; - 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; } - $attributeKeys = array_unique($attributeKeys); - - if ($hasSequence) { - $attributeKeys[] = '_id'; + $aggregationQueries[] = $query; + $aggregateAlias = $query->getValue(''); + if ($method->isAggregate() && \is_string($aggregateAlias) && $aggregateAlias !== '') { + $aggregateAliases[$aggregateAlias] = true; } + } - if ($this->sharedTables) { - $attributeKeys[] = '_tenant'; + $joinAliases = \array_column($joinTablePrefixes, 'alias'); + $aggregation = $this->createBuilder(); + // 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 = []; + foreach ([[$leftQueries, $leftPreserving], [$rightQueries, true]] as [$halfQueries, $preservingOuter]) { + $half = $this->newBuilder($name, $alias, $preservingOuter); + if ($columns === []) { + $half->selectRaw('1'); } - - $columns = []; - foreach ($attributeKeys as $key => $attribute) { - $columns[$key] = $this->quote($this->filter($attribute)); + 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; + } - $columns = '(' . \implode(', ', $columns) . ')'; - - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $permissions = []; - $bindValuesPermissions = []; - - 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 (!empty($document->getSequence())) { - $attributes['_id'] = $document->getSequence(); + [$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; } + } + } - if ($this->sharedTables) { - $attributes['_tenant'] = $document->getTenant(); - } + return $this->fullOuterJoinResultNames($this->executeSelect($aggregation, Event::DocumentFind, $name), $columns, $qualifiedGroups); + } - $bindKeys = []; + private function shapesAggregatedRows(Method $method): bool + { + return $method->isAggregate() || match ($method) { + Method::GroupBy, Method::Having, Method::Select, Method::Distinct => true, + default => false, + }; + } - 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; + /** + * 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 + { + $references = []; + while ($queries !== []) { + $query = \array_shift($queries); + $method = $query->getMethod(); + + if ($method->isNested()) { + foreach ($query->getValues() as $condition) { + if ($condition instanceof BaseQuery) { + $queries[] = $condition; } - $bindValues[$bindKey] = $value; - $bindIndex++; } - - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - - 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(); - } + } elseif ($method === Method::GroupBy) { + foreach ($query->getValues() as $column) { + if (\is_string($column)) { + $references[] = $column; } } + } elseif ($method !== Method::Select) { + $references[] = $query->getAttribute(); } + } - $batchKeys = \implode(', ', $batchKeys); - - $stmt = $this->getPDO()->prepare(" - {$this->getInsertKeyword()} {$this->getSQLTable($name)} {$columns} - VALUES {$batchKeys} - {$this->getInsertSuffix($name)} - "); - - foreach ($bindValues as $key => $value) { - $stmt->bindValue($key, $value, $this->getPDOType($value)); + foreach ($orderAttributes as $i => $attribute) { + if (($orderTypes[$i] ?? OrderDirection::Asc) !== OrderDirection::Random) { + $references[] = $this->qualifyOrderAttribute($attribute, $joinAliases); } + } - $this->execute($stmt); + $columns = []; + foreach ($references as $reference) { + if ($reference === '' || $reference === '*' || \is_numeric($reference) || isset($aggregateAliases[$reference])) { + continue; + } - if (!empty($permissions)) { - $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $permissions = \implode(', ', $permissions); + $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); + } - $sqlPermissions = " - {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) - VALUES {$permissions} - {$this->getInsertPermissionsSuffix()} - "; + return $columns; + } - $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); + /** + * 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 + */ + private function fullOuterJoinColumnSpellings(array $columns, array $aggregateAliases, string $alias): array + { + $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 ($bindValuesPermissions as $key => $value) { - $stmtPermissions->bindValue($key, $value, $this->getPDOType($value)); + foreach ($candidates as $spelling) { + if (! isset($aggregateAliases[$spelling])) { + $spellings[$spelling] = self::FOJ_ROWS_ALIAS.'.'.$column; } - - $this->execute($stmtPermissions); } - - } catch (PDOException $e) { - throw $this->processException($e); } - return $documents; + return $spellings; } /** - * @param Document $collection - * @param string $attribute - * @param array $changes - * @return array - * @throws DatabaseException + * 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. + * + * A joined group that qualifiedGroupNames() names by its alias comes last under that name, where the + * single statement selects it. + * + * @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 upsertDocuments( - Document $collection, - string $attribute, - array $changes - ): array { - if (empty($changes)) { - return $changes; - } - try { - $spatialAttributes = $this->getSpatialAttributes($collection); - - $attributeDefaults = []; - foreach ($collection->getAttribute('attributes', []) as $attr) { - $attributeDefaults[$attr['$id']] = $attr['default'] ?? null; + private function fullOuterJoinResultNames(array $rows, array $columns, array $qualifiedGroups = []): array + { + $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); + } - $collection = $collection->getId(); - $name = $this->filter($collection); - - $hasOperators = false; - $firstChange = $changes[0]; - $firstDoc = $firstChange->getNew(); - $firstExtracted = Operator::extractOperators($firstDoc->getAttributes()); + foreach ($rows as $index => $row) { + $named = []; + $trailing = []; + foreach ($row as $key => $value) { + $key = (string) $key; + if (isset($qualified[$key])) { + $trailing[$qualified[$key]] = $value; - 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; - } + continue; } + $named[$names[$key] ?? \strtr($key, $expressions)] = $value; } + $rows[$index] = [...$named, ...$trailing]; + } - if (!$hasOperators) { - $bindIndex = 0; - $batchKeys = []; - $bindValues = []; - $allColumnNames = []; - $documentsData = []; - - foreach ($changes as $change) { - $document = $change->getNew(); - $currentRegularAttributes = $document->getAttributes(); + return $rows; + } - $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()); + /** + * 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 (!empty($document->getSequence())) { - $currentRegularAttributes['_id'] = $document->getSequence(); - } + $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) { - $currentRegularAttributes['_tenant'] = $document->getTenant(); - } + return $qualified; + } - foreach (\array_keys($currentRegularAttributes) as $colName) { - $allColumnNames[$colName] = true; - } + /** + * 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; + } - $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; + foreach ($query->getValues() as $value) { + if ($value === '*') { + return; + } + if (\is_string($value)) { + $selected[$this->qualifyOrderAttribute($value, $joinAliases)] = true; } + } + } - $allColumnNames = \array_keys($allColumnNames); - \sort($allColumnNames); + if ($selected === []) { + return; + } - $columnsArray = []; - foreach ($allColumnNames as $attr) { - $columnsArray[] = "{$this->quote($this->filter($attr))}"; - } - $columns = '(' . \implode(', ', $columnsArray) . ')'; + foreach ($orderAttributes as $i => $attribute) { + if (($orderTypes[$i] ?? OrderDirection::Asc) === OrderDirection::Random) { + continue; + } - foreach ($documentsData as $docData) { - $currentRegularAttributes = $docData['regularAttributes']; - $bindKeys = []; + 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"); + } + } + } - foreach ($allColumnNames as $attributeKey) { - $attrValue = $currentRegularAttributes[$attributeKey] ?? null; + /** + * 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 (\is_array($attrValue)) { - $attrValue = \json_encode($attrValue); - } + $cursorConditions = []; - 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++; - } + foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; + if ($orderType === OrderDirection::Random) { + continue; + } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; - } + $direction = $orderType; - $regularAttributes = []; - foreach ($allColumnNames as $colName) { - $regularAttributes[$colName] = null; - } - foreach ($documentsData[0]['regularAttributes'] as $key => $value) { - $regularAttributes[$key] = $value; - } + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; + } - $stmt = $this->getUpsertStatement($name, $columns, $batchKeys, $regularAttributes, $bindValues, $attribute, []); - $stmt->execute(); - $stmt->closeCursor(); - } else { - $groups = []; + $internalAttr = $resolveInternalKey($originalAttribute); - foreach ($changes as $change) { - $document = $change->getNew(); - $extracted = Operator::extractOperators($document->getAttributes()); - $operators = $extracted['operators']; + 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; + } - 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); - } + $andConditions = []; - if (!isset($groups[$signature])) { - $groups[$signature] = [ - 'documents' => [], - 'operators' => $operators - ]; - } + for ($j = 0; $j < $i; $j++) { + $prevOriginal = $orderAttributes[$j]; + $prevAttr = $resolveInternalKey($prevOriginal); + if ($nullable && $cursor[$prevOriginal] === null) { + $andConditions[] = BaseQuery::isNull($prevAttr); - $groups[$signature]['documents'][] = $change; + continue; } + /** @var array|bool|float|int|string|null> $prevCursorVals */ + $prevCursorVals = [$cursor[$prevOriginal]]; + $andConditions[] = BaseQuery::equal($prevAttr, $prevCursorVals); + } - 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); - } - } + 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); + } + } - $currentRegularAttributes['_uid'] = $document->getId(); - $currentRegularAttributes['_createdAt'] = $document->getCreatedAt() ? $document->getCreatedAt() : null; - $currentRegularAttributes['_updatedAt'] = $document->getUpdatedAt() ? $document->getUpdatedAt() : null; - $currentRegularAttributes['_permissions'] = \json_encode($document->getPermissions()); + if (count($andConditions) === 1) { + $cursorConditions[] = $andConditions[0]; + } else { + $cursorConditions[] = BaseQuery::and($andConditions); + } + } - if (!empty($document->getSequence())) { - $currentRegularAttributes['_id'] = $document->getSequence(); - } + if ($cursorConditions === []) { + return; + } - if ($this->sharedTables) { - $currentRegularAttributes['_tenant'] = $document->getTenant(); - } + if (count($cursorConditions) === 1) { + $builder->filter($cursorConditions); + } else { + $builder->filter([BaseQuery::or($cursorConditions)]); + } + } - foreach (\array_keys($currentRegularAttributes) as $colName) { - $allColumnNames[$colName] = true; - } + /** + * 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(); - $documentsData[] = ['regularAttributes' => $currentRegularAttributes]; - } + if ($value === null) { + return $nullsFirst ? BaseQuery::isNotNull($attribute) : null; + } - foreach (\array_keys($operators) as $colName) { - $allColumnNames[$colName] = true; - } + $comparison = $direction === OrderDirection::Desc + ? BaseQuery::lessThan($attribute, $value) + : BaseQuery::greaterThan($attribute, $value); - $allColumnNames = \array_keys($allColumnNames); - \sort($allColumnNames); + return $nullsFirst ? $comparison : BaseQuery::or([$comparison, BaseQuery::isNull($attribute)]); + } - $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++; - } + /** + * @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; + } - $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; + if ($afterUnion) { + $quote = $this->getIdentifierQuoteChar(); + $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->createBuilder()->compileOrder(BaseQuery::orderRandom()); + $sql = 'SELECT * FROM ('.$result->query.') AS '.$quote.self::FOJ_ROWS_ALIAS.$quote; + + continue; } - $regularAttributes = []; - foreach ($allColumnNames as $colName) { - $regularAttributes[$colName] = null; + $direction = $orderType; + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; } - foreach ($documentsData[0]['regularAttributes'] as $key => $value) { - $regularAttributes[$key] = $value; - } - - $stmt = $this->getUpsertStatement( - $name, - $columns, - $batchKeys, - $regularAttributes, - $bindValues, - '', - $operators - ); - $stmt->execute(); - $stmt->closeCursor(); + $orderParts[] = $quote.self::FOJ_ORDER_ALIAS_PREFIX.$i.$quote.($direction === OrderDirection::Desc ? ' DESC' : ' ASC'); } - } - - $removeQueries = []; - $removeBindValues = []; - $addQueries = []; - $addBindValues = []; - foreach ($changes as $index => $change) { - $old = $change->getOld(); - $document = $change->getNew(); - - $current = []; - foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); + if ($orderParts !== []) { + $sql .= ' ORDER BY '.\implode(', ', $orderParts); } - - 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 (! \is_null($limit)) { + $sql .= ' LIMIT ?'; + $bindings[] = $limit; + } + if (! \is_null($offset)) { + $sql .= ' OFFSET ?'; + $bindings[] = $offset; } - foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + return new Statement($sql, $bindings, $result->readOnly); + }); - foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + return; + } - if ($this->sharedTables) { - $addQuery .= ", :_tenant_{$index}"; - } + foreach ($orderAttributes as $i => $originalAttribute) { + $orderType = $orderTypes[$i] ?? OrderDirection::Asc; - $addQuery .= ")"; - $addQueries[] = $addQuery; - $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + if ($orderType === OrderDirection::Random) { + $builder->sortRandom(); - if ($this->sharedTables) { - $addBindValues[":_tenant_{$index}"] = $document->getTenant(); - } - } - } + continue; } - 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(); + $internalAttr = $this->qualifyOrderAttribute($originalAttribute, $joinAliases); + $direction = $orderType; + + if ($cursorDirection === CursorDirection::Before) { + $direction = ($direction === OrderDirection::Asc) + ? OrderDirection::Desc + : OrderDirection::Asc; } - 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(); + if ($direction === OrderDirection::Desc) { + $builder->sortDesc($internalAttr); + } else { + $builder->sortAsc($internalAttr); } - } catch (PDOException $e) { - throw $this->processException($e); } - return \array_map(fn ($change) => $change->getNew(), $changes); + if (! \is_null($limit)) { + $builder->limit($limit); + } + if (! \is_null($offset)) { + $builder->offset($offset); + } } /** - * Build geometry WKT string from array input for spatial queries - * - * @param array $geometry - * @return string - * @throws DatabaseException + * @return array> */ - protected function convertArrayToWKT(array $geometry): string + private function executeSelect(SQLBuilder $builder, Event $event, string $collection = ''): array { - // point [x, y] - if (count($geometry) === 2 && is_numeric($geometry[0]) && is_numeric($geometry[1])) { - return "POINT({$geometry[0]} {$geometry[1]})"; + try { + $result = $builder->build(); + } catch (ValidationException|UnsupportedException $e) { + throw new QueryException($e->getMessage(), $e->getCode(), $e); } - // 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'); + $stmt = null; + $results = []; + $exception = null; + try { + $stmt = $this->executeResult($result, $event, $collection); + $this->execute($stmt); + /** @var array> $results */ + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($stmt !== null) { + try { + $stmt->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; } - $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) . ')'; + if ($exception !== null) { + throw $this->processSelectException($exception, $result); } - throw new DatabaseException('Unrecognized geometry array format'); + return $results; + } + + private function qualifyJoinColumn(string $column, string $defaultAlias): string + { + $dot = \strpos($column, '.'); + if ($dot === false) { + return $defaultAlias.'.'.$this->getInternalKeyForAttribute($column); + } + + $prefix = \substr($column, 0, $dot); + $name = \substr($column, $dot + 1); + + return $prefix.'.'.$this->getInternalKeyForAttribute($name); } /** - * Find Documents + * 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 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 $orderAttributes + * @param array $cursor + * @param list $joinTablePrefixes + * @return array{array, array} + * + * @throws QueryException */ - 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 qualifyJoinedOrders(array $orderAttributes, array $cursor, Document $collection, array $joinTablePrefixes): array { - $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); + $main = []; + foreach (Database::internalAttributes() as $attribute) { + $main[$attribute->key] = true; + } + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + foreach ($attributes as $attribute) { + $main[$attribute->getId()] = true; + } - // 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; + $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']; + } } } - $queries = $otherQueries; - - $cursorWhere = []; - - foreach ($orderAttributes as $i => $originalAttribute) { - $orderType = $orderTypes[$i] ?? Database::ORDER_ASC; - - // Handle random ordering - if ($orderType === Database::ORDER_RANDOM) { - $orders[] = $this->getRandomOrder(); + foreach ($orderAttributes as $index => $attribute) { + if (\str_contains($attribute, '.') || isset($main[$attribute]) || ! isset($declared[$attribute])) { continue; } - $attribute = $this->getInternalKeyForAttribute($originalAttribute); - $attribute = $this->filter($attribute); - - $orderType = $this->filter($orderType); - $direction = $orderType; - - if ($cursorDirection === Database::CURSOR_BEFORE) { - $direction = ($direction === Database::ORDER_ASC) - ? Database::ORDER_DESC - : Database::ORDER_ASC; + $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'); } - $orders[] = "{$this->quote($attribute)} {$direction}"; - - // 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; - - $bindName = ":cursor_pk"; - $binds[$bindName] = $cursor[$originalAttribute]; - - $cursorWhere[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; - break; - } + $qualified = $aliases[0].'.'.$attribute; + $orderAttributes[$index] = $qualified; + if (\array_key_exists($attribute, $cursor) && ! \array_key_exists($qualified, $cursor)) { + $cursor[$qualified] = $cursor[$attribute]; + } + } - $conditions = []; + return [$orderAttributes, $cursor]; + } - // Add equality conditions for previous attributes - for ($j = 0; $j < $i; $j++) { - $prevOriginal = $orderAttributes[$j]; - $prevAttr = $this->filter($this->getInternalKeyForAttribute($prevOriginal)); + /** + * @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); - $bindName = ":cursor_{$j}"; - $binds[$bindName] = $cursor[$prevOriginal]; + return $this->filter($prefix).'.'.$this->filter($this->getInternalKeyForAttribute($name)); + } + } - $conditions[] = "{$this->quote($alias)}.{$this->quote($prevAttr)} = {$bindName}"; - } + return $this->filter($this->getInternalKeyForAttribute($attribute)); + } - // Add comparison for current attribute - $operator = ($direction === Database::ORDER_DESC) - ? Query::TYPE_LESSER - : Query::TYPE_GREATER; + /** + * @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]); - $bindName = ":cursor_{$i}"; - $binds[$bindName] = $cursor[$originalAttribute]; + continue; + } + if (! \str_contains($key, '.')) { + continue; + } + $separator = \strrpos($key, '.'); + if (! \is_int($separator)) { + continue; + } + $prefix = \substr($key, 0, $separator); + $bare = \trim(\substr($key, $separator + 1), '`"'); + $public = Storage::attribute($bare); + $dotted = $prefix.'.'.$public; - $conditions[] = "{$this->quote($alias)}.{$this->quote($attribute)} {$this->getSQLOperator($operator)} {$bindName}"; + if ($prefix === Query::DEFAULT_ALIAS && $bare !== '' && ! \array_key_exists($bare, $row)) { + $row[$bare] = $row[$key]; + } - $cursorWhere[] = '(' . implode(' AND ', $conditions) . ')'; + $value = $row[$key]; + if ($bare === Storage::PERMISSIONS || $public === Document::PERMISSIONS) { + $value = \json_decode(\is_string($value) ? $value : '[]', true); + } + if (! \array_key_exists($dotted, $row) || $key === $dotted) { + $row[$dotted] = $value; + } + if ($key !== $dotted) { + unset($row[$key]); } } - if (!empty($cursorWhere)) { - $where[] = '(' . implode(' OR ', $cursorWhere) . ')'; + 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]); + } } - - $conditions = $this->getSQLConditionsForCollection($name, $queries, $binds); - if (!empty($conditions)) { - $where[] = $conditions; + 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 ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + if (\array_key_exists(Storage::DISTANCE, $row)) { + $distance = $row[Storage::DISTANCE]; + $row[Document::DISTANCE] = \is_numeric($distance) ? (float) $distance : null; + unset($row[Storage::DISTANCE]); } + } - if ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; + /** + * Build a key-value row array from a Document for batch INSERT. + * + * 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 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 + */ + protected function buildDocumentRow(Document $document, array $attributeKeys, array $spatialMap = [], ?bool $intBools = null): array + { + $attributes = $document->getAttributes(); + $row = [ + Storage::UID => $document->getId(), + Storage::CREATED_AT => $document->getCreatedAt(), + Storage::UPDATED_AT => $document->getUpdatedAt(), + Storage::PERMISSIONS => \json_encode($document->getPermissions()), + ]; + + if (! empty($document->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + $intBools ??= $this->supports(Capability::IntegerBooleans); - $vectorDistances = []; - foreach ($vectorQueries as $query) { - $vectorDistance = $this->getSQLVectorDistance($query, $binds, $alias); - if ($vectorDistance) { - $vectorDistances[] = $vectorDistance; + 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; } - if (!empty($vectorDistances)) { - // Vector orders should come first for similarity search - $orders = \array_merge($vectorDistances, $orders); + return $row; + } + + /** + * @return list + */ + protected function getSpatialAttributes(Document $collection): array + { + /** @var array $collectionAttributes */ + $collectionAttributes = $collection->getAttribute('attributes', []); + $spatialTypes = [ColumnType::Point->value, ColumnType::Linestring->value, ColumnType::Polygon->value]; + + $spatialAttributes = []; + foreach ($collectionAttributes as $attribute) { + [$attributeKey, $attributeType] = $this->attributeKeyAndType($attribute); + if (\is_string($attributeKey) && \in_array($attributeType, $spatialTypes, true)) { + $spatialAttributes[] = $attributeKey; + } } - $sqlOrder = !empty($orders) ? 'ORDER BY ' . implode(', ', $orders) : ''; + return $spatialAttributes; + } - $sqlLimit = ''; - if (! \is_null($limit)) { - $binds[':limit'] = $limit; - $sqlLimit = 'LIMIT :limit'; + /** + * @return array{0: ?string, 1: ?string} + */ + private function attributeKeyAndType(mixed $attr): array + { + if ($attr instanceof Attribute) { + return [$attr->key, $attr->type->value]; } - if (! \is_null($offset)) { - $binds[':offset'] = $offset; - $sqlLimit .= ' OFFSET :offset'; + if ($attr instanceof Document) { + $type = $attr->getAttribute('type'); + $key = $attr->getAttribute('key', $attr->getId()); + } elseif (\is_array($attr)) { + $type = $attr['type'] ?? null; + $key = $attr['key'] ?? $attr[Document::ID] ?? null; + } else { + return [null, null]; } - $selections = $this->getAttributeSelections($queries); + if ($type instanceof ColumnType) { + $type = $type->value; + } - $projection = $this->getAttributeProjection($selections, $alias); + return [ + \is_string($key) ? $key : null, + \is_string($type) ? $type : null, + ]; + } - if (!empty($vectorDistances)) { - $readable = $this->getSQLReadableDistance($vectorDistances[0]); - $projection .= ", {$readable} AS {$this->quote(static::VECTOR_DISTANCE_COLUMN)}"; + protected function encodeSpatialWriteValue(mixed $value): mixed + { + if (\is_array($value)) { + return $this->convertArrayToWKT($value); } - $sql = " - SELECT {$projection} - FROM {$this->getSQLTable($name)} AS {$this->quote($alias)} - {$sqlWhere} - {$sqlOrder} - {$sqlLimit}; - "; + return $value; + } + + /** + * Generate SQL expression for operator + * Each adapter must implement operators specific to their SQL dialect + * + * @return string|null Returns null if operator can't be expressed in SQL + */ + abstract protected function getOperatorSQL(string $column, Operator $operator, int &$bindIndex): ?string; + + /** + * Bind operator parameters to prepared statement + */ + protected function bindOperatorParams(PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt, Operator $operator, int &$bindIndex): void + { + $method = $operator->getMethod(); + $values = $operator->getValues(); + + switch ($method) { + // Numeric operators with optional limits + case OperatorType::Increment: + case OperatorType::Decrement: + case OperatorType::Multiply: + case OperatorType::Divide: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + + // Bind limit if provided + if (isset($values[1])) { + $limitKey = "op_{$bindIndex}"; + $limit = self::exactLimit($values[1]); + $stmt->bindValue(':'.$limitKey, $limit, $this->getPDOType($limit)); + $bindIndex++; + } + break; + + case OperatorType::Modulo: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + break; + + case OperatorType::Power: + $value = $values[0] ?? 1; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + + // Bind max limit if provided + if (isset($values[1])) { + $maxKey = "op_{$bindIndex}"; + $limit = self::exactLimit($values[1]); + $stmt->bindValue(':'.$maxKey, $limit, $this->getPDOType($limit)); + $bindIndex++; + } + break; + + // String operators + case OperatorType::StringConcat: + $value = $values[0] ?? ''; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $value, PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::StringReplace: + $search = $values[0] ?? ''; + $replace = $values[1] ?? ''; + $searchKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$searchKey, $search, PDO::PARAM_STR); + $bindIndex++; + $replaceKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$replaceKey, $replace, PDO::PARAM_STR); + $bindIndex++; + break; - $sql = $this->trigger(Database::EVENT_DOCUMENT_FIND, $sql); + // Boolean operators + case OperatorType::Toggle: + // No parameters to bind + break; - try { - $stmt = $this->getPDO()->prepare($sql); + // Date operators + case OperatorType::DateAddDays: + case OperatorType::DateSubDays: + $days = $values[0] ?? 0; + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $days, PDO::PARAM_INT); + $bindIndex++; + break; + + case OperatorType::DateSetNow: + // No parameters to bind + break; - 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)); + // Array operators + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: + // PERFORMANCE: Validate array size to prevent memory exhaustion + 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'); } - } - $this->execute($stmt); - } catch (PDOException $e) { - throw $this->processException($e); - } + // Bind JSON array + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->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); + } + $stmt->bindValue(':'.$bindKey, $value, $this->getPDOType($value)); + $bindIndex++; + break; - $results = $stmt->fetchAll(); - $stmt->closeCursor(); + case OperatorType::ArrayUnique: + // No parameters to bind + break; - foreach ($results as $index => $document) { - if (\array_key_exists('_uid', $document)) { - $results[$index]['$id'] = $document['_uid']; - unset($results[$index]['_uid']); - } - if (\array_key_exists('_id', $document)) { - $results[$index]['$sequence'] = $document['_id']; - unset($results[$index]['_id']); - } - if (\array_key_exists('_tenant', $document)) { - $results[$index]['$tenant'] = $document['_tenant']; - unset($results[$index]['_tenant']); - } - if (\array_key_exists('_createdAt', $document)) { - $results[$index]['$createdAt'] = $document['_createdAt']; - unset($results[$index]['_createdAt']); - } - if (\array_key_exists('_updatedAt', $document)) { - $results[$index]['$updatedAt'] = $document['_updatedAt']; - unset($results[$index]['_updatedAt']); - } - if (\array_key_exists('_permissions', $document)) { - $results[$index]['$permissions'] = \json_decode($document['_permissions'] ?? '[]', true); - unset($results[$index]['_permissions']); - } - 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]); - } + // Complex array operators + case OperatorType::ArrayInsert: + $index = $values[0] ?? 0; + $value = $values[1] ?? null; + $indexKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$indexKey, $index, PDO::PARAM_INT); + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$valueKey, json_encode($value), PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::ArrayIntersect: + case OperatorType::ArrayDiff: + // PERFORMANCE: Validate array size to prevent memory exhaustion + 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'); + } - $results[$index] = new Document($results[$index]); - } + $arrayValue = json_encode($values); + $bindKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$bindKey, $arrayValue, PDO::PARAM_STR); + $bindIndex++; + break; + + case OperatorType::ArrayFilter: + $condition = \is_string($values[0] ?? null) ? $values[0] : 'equal'; + $value = $values[1] ?? null; + + $validConditions = [ + 'equal', 'notEqual', // Comparison + 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // Numeric + 'isNull', 'isNotNull', // Null checks + ]; + if (! in_array($condition, $validConditions, true)) { + throw new DatabaseException("Invalid filter condition: {$condition}. Must be one of: ".implode(', ', $validConditions)); + } - if ($cursorDirection === Database::CURSOR_BEFORE) { - $results = \array_reverse($results); + $conditionKey = "op_{$bindIndex}"; + $stmt->bindValue(':'.$conditionKey, $condition, PDO::PARAM_STR); + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + if ($value !== null) { + $stmt->bindValue(':'.$valueKey, json_encode($value), PDO::PARAM_STR); + } else { + $stmt->bindValue(':'.$valueKey, null, PDO::PARAM_NULL); + } + $bindIndex++; + break; } - - return $results; } /** - * Count Documents + * Get the operator expression and positional bindings for use with the query builder's setRaw(). * - * @param Document $collection - * @param array $queries - * @param int|null $max - * @return int - * @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 + * @param Operator $operator The operator to convert + * @return array{expression: string, bindings: list} The expression and binding values + * + * @throws DatabaseException */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + protected function getOperatorBuilderExpression(string $column, Operator $operator): array { - $collection = $collection->getId(); - $name = $this->filter($collection); - $roles = $this->authorization->getRoles(); - $binds = []; - $where = []; - $alias = Query::DEFAULT_ALIAS; - - $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; + // Strip the "quotedColumn = " prefix to get just the RHS expression + $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); - } + // Collect the named binding keys and their values in order + /** @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 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 - "; + // Replace each named binding occurrence with ? and collect positional bindings + // 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)); + + // Find all occurrences of all named bindings and sort by position + $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); + } } - $sql = $this->trigger(Database::EVENT_DOCUMENT_COUNT, $sql); - - $stmt = $this->getPDO()->prepare($sql); + // Sort by position (ascending) to replace in order + usort($replacements, fn ($a, $b) => $a['pos'] - $b['pos']); - 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]; + // Collect bindings in positional order (left to right) + foreach ($replacements as $r) { + $positionalBindings[] = $namedBindings[$r['key']]; } - return $result['sum'] ?? 0; + return ['expression' => $result, 'bindings' => $positionalBindings]; } /** - * Sum an Attribute + * Get a builder-compatible operator expression for use in upsert conflict resolution. * - * @param Document $collection - * @param string $attribute - * @param array $queries - * @param int|null $max - * @return int|float - * @throws Exception - * @throws PDOException + * 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 + * @param Operator $operator The operator to convert + * @return array{expression: string, bindings: list} */ - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + protected function getOperatorUpsertExpression(string $column, Operator $operator): array { - $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'; - } - - $queries = array_map(fn ($query) => clone $query, $queries); + return $this->getOperatorBuilderExpression($column, $operator); + } - $otherQueries = []; - foreach ($queries as $query) { - if (!in_array($query->getMethod(), Query::VECTOR_TYPES)) { - $otherQueries[] = $query; + /** + * 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) + * @return mixed The result after applying the operator + */ + protected function applyOperatorToValue(Operator $operator, mixed $value): mixed + { + $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); + } } - } - $conditions = $this->getSQLConditionsForCollection($name, $otherQueries, $binds); - if (!empty($conditions)) { - $where[] = $conditions; + return $exact; } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); - } + $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 ($this->sharedTables) { - $binds[':_tenant'] = $this->tenant; - $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; - } + 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(), + }; - $sqlWhere = !empty($where) - ? 'WHERE ' . \implode(' AND ', $where) - : ''; + return self::keepWithinBound($method, $numVal, $result, $values[1] ?? null); + } - 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 - "; + protected static function exactLimit(mixed $limit): mixed + { + if (! \is_float($limit) || ! \is_finite($limit)) { + return $limit; } + return BigInt::integralValue($limit) ?? $limit; + } - $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)); + private static function keepWithinBound(OperatorType $method, int|float $current, mixed $result, mixed $bound): mixed + { + if (! \is_numeric($bound) || (! \is_int($result) && ! \is_float($result))) { + return $result; } - try { - $this->execute($stmt); - } catch (PDOException $e) { - throw $this->processException($e); - } + $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); - $result = $stmt->fetchAll(); - $stmt->closeCursor(); - if (!empty($result)) { - $result = $result[0]; - } + $crossed = match ($method) { + OperatorType::Increment, OperatorType::Multiply, OperatorType::Power => \is_nan((float) $result) || $comparison > 0, + OperatorType::Decrement, OperatorType::Divide => $comparison < 0, + default => false, + }; - return $result['sum'] ?? 0; + return $crossed ? $current : $result; } - public function getSpatialTypeFromWKT(string $wkt): string + /** + * @param array $items + * @return list + */ + private static function filterArray(array $items, mixed $condition, mixed $compare): array { - $wkt = trim($wkt); - $pos = strpos($wkt, '('); - if ($pos === false) { - throw new DatabaseException("Invalid spatial type"); - } - return strtolower(trim(substr($wkt, 0, $pos))); + 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, + })); } - public function decodePoint(string $wkb): array + private static function shiftDays(mixed $value, int $days): 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.) - */ - - if (strlen($wkb) < 25) { - throw new DatabaseException('Invalid WKB: too short for POINT'); - } - - // 4 bytes SRID first → skip to byteOrder at offset 4 - $byteOrder = ord($wkb[4]); - $littleEndian = ($byteOrder === 1); - - if (!$littleEndian) { - throw new DatabaseException('Only little-endian WKB supported'); + if (! \is_string($value) || $value === '') { + return $value; } - // 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 { + $date = new \DateTime($value); + } catch (Throwable) { + return $value; } - // Unpack two doubles - $coords = unpack('d2', $coordsBin); - if ($coords === false || !isset($coords[1], $coords[2])) { - throw new DatabaseException('Invalid WKB: failed to unpack coordinates'); - } + $date->setTimezone(new \DateTimeZone(\date_default_timezone_get())); + $date->modify(\sprintf('%+d days', $days)); - return [(float)$coords[1], (float)$coords[2]]; + return DateTime::format($date); } - public function decodeLinestring(string $wkb): array + /** + * @param array $values + * @return list + */ + private static function stringifyList(array $values): 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); + $out = []; + foreach ($values as $value) { + $out[] = self::stringify($value); } - // Skip 1 byte (endianness) + 4 bytes (type) + 4 bytes (SRID) - $offset = 9; + return $out; + } - // 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'); + private static function stringify(mixed $value): string + { + if (\is_string($value)) { + return $value; } - - $numPoints = $numPointsArr[1]; - $offset += 4; - - $points = []; - for ($i = 0; $i < $numPoints; $i++) { - $xArr = unpack('d', substr($wkb, $offset, 8)); - $yArr = unpack('d', substr($wkb, $offset + 8, 8)); - - if ($xArr === false || !isset($xArr[1]) || $yArr === false || !isset($yArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack point coordinates'); - } - - $points[] = [(float)$xArr[1], (float)$yArr[1]]; - $offset += 16; + if (\is_scalar($value) || $value === null) { + return (string) $value; } - return $points; + return \get_debug_type($value); } - public function decodePolygon(string $wkb): array + /** + * Quote an identifier (table name, column name) with the appropriate quoting character. + */ + protected function quote(string $string): string { - // 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); - } + return "`{$string}`"; + } - // 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'); - } - } + /** + * 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; + } - if (strlen($wkb) < 21) { - throw new DatabaseException('WKB too short to be a POLYGON'); - } + /** + * 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; - // MySQL SRID-aware WKB layout: 4 bytes SRID prefix - $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; - $byteOrder = ord($wkb[$offset]); - if ($byteOrder !== 1) { - throw new DatabaseException('Only little-endian WKB supported'); - } - $offset += 1; + /** + * 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; - $typeArr = unpack('V', substr($wkb, $offset, 4)); - if ($typeArr === false || !isset($typeArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack geometry type'); - } + /** + * Get PDO Type + * + * @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)), + }; + } - $type = $typeArr[1]; - $hasSRID = ($type & 0x20000000) === 0x20000000; - $geomType = $type & 0xFF; - $offset += 4; + /** + * Get the SQL function for random ordering + */ + protected function getRandomOrder(): string + { + return 'RANDOM()'; + } - if ($geomType !== 3) { // 3 = POLYGON - throw new DatabaseException("Not a POLYGON geometry type, got {$geomType}"); - } + /** + * The direction in which this engine sorts null before every other value. + */ + protected function getNullOrder(): OrderDirection + { + return OrderDirection::Asc; + } - // Skip SRID in type flag if present - if ($hasSRID) { - $offset += 4; - } + /** + * 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. + * + * @return array{expression: string, bindings: list}|null + */ + protected function getVectorOrderRaw(Query $query, string $alias): ?array + { + return null; + } - $numRingsArr = unpack('V', substr($wkb, $offset, 4)); + /** + * @param array{expression: string, bindings: list} $vector + * @param list $orderAttributes + * @param list $orderTypes + * @param array $cursor + * @param callable(string): string $resolveInternalKey + * @return array{expression: string, bindings: list} + */ + private function getVectorCursorCondition( + array $vector, + float $distance, + array $orderAttributes, + array $orderTypes, + array $cursor, + CursorDirection $cursorDirection, + string $alias, + callable $resolveInternalKey, + bool $nullable = false, + ): array { + $distance = \json_encode($distance, JSON_THROW_ON_ERROR); + $distanceOperator = $cursorDirection === CursorDirection::Before ? '<' : '>'; + $clauses = ["({$vector['expression']}) {$distanceOperator} ?"]; + $bindings = []; + \array_push($bindings, ...$vector['bindings']); + $bindings[] = $distance; - if ($numRingsArr === false || !isset($numRingsArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack number of rings'); - } + foreach ($orderAttributes as $index => $attribute) { + if (! \array_key_exists($attribute, $cursor)) { + throw new QueryException("Vector cursor is missing order attribute '{$attribute}'"); + } - $numRings = $numRingsArr[1]; - $offset += 4; + $parts = ["({$vector['expression']}) = ?"]; + $clauseBindings = []; + \array_push($clauseBindings, ...$vector['bindings']); + $clauseBindings[] = $distance; - $rings = []; + 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}'"); + } - for ($r = 0; $r < $numRings; $r++) { - $numPointsArr = unpack('V', substr($wkb, $offset, 4)); + $previousColumn = $this->quoteOrderColumn($resolveInternalKey($previousAttribute), $alias); + if ($nullable && $cursor[$previousAttribute] === null) { + $parts[] = "{$previousColumn} IS NULL"; - if ($numPointsArr === false || !isset($numPointsArr[1])) { - throw new DatabaseException('Invalid WKB: cannot unpack number of points'); + continue; + } + $parts[] = "{$previousColumn} = ?"; + $clauseBindings[] = $cursor[$previousAttribute]; } - $numPoints = $numPointsArr[1]; - $offset += 4; - $ring = []; - - 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.'); + $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); + } - $x = (float) $xArr[1]; - - $yArr = unpack('d', substr($wkb, $offset + 8, 8)); - if ($yArr === false) { - throw new DatabaseException('Failed to unpack Y coordinate from WKB.'); - } + return [ + 'expression' => '('.\implode(' OR ', $clauses).')', + 'bindings' => $bindings, + ]; + } - $y = (float) $yArr[1]; + /** + * Render a vector distance expression in a form safe to hydrate as a PHP float. + */ + protected function getSQLReadableDistance(string $distance): string + { + return $distance; + } - $ring[] = [$x, $y]; - $offset += 16; - } + 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 + protected function processSelectException(PDOException $e, Statement $statement): Exception { - return false; + return $this->processException($e); } - public function getLockType(): string + /** + * 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 { - if ($this->getSupportForAlterLocks() && $this->alterLocks) { - return ',LOCK=SHARED'; + $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 + * @return array{expression: string, bindings: list}|null + */ + protected function compileAdapterFilter(Query $query, string $collection, string $alias, array $joins = []): ?array { - return true; + return null; } } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 3880aec167..26ae1264e5 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -3,22 +3,48 @@ 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\Attribute; +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\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\Hook\PermissionFilter; +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\RelationSide; +use Utopia\Database\RelationType; +use Utopia\Database\Storage; +use Utopia\Database\Validator\BigInt; +use Utopia\Query\Builder\SQL as SQLBuilder; +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,7 +60,7 @@ * 9. MODIFY COLUMN is not supported * 10. Can't rename an index directly */ -class SQLite extends MariaDB +class SQLite extends SQL implements Feature\SchemaAttributes, Feature\SchemaIndexes { /** Suffix appended to every FTS5 virtual table name created by this adapter. */ private const FTS_TABLE_SUFFIX = '_fts'; @@ -48,9 +74,27 @@ class SQLite extends MariaDB /** AFTER UPDATE trigger suffix on the parent collection. */ private const 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 REGEXP_MAX_PATTERN_LENGTH = 512; + + /** + * 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; + /** - * Per-collection attribute → FTS5 table memo. Populated in one pass - * so multi-attribute SEARCH batches don't issue PRAGMA per attribute. + * 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> */ @@ -72,13 +116,90 @@ class SQLite extends MariaDB */ 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->pcreRegistered = false; + $this->capabilitySet = null; + $this->registerUserFunctions(); + } + + protected function getPDO(): DatabasePDO|PDOProxy|PDO + { + return parent::getPDO(); + } + + /** + * Prepare a statement and reject drivers that return false or another + * non-statement value before a method is called on it. + */ + private function prepare( + string $query, + string $message = 'Failed to prepare SQLite statement', + ?Event $event = null, + ): DatabasePDOStatement|PDOStatementProxy|PDOStatement { + 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); + } + } + + /** + * Get the list of capabilities supported by the SQLite adapter. + * + * @return array + */ + public function capabilities(): array + { + $remove = [ + Capability::Schemas, + Capability::UpdateLock, + Capability::Hostname, + Capability::UpsertOnUniqueIndex, + Capability::StatisticalAggregates, + Capability::BitwiseAggregates, + ]; + + if (! $this->emulateMySQL) { + $remove[] = Capability::AttributeResizing; + } + + if (! $this->pcreRegistered) { + $remove[] = Capability::Regex; + } + + $extras = [ + Capability::IntegerBooleans, + Capability::NumericCasting, + ]; + + if ($this->pcreRegistered) { + $extras[] = Capability::PCRE; + } + + return array_merge( + array_values(array_filter( + parent::capabilities(), + fn (Capability $c) => ! in_array($c, $remove, true) + )), + $extras + ); + } + /** * Toggle MariaDB/MySQL emulation. See $emulateMySQL for what this * actually changes. @@ -86,6 +207,8 @@ public function __construct(mixed $pdo) public function setEmulateMySQL(bool $emulate): static { $this->emulateMySQL = $emulate; + // Capability set is computed from $emulateMySQL — invalidate the cache. + $this->capabilitySet = null; return $this; } @@ -97,7 +220,7 @@ public function getEmulateMySQL(): bool public function setTenant(int|string|null $tenant): bool { - $changed = $this->tenant !== $tenant; + $changed = $this->currentTenant() !== $tenant; $result = parent::setTenant($tenant); if ($changed) { // Invalidate after the parent setter so a validation failure @@ -129,19 +252,6 @@ public function setSharedTables(bool $sharedTables): bool return $result; } - /** - * 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; - - /** - * 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; - /** * Register a preg_match-backed REGEXP UDF so the inherited REGEXP * path resolves. Best-effort — non-SQLite PDOs simply skip it. @@ -180,14 +290,36 @@ private function registerUserFunctions(): void }; try { - $this->getPDO()->sqliteCreateFunction('REGEXP', $pcre, 2); + $pdo = $this->getPDO(); + $registered = false; + + if ($pdo instanceof DatabasePDO) { + $registered = $pdo->__call('createFunction', ['REGEXP', $pcre, 2]); + } elseif (\method_exists($pdo, 'createFunction')) { + $registered = $pdo->createFunction('REGEXP', $pcre, 2); + } + + if ($registered !== true) { + return; + } + $this->pcreRegistered = true; + // Capability::PCRE is conditional on UDF registration — invalidate cache. + $this->capabilitySet = null; } catch (\Throwable) { } } /** - * @inheritDoc + * @param PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt + */ + protected function execute(mixed $stmt, ?Event $event = null): bool + { + return $this->executeAndProfile($stmt); + } + + /** + * {@inheritDoc} * * SQLite serialises writers through a single file lock. PDO's default * `BEGIN` is `DEFERRED`, which acquires the writer lock lazily on the @@ -202,24 +334,24 @@ public function startTransaction(): bool try { if ($this->inTransaction === 0) { if ($this->getPDO()->inTransaction()) { - $this->getPDO() + $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'); } @@ -245,14 +377,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; @@ -277,12 +409,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; @@ -295,13 +427,21 @@ public function rollbackTransaction(): bool return true; } + /** + * Create Database + * + * @throws Exception + * @throws PDOException + */ + public function create(string $name): bool + { + return true; + } + /** * Check if Database exists * Optionally check if collection exists in Database * - * @param string $database - * @param string|null $collection - * @return bool * @throws DatabaseException */ public function exists(string $database, ?string $collection = null): bool @@ -315,45 +455,32 @@ public function exists(string $database, ?string $collection = null): 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); - - $stmt = $this->getPDO()->prepare($sql); + $stmt = $this->prepare($sql, 'Failed to prepare collection existence query', Event::CollectionRead); $stmt->bindValue(':table', "{$this->getNamespace()}_{$collection}", PDO::PARAM_STR); - $stmt->execute(); + $this->execute($stmt); $document = $stmt->fetchAll(); $stmt->closeCursor(); - if (!empty($document)) { - $document = $document[0]; - } + if (! empty($document)) { + /** @var array $firstDoc */ + $firstDoc = $document[0]; + $docName = $firstDoc['name'] ?? ''; - return (($document['name'] ?? '') === "{$this->getNamespace()}_{$collection}"); - } + return (\is_string($docName) ? $docName : '') === "{$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 */ @@ -365,10 +492,9 @@ public function delete(string $name): bool /** * Create Collection * - * @param string $name - * @param array $attributes - * @param array $indexes - * @return bool + * @param array $attributes + * @param array $indexes + * * @throws Exception * @throws PDOException */ @@ -380,14 +506,14 @@ public function createCollection(string $name, array $attributes = [], array $in $attributeStrings = []; foreach ($attributes as $key => $attribute) { - $attrId = $this->filter($attribute->getId()); + $attrId = $this->filter($attribute->key); $attrType = $this->getSQLType( - $attribute->getAttribute('type'), - $attribute->getAttribute('size', 0), - $attribute->getAttribute('signed', true), - $attribute->getAttribute('array', false), - $attribute->getAttribute('required', false) + $attribute->type, + $attribute->size, + $attribute->signed, + $attribute->array, + $attribute->required ); $attributeStrings[$key] = "`{$attrId}` {$attrType}, "; @@ -399,103 +525,129 @@ public function createCollection(string $name, array $attributes = [], array $in // PRAGMA table_info echoes the exact string under emulation; // otherwise use INTEGER, the affinity-correct vanilla form. $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, + {$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->getSQLTable(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::PERM_TYPE)} VARCHAR(12) NOT NULL, + {$this->quote(Storage::PERM_PERMISSION)} VARCHAR(255) NOT NULL, + {$this->quote(Storage::PERM_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($collection, 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::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_PERMISSION]), event: Event::CollectionCreate); + $this->createIndex(Storage::permissionsTable($id), Index::key(key: self::INDEX_2, attributes: [Storage::PERM_PERMISSION, Storage::PERM_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, new Index( + key: $this->filter($index->key), + type: $index->type, + attributes: $index->attributes, + lengths: $index->lengths, + orders: $index->orders, + ttl: $index->ttl, + ), event: Event::CollectionCreate); + } + } catch (Throwable $e) { + if ($e instanceof PDOException) { + $e = $this->processException($e); + } + + if ($created && ! $e instanceof DuplicateException) { + $this->discardCreatedCollection($id); + } + + throw $e; + } + + 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->getSQLTable($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 * + * @throws DatabaseException */ 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(" + $stmt = $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); try { - $stmt->execute(); - $size = (int) $stmt->fetchColumn(); + $this->execute($stmt); + $result = $stmt->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; $stmt->closeCursor(); } catch (PDOException $e) { throw new DatabaseException('Failed to get collection size: ' . $e->getMessage()); @@ -506,8 +658,7 @@ public function getSizeOfCollection(string $collection): int /** * Get Collection Size on disk - * @param string $collection - * @return int + * * @throws DatabaseException */ public function getSizeOfCollectionOnDisk(string $collection): int @@ -515,10 +666,15 @@ 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 */ @@ -529,59 +685,32 @@ public function deleteCollection(string $id): bool // 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); - $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->getSQLTable(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 + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool { - if (!empty($newKey) && $newKey !== $id) { - return $this->renameAttribute($collection, $id, $newKey); + if (! empty($newKey) && $newKey !== $attribute->key) { + return $this->renameAttribute($collection, $attribute->key, $newKey); } // SQLite is dynamically typed — `ALTER TABLE ... MODIFY COLUMN` is @@ -590,32 +719,32 @@ 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) { + if ($this->emulateMySQL && $attribute->type === ColumnType::String && $attribute->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' : ''; + $tenantClause = $this->sharedTables ? ' AND '.$this->quote(Storage::TENANT).' = :'.Storage::TENANT : ''; $sql = "SELECT 1 FROM {$this->getSQLTable($name)} WHERE LENGTH(`{$column}`) > :max{$tenantClause} LIMIT 1"; - $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':max', $size, PDO::PARAM_INT); + $stmt = $this->prepare($sql, event: Event::AttributeUpdate); + $stmt->bindValue(':max', $attribute->size, PDO::PARAM_INT); if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant, \is_int($this->tenant) ? PDO::PARAM_INT : PDO::PARAM_STR); + $stmt->bindValue(':'.Storage::TENANT, $this->currentTenant(), \is_int($this->currentTenant()) ? PDO::PARAM_INT : PDO::PARAM_STR); } try { - $stmt->execute(); + $this->execute($stmt); $exceeds = $stmt->fetchColumn() !== false; } finally { $stmt->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 {$attribute->size}"); } } @@ -625,47 +754,47 @@ 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 + public function deleteAttribute(string $collection, string $id): bool { $name = $this->filter($collection); $id = $this->filter($id); - $metadataCollection = new Document(['$id' => Database::METADATA]); + $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) ?? []; - } + $rawIndexes = $collection->getAttribute('indexes', '[]'); + /** @var array> $indexes */ + $indexes = \json_decode(\is_string($rawIndexes) ? $rawIndexes : '[]', true) ?? []; foreach ($indexes as $index) { - $attributes = $index['attributes']; + /** @var array $index */ + $attributes = $index['attributes'] ?? []; + $indexId = \is_string($index[Document::ID] ?? null) ? (string) $index[Document::ID] : ''; + $indexType = \is_string($index['type'] ?? null) ? (string) $index['type'] : ''; 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']); + $this->deleteIndex($name, $indexId, Event::AttributeDelete); + } elseif (\in_array($id, \is_array($attributes) ? $attributes : [])) { + $this->deleteIndex($name, $indexId, Event::AttributeDelete); + $this->createIndex($name, Index::fromArray([ + 'key' => $indexId, + 'type' => $indexType, + 'attributes' => \array_map(fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', \is_array($attributes) ? \array_values(\array_filter($attributes, fn ($v) => $v !== $id)) : []), + 'lengths' => \array_map(fn (mixed $v): int => \is_numeric($v) ? (int) $v : 0, \is_array($index['lengths'] ?? null) ? $index['lengths'] : []), + 'orders' => \is_array($index['orders'] ?? null) ? $index['orders'] : [], + ]), event: Event::AttributeDelete); } } $sql = "ALTER TABLE {$this->getSQLTable($name)} DROP COLUMN `{$id}`"; - $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); - 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; @@ -675,99 +804,47 @@ 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 string $collection - * @param string $id - * @param string $type - * @param array $attributes - * @param array $lengths - * @param array $orders - * @param array $indexAttributeTypes - * @return bool + * @param array $indexAttributeTypes + * @param array $collation + * * @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 - { + 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(" + $stmt = $this->prepare(" SELECT name FROM sqlite_master WHERE type='index' AND name=:_index; - "); + ", event: $event); $stmt->bindValue(':_index', "{$this->getNamespace()}_{$this->getTenantSegment()}_{$name}_{$id}"); - $stmt->execute(); - $index = $stmt->fetch(); - if (!empty($index)) { + $this->execute($stmt); + $existingIndex = $stmt->fetch(); + if (! empty($existingIndex)) { return true; } $sql = $this->getSQLIndex($name, $id, $type, $attributes); - $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); - - return $this->getPDO() - ->prepare($sql) - ->execute(); + return $this->execute($this->prepare($sql, event: $event)); } /** @@ -777,7 +854,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'); @@ -787,13 +864,13 @@ protected function createFulltextIndex(string $collection, string $id, array $at $ftsTable = $this->getFulltextTableName($collection, $attributes); $parentTable = "{$this->getNamespace()}_{$collection}"; - $stmt = $this->getPDO()->prepare(" + $stmt = $this->prepare(" SELECT name FROM sqlite_master WHERE type='table' AND name=:_table; - "); + ", event: $event); $stmt->bindValue(':_table', $ftsTable); - $stmt->execute(); + $this->execute($stmt); $exists = !empty($stmt->fetch()); $stmt->closeCursor(); if ($exists) { @@ -811,47 +888,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) { @@ -865,7 +941,7 @@ protected function createFulltextIndex(string $collection, string $id, array $at throw $e; } - unset($this->ftsTableCache[$collection]); + unset($this->ftsTableCache[$this->getFulltextTablePrefix($collection)]); return true; } @@ -905,7 +981,7 @@ protected function getFulltextTablePrefix(string $collection): string */ private function getTenantSegment(): string { - return $this->filter((string) ($this->tenant ?? '')); + return $this->filter((string) ($this->currentTenant() ?? '')); } /** @@ -914,26 +990,31 @@ 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(); + } + + $pdo = $this->getPDO(); + $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 $this->getPDO()->quote((string) $this->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 + public function deleteIndex(string $collection, string $id, Event $event = Event::IndexDelete): bool { $name = $this->filter($collection); $id = $this->filter($id); @@ -943,11 +1024,11 @@ public function deleteIndex(string $collection, string $id): bool // 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(" + $stmt = $this->prepare(" SELECT name FROM sqlite_master WHERE type='index' AND name=:_index - "); + ", event: $event); $stmt->bindValue(':_index', $regularIndex); - $stmt->execute(); + $this->execute($stmt); $hasRegular = $stmt->fetchColumn() !== false; // Free the read cursor before issuing DDL — SQLite holds a SHARED // lock on the database while a statement has unfetched rows, and @@ -955,17 +1036,14 @@ public function deleteIndex(string $collection, string $id): bool // will trip "database table is locked". $stmt->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; @@ -975,11 +1053,61 @@ public function deleteIndex(string $collection, string $id): bool } } + /** + * Rename Index + * + * @throws Exception + * @throws PDOException + */ + 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); + $rawIdxs = $collection->getAttribute('indexes', '[]'); + /** @var array> $indexes */ + $indexes = \json_decode(\is_string($rawIdxs) ? $rawIdxs : '[]', true) ?? []; + /** @var array|null $index */ + $index = null; + + foreach ($indexes as $node) { + /** @var array $node */ + if (($node['key'] ?? null) === $old) { + $index = $node; + break; + } + } + + if ($index + && $this->deleteIndex($collection->getId(), $old, Event::IndexRename) + && $this->createIndex( + $collection->getId(), + Index::fromArray([ + 'key' => $new, + 'type' => \is_string($index['type'] ?? null) ? (string) $index['type'] : '', + 'attributes' => \array_map(fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', \is_array($index['attributes'] ?? null) ? $index['attributes'] : []), + 'lengths' => \array_map(fn (mixed $v): int => \is_numeric($v) ? (int) $v : 0, \is_array($index['lengths'] ?? null) ? $index['lengths'] : []), + 'orders' => \is_array($index['orders'] ?? null) ? $index['orders'] : [], + ]), + 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); @@ -1017,11 +1145,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 { @@ -1031,12 +1158,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. @@ -1044,59 +1170,55 @@ 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; + return []; } - if ($collectionDoc->isEmpty()) { - return null; + $indexes = $metadata->getAttribute('indexes', []); + if (\is_string($indexes)) { + $indexes = \json_decode($indexes, true); + } + if (! \is_array($indexes)) { + return []; } - $indexes = $collectionDoc->getAttribute('indexes', []); - $filteredId = $this->filter($id); - + $tables = []; foreach ($indexes as $index) { - $indexId = $index instanceof Document - ? $index->getId() - : (\is_array($index) ? ($index['$id'] ?? null) : null); - - if ($indexId === null) { - continue; + if ($index instanceof Document) { + $index = $index->getArrayCopy(); } - if ($this->filter((string) $indexId) !== $filteredId) { + if (! \is_array($index) || ($index['type'] ?? null) !== IndexType::Fulltext->value) { continue; } - $type = $index instanceof Document - ? $index->getAttribute('type') - : ($index['type'] ?? null); - - if ($type !== Database::INDEX_FULLTEXT) { - return null; + $id = $index[Document::ID] ?? $index['key'] ?? null; + $attributes = $index['attributes'] ?? []; + if (! \is_scalar($id) || ! \is_array($attributes)) { + continue; } - $attributes = $index instanceof Document - ? $index->getAttribute('attributes', []) - : ($index['attributes'] ?? []); - $internal = \array_map( - fn (string $a) => $this->getInternalKeyForAttribute($a), - (array) $attributes + fn (mixed $attribute): string => \is_string($attribute) ? $this->getInternalKeyForAttribute($attribute) : '', + $attributes, ); - $candidate = $this->getFulltextTableName($collection, $internal); - - return \in_array($candidate, $candidates, true) ? $candidate : null; + $tables[$this->filter((string) $id)] = $this->getFulltextTableName($collection, $internal); } - return null; + return $tables; } /** @@ -1108,7 +1230,7 @@ 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(" + $stmt = $this->prepare(" SELECT name FROM sqlite_master WHERE type='table' AND name LIKE :_prefix ESCAPE '\\' @@ -1120,7 +1242,7 @@ protected function findFulltextTables(string $collection): array $tables = $stmt->fetchAll(PDO::FETCH_COLUMN); $stmt->closeCursor(); - return $tables; + return \array_map(fn (mixed $t): string => \is_string($t) ? $t : '', $tables); } /** @@ -1136,274 +1258,130 @@ private function escapeLikePattern(string $value): string /** * Create Document * - * @param Document $collection - * @param Document $document - * @return Document * @throws Exception * @throws PDOException * @throws DuplicateException */ 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; - } - - $name = $this->filter($collection); - $columns = ['_uid']; - $values = ['_uid']; - - /** - * Insert Attributes - */ - $bindIndex = 0; - foreach ($attributes as $attribute => $value) { // Parse statement - $column = $this->filter($attribute); - $values[] = 'value_' . $bindIndex; - $columns[] = "`{$column}`"; - $bindIndex++; - } - - // Insert manual id if set - if (!empty($document->getSequence())) { - $values[] = '_id'; - $columns[] = "_id"; - } - - $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}` (".\implode(', ', $columns).") - VALUES (:".\implode(', :', $values)."); - "; - - $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); + $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 = $this->getPDO()->prepare($sql); - - $stmt->bindValue(':_uid', $document->getId(), PDO::PARAM_STR); + $name = $this->filter($collection); - // Bind internal id if set - if (!empty($document->getSequence())) { - $stmt->bindValue(':_id', $document->getSequence(), PDO::PARAM_STR); - } + $builder = $this->createBuilder()->into($this->getSQLTableRaw($name)); + $row = [Storage::UID => $document->getId()]; - $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->getSequence())) { + $row[Storage::SEQUENCE] = $document->getSequence(); } - $bindKey = 'value_' . $attributeIndex; - $attribute = $this->filter($attribute); - $value = (is_bool($value)) ? (int)$value : $value; - $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); - $attributeIndex++; - } + foreach ($attributes as $attr => $value) { + $column = $this->filter($attr); - $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 (is_array($value)) { + $value = json_encode($value); + } + $value = (is_bool($value)) ? (int) $value : $value; + $row[$column] = $value; } - } - if (!empty($permissions)) { - $tenantQuery = $this->sharedTables ? ', _tenant' : ''; + $row = $this->decorateRow($row, $this->documentMetadata($document)); + $builder->set($row); + $result = $builder->insert(); + $stmt = $this->executeResult($result, Event::DocumentCreate); - $queryPermissions = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _document {$tenantQuery}) - VALUES " . \implode(', ', $permissions); + $this->execute($stmt); - $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); + $document[Document::SEQUENCE] = $this->getPDO()->lastInsertId(); - $stmtPermissions = $this->getPDO()->prepare($queryPermissions); - - if ($this->sharedTables) { - $stmtPermissions->bindValue(':_tenant', $this->tenant); + if (empty($document[Document::SEQUENCE])) { + throw new DatabaseException('Error creating document empty "'.Document::SEQUENCE.'"'); } - } - try { - $stmt->execute(); - - $document['$sequence'] = (int) $this->getPDO()->lastInsertId(); - - if (isset($stmtPermissions)) { - $stmtPermissions->execute(); - } + $ctx = $this->buildWriteContext($name); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentCreate($name, [$document], $ctx)); } 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 */ 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); - - $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; - } + foreach ($attributes as $attribute => $value) { + $column = $this->filter($attribute); - // 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])) { + $op = $operators[$attribute]; + if ($op instanceof Operator) { + $opResult = $this->getOperatorBuilderExpression($column, $op); + $builder->setRaw($column, $opResult['expression'], $opResult['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->getSpatialGeomFromText('?'), [$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(); + $stmt = $this->executeResult($result, Event::DocumentUpdate); - foreach ($operatorBinds as $bindKey => $bindValue) { - $stmt->bindValue($bindKey, $bindValue, $this->getPDOType($bindValue)); - } + $this->execute($stmt); - try { - $stmt->execute(); - if (isset($stmtRemovePermissions)) { - $stmtRemovePermissions->execute(); - } - if (isset($stmtAddPermissions)) { - $stmtAddPermissions->execute(); - } + $ctx = $this->buildWriteContext($name, $id); + $this->runWriteHooks(fn ($hook) => $hook->afterDocumentUpdate($name, $document, $skipPermissions, $ctx)); } catch (PDOException $e) { throw $this->processException($e); } @@ -1411,291 +1389,6 @@ 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? - * - * @return bool - */ - 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 @@ -1855,13 +1548,253 @@ public function getKeywords(): array ]; } - protected function processException(PDOException $e): \Exception + protected function createBuilder(): SQLBuilder + { + 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): PermissionFilter + { + return parent::newPermissionHook($collection, $roles, $type, $documentColumn)->collate(SQLiteBuilder::COLLATION); + } + + 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'; + } + + if ($type === ColumnType::String) { + if ($size > 16777215) { + return 'LONGTEXT'; + } + if ($size > 65535) { + return 'MEDIUMTEXT'; + } + if ($size > $this->getMaxVarcharLength()) { + 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), + }; + } + + protected function getMaxPointSize(): int + { + return 0; + } + + /** + * Override getSpatialGeomFromText to return placeholder unchanged for SQLite + * SQLite does not support ST_GeomFromText, so we return the raw placeholder + */ + protected function getSpatialGeomFromText(string $wktPlaceholder, ?int $srid = null): string + { + return $wktPlaceholder; + } + + /** + * Get SQL Index Type + * + * @throws Exception + */ + protected function getSQLIndexType(IndexType $type): string + { + return match ($type) { + IndexType::Key => 'INDEX', + IndexType::Unique => 'UNIQUE INDEX', + default => throw new DatabaseException('Unknown index type: '.$type->value.'. Must be one of '.IndexType::Key->value.', '.IndexType::Unique->value.', '.IndexType::Fulltext->value), + }; + } + + /** + * Get SQL Index + * + * @param array $attributes + * + * @throws Exception + */ + protected function getSQLIndex(string $collection, string $id, IndexType $type, array $attributes): string { - // Timeout - if ($e->getCode() === 'HY000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 3024) { - return new TimeoutException('Query timed out', $e->getCode(), $e); + [$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})"; + } + + /** + * Get SQL table + */ + protected function getSQLTable(string $name): string + { + return $this->quote("{$this->getNamespace()}_{$this->filter($name)}"); + } + + /** + * SQLite doesn't use database-qualified table names. + */ + protected function getSQLTableRaw(string $name): string + { + return $this->getNamespace().'_'.$this->filter($name); + } + + /** + * Check if SQLite math functions (like POWER) are available + * SQLite must be compiled with -DSQLITE_ENABLE_MATH_FUNCTIONS + */ + private function getSupportForMathFunctions(): bool + { + static $available = null; + + if ($available !== null) { + return (bool) $available; + } + + try { + // Test if POWER function exists by attempting to use it + $pdo = $this->getPDO(); + $stmt = $pdo instanceof PDOProxy + ? $pdo->__call('query', ['SELECT POWER(2, 3) as test']) + : $pdo->query('SELECT POWER(2, 3) as test'); + if (! $stmt instanceof PDOStatement && ! $stmt instanceof PDOStatementProxy) { + $available = false; + + return false; + } + $result = $stmt->fetch(); + /** @var array|false $result */ + $testVal = \is_array($result) ? ($result['test'] ?? null) : null; + $available = ($testVal == 8); + + return $available; + } catch (PDOException $e) { + // Function doesn't exist + $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 + * @return array{expression: string, bindings: list}|null + */ + #[\Override] + protected function compileAdapterFilter(Query $query, string $collection, string $alias, array $joins = []): ?array + { + $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 [ + 'expression' => $method === Method::Search ? '1 = 0' : '1 = 1', + 'bindings' => [], + ]; + } + + $ftsTable = $this->findSearchFulltextTable($rawAttribute, $collection, $joins); + + if ($ftsTable === null) { + $likeExpr = "{$quotedAlias}.{$quotedAttribute} LIKE ? ESCAPE '\\'"; + $likeBinding = '%' . $this->escapeWildcards($rawValue) . '%'; + + return [ + 'expression' => $method === Method::Search ? $likeExpr : "NOT ({$likeExpr})", + 'bindings' => [$likeBinding], + ]; + } + + $subquery = "{$quotedAlias}.{$this->quote(Storage::SEQUENCE)} IN (SELECT rowid FROM `{$ftsTable}` WHERE `{$ftsTable}` MATCH ?)"; + + return [ + 'expression' => $method === Method::Search ? $subquery : "NOT ({$subquery})", + 'bindings' => [$ftsValue], + ]; + } + + protected function processException(PDOException $e): Exception + { // Table/index already exists (SQLITE_ERROR with "already exists" message) 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); @@ -1872,6 +1805,14 @@ protected function processException(PDOException $e): \Exception 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 @@ -1888,9 +1829,10 @@ 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); } } @@ -1900,15 +1842,15 @@ protected function processException(PDOException $e): \Exception 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 { @@ -1930,87 +1872,108 @@ 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)? - * - * @return bool + * Bind operator parameters to statement + * Override to handle SQLite-specific operator bindings */ - public function getSupportForDistanceBetweenMultiDimensionGeometryInMeters(): bool + protected function bindOperatorParams(PDOStatement|DatabasePDOStatement|PDOStatementProxy $stmt, 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; - } + // For operators that SQLite doesn't use bind parameters for, skip binding entirely + // Note: The bindIndex increment happens in getOperatorSQL(), NOT here + if (in_array($method, [OperatorType::Toggle, OperatorType::DateSetNow, OperatorType::ArrayUnique])) { + // These operators don't bind any parameters - they're handled purely in SQL + // DO NOT increment bindIndex here as it's already handled in getOperatorSQL() + return; + } + + // For ARRAY_FILTER, bind the filter value if present + if ($method === OperatorType::ArrayFilter) { + $values = $operator->getValues(); + if (! empty($values) && count($values) >= 2) { + $filterType = $values[0]; + $filterValue = $values[1]; + + // Only bind if we support this filter type (all comparison operators need binding) + $comparisonTypes = ['equal', 'notEqual', 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual']; + if (in_array($filterType, $comparisonTypes)) { + $bindKey = "op_{$bindIndex}"; + $value = (is_bool($filterValue)) ? (int) $filterValue : $filterValue; + $stmt->bindValue(":{$bindKey}", $value, $this->getPDOType($value)); + $bindIndex++; + } + } - /** - * Adapter supports optional spatial attributes with existing rows. - * - * @return bool - */ - public function getSupportForOptionalSpatialAttributeWithExistingRows(): bool - { - return true; - } + return; + } - /** - * Get the SQL function for random ordering - * - * @return string - */ - protected function getRandomOrder(): string - { - return 'RANDOM()'; + // For all other operators, use parent implementation + parent::bindOperatorParams($stmt, $operator, $bindIndex); } - - /** - * Check if SQLite math functions (like POWER) are available - * SQLite must be compiled with -DSQLITE_ENABLE_MATH_FUNCTIONS - * - * @return bool + + /** + * {@inheritDoc} */ - private function getSupportForMathFunctions(): bool + protected function getOperatorBuilderExpression(string $column, Operator $operator): array { - 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]; + } + } + + // Replace named bindings with positional + $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 ['expression' => $result, 'bindings' => $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) @@ -2019,150 +1982,153 @@ 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 + 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: + case OperatorType::Toggle: // SQLite: toggle boolean (0 or 1), treat NULL as 0 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)); + case OperatorType::ArrayAppend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: merge arrays by using json_group_array on extracted elements // We use json_each to extract elements from both arrays and combine them return "{$quotedColumn} = ( @@ -2174,9 +2140,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)); + case OperatorType::ArrayPrepend: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: prepend by extracting and recombining with new elements first return "{$quotedColumn} = ( SELECT json_group_array(value) @@ -2187,32 +2154,31 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) )"; - case Operator::TYPE_ARRAY_UNIQUE: + case OperatorType::ArrayUnique: // SQLite: get distinct values from JSON array 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); + case OperatorType::ArrayRemove: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + $removed = \is_float($values[0] ?? null) ? "CAST(:$bindKey AS REAL)" : ":$bindKey"; + // SQLite: remove specific value from array 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)); + case OperatorType::ArrayInsert: + $indexKey = "op_{$bindIndex}"; + $bindIndex++; + $valueKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: Insert element at specific index by: // 1. Take elements before index (0 to index-1) // 2. Add new element @@ -2242,9 +2208,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi ) )"; - case Operator::TYPE_ARRAY_INTERSECT: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayIntersect: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: keep only values that exist in both arrays return "{$quotedColumn} = ( SELECT json_group_array(value) @@ -2252,9 +2219,10 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi WHERE value IN (SELECT value FROM json_each(:$bindKey)) )"; - case Operator::TYPE_ARRAY_DIFF: - $values = $operator->getValues(); - $bindKey = $this->registerOperatorBind($binds, json_encode($values)); + case OperatorType::ArrayDiff: + $bindKey = "op_{$bindIndex}"; + $bindIndex++; + // SQLite: remove values that exist in the comparison array return "{$quotedColumn} = ( SELECT json_group_array(value) @@ -2262,7 +2230,7 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi 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 @@ -2298,9 +2266,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' => '=', @@ -2309,7 +2276,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 @@ -2335,48 +2302,179 @@ protected function getOperatorSQL(string $column, Operator $operator, array &$bi // 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 getUpsertStatement to use SQLite's ON CONFLICT syntax instead of MariaDB's ON DUPLICATE KEY UPDATE + * {@inheritDoc} + */ + 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"; + } + + /** + * {@inheritDoc} + */ + protected function getConflictIncrementExpression(string $column): string + { + $quoted = $this->quote($this->filter($column)); + + return "{$quoted} + excluded.{$quoted}"; + } + + /** + * {@inheritDoc} + */ + 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 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 - */ - public function getUpsertStatement( - string $tableName, - string $columns, - array $batchKeys, - array $attributes, - array $bindValues, - string $attribute = '', - array $operators = [], - ): 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 + * @param bool $hasOperators Whether this batch contains operator expressions + * + * @throws DatabaseException + */ + 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->getNew(); + + if ($hasOperators) { + $extracted = Operator::extractOperators($document->getAttributes()); + $currentRegularAttributes = $extracted['updates']; + $extractedOperators = $extracted['operators']; + + if ($change->getOld()->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, $this->documentMetadata($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->getSpatialGeomFromText(':'.$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; + } + + // Build ON CONFLICT clause manually for SQLite $getUpdateClause = function (string $attribute, bool $increment = false): string { $attribute = $this->quote($this->filter($attribute)); if ($increment) { @@ -2386,37 +2484,32 @@ 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); } } @@ -2427,31 +2520,31 @@ public function getUpsertStatement( // 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) . " + $stmt = $this->prepare( + "INSERT INTO {$this->getSQLTable($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)); } - 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->bindOperatorParams($stmt, $operators[$attr], $opIndexForBinding); + } } - return $stmt; - } - - public function getSupportForAlterLocks(): bool - { - return false; + $this->execute($stmt); + $stmt->closeCursor(); } public function getSupportNonUtfCharacters(): bool @@ -2459,45 +2552,12 @@ 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'; - } - /** * SQLite's ALTER TABLE accepts a single column per statement, so the * shared SQL implementation that joins many ADD COLUMN clauses with * commas doesn't parse here. Loop over createAttribute instead. * - * @param array> $attributes + * @param array $attributes */ public function createAttributes(string $collection, array $attributes): bool { @@ -2508,15 +2568,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) { @@ -2531,194 +2583,176 @@ 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); + public function createRelationship(Relationship $relationship): bool + { + $name = $this->filter($relationship->collection); + $relatedName = $this->filter($relationship->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); + $id = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $sqlType = $this->getSQLType(ColumnType::Relationship, 0, false, false, false); + $twoWay = $relationship->twoWay; - $statements = match ($type) { - Database::RELATION_ONE_TO_ONE => $twoWay + $statements = match ($relationship->type) { + RelationType::OneToOne => $twoWay ? [ "ALTER TABLE {$table} ADD COLUMN `{$id}` {$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'), + RelationType::OneToMany => ["ALTER TABLE {$relatedTable} ADD COLUMN `{$twoWayKey}` {$sqlType} DEFAULT NULL"], + RelationType::ManyToOne => ["ALTER TABLE {$table} ADD COLUMN `{$id}` {$sqlType} DEFAULT NULL"], + RelationType::ManyToMany => [], }; foreach ($statements as $stmt) { - $stmt = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $stmt); - $this->getPDO()->prepare($stmt)->execute(); + $this->execute($this->prepare($stmt, event: Event::AttributeCreate)); } return true; } public function updateRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side, + Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null, ): bool { + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; $name = $this->filter($collection); $relatedName = $this->filter($relatedCollection); $table = $this->getSQLTable($name); $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $twoWay = $relationship->twoWay; + $side = $relationship->side; - if (!\is_null($newKey)) { + if ($newKey !== null) { $newKey = $this->filter($newKey); } - if (!\is_null($newTwoWayKey)) { + if ($newTwoWayKey !== null) { $newTwoWayKey = $this->filter($newTwoWayKey); } $statements = []; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if (!\is_null($newKey) && $key !== $newKey) { + switch ($relationship->type) { + case RelationType::OneToOne: + if ($newKey !== null && $key !== $newKey) { $statements[] = "ALTER TABLE {$table} RENAME COLUMN `{$key}` TO `{$newKey}`"; } - if ($twoWay && !\is_null($newTwoWayKey) && $twoWayKey !== $newTwoWayKey) { + if ($twoWay && $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 RelationType::OneToMany: + if ($side === RelationSide::Parent) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $statements[] = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`"; } } else { - if (!\is_null($newKey) && $key !== $newKey) { + if ($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 RelationType::ManyToOne: + if ($side === RelationSide::Child) { + if ($newTwoWayKey !== null && $twoWayKey !== $newTwoWayKey) { $statements[] = "ALTER TABLE {$relatedTable} RENAME COLUMN `{$twoWayKey}` TO `{$newTwoWayKey}`"; } } else { - if (!\is_null($newKey) && $key !== $newKey) { + if ($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); + case RelationType::ManyToMany: + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collectionDoc = $this->getDocument($metadataCollection, $collection); + $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection); - $junction = $this->getSQLTable('_' . $collection->getSequence() . '_' . $relatedCollection->getSequence()); + $junction = $this->getSQLTable('_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence()); - if (!\is_null($newKey)) { + if ($newKey !== null) { $statements[] = "ALTER TABLE {$junction} RENAME COLUMN `{$key}` TO `{$newKey}`"; } - if ($twoWay && !\is_null($newTwoWayKey)) { + if ($twoWay && $newTwoWayKey !== null) { $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(); + $this->execute($this->prepare($stmt, event: Event::AttributeUpdate)); } return true; } - public function deleteRelationship( - string $collection, - string $relatedCollection, - string $type, - bool $twoWay, - string $key, - string $twoWayKey, - string $side - ): bool { + public function deleteRelationship(Relationship $relationship): bool + { + $collection = $relationship->collection; + $relatedCollection = $relationship->relatedCollection; $name = $this->filter($collection); $relatedName = $this->filter($relatedCollection); $table = $this->getSQLTable($name); $relatedTable = $this->getSQLTable($relatedName); - $key = $this->filter($key); - $twoWayKey = $this->filter($twoWayKey); + $key = $this->filter($relationship->key); + $twoWayKey = $this->filter($relationship->twoWayKey); + $twoWay = $relationship->twoWay; + $side = $relationship->side; $statements = []; - switch ($type) { - case Database::RELATION_ONE_TO_ONE: - if ($side === Database::RELATION_SIDE_PARENT) { + switch ($relationship->type) { + case RelationType::OneToOne: + if ($side === RelationSide::Parent) { $statements[] = "ALTER TABLE {$table} DROP COLUMN `{$key}`"; if ($twoWay) { $statements[] = "ALTER TABLE {$relatedTable} DROP COLUMN `{$twoWayKey}`"; } - } elseif ($side === Database::RELATION_SIDE_CHILD) { + } elseif ($side === RelationSide::Child) { $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 RelationType::OneToMany: + $statements[] = $side === RelationSide::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 RelationType::ManyToOne: + $statements[] = $side === RelationSide::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 RelationType::ManyToMany: + $metadataCollection = new Document([Document::ID => Database::METADATA]); + $collectionDoc = $this->getDocument($metadataCollection, $collection); + $relatedCollectionDoc = $this->getDocument($metadataCollection, $relatedCollection); - $junctionBase = $side === Database::RELATION_SIDE_PARENT - ? '_' . $collection->getSequence() . '_' . $relatedCollection->getSequence() - : '_' . $relatedCollection->getSequence() . '_' . $collection->getSequence(); + $junctionBase = $side === RelationSide::Parent + ? '_' . $collectionDoc->getSequence() . '_' . $relatedCollectionDoc->getSequence() + : '_' . $relatedCollectionDoc->getSequence() . '_' . $collectionDoc->getSequence(); $statements[] = "DROP TABLE {$this->getSQLTable($junctionBase)}"; - $statements[] = "DROP TABLE {$this->getSQLTable($junctionBase . '_perms')}"; + $statements[] = "DROP TABLE {$this->getSQLTable(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(); + $this->execute($this->prepare($stmt, event: Event::AttributeDelete)); } return true; @@ -2737,18 +2771,22 @@ public function getSchemaAttributes(string $collection): array { $table = "{$this->getNamespace()}_{$this->filter($collection)}"; - $stmt = $this->getPDO()->prepare("PRAGMA table_info(`{$table}`)"); - $stmt->execute(); + $stmt = $this->prepare("PRAGMA table_info(`{$table}`)", event: Event::CollectionRead); + $this->execute($stmt); $rows = $stmt->fetchAll(); $stmt->closeCursor(); $results = []; foreach ($rows as $row) { - $rawType = (string) ($row['type'] ?? ''); + if (! \is_array($row)) { + continue; + } + $rawType = \is_scalar($row['type'] ?? null) ? (string) $row['type'] : ''; $parsed = $this->parseSqliteColumnType($rawType); + $name = \is_scalar($row['name'] ?? null) ? (string) $row['name'] : ''; $results[] = new Document([ - '$id' => $row['name'], + Document::ID => $name, 'columnDefault' => $row['dflt_value'] ?? null, 'isNullable' => empty($row['notnull']) ? 'YES' : 'NO', 'dataType' => $parsed['dataType'], @@ -2757,7 +2795,7 @@ public function getSchemaAttributes(string $collection): array 'numericScale' => $parsed['numericScale'], 'datetimePrecision' => $parsed['datetimePrecision'], 'columnType' => \strtolower($rawType), - 'columnKey' => !empty($row['pk']) ? 'PRI' : '', + 'columnKey' => ! empty($row['pk']) ? 'PRI' : '', 'extra' => '', ]); } @@ -2765,6 +2803,17 @@ public function getSchemaAttributes(string $collection): array return $results; } + /** + * @return array + */ + protected function getColumnNames(string $collection): array + { + return \array_map( + static fn (Document $column): string => $column->getId(), + $this->getSchemaAttributes($collection), + ); + } + /** * Introspect a collection's indexes via PRAGMA index_list + * PRAGMA index_info. Returns one Document per index with a `columns` @@ -2776,41 +2825,68 @@ public function getSchemaAttributes(string $collection): array */ public function getSchemaIndexes(string $collection): array { - $table = "{$this->getNamespace()}_{$this->filter($collection)}"; + $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, '/').'_(.+)$/'; - $stmt = $this->getPDO()->prepare("PRAGMA index_list(`{$table}`)"); - $stmt->execute(); + $stmt = $this->prepare("PRAGMA index_list(`{$table}`)", event: Event::CollectionRead); + $this->execute($stmt); $indexes = $stmt->fetchAll(); $stmt->closeCursor(); $results = []; foreach ($indexes as $index) { - $name = $index['name']; - $unique = !empty($index['unique']); + if (! \is_array($index)) { + continue; + } + $name = \is_scalar($index['name'] ?? null) ? (string) $index['name'] : ''; + $unique = ! empty($index['unique']); + + $owned = \str_starts_with($name, $own); + $id = match (true) { + $owned => \substr($name, \strlen($own)), + \preg_match($anyTenant, $name, $matches) === 1 => $matches[1], + default => $name, + }; + if (! $owned && isset($results[$id])) { + continue; + } - $colStmt = $this->getPDO()->prepare("PRAGMA index_info(`{$name}`)"); - $colStmt->execute(); + $colStmt = $this->prepare("PRAGMA index_info(`{$name}`)", event: Event::CollectionRead); + $this->execute($colStmt); $cols = $colStmt->fetchAll(); $colStmt->closeCursor(); - \usort($cols, fn ($a, $b) => ((int) $a['seqno']) <=> ((int) $b['seqno'])); + \usort( + $cols, + fn (mixed $a, mixed $b) => ( + \is_array($a) && \is_scalar($a['seqno'] ?? null) ? (int) $a['seqno'] : 0 + ) <=> ( + \is_array($b) && \is_scalar($b['seqno'] ?? null) ? (int) $b['seqno'] : 0 + ) + ); $columns = []; $lengths = []; foreach ($cols as $col) { - $columns[] = $col['name']; + if (! \is_array($col)) { + continue; + } + $columns[] = \is_scalar($col['name'] ?? null) ? (string) $col['name'] : ''; $lengths[] = null; } - $results[] = new Document([ - '$id' => $name, - 'indexName' => $name, + $results[$id] = new Document([ + Document::ID => $id, + 'indexName' => $id, 'indexType' => 'BTREE', 'nonUnique' => $unique ? 0 : 1, 'columns' => $columns, 'lengths' => $lengths, ]); } + $results = \array_values($results); // PRAGMA index_list misses FTS5 vtables. foreach ($this->getFulltextSchemaIndexes($collection) as $entry) { @@ -2842,47 +2918,21 @@ protected function getFulltextSchemaIndexes(string $collection): array 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) { - } + $hashToId = \array_flip($this->getFulltextTablesByIndexId($collection)); $entries = []; foreach ($tables as $ftsTable) { - $info = $this->getPDO()->prepare("PRAGMA table_info(`{$ftsTable}`)"); + $info = $this->prepare("PRAGMA table_info(`{$ftsTable}`)"); $info->execute(); $cols = $info->fetchAll(PDO::FETCH_ASSOC); $info->closeCursor(); $columns = []; foreach ($cols as $col) { - $name = (string) ($col['name'] ?? ''); + if (! \is_array($col)) { + continue; + } + $name = \is_scalar($col['name'] ?? null) ? (string) $col['name'] : ''; if ($name === '') { continue; } @@ -2892,7 +2942,7 @@ protected function getFulltextSchemaIndexes(string $collection): array $id = $hashToId[$ftsTable] ?? $ftsTable; $entries[] = [ - '$id' => $id, + Document::ID => $id, 'indexName' => $id, 'indexType' => 'FULLTEXT', 'nonUnique' => 1, @@ -2931,7 +2981,7 @@ private function parseSqliteColumnType(string $declaration): array 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] !== '') { + if (isset($matches[3])) { $secondArgument = (int) $matches[3]; } } @@ -3051,128 +3101,23 @@ private function parseSqliteColumnType(string $declaration): array } /** - * 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. - */ - protected function getSQLCondition(Query $query, array &$binds, ?string $forCollection = null): string - { - $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'; - } - - $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); - } - - $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})"; - } - - /** - * 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; } /** @@ -3182,11 +3127,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; } /** @@ -3196,11 +3149,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; @@ -3211,47 +3167,6 @@ 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. @@ -3285,7 +3200,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/Attribute.php b/src/Database/Attribute.php new file mode 100644 index 0000000000..a234e990c3 --- /dev/null +++ b/src/Database/Attribute.php @@ -0,0 +1,1352 @@ + $formatOptions + * @property array $filters + * @property string|null $status + * @property array|null $options + */ +class Attribute extends Document +{ + private const string PERSISTED_BIG_INTEGER = 'bigint'; + + /** + * The column types an attribute can be stored as. Object, spatial and vector attributes also need + * the adapter to support them. + * + * @var list + */ + 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, + ]; + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + ColumnType $type = ColumnType::String, + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + if ($type === ColumnType::BigInteger) { + $size = 0; + } + + $data = [ + self::ID => $key, + 'key' => $key, + 'type' => self::persistedType($type), + 'size' => $size, + 'required' => $required, + 'default' => $default, + 'signed' => $signed, + 'array' => $array, + 'format' => $format === '' ? null : $format, + 'formatOptions' => $formatOptions, + 'filters' => $filters, + ]; + if ($status !== null) { + $data['status'] = $status; + } + if ($options !== null) { + $data['options'] = $options; + } + parent::__construct($data); + } + + /** + * @return ( + * $name is 'key' ? string : + * $name is 'type' ? ColumnType : + * $name is 'size' ? int : + * $name is 'required' ? bool : + * $name is 'default' ? mixed : + * $name is 'signed' ? bool : + * $name is 'array' ? bool : + * $name is 'format' ? string|null : + * $name is 'formatOptions' ? array : + * $name is 'filters' ? array : + * $name is 'status' ? string|null : + * $name is 'options' ? array|null : + * mixed + * ) + */ + public function __get(string $name): mixed + { + switch ($name) { + case 'key': + /** @var string $key */ + $key = $this->getAttribute('key', $this->getId()); + + return $key; + case 'type': + /** @var ColumnType|string $type */ + $type = $this->getAttribute('type', ColumnType::String->value); + + return self::normalizeType($type); + case 'size': + /** @var int $size */ + $size = $this->getAttribute('size', 0); + + return $size; + case 'required': + return (bool) $this->getAttribute('required', false); + case 'default': + return $this->getAttribute('default'); + case 'signed': + return (bool) $this->getAttribute('signed', true); + case 'array': + return (bool) $this->getAttribute('array', false); + case 'format': + $format = $this->getAttribute('format'); + + return \is_string($format) && $format !== '' ? $format : null; + case 'formatOptions': + $formatOptions = $this->getAttribute('formatOptions', []); + if (! \is_array($formatOptions)) { + return []; + } + /** @var array $formatOptions */ + + return $formatOptions; + case 'filters': + $filters = $this->getAttribute('filters', []); + if (! \is_array($filters)) { + return []; + } + /** @var array $filters */ + + return $filters; + case 'status': + $status = $this->getAttribute('status'); + + return \is_string($status) ? $status : null; + case 'options': + $options = $this->getAttribute('options'); + if (! \is_array($options)) { + return null; + } + /** @var array $options */ + + return $options; + default: + return $this->getAttribute($name); + } + } + + public function __set(string $name, mixed $value): void + { + match ($name) { + 'key' => $this->setAttribute('key', $value)->setAttribute(self::ID, $value), + 'type' => $this->setAttribute('type', $value), + 'size' => $this->setAttribute('size', $value), + 'required' => $this->setAttribute('required', $value), + 'default' => $this->setAttribute('default', $value), + 'signed' => $this->setAttribute('signed', $value), + 'array' => $this->setAttribute('array', $value), + 'format' => $this->setAttribute('format', $value === '' ? null : $value), + 'formatOptions' => $this->setAttribute('formatOptions', $value), + 'filters' => $this->setAttribute('filters', $value), + 'status' => $this->setAttribute('status', $value), + 'options' => $this->setAttribute('options', $value), + default => $this->setAttribute($name, $value), + }; + } + + public function __isset(string $name): bool + { + return match ($name) { + 'key', 'type', 'size', 'required', 'default', 'signed', 'array', 'format', 'formatOptions', 'filters', 'status', 'options' => true, + default => $this->offsetExists($name), + }; + } + + /** + * @param string|null $key + */ + #[\Override] + public function offsetSet(mixed $key, mixed $value): void + { + $type = $key === 'type' && ($value instanceof ColumnType || \is_string($value)) + ? self::tryNormalizeType($value) + : null; + + parent::offsetSet($key, $type === null ? $value : self::persistedType($type)); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function string( + string $key = '', + int $size = Database::LENGTH_KEY, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\StringType { + return new Attribute\StringType( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function varchar( + string $key = '', + int $size = Database::LENGTH_KEY, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Varchar { + return new Attribute\Varchar( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function text( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Text { + return new Attribute\Text( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * A text attribute declared without a size (size 0, the default of text(), mediumText() and + * longText()) holds up to the engine's maximum for its type. + */ + public function resolvedSize(): int + { + if ($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 => $this->size, + }; + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function mediumText( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\MediumText { + return new Attribute\MediumText( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function longText( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\LongText { + return new Attribute\LongText( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function integer( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Integer { + return new Attribute\Integer( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function bigInteger( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\BigInteger { + return new Attribute\BigInteger( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function float( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\FloatType { + return new Attribute\FloatType( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function double( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Double { + return new Attribute\Double( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function boolean( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Boolean { + return new Attribute\Boolean( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function datetime( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Datetime { + return new Attribute\Datetime( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function point( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Point { + return new Attribute\Point( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function linestring( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Linestring { + return new Attribute\Linestring( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function polygon( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Polygon { + return new Attribute\Polygon( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function vector( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Vector { + return new Attribute\Vector( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function id( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Id { + return new Attribute\Id( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function object( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\ObjectType { + return new Attribute\ObjectType( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + public static function relationship( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ): Attribute\Relationship { + return new Attribute\Relationship( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + public static function persistedType(ColumnType $type): string + { + return $type === ColumnType::BigInteger ? self::PERSISTED_BIG_INTEGER : $type->value; + } + + public static function normalizeType(ColumnType|string $type): ColumnType + { + if ($type instanceof ColumnType) { + return $type; + } + + return $type === self::PERSISTED_BIG_INTEGER ? ColumnType::BigInteger : ColumnType::from($type); + } + + public static function tryNormalizeType(ColumnType|string $type): ?ColumnType + { + if ($type instanceof ColumnType) { + return $type; + } + + return $type === self::PERSISTED_BIG_INTEGER ? ColumnType::BigInteger : ColumnType::tryFrom($type); + } + + /** + * The types in {@see self::TYPES} that the given capabilities make available, in table order. + * + * @return list + */ + public static function availableTypes(bool $objects, bool $spatial, bool $vectors): array + { + return \array_values(\array_filter( + self::TYPES, + fn (ColumnType $type): bool => match (true) { + $type === ColumnType::Object => $objects, + self::isSpatialType($type) => $spatial, + $type === ColumnType::Vector => $vectors, + default => true, + }, + )); + } + + public static function isSpatialType(ColumnType|string $type): bool + { + $type = self::tryNormalizeType($type); + + return \in_array($type, [ + ColumnType::Point, + ColumnType::Linestring, + ColumnType::Polygon, + ], true); + } + + public static function isNumericType(ColumnType|string $type): bool + { + $type = self::tryNormalizeType($type); + + return \in_array($type, [ + ColumnType::Integer, + ColumnType::BigInteger, + ColumnType::Float, + ColumnType::Double, + ], true); + } + + public static function isIntegerType(ColumnType|string $type): bool + { + $type = self::tryNormalizeType($type); + + return \in_array($type, [ + ColumnType::Integer, + ColumnType::BigInteger, + ], true); + } + + /** + * @return array{min: int|float|string, max: int|float|string}|null + */ + public static function getNumericBounds(ColumnType|string $type, bool $signed = true): ?array + { + $type = self::tryNormalizeType($type); + + return match ($type) { + ColumnType::Integer => [ + 'min' => $signed ? Database::MIN_INT : 0, + 'max' => Database::MAX_INT, + ], + ColumnType::BigInteger => [ + 'min' => $signed ? \PHP_INT_MIN : 0, + 'max' => $signed ? Database::MAX_BIG_INT : BigInt::UNSIGNED_MAX, + ], + ColumnType::Float, + ColumnType::Double => [ + 'min' => $signed ? -Database::MAX_DOUBLE : 0, + 'max' => Database::MAX_DOUBLE, + ], + default => null, + }; + } + + /** + * Convert this attribute to a Document representation. + * + * @return Document + */ + public function toDocument(): Document + { + $data = [ + Document::ID => ID::custom($this->key), + 'key' => $this->key, + 'type' => self::persistedType($this->type), + 'size' => $this->size, + 'required' => $this->required, + 'default' => $this->default, + 'signed' => $this->signed, + 'array' => $this->array, + 'format' => $this->format, + 'formatOptions' => $this->formatOptions, + 'filters' => $this->filters, + ]; + + if ($this->status !== null) { + $data['status'] = $this->status; + } + + if ($this->options !== null) { + $data['options'] = $this->options; + } + + return new Document($data); + } + + /** + * Create an Attribute instance from a Document. + * + * @param Document $document The document to convert + * @return self + */ + public static function fromDocument(Document $document): self + { + /** @var string $key */ + $key = $document->getAttribute('key', $document->getId()); + /** @var ColumnType|string $type */ + $type = $document->getAttribute('type', 'string'); + /** @var int $size */ + $size = $document->getAttribute('size', 0); + /** @var bool $required */ + $required = $document->getAttribute('required', false); + /** @var bool $signed */ + $signed = $document->getAttribute('signed', true); + /** @var bool $array */ + $array = $document->getAttribute('array', false); + /** @var string|null $format */ + $format = $document->getAttribute('format'); + /** @var array $formatOptions */ + $formatOptions = $document->getAttribute('formatOptions', []); + /** @var array $filters */ + $filters = $document->getAttribute('filters', []); + /** @var string|null $status */ + $status = $document->getAttribute('status'); + /** @var array|null $options */ + $options = $document->getAttribute('options'); + + return self::make( + key: $key, + type: self::normalizeType($type), + size: $size, + required: $required, + default: $document->getAttribute('default'), + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + /** + * Cheap relationship-type check that avoids materializing a typed Attribute. + * Use in hot read paths where only the type matters. + * + * Mirrors the normalization in {@see self::fromDocument()} — accepts both + * the (always-stored) string form and the defensive ColumnType-enum form. + */ + public static function isRelationship(self|Document $attribute): bool + { + if ($attribute instanceof self) { + return $attribute->type === ColumnType::Relationship; + } + + $type = $attribute->getAttribute('type'); + + if ($type instanceof ColumnType) { + return $type === ColumnType::Relationship; + } + + return $type === ColumnType::Relationship->value; + } + + /** + * Create from an associative array (used by batch operations). + * + * @param array $data + * @return self + */ + public static function fromArray(array $data): self + { + /** @var ColumnType|string $type */ + $type = $data['type'] ?? 'string'; + + /** @var string $key */ + $key = $data[Document::ID] ?? $data['key'] ?? ''; + /** @var int $size */ + $size = $data['size'] ?? 0; + /** @var bool $required */ + $required = $data['required'] ?? false; + /** @var bool $signed */ + $signed = $data['signed'] ?? true; + /** @var bool $array */ + $array = $data['array'] ?? false; + /** @var string|null $format */ + $format = $data['format'] ?? null; + /** @var array $formatOptions */ + $formatOptions = $data['formatOptions'] ?? []; + /** @var array $filters */ + $filters = $data['filters'] ?? []; + /** @var string|null $status */ + $status = $data['status'] ?? null; + /** @var array|null $options */ + $options = $data['options'] ?? null; + + return self::make( + key: $key, + type: self::normalizeType($type), + size: $size, + required: $required, + default: $data['default'] ?? null, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } + + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $options + */ + private static function make( + string $key, + ColumnType $type, + int $size, + bool $required, + mixed $default, + bool $signed, + bool $array, + ?string $format, + array $formatOptions, + array $filters, + ?string $status = null, + ?array $options = null, + ): self { + return match ($type) { + ColumnType::String => new Attribute\StringType( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Varchar => new Attribute\Varchar( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Text => new Attribute\Text( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::MediumText => new Attribute\MediumText( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::LongText => new Attribute\LongText( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Integer => new Attribute\Integer( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::BigInteger => new Attribute\BigInteger( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Float => new Attribute\FloatType( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Double => new Attribute\Double( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Boolean => new Attribute\Boolean( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Datetime => new Attribute\Datetime( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Point => new Attribute\Point( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Linestring => new Attribute\Linestring( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Polygon => new Attribute\Polygon( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Vector => new Attribute\Vector( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Id => new Attribute\Id( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Object => new Attribute\ObjectType( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + ColumnType::Relationship => new Attribute\Relationship( + key: $key, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + default => new self( + key: $key, + type: $type, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ), + }; + } +} diff --git a/src/Database/Attribute/BigInteger.php b/src/Database/Attribute/BigInteger.php new file mode 100644 index 0000000000..70b50950e0 --- /dev/null +++ b/src/Database/Attribute/BigInteger.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::BigInteger, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Boolean.php b/src/Database/Attribute/Boolean.php new file mode 100644 index 0000000000..8871a97273 --- /dev/null +++ b/src/Database/Attribute/Boolean.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Boolean, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Datetime.php b/src/Database/Attribute/Datetime.php new file mode 100644 index 0000000000..4c2d39a3a4 --- /dev/null +++ b/src/Database/Attribute/Datetime.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Datetime, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Double.php b/src/Database/Attribute/Double.php new file mode 100644 index 0000000000..75dda9cf2a --- /dev/null +++ b/src/Database/Attribute/Double.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Double, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/FloatType.php b/src/Database/Attribute/FloatType.php new file mode 100644 index 0000000000..4587ee95df --- /dev/null +++ b/src/Database/Attribute/FloatType.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Float, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Id.php b/src/Database/Attribute/Id.php new file mode 100644 index 0000000000..369d218427 --- /dev/null +++ b/src/Database/Attribute/Id.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Id, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Integer.php b/src/Database/Attribute/Integer.php new file mode 100644 index 0000000000..37af2bb8c5 --- /dev/null +++ b/src/Database/Attribute/Integer.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Integer, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Linestring.php b/src/Database/Attribute/Linestring.php new file mode 100644 index 0000000000..bcbb0fc605 --- /dev/null +++ b/src/Database/Attribute/Linestring.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Linestring, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/LongText.php b/src/Database/Attribute/LongText.php new file mode 100644 index 0000000000..34a46efd56 --- /dev/null +++ b/src/Database/Attribute/LongText.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::LongText, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/MediumText.php b/src/Database/Attribute/MediumText.php new file mode 100644 index 0000000000..4689f4ca8c --- /dev/null +++ b/src/Database/Attribute/MediumText.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::MediumText, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/ObjectType.php b/src/Database/Attribute/ObjectType.php new file mode 100644 index 0000000000..b00567144c --- /dev/null +++ b/src/Database/Attribute/ObjectType.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Object, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Point.php b/src/Database/Attribute/Point.php new file mode 100644 index 0000000000..70e0a6383f --- /dev/null +++ b/src/Database/Attribute/Point.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Point, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Polygon.php b/src/Database/Attribute/Polygon.php new file mode 100644 index 0000000000..cb80e09a0a --- /dev/null +++ b/src/Database/Attribute/Polygon.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Polygon, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Relationship.php b/src/Database/Attribute/Relationship.php new file mode 100644 index 0000000000..a7c6db8a94 --- /dev/null +++ b/src/Database/Attribute/Relationship.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Relationship, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/StringType.php b/src/Database/Attribute/StringType.php new file mode 100644 index 0000000000..d782dabe53 --- /dev/null +++ b/src/Database/Attribute/StringType.php @@ -0,0 +1,44 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = Database::LENGTH_KEY, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::String, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Text.php b/src/Database/Attribute/Text.php new file mode 100644 index 0000000000..dac374f200 --- /dev/null +++ b/src/Database/Attribute/Text.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Text, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Varchar.php b/src/Database/Attribute/Varchar.php new file mode 100644 index 0000000000..4b361118d9 --- /dev/null +++ b/src/Database/Attribute/Varchar.php @@ -0,0 +1,44 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = Database::LENGTH_KEY, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Varchar, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Attribute/Vector.php b/src/Database/Attribute/Vector.php new file mode 100644 index 0000000000..f6e0e23c2e --- /dev/null +++ b/src/Database/Attribute/Vector.php @@ -0,0 +1,43 @@ + $formatOptions + * @param array $filters + * @param array|null $options + */ + public function __construct( + string $key = '', + int $size = 0, + bool $required = false, + mixed $default = null, + bool $signed = true, + bool $array = false, + ?string $format = null, + array $formatOptions = [], + array $filters = [], + ?string $status = null, + ?array $options = null, + ) { + parent::__construct( + key: $key, + type: ColumnType::Vector, + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions, + filters: $filters, + status: $status, + options: $options, + ); + } +} diff --git a/src/Database/Builder/SQLite.php b/src/Database/Builder/SQLite.php new file mode 100644 index 0000000000..ceae213bae --- /dev/null +++ b/src/Database/Builder/SQLite.php @@ -0,0 +1,249 @@ +']; + + 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->joinComparisons($query) 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->compileJsonOverlapsExpr($attribute, [$query->getValues()])); + } + + return parent::compileArrayFilter($method, $attribute, $query); + } + + /** + * @param array $values + */ + #[\Override] + protected function compileJsonContainsExpr(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 compileJsonOverlapsExpr(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); + } + + /** + * @return list + */ + private function joinComparisons(Query $query): array + { + if ($query->isNestedJoin()) { + return $this->onComparisons($query->getJoinOnQueries()); + } + + return $this->comparison($query->getValues()); + } + + /** + * @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/Cache/Entry.php b/src/Database/Cache/Entry.php new file mode 100644 index 0000000000..c77819c16e --- /dev/null +++ b/src/Database/Cache/Entry.php @@ -0,0 +1,15 @@ +|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..a8840e41f5 --- /dev/null +++ b/src/Database/Cache/Invalidator.php @@ -0,0 +1,211 @@ +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); + } + + /** + * 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] ??= new Scope( + hostname: $scope->hostname, + database: $scope->database, + namespace: $scope->namespace, + tenant: $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 + */ + public function activate(array $tokens): void + { + $failure = null; + foreach ($tokens as $key => $token) { + try { + $this->queryCache->activateCollection($key, $token); + } 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::AttributeDelete, + Event::IndexCreate, + Event::IndexRename, + Event::IndexDelete, + Event::DocumentPurge, + Event::DocumentCreate, + Event::DocumentsCreate, + Event::DocumentUpdate, + Event::DocumentsUpdate, + 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::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)) { + return $collections; + } + + $options = $data->getAttribute('options', []); + if ($options instanceof Document) { + $options = $options->getArrayCopy(); + } + $related = \is_array($options) ? ($options['relatedCollection'] ?? null) : null; + if (\is_string($related) && $related !== '') { + $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/QueryCache.php b/src/Database/Cache/QueryCache.php new file mode 100644 index 0000000000..221fb625c5 --- /dev/null +++ b/src/Database/Cache/QueryCache.php @@ -0,0 +1,435 @@ + */ + private array $regions = []; + + /** + * @param int $writerTimeout Seconds after which a write that has not activated is treated as abandoned + * @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 string $cacheName = 'default', + private readonly int $writerTimeout = self::WRITER_TIMEOUT, + 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): void + { + $this->regions[$collection] = $region; + } + + 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 "{$this->cacheName}: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); + 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); + } + + /** + * 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. + */ + public function activateCollection(string $key, string $token): 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)) { + $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): ?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); + } + + 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): ?string + { + $now = \time(); + $ttl = $this->getRegion($collection)->ttl; + if ($stamp + \min($ttl, $this->writerTimeout) > $now) { + return null; + } + + $started = $this->cache->getGeneration($this->getStartedKey($key)); + $finished = $this->cache->getGeneration($this->getFinishedKey($key)); + if ($stamp + ($started === $finished ? $ttl : $this->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): bool + { + $now = \time(); + $abandoned = []; + foreach ($this->cache->list($owners) as $token) { + $created = $this->getTokenTime($token); + if ($created === null || $created + $this->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..4772cef3fe --- /dev/null +++ b/src/Database/Cache/Region.php @@ -0,0 +1,12 @@ +old; } + /** + * Set the old document before the change. + * + * @param Document $old The previous document state + * @return void + */ public function setOld(Document $old): void { $this->old = $old; } + /** + * Get the new document after the change. + * + * @return Document + */ public function getNew(): Document { return $this->new; } + /** + * Set the new document after the change. + * + * @param Document $new The updated document state + * @return void + */ 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..b5c4a056bd --- /dev/null +++ b/src/Database/Collection.php @@ -0,0 +1,234 @@ + $attributes + * @property array $indexes + * @property array|null $permissions + * @property bool $documentSecurity + * @property array $metadata + */ +class Collection extends Document +{ + /** + * @param array> $attributes + * @param array> $indexes + * @param array|null $permissions Null means default create-any; empty means none + * @param array $metadata + */ + public function __construct( + string $id = '', + string $name = '', + array $attributes = [], + array $indexes = [], + ?array $permissions = null, + bool $documentSecurity = true, + public array $metadata = [], + ) { + $data = [ + self::ID => ID::custom($id), + 'name' => $name !== '' ? $name : $id, + 'attributes' => self::castAttributes($attributes), + 'indexes' => self::castIndexes($indexes), + ]; + if ($id !== '') { + $data['documentSecurity'] = $documentSecurity; + } + if ($permissions !== null) { + $data[self::PERMISSIONS] = $permissions; + } + + parent::__construct(\array_merge($data, $this->metadata)); + } + + public function isEmpty(): bool + { + return $this->getId() === ''; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $id = $data[self::ID] ?? ''; + if (! \is_string($id)) { + $id = ''; + } + + $name = $data['name'] ?? $id; + if (! \is_string($name)) { + $name = $id; + } + + $permissions = null; + if (\array_key_exists(self::PERMISSIONS, $data) && \is_array($data[self::PERMISSIONS])) { + /** @var array $permissions */ + $permissions = $data[self::PERMISSIONS]; + } + + $rawAttributes = $data['attributes'] ?? []; + $rawIndexes = $data['indexes'] ?? []; + + $collection = new self( + id: $id, + name: $name, + attributes: \is_array($rawAttributes) ? self::castAttributes($rawAttributes) : [], + indexes: \is_array($rawIndexes) ? self::castIndexes($rawIndexes) : [], + permissions: $permissions, + documentSecurity: (bool) ($data['documentSecurity'] ?? true), + ); + + if (\is_string($rawAttributes)) { + $collection->setAttribute('attributes', $rawAttributes); + } + if (\is_string($rawIndexes)) { + $collection->setAttribute('indexes', $rawIndexes); + } + + foreach ($data as $key => $value) { + if (\in_array($key, [self::ID, 'name', 'attributes', 'indexes', self::PERMISSIONS, 'documentSecurity'], true)) { + continue; + } + $collection->setAttribute($key, $value); + } + + return $collection; + } + + /** + * @return ( + * $name is 'id' ? string : + * $name is 'name' ? string : + * $name is 'attributes' ? array : + * $name is 'indexes' ? array : + * $name is 'permissions' ? array|null : + * $name is 'documentSecurity' ? bool : + * mixed + * ) + */ + public function __get(string $name): mixed + { + switch ($name) { + case 'id': + return $this->getId(); + case 'name': + /** @var string $value */ + $value = $this->getAttribute('name', $this->getId()); + + return $value; + case 'attributes': + return self::castAttributes($this->getArray('attributes')); + case 'indexes': + return self::castIndexes($this->getArray('indexes')); + case 'permissions': + return $this->offsetExists(self::PERMISSIONS) ? $this->getPermissions() : null; + case 'documentSecurity': + return (bool) $this->getAttribute('documentSecurity', true); + default: + return $this->getAttribute($name); + } + } + + public function __set(string $name, mixed $value): void + { + match ($name) { + 'id' => $this->setAttribute(self::ID, $value), + 'name' => $this->setAttribute('name', $value), + 'attributes' => $this->setAttribute('attributes', $value), + 'indexes' => $this->setAttribute('indexes', $value), + 'permissions' => $this->setAttribute(self::PERMISSIONS, $value ?? []), + 'documentSecurity' => $this->setAttribute('documentSecurity', $value), + default => $this->setAttribute($name, $value), + }; + } + + public function __isset(string $name): bool + { + return match ($name) { + 'id', 'name', 'attributes', 'indexes', 'permissions', 'documentSecurity', 'metadata' => true, + default => $this->offsetExists($name), + }; + } + + /** + * @param mixed $attributes + * @return array + */ + private static function castAttributes(mixed $attributes): array + { + if (! \is_array($attributes)) { + return []; + } + + $cast = []; + foreach ($attributes as $attr) { + if ($attr instanceof Attribute) { + $cast[] = $attr; + + continue; + } + if ($attr instanceof Document) { + $cast[] = Attribute::fromArray($attr->getArrayCopy()); + + continue; + } + if (! \is_array($attr)) { + throw new \InvalidArgumentException('Collection attributes must be Attribute models'); + } + $typed = []; + foreach ($attr as $name => $item) { + if (\is_string($name)) { + $typed[$name] = $item; + } + } + $cast[] = Attribute::fromArray($typed); + } + + return $cast; + } + + /** + * @param mixed $indexes + * @return array + */ + private static function castIndexes(mixed $indexes): array + { + if (! \is_array($indexes)) { + return []; + } + + $cast = []; + foreach ($indexes as $idx) { + if ($idx instanceof Index) { + $cast[] = $idx; + + continue; + } + if ($idx instanceof Document) { + $cast[] = Index::fromArray($idx->getArrayCopy()); + + continue; + } + if (! \is_array($idx)) { + throw new \InvalidArgumentException('Collection indexes must be Index models'); + } + $typed = []; + foreach ($idx as $name => $item) { + if (\is_string($name)) { + $typed[$name] = $item; + } + } + $cast[] = Index::fromArray($typed); + } + + return $cast; + } +} 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 6c2c3f488b..b080bdd581 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2,17 +2,21 @@ 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\Cache\Invalidator; +use Utopia\Database\Cache\QueryCache; +use Utopia\Database\Cache\Scope; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; -use Utopia\Database\Exception\Conflict as ConflictException; -use Utopia\Database\Exception\Dependency as DependencyException; +use Utopia\Database\Exception\Character as CharacterException; 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\Order as OrderException; @@ -20,102 +24,52 @@ 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\Type as TypeException; 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\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\Profiler\QueryProfiler; +use Utopia\Database\State\Snapshot; +use Utopia\Database\State\Value; +use Utopia\Database\Type\TypeRegistry; 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\BigInt; +use Utopia\Database\Validator\Spatial as SpatialValidator; use Utopia\Database\Validator\Structure; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; +/** + * High-level database interface providing CRUD operations for documents, collections, attributes, indexes, and relationships with built-in caching, filtering, validation, and authorization. + */ 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, - ]; - - // All spatial types - public const SPATIAL_TYPES = [ - self::VAR_POINT, - self::VAR_LINESTRING, - self::VAR_POLYGON - ]; - - // All types which requires filters - public const ATTRIBUTE_FILTER_TYPES = [ - ...self::SPATIAL_TYPES, - self::VAR_VECTOR, - self::VAR_OBJECT, - self::VAR_DATETIME - ]; - - // 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'; + use Traits\Attributes; + use Traits\Collections; + use Traits\Databases; + use Traits\Documents; + use Traits\Indexes; + use Traits\Relationships; + use Traits\Transactions; // 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 string VECTOR_DISTANCE = Document::DISTANCE; + public const MAX_ARRAY_INDEX_LENGTH = 255; + public const MAX_UID_DEFAULT_LENGTH = 36; // Maximum byte capacity for TEXT @@ -128,52 +82,14 @@ class Database // 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 EARTH_RADIUS = 6371000; 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, - ]; - - // Collections - public const METADATA = '_metadata'; + public const RELATION_QUERY_CHUNK_SIZE = 5000; - // Cursor - public const CURSOR_BEFORE = 'before'; - public const CURSOR_AFTER = 'after'; + public const string METADATA = '_metadata'; // Lengths public const LENGTH_KEY = 255; @@ -181,53 +97,60 @@ class Database // 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'; + /** + * Failures that fail the same way on every attempt, so withRetries() rethrows them at once. + * + * @var list> + */ + private const array DETERMINISTIC_FAILURES = [ + AuthorizationException::class, + CharacterException::class, + DuplicateException::class, + LimitException::class, + NotFoundException::class, + OrderException::class, + QueryException::class, + RelationshipException::class, + RestrictedException::class, + StructureException::class, + TypeException::class, + ]; public const INSERT_BATCH_SIZE = 1_000; + public const DELETE_BATCH_SIZE = 1_000; + /** + * Attribute types whose stored value is produced by a filter of the same name. Every public + * creation path adds it, so an attribute made through createCollection(), createAttribute() + * or createAttributes() encodes and decodes the same way as any identical one. + * + * @var list + */ + public const array ATTRIBUTE_FILTER_COLUMN_TYPES = [ + ColumnType::Point, + ColumnType::Linestring, + ColumnType::Polygon, + ColumnType::Vector, + ColumnType::Object, + ColumnType::Datetime, + ]; + + /** + * @var list + */ + public const array DEFAULT_FILTERS = [ + 'json', + 'datetime', + ColumnType::Point->value, + ColumnType::Linestring->value, + ColumnType::Polygon->value, + ColumnType::Vector->value, + ColumnType::Object->value, + ]; + /** * List of Internal attributes * @@ -235,8 +158,8 @@ class Database */ public const INTERNAL_ATTRIBUTES = [ [ - '$id' => '$id', - 'type' => self::VAR_STRING, + Document::ID => Document::ID, + 'type' => ColumnType::String->value, 'size' => Database::LENGTH_KEY, 'required' => true, 'signed' => true, @@ -244,8 +167,8 @@ class Database 'filters' => [], ], [ - '$id' => '$sequence', - 'type' => self::VAR_ID, + Document::ID => Document::SEQUENCE, + 'type' => ColumnType::Id->value, 'size' => 0, 'required' => true, 'signed' => true, @@ -253,8 +176,8 @@ class Database 'filters' => [], ], [ - '$id' => '$collection', - 'type' => self::VAR_STRING, + Document::ID => Document::COLLECTION, + 'type' => ColumnType::String->value, 'size' => Database::LENGTH_KEY, 'required' => true, 'signed' => true, @@ -262,8 +185,8 @@ class Database 'filters' => [], ], [ - '$id' => '$tenant', - 'type' => self::VAR_ID, + Document::ID => Document::TENANT, + 'type' => ColumnType::Id->value, 'size' => 0, 'required' => false, 'default' => null, @@ -272,108 +195,53 @@ class Database 'filters' => [], ], [ - '$id' => '$createdAt', - 'type' => Database::VAR_DATETIME, + Document::ID => Document::CREATED_AT, + 'type' => ColumnType::Datetime->value, 'format' => '', 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, - 'filters' => ['datetime'] + 'filters' => ['datetime'], ], [ - '$id' => '$updatedAt', - 'type' => Database::VAR_DATETIME, + Document::ID => Document::UPDATED_AT, + 'type' => ColumnType::Datetime->value, 'format' => '', 'size' => 0, 'signed' => false, 'required' => false, 'default' => null, 'array' => false, - 'filters' => ['datetime'] + 'filters' => ['datetime'], ], [ - '$id' => '$permissions', - 'type' => Database::VAR_STRING, + Document::ID => Document::PERMISSIONS, + 'type' => ColumnType::String->value, 'size' => 1_000_000, 'signed' => true, 'required' => false, 'default' => [], 'array' => false, - 'filters' => ['json'] + 'filters' => ['json'], ], ]; public const INTERNAL_ATTRIBUTE_KEYS = [ - '_uid', - '_createdAt', - '_updatedAt', - '_permissions', + Storage::UID, + Storage::CREATED_AT, + Storage::UPDATED_AT, + Storage::PERMISSIONS, ]; public const INTERNAL_INDEXES = [ - '_id', - '_uid', - '_createdAt', - '_updatedAt', - '_permissions_id', - '_permissions', - ]; - - /** - * Parent Collection - * Defines the structure for both system and custom collections - * - * @var array - */ - 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' => [], + Storage::SEQUENCE, + Storage::UID, + Storage::CREATED_AT, + Storage::UPDATED_AT, + Storage::INDEX_PERMISSIONS_ID, + Storage::PERMISSIONS, ]; protected Adapter $adapter; @@ -389,74 +257,140 @@ class Database protected static bool $defaultFiltersRegistered = false; - protected static int $filtersVersion = 0; - /** - * @var array>|null + * Process-lifetime cache of internal attribute definitions as typed Attribute objects. + * Built once from {@see self::INTERNAL_ATTRIBUTES} (a class constant) and reused across calls. + * + * Returned arrays/objects MUST NOT be mutated by callers — they are shared singletons. + * + * @var array|null */ - private static ?array $tenantlessInternalAttributes = null; + private static ?array $internalAttributes = null; /** - * @var array + * Process-lifetime cache of internal attribute definitions as Document instances. + * Built once from {@see self::INTERNAL_ATTRIBUTES} (a class constant) and reused across calls. + * + * Returned Documents MUST NOT be mutated by callers — they are shared singletons. + * + * @var array|null */ - protected array $instanceFilters = []; + private static ?array $internalAttributeDocuments = null; /** - * @var array + * Process-lifetime cache of internal attribute definitions as raw arrays, keyed by + * whether the adapter has shared tables enabled. The shared-tables variant includes + * `$tenant`, the other variant excludes it. + * + * Returned arrays MUST NOT be mutated by callers — they are shared singletons. + * + * @var array>>|null */ - private array $filterSignatures = []; - - private string $filterSignaturesEncoded = ''; - - private int $filterSignaturesVersion = -1; + private static ?array $internalAttributeArrays = null; /** * @var array */ - private array $filterSignaturesSource = []; + protected array $instanceFilters = []; /** - * @var array> + * @var array */ - protected array $listeners = [ - '*' => [], - ]; + protected array $lifecycleHooks = []; /** - * 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 + * @var array */ - protected ?array $silentListeners = []; + protected array $decorators = []; + + /** @var Value|null Whether every lifecycle hook is silenced. */ + private ?Value $silenced = null; + + /** @var Value>|null Names of the silenced lifecycle hooks. */ + private ?Value $silencedListeners = null; - protected ?\DateTime $timestamp = null; + /** @var array> Pending query-cache tombstones by coroutine id. */ + protected array $queryCacheMutations = []; - protected bool $resolveRelationships = true; + /** @var array> Pending document-cache tombstones by coroutine id. */ + protected array $documentCacheMutations = []; - protected bool $checkRelationshipsExist = true; + /** @var Value|null */ + private ?Value $requestTimestamp = null; - protected int $relationshipFetchDepth = 0; + /** @var Value|null */ + private ?Value $filtering = null; + + /** @var Value|null>|null */ + private ?Value $filterExclusions = null; + + /** @var Value|null */ + private ?Value $validation = null; + + /** @var Value|null */ + private ?Value $datePreservation = null; + + /** @var Value|null */ + private ?Value $sequencePreservation = null; + + /** @var Value|null */ + private ?Value $duplicateSkipping = null; + + protected ?NativeDateTime $timestamp { + get => $this->requestTimestamp()->get(); + set { + $this->requestTimestamp()->set($value); + } + } - protected bool $inBatchRelationshipPopulation = false; + protected ?Relationships $relationshipHook = null; - protected bool $filter = true; + protected bool $filter { + get => $this->filtering()->get(); + set { + $this->filtering()->set($value); + } + } /** * @var array|null */ - protected ?array $disabledFilters = []; + protected ?array $disabledFilters { + get => $this->filterExclusions()->get(); + set { + $this->filterExclusions()->set($value); + } + } - protected bool $validate = true; + protected bool $validate { + get => $this->validation()->get(); + set { + $this->validation()->set($value); + } + } protected bool $dropUnknownAttributes = false; - protected bool $preserveDates = false; + protected bool $preserveDates { + get => $this->datePreservation()->get(); + set { + $this->datePreservation()->set($value); + } + } - protected bool $skipDuplicates = false; + protected bool $preserveSequence { + get => $this->sequencePreservation()->get(); + set { + $this->sequencePreservation()->set($value); + } + } - protected bool $preserveSequence = false; + protected bool $skipDuplicates { + get => $this->duplicateSkipping()->get(); + set { + $this->duplicateSkipping()->set($value); + } + } protected int $maxQueryValues = 5000; @@ -469,38 +403,29 @@ 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 ?TypeRegistry $typeRegistry = null; + + protected ?QueryCache $queryCache = null; + + protected ?Invalidator $queryCacheInvalidator = null; + + protected ?QueryProfiler $profiler = null; - /** - * @var Authorization - */ private Authorization $authorization; /** - * @param Adapter $adapter - * @param Cache $cache - * @param array $filters + * Construct a new Database instance with the given adapter, cache, and optional instance-level filters. + * + * @param Adapter $adapter The database adapter to use for storage operations. + * @param Cache $cache The cache instance for document and collection caching. + * @param array $filters Instance-level encode/decode filters. */ public function __construct( Adapter $adapter, @@ -516,86 +441,93 @@ public function __construct( $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', /** - * @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(function ($item) use ($document, $attribute) { + if (! is_array($item) || ! array_key_exists(Document::ID, $item)) { + return $item; + } + /** @var array $item */ + if ($document instanceof Collection && $attribute === 'attributes') { + return Attribute::fromArray($item); + } + if ($document instanceof Collection && $attribute === 'indexes') { + return Index::fromArray($item); + } + + return Document::fromStorage($item); + }, $decoded); + + return $decoded; } ); self::addFilter( 'datetime', /** - * @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) { @@ -604,153 +536,142 @@ static function (?string $value) { ); self::addFilter( - Database::VAR_POINT, + ColumnType::Point->value, /** - * @param mixed $value - * @param Document $document - * @param Database $database * @return mixed */ static function (mixed $value, Document $document, Database $database) { - if (!is_array($value)) { + if (! is_array($value)) { return $value; } try { - return $database->encodeSpatialData($value, Database::VAR_POINT); - } catch (\Throwable) { + return $database->encodeSpatialData($value, ColumnType::Point->value); + } catch (Throwable) { 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)) { + return $database->adapter->decodePoint($value); + } + + return null; } ); self::addFilter( - Database::VAR_LINESTRING, + ColumnType::Linestring->value, /** - * @param mixed $value - * @param Document $document - * @param Database $database * @return mixed */ static function (mixed $value, Document $document, Database $database) { - if (!is_array($value)) { + if (! is_array($value)) { return $value; } try { - return $database->encodeSpatialData($value, Database::VAR_LINESTRING); - } catch (\Throwable) { + return $database->encodeSpatialData($value, ColumnType::Linestring->value); + } catch (Throwable) { 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)) { + return $database->adapter->decodeLinestring($value); + } + + return null; } ); self::addFilter( - Database::VAR_POLYGON, + ColumnType::Polygon->value, /** - * @param mixed $value - * @param Document $document - * @param Database $database * @return mixed */ static function (mixed $value, Document $document, Database $database) { - if (!is_array($value)) { + if (! is_array($value)) { return $value; } try { - return $database->encodeSpatialData($value, Database::VAR_POLYGON); - } catch (\Throwable) { + return $database->encodeSpatialData($value, ColumnType::Polygon->value); + } catch (Throwable) { 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)) { + return $database->adapter->decodePolygon($value); + } + + 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) { @@ -758,7 +679,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); @@ -816,323 +737,240 @@ private static function valuesEqual(mixed $value, mixed $old): bool } /** - * Add listener to events - * Passing a null $callback will remove the listener + * Set database to use for current scope + * * - * @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 + * Get Database. * - * @param string $event - * @param string $name - * @param ?callable $callback - * @return $this + * Get Database from current scope + * + * @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 + * Set Namespace. * - * @template T - * @param callable(): T $callback - * @param array|null $listeners List of listeners to silence; if null, all listeners will be silenced - * @return T + * Set namespace to divide different scope of data sets + * + * + * @return $this + * + * @throws DatabaseException */ - public function silent(callable $callback, ?array $listeners = null): mixed + public function setNamespace(string $namespace): static { - $previous = $this->silentListeners; - - if (is_null($listeners)) { - $this->silentListeners = null; - } else { - $silentListeners = []; - foreach ($listeners as $listener) { - $silentListeners[$listener] = true; - } - $this->silentListeners = $silentListeners; - } + $this->adapter->setNamespace($namespace); - try { - return $callback(); - } finally { - $this->silentListeners = $previous; - } + return $this; } /** - * Get getConnection Id + * Get Namespace. * - * @return string - * @throws Exception + * Get namespace of current set scope */ - public function getConnectionId(): string + public function getNamespace(): string { - return $this->adapter->getConnectionId(); + return $this->adapter->getNamespace(); } /** - * Skip relationships for all the calls inside the callback + * Get ID Attribute Type. * - * @template T - * @param callable(): T $callback - * @return T + * Returns the type of the internal ID attribute (e.g. integer for SQL, uuid7 for MongoDB) */ - public function skipRelationships(callable $callback): mixed + public function getIdAttributeType(): string { - $previous = $this->resolveRelationships; - $this->resolveRelationships = false; - - try { - return $callback(); - } finally { - $this->resolveRelationships = $previous; - } + return $this->adapter->getIdAttributeType(); } /** - * Refetch documents after operator updates to get computed values - * - * @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 + * Get Database Adapter */ - protected function refetchDocuments(Document $collection, array $documents, array $selections = []): array + public function getAdapter(): Adapter { - 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; - } - } - - $result = []; - foreach ($documents as $index => $doc) { - $result[$index] = $refetchedMap[$sequences[$index]] ?? $doc; - } - - return $result; + return $this->adapter; } - public function skipRelationshipsExistCheck(callable $callback): mixed + /** + * 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(). + * + * Skipping authorization lifts permissions, never tenancy: under shared tables every statement + * stays within the tenant selected when the builder was handed out (see SQL::getBuilder() for + * what that covers). Another tenant's rows are read by selecting that tenant, with setTenant() + * or withTenant(). + * + * @throws AuthorizationException While authorization is enabled + * @throws DatabaseException When the adapter has no query builder + */ + public function from(string $collection): \Utopia\Query\Builder { - $previous = $this->checkRelationshipsExist; - $this->checkRelationshipsExist = false; + $this->requireSkippedAuthorization(); - try { - return $callback(); - } finally { - $this->checkRelationshipsExist = $previous; + if (! $this->adapter->hasFeature(Feature\QueryBuilder::class)) { + throw new DatabaseException('Query builder is not supported by this adapter'); } - } - public function skipDuplicates(callable $callback): mixed - { - $previous = $this->skipDuplicates; - $this->skipDuplicates = true; + $builder = $this->adapter->getBuilder($collection); + $builder->setExecutor(fn (\Utopia\Query\Builder\Statement $statement) => $this->execute($statement)); - try { - return $callback(); - } finally { - $this->skipDuplicates = $previous; - } + return $builder; } /** - * Build a tenant-aware identity key for a document. - * Returns ":" in tenant-per-document shared-table mode, otherwise just the id. + * Get a utopia-php/query Schema builder for DDL operations. */ - private function tenantKey(Document $document): string + public function schema(): \Utopia\Query\Schema { - return ($this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument()) - ? $document->getTenant() . ':' . $document->getId() - : $document->getId(); + if (! $this->adapter->hasFeature(Feature\QueryBuilder::class)) { + throw new DatabaseException('Schema builder is not supported by this adapter'); + } + + $schema = $this->adapter->getSchema(); + $schema->setExecutor(fn (\Utopia\Query\Builder\Statement $statement) => $this->execute($statement)); + + return $schema; } /** - * Trigger callback for events + * Run a statement as written, with everything from() says it bypasses; a builder runs its SELECT. * - * @param string $event - * @param mixed $args - * @return void + * @return array|int The rows a read returns, or how many rows a write changed + * @throws AuthorizationException While authorization is enabled + * @throws DatabaseException When the adapter cannot run raw statements */ - protected function trigger(string $event, mixed $args = null): void + public function execute(\Utopia\Query\Builder|\Utopia\Query\Builder\Statement $query): array|int { - if (\is_null($this->silentListeners)) { - return; - } - foreach ($this->listeners[self::EVENT_ALL] as $name => $callback) { - if (isset($this->silentListeners[$name])) { - continue; - } - $callback($event, $args); + $this->requireSkippedAuthorization(); + + if (! $this->adapter->hasFeature(Feature\RawQuery::class)) { + throw new DatabaseException('Raw queries are not supported by this adapter'); } - foreach (($this->listeners[$event] ?? []) as $name => $callback) { - if (isset($this->silentListeners[$name])) { - continue; - } - $callback($event, $args); + $result = $query instanceof \Utopia\Query\Builder\Statement ? $query : $query->build(); + + if ($result->readOnly) { + return $this->adapter->rawQuery($result->query, $result->bindings); } + + return $this->adapter->rawMutation($result->query, $result->bindings); } /** - * Executes $callback with $timestamp set to $requestTimestamp - * - * @template T - * @param ?\DateTime $requestTimestamp - * @param callable(): T $callback - * @return T + * @throws AuthorizationException */ - public function withRequestTimestamp(?\DateTime $requestTimestamp, callable $callback): mixed + private function requireSkippedAuthorization(): void { - $previous = $this->timestamp; - $this->timestamp = $requestTimestamp; - try { - $result = $callback(); - } finally { - $this->timestamp = $previous; + if ($this->authorization->getStatus()) { + throw new AuthorizationException('The query builder bypasses permissions, caches and events: build and run it inside getAuthorization()->skip()'); } - return $result; } - /** - * Set Namespace. - * - * Set namespace to divide different scope of data sets - * - * @param string $namespace - * - * @return $this - * - * @throws DatabaseException - */ - public function setNamespace(string $namespace): static + public function setTypeRegistry(?TypeRegistry $typeRegistry): static { - $this->adapter->setNamespace($namespace); + $this->typeRegistry = $typeRegistry; return $this; } - /** - * Get Namespace. - * - * Get namespace of current set scope - * - * @return string - */ - public function getNamespace(): string + public function getTypeRegistry(): ?TypeRegistry { - return $this->adapter->getNamespace(); + return $this->typeRegistry; } - /** - * Get ID Attribute Type. - * - * Returns the type of the internal ID attribute (e.g. VAR_INTEGER for SQL, VAR_UUID7 for MongoDB) - * - * @return string - */ - public function getIdAttributeType(): string + public function setQueryCache(?QueryCache $queryCache): static { - return $this->adapter->getIdAttributeType(); + $this->lifecycleHooks = \array_values(\array_filter( + $this->lifecycleHooks, + static fn (Lifecycle $hook): bool => ! $hook instanceof Invalidator, + )); + $this->queryCacheInvalidator = null; + $this->queryCache = $queryCache; + + if ($queryCache !== null) { + $this->queryCacheInvalidator = new Invalidator($queryCache); + } + + return $this; } - /** - * Set database to use for current scope - * - * @param string $name - * - * @return static - * @throws DatabaseException - */ - public function setDatabase(string $name): static + public function getQueryCache(): ?QueryCache { - $this->adapter->setDatabase($name); + return $this->queryCache; + } + + public function enableProfiling(): static + { + if ($this->profiler === null) { + $this->profiler = new QueryProfiler(); + } + + $this->profiler->enable(); + $this->adapter->setProfiler($this->profiler); + + return $this; + } + + public function disableProfiling(): static + { + if ($this->profiler !== null) { + $this->profiler->disable(); + } + + $this->adapter->setProfiler(null); return $this; } + public function getProfiler(): ?QueryProfiler + { + return $this->profiler; + } + /** - * Get Database. - * - * Get Database from current scope + * Get list of keywords that cannot be used * - * @return string - * @throws DatabaseException + * @return string[] */ - public function getDatabase(): string + public function getKeywords(): array { - return $this->adapter->getDatabase(); + return $this->adapter->getKeywords(); } /** * 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 { @@ -1142,7 +980,6 @@ public function getCache(): Cache /** * Set the name to use for cache * - * @param string $name * @return $this */ public function setCacheName(string $name): static @@ -1154,8 +991,6 @@ public function setCacheName(string $name): static /** * Get the cache name - * - * @return string */ public function getCacheName(): string { @@ -1163,8389 +998,912 @@ public function getCacheName(): string } /** - * Set a metadata value to be printed in the query comments + * Set shard tables * - * @param string $key - * @param mixed $value - * @return static + * Set whether to share tables between tenants */ - public function setMetadata(string $key, mixed $value): static + public function setSharedTables(bool $sharedTables): static { - $this->adapter->setMetadata($key, $value); + $this->adapter->setSharedTables($sharedTables); return $this; } /** - * Get metadata + * Get shared tables * - * @return array + * Get whether to share tables between tenants */ - public function getMetadata(): array + public function getSharedTables(): bool { - return $this->adapter->getMetadata(); + return $this->adapter->getSharedTables(); } /** - * Sets instance of authorization for permission checks + * Set Tenant * - * @param Authorization $authorization - * @return self + * Set tenant to use if tables are shared */ - 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 + * Get Tenant * - * @return Authorization + * Get tenant to use if tables are shared */ - public function getAuthorization(): Authorization + public function getTenant(): int|string|null { - return $this->authorization; + return $this->adapter->getTenant(); } /** - * Clear metadata + * With Tenant + * + * 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 + * Set whether to allow creating documents with tenant set per document. */ - 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 + * Get whether to allow creating documents with tenant set per document. */ - public function clearTimeout(string $event = Database::EVENT_ALL): void + public function getTenantPerDocument(): bool { - $this->adapter->clearTimeout($event); + return $this->adapter->getTenantPerDocument(); } /** - * Enable filters - * - * @return $this + * Sets instance of authorization for permission checks */ - public function enableFilters(): static + public function setAuthorization(Authorization $authorization): self { - $this->filter = true; + $this->adapter->setAuthorization($authorization); + $this->authorization = $authorization; + return $this; } /** - * Disable filters - * - * @return $this + * Get Authorization */ - public function disableFilters(): static + public function getAuthorization(): Authorization { - $this->filter = false; - return $this; + return $this->authorization; } /** - * Skip filters - * - * Execute a callback without filters + * Set maximum query execution time * - * @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; - } + // Not hasFeature(): on a pool that is a delegated call, so guarding + // with it dials the database just to configure a handle. A pool holds + // the timeout without checking out and defers the backing adapter's + // refusal to the moment it is applied, which is why the method's + // presence -- not the feature interface -- is what is asked here. + if (! \method_exists($this->adapter, 'setTimeout')) { + 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; + } + + /** + * Clear maximum query execution time + */ + public function clearTimeout(Event $event = Event::All): void + { + if (! \method_exists($this->adapter, 'clearTimeout')) { + throw new DatabaseException('Adapter does not support timeouts'); } + + $this->adapter->clearTimeout($event); } /** - * Get instance filters + * Get the current relationship hook. * - * @return array + * @return Relationships|null The relationship hook, or null if not set. */ - 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. * + * @param bool $preserve True to preserve dates on write operations. * @return $this */ - public function enableValidation(): static + public function setPreserveDates(bool $preserve): static { - $this->validate = true; + $this->preserveDates = $preserve; return $this; } + public function getDropUnknownAttributes(): 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; } /** - * Skip Validation - * - * Execute a callback without validation + * Get whether date preservation is enabled. * - * @template T - * @param callable(): T $callback - * @return T + * @return bool True if dates are being preserved. */ - public function skipValidation(callable $callback): mixed + public function getPreserveDates(): bool { - $initial = $this->validate; - $this->disableValidation(); - - try { - return $callback(); - } finally { - $this->validate = $initial; - } + return $this->preserveDates; } /** - * Get shared tables + * Execute a callback with date preservation enabled, restoring the previous state afterward. + * Scoped to the calling coroutine and the coroutines it starts. * - * Get whether to share tables between tenants - * @return bool + * @param callable $callback The callback to execute. + * @return mixed The callback's return value. */ - public function getSharedTables(): bool + public function withPreserveDates(callable $callback): mixed { - return $this->adapter->getSharedTables(); + return $this->datePreservation()->with(true, $callback); } /** - * Set shard tables - * - * Set whether to share tables between tenants + * Execute a callback with skipDuplicates enabled, restoring the previous state afterward. + * Scoped to the calling coroutine and the coroutines it starts. * - * @param bool $sharedTables - * @return static + * @template T + * @param callable(): T $callback + * @return T */ - public function setSharedTables(bool $sharedTables): static + public function skipDuplicates(callable $callback): mixed { - $this->adapter->setSharedTables($sharedTables); - - return $this; + return $this->duplicateSkipping()->with(true, $callback); } /** - * Set Tenant + * Set whether to preserve original sequence values instead of auto-generating them. * - * Set tenant to use if tables are shared - * - * @param int|string|null $tenant - * @return static + * @param bool $preserve True to preserve sequence values on write operations. + * @return $this */ - public function setTenant(int|string|null $tenant): static + public function setPreserveSequence(bool $preserve): static { - $this->adapter->setTenant($tenant); + $this->preserveSequence = $preserve; return $this; } /** - * Get Tenant + * Get whether sequence preservation is enabled. * - * Get tenant to use if tables are shared - * - * @return int|string|null + * @return bool True if sequence values are being preserved. */ - public function getTenant(): int|string|null + public function getPreserveSequence(): bool { - return $this->adapter->getTenant(); + return $this->preserveSequence; } /** - * With Tenant - * - * Execute a callback with a specific tenant + * Execute a callback with sequence preservation enabled, restoring the previous state afterward. + * Scoped to the calling coroutine and the coroutines it starts. * - * @param int|string|null $tenant - * @param callable $callback - * @return mixed + * @param callable $callback The callback to execute. + * @return mixed The callback's return value. */ - public function withTenant(int|string|null $tenant, callable $callback): mixed + public function withPreserveSequence(callable $callback): mixed { - $previous = $this->adapter->getTenant(); - $this->adapter->setTenant($tenant); - - try { - return $callback(); - } finally { - $this->adapter->setTenant($previous); - } + return $this->sequencePreservation()->with(true, $callback); } /** - * Set whether to allow creating documents with tenant set per document. + * Set the migration mode flag, which relaxes certain constraints during data migrations. * - * @param bool $enabled - * @return static + * @param bool $migrating True to enable migration mode. + * @return $this */ - public function setTenantPerDocument(bool $enabled): static + public function setMigrating(bool $migrating): self { - $this->adapter->setTenantPerDocument($enabled); + $this->migrating = $migrating; return $this; } /** - * Get whether to allow creating documents with tenant set per document. + * Check whether the database is currently in migration mode. * - * @return bool + * @return bool True if migration mode is active. */ - public function getTenantPerDocument(): bool + public function isMigrating(): bool { - return $this->adapter->getTenantPerDocument(); + return $this->migrating; } /** - * Enable or disable LOCK=SHARED during ALTER TABLE operation - * - * Set lock mode when altering tables + * Set the maximum number of values allowed in a single query (e.g., IN clauses). * - * @param bool $enabled - * @return static + * @param int $max The maximum number of query values. + * @return $this */ - public function enableLocks(bool $enabled): static + public function setMaxQueryValues(int $max): self { - if ($this->adapter->getSupportForAlterLocks()) { - $this->adapter->enableAlterLocks($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; } /** - * Set custom document class for a collection + * Get the maximum number of values allowed in a single query. * - * @param string $collection Collection ID - * @param class-string $className Fully qualified class name that extends Document - * @return static - * @throws DatabaseException + * @return int The current maximum query values limit. */ - public function setDocumentType(string $collection, string $className): static + public function getMaxQueryValues(): int { - if (!\class_exists($className)) { - throw new DatabaseException("Class {$className} does not exist"); - } + return $this->maxQueryValues; + } - if (!\is_subclass_of($className, Document::class)) { - throw new DatabaseException("Class {$className} must extend " . Document::class); + /** + * Set list of collections which are globally accessible + * + * @param array $collections + * @return $this + */ + public function setGlobalCollections(array $collections): static + { + foreach ($collections as $collection) { + $this->globalCollections[$collection] = true; } - $this->documentTypes[$collection] = $className; - return $this; } /** - * Get custom document class for a collection + * Get list of collections which are globally accessible * - * @param string $collection Collection ID - * @return class-string|null + * @return array */ - public function getDocumentType(string $collection): ?string + public function getGlobalCollections(): array { - return $this->documentTypes[$collection] ?? null; + return \array_keys($this->globalCollections); } /** - * Clear document type mapping for a collection - * - * @param string $collection Collection ID - * @return static + * Clear global collections */ - public function clearDocumentType(string $collection): static + public function resetGlobalCollections(): void { - unset($this->documentTypes[$collection]); - - return $this; + $this->globalCollections = []; } /** - * Clear all document type mappings + * Set custom document class for a collection * - * @return static + * @param string $collection Collection ID + * @param string $className Fully qualified class name that extends Document + * + * @throws DatabaseException */ - public function clearAllDocumentTypes(): static + public function setDocumentType(string $collection, string $className): static { - $this->documentTypes = []; + 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); + } + + $this->documentTypes[$collection] = $className; return $this; } /** - * Create a document instance of the appropriate type + * Get custom document class for a collection * - * @param string $collection Collection ID - * @param array $data Document data - * @return Document + * @param string $collection Collection ID + * @return class-string|null */ - protected function createDocumentInstance(string $collection, array $data): Document + public function getDocumentType(string $collection): ?string { - $className = $this->documentTypes[$collection] ?? Document::class; - - return new $className($data); + return $this->documentTypes[$collection] ?? null; } - public function getDropUnknownAttributes(): bool + /** + * Clear document type mapping for a collection + * + * @param string $collection Collection ID + */ + public function clearDocumentType(string $collection): static { - return $this->dropUnknownAttributes; + unset($this->documentTypes[$collection]); + + return $this; } /** - * Drop attributes missing from the collection schema instead of rejecting the write. - * - * 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. + * Clear all document type mappings */ - public function setDropUnknownAttributes(bool $drop): static + public function clearAllDocumentTypes(): static { - $this->dropUnknownAttributes = $drop; + $this->documentTypes = [self::METADATA => Collection::class]; return $this; } - public function getPreserveDates(): bool + /** + * Enable or disable LOCK=SHARED during ALTER TABLE operation + * + * Set lock mode when altering tables + */ + public function enableLocks(bool $enabled): static { - return $this->preserveDates; + if ($this->adapter->supports(Capability::AlterLock)) { + $this->adapter->enableAlterLocks($enabled); + } + + return $this; } - public function setPreserveDates(bool $preserve): static + /** + * Enable validation + * + * @return $this + */ + public function enableValidation(): static { - $this->preserveDates = $preserve; + $this->validate = true; return $this; } - public function setMigrating(bool $migrating): self + /** + * Disable validation + * + * @return $this + */ + public function disableValidation(): static { - $this->migrating = $migrating; + $this->validate = false; return $this; } - public function isMigrating(): bool + /** + * Whether document structure validation is currently enabled. + */ + public function isValidationEnabled(): bool { - return $this->migrating; + return $this->validate; } - public function withPreserveDates(callable $callback): mixed + /** + * Skip Validation + * + * Execute a callback without validation. Scoped to the calling coroutine and the coroutines it starts. + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + public function skipValidation(callable $callback): mixed { - $previous = $this->preserveDates; - $this->preserveDates = true; - - try { - return $callback(); - } finally { - $this->preserveDates = $previous; - } + return $this->validation()->with(false, $callback); } - public function getPreserveSequence(): bool + /** + * 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 + */ + public function addHook(\Utopia\Query\Hook $hook): static { - return $this->preserveSequence; + if ($hook instanceof Lifecycle) { + if ($hook instanceof Invalidator) { + $this->lifecycleHooks = \array_values(\array_filter( + $this->lifecycleHooks, + static fn (Lifecycle $registered): bool => ! $registered instanceof Invalidator, + )); + $this->queryCacheInvalidator = $hook; + } else { + $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 setPreserveSequence(bool $preserve): static + private function registerLifecycleHook(Lifecycle $hook): void { - $this->preserveSequence = $preserve; + if ($hook instanceof Named) { + foreach ($this->lifecycleHooks as $index => $registered) { + if ($registered instanceof Named && $registered->getName() === $hook->getName()) { + $this->lifecycleHooks[$index] = $hook; - return $this; + return; + } + } + } + + $this->lifecycleHooks[] = $hook; } - public function withPreserveSequence(callable $callback): mixed + /** + * Apply all registered decorators to a single document. + */ + protected function decorateDocument(Event $event, Document $collection, Document $document): Document { - $previous = $this->preserveSequence; - $this->preserveSequence = true; + if ($this->areEventsSilenced()) { + return $document; + } - try { - return $callback(); - } finally { - $this->preserveSequence = $previous; + foreach ($this->decorators as $decorator) { + $document = $decorator->decorate($event, $collection, $document); } + + return $document; } - public function setMaxQueryValues(int $max): self + /** + * Apply all registered document decorators to an array of documents. + * + * @param array $documents + * @return array + */ + protected function decorateDocuments(Event $event, Document $collection, array $documents): array { - $this->maxQueryValues = $max; + if (empty($this->decorators)) { + return $documents; + } - return $this; + foreach ($documents as $i => $document) { + $documents[$i] = $this->decorateDocument($event, $collection, $document); + } + + return $documents; } - public function getMaxQueryValues(): int + + /** + * Remove a query transform hook from the adapter. + */ + public function removeTransform(string $name): static { - return $this->maxQueryValues; + $this->adapter->removeTransform($name); + + return $this; } /** - * Set list of collections which are globally accessible + * 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. * - * @param array $collections - * @return $this + * @template T + * + * @param callable(): T $callback + * @param array|null $listeners Names of the hooks to silence; null silences every hook + * @return T */ - public function setGlobalCollections(array $collections): static + public function silent(callable $callback, ?array $listeners = null): mixed { - foreach ($collections as $collection) { - $this->globalCollections[$collection] = true; + if ($listeners !== null) { + return $this->silenceListeners($callback, $listeners); } - return $this; + return $this->silenced()->with(true, $callback); } /** - * Get list of collections which are globally accessible + * @template T * - * @return array + * @param callable(): T $callback + * @param array $listeners + * @return T */ - public function getGlobalCollections(): array + private function silenceListeners(callable $callback, array $listeners): mixed { - return \array_keys($this->globalCollections); + $silencedListeners = $this->silencedListeners(); + + return $silencedListeners->with($silencedListeners->get() + \array_fill_keys($listeners, true), $callback); + } + + protected function areEventsSilenced(): bool + { + return $this->silenced()->get(); } /** - * Clear global collections - * - * @return void + * @return Value */ - public function resetGlobalCollections(): void + private function silenced(): Value { - $this->globalCollections = []; + return $this->silenced ??= new Value(false); } /** - * Get list of keywords that cannot be used - * - * @return string[] + * @return Value> */ - public function getKeywords(): array + private function silencedListeners(): Value { - return $this->adapter->getKeywords(); + if ($this->silencedListeners === null) { + /** @var Value> $silencedListeners */ + $silencedListeners = new Value([]); + $this->silencedListeners = $silencedListeners; + } + + return $this->silencedListeners; } /** - * Get Database Adapter - * - * @return Adapter + * 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(). */ - public function getAdapter(): Adapter + public function snapshot(): Snapshot { - return $this->adapter; + 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->filter, + disabledFilters: $this->disabledFilters, + validation: $this->validate, + preserveDates: $this->preserveDates, + preserveSequence: $this->preserveSequence, + skipDuplicates: $this->skipDuplicates, + requestTimestamp: $this->timestamp, + ); } /** - * Run a callback inside a transaction. + * 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. * * @template T - * @param callable(): T $callback + * + * @param callable(): T $callback * @return T - * @throws \Throwable */ - public function withTransaction(callable $callback): mixed + public function withSnapshot(Snapshot $snapshot, callable $callback): mixed { - return $this->adapter->withTransaction($callback); + $hook = $this->relationshipHook; + $scoped = fn () => $this->silenced()->with( + $snapshot->silenced, + fn () => $this->silencedListeners()->with( + $snapshot->silencedListeners, + fn () => $this->withToggles($snapshot, $callback), + ), + ); + + $authorized = fn () => $this->authorization->withRoles( + $snapshot->roles, + $hook === null ? $scoped : fn () => $hook->withSnapshot($snapshot, $scoped), + ); + + return $this->authorization->withStatus($snapshot->authorization, $authorized); } /** - * Ping Database + * @template T * - * @return bool + * @param callable(): T $callback + * @return T */ - public function ping(): bool + private function withToggles(Snapshot $snapshot, callable $callback): mixed { - return $this->adapter->ping(); - } + $timestamped = fn (): mixed => $this->requestTimestamp()->with($snapshot->requestTimestamp, $callback); + $deduplicated = fn (): mixed => $this->duplicateSkipping()->with($snapshot->skipDuplicates, $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); - public function reconnect(): void - { - $this->adapter->reconnect(); + return $this->adapter->withTenant($snapshot->tenant, $filtered); } /** - * Create the database - * - * @param string|null $database - * @return bool - * @throws DuplicateException - * @throws LimitException - * @throws Exception + * @return Value */ - public function create(?string $database = null): bool + private function requestTimestamp(): Value { - $database ??= $this->adapter->getDatabase(); - - $this->adapter->create($database); + if ($this->requestTimestamp === null) { + /** @var Value $requestTimestamp */ + $requestTimestamp = new Value(null); + $this->requestTimestamp = $requestTimestamp; + } - /** - * Create array of attribute documents - * @var array $attributes - */ - $attributes = \array_map(function ($attribute) { - return new Document($attribute); - }, self::COLLECTION['attributes']); + return $this->requestTimestamp; + } - $this->silent(fn () => $this->createCollection(self::METADATA, $attributes)); + /** + * @return Value + */ + private function filtering(): Value + { + return $this->filtering ??= new Value(true); + } - try { - $this->trigger(self::EVENT_DATABASE_CREATE, $database); - } catch (\Throwable $e) { - // Ignore + /** + * @return Value|null> + */ + private function filterExclusions(): Value + { + if ($this->filterExclusions === null) { + /** @var Value|null> $filterExclusions */ + $filterExclusions = new Value([]); + $this->filterExclusions = $filterExclusions; } - return true; + return $this->filterExclusions; } /** - * 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 + * @return Value */ - public function exists(?string $database = null, ?string $collection = null): bool + private function validation(): Value { - $database ??= $this->adapter->getDatabase(); - - return $this->adapter->exists($database, $collection); + return $this->validation ??= new Value(true); } /** - * List Databases - * - * @return array + * @return Value */ - public function list(): array + private function datePreservation(): Value { - $databases = $this->adapter->list(); - - try { - $this->trigger(self::EVENT_DATABASE_LIST, $databases); - } catch (\Throwable $e) { - // Ignore - } - - return $databases; + return $this->datePreservation ??= new Value(false); } /** - * Delete Database - * - * @param string|null $database - * @return bool - * @throws DatabaseException + * @return Value */ - public function delete(?string $database = null): bool + private function sequencePreservation(): Value { - $database = $database ?? $this->adapter->getDatabase(); + return $this->sequencePreservation ??= new Value(false); + } - $deleted = $this->adapter->delete($database); + /** + * @return Value + */ + private function duplicateSkipping(): Value + { + return $this->duplicateSkipping ??= new Value(false); + } - try { - $this->trigger(self::EVENT_DATABASE_DELETE, [ - 'name' => $database, - 'deleted' => $deleted - ]); - } catch (\Throwable $e) { - // Ignore + private function getEventContext(): int + { + if (! \extension_loaded('swoole')) { + return -1; } - $this->cache->flush(); + $context = Coroutine::getCid(); - return $deleted; + return \is_int($context) ? $context : -1; } /** - * Create Collection + * Register a global attribute filter with encode and decode callbacks for data transformation. * - * @param string $id - * @param array $attributes - * @param array $indexes - * @param array|null $permissions - * @param bool $documentSecurity - * @return Document - * @throws DatabaseException - * @throws DuplicateException - * @throws LimitException + * @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 function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document + public static function addFilter(string $name, callable $encode, callable $decode): void { - 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); + self::registerDefaultFilters(); - $permissions ??= [ - Permission::create(Role::any()), + self::$filters[$name] = [ + 'encode' => $encode, + 'decode' => $decode, + 'signature' => self::computeCallableSignature($encode) . ':' . self::computeCallableSignature($decode), ]; + } - if ($this->validate) { - $validator = new Permissions(); - if (!$validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); - } + private static function computeCallableSignature(callable $callable): string + { + if (\is_string($callable)) { + return $callable; } - $collection = $this->silent(fn () => $this->getCollection($id)); - - if (!$collection->isEmpty() && $id !== self::METADATA) { - throw new DuplicateException('Collection ' . $id . ' already exists'); + if (\is_array($callable)) { + $class = \is_object($callable[0]) ? \get_class($callable[0]) : $callable[0]; + return $class . '::' . $callable[1]; } - // 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; - } - - /** - * Update Collections Permissions. - * - * @param string $id - * @param array $permissions - * @param bool $documentSecurity - * - * @return Document - * @throws ConflictException - * @throws DatabaseException - */ - public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document - { - 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 - } - - return $collection; - } - - /** - * Get Collection - * - * @param string $id - * - * @return Document - * @throws DatabaseException - */ - public function getCollection(string $id): Document - { - $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 - } - - return $collection; - } - - /** - * List Collections - * - * @param int $offset - * @param int $limit - * - * @return array - * @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) - ])); - - try { - $this->trigger(self::EVENT_COLLECTION_LIST, $result); - } catch (\Throwable $e) { - // Ignore - } - - return $result; - } - - /** - * Get Collection Size - * - * @param string $collection - * - * @return int - * @throws Exception - */ - public function getSizeOfCollection(string $collection): int - { - $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()); - } - - /** - * Get Collection Size on disk - * - * @param string $collection - * - * @return int - */ - public function getSizeOfCollectionOnDisk(string $collection): int - { - 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'); - } - - 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); - } - - /** - * Delete Collection - * - * @param string $id - * - * @return bool - * @throws DatabaseException - */ - public function deleteCollection(string $id): bool - { - $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; - } - - /** - * 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 - */ - 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 - { - $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 (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)]; - } - } + $closure = \Closure::fromCallable($callable); + $ref = new \ReflectionFunction($closure); + return ($ref->getFileName() ?: 'unknown') . ':' . $ref->getStartLine(); + } /** - * @param string $collection - * @param array $queries - * @return Document - * @throws DatabaseException + * Enable filters + * + * @return $this */ - public function findOne(string $collection, array $queries = []): Document + public function enableFilters(): static { - $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(); - } + $this->filter = true; - return $found; + return $this; } /** - * Count Documents - * - * Count the number of documents. - * - * @param string $collection - * @param array $queries - * @param int|null $max + * Disable filters * - * @return int - * @throws DatabaseException + * @return $this */ - public function count(string $collection, array $queries = [], ?int $max = null): int + public function disableFilters(): static { - $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); + $this->filter = false; - return $count; + return $this; } /** - * Sum an attribute + * Skip filters * - * Sum an attribute for all the documents. Pass $max=0 for unlimited count + * Execute a callback without filters, or without the named ones. + * Scoped to the calling coroutine and the coroutines it starts. * - * @param string $collection - * @param string $attribute - * @param array $queries - * @param int|null $max + * @template T * - * @return int|float - * @throws DatabaseException + * @param callable(): T $callback + * @param array|null $filters + * @return T */ - public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int + public function skipFilters(callable $callback, ?array $filters = null): mixed { - $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()); + if (empty($filters)) { + return $this->filtering()->with(false, $callback); } - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP + return $this->filtering()->with( + $this->filter, + fn (): mixed => $this->filterExclusions()->with(\array_fill_keys($filters, true), $callback), ); - - $queries = $this->convertQueries($collection, $queries); - $queriesOrNull = $this->convertRelationshipQueries($relationships, $queries, $collection); - - // If conversion returns null, it means no documents can match (relationship filter found no matches) - if ($queriesOrNull === null) { - return 0; - } - - $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); - - return $sum; - } - - /** - * Add Attribute Filter - * - * @param string $name - * @param callable $encode - * @param callable $decode - * - * @return void - */ - public static function addFilter(string $name, callable $encode, callable $decode): void - { - self::registerDefaultFilters(); - - self::$filters[$name] = [ - 'encode' => $encode, - '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) + * Get instance filters * - * @return Document + * @return array */ - protected function removeUnknownAttributes(Document $collection, Document $document, ?array $known = null): Document + public function getInstanceFilters(): array { - if (!$this->dropUnknownAttributes || !$this->adapter->getSupportForAttributes()) { - return $document; - } - - if ($known === null) { - $known = []; - foreach ($collection->getAttribute('attributes', []) as $attribute) { - $known[$attribute['$id'] ?? ''] = true; - } - } - - $dropped = []; - $documentKeys = []; - foreach ($document as $key => $value) { - $documentKeys[] = $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; + return $this->instanceFilters; } /** * Encode Document * - * When dropUnknownAttributes is enabled, attributes missing from the - * collection schema are removed here while the known set is collected. + * @param bool $applyDefaults Whether to apply default values to null attributes * - * @param Document $collection - * @param Document $document - * @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']; + $rawAttributes = $collection->getAttribute('attributes', []); + $attributes = \is_array($rawAttributes) ? $rawAttributes : []; + $internalDateAttributes = [Document::CREATED_AT, Document::UPDATED_AT]; foreach ($this->getInternalAttributes() as $attribute) { $attributes[] = $attribute; } $known = []; + foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; + if ($attribute instanceof Attribute) { + $key = $attribute->key; + $array = $attribute->array; + $default = $attribute->default; + $filters = $attribute->filters; + } elseif ($attribute instanceof Document) { + $key = $attribute->getId(); + $array = (bool) $attribute->getAttribute('array', false); + $default = $attribute->offsetExists('default') ? $attribute['default'] : null; + $filters = $attribute->getArray('filters'); + } elseif (\is_array($attribute)) { + $key = \is_string($attribute[Document::ID] ?? null) ? $attribute[Document::ID] : ''; + $array = $attribute['array'] ?? false; + $default = $attribute['default'] ?? null; + $filters = \is_array($attribute['filters'] ?? null) ? $attribute['filters'] : []; + } else { + continue; + } $known[$key] = true; - $array = $attribute['array'] ?? false; - $default = $attribute['default'] ?? null; - $filters = $attribute['filters'] ?? []; - $value = $document->getAttribute($key); + /** @var array $filters */ + $exists = $document->offsetExists($key); + $value = $exists ? $document[$key] : null; if (in_array($key, $internalDateAttributes) && 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; } @@ -9554,12 +1912,10 @@ public function encode(Document $collection, Document $document, bool $applyDefa 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)) { + // Assign default when no value is provided or the value is explicitly null + if ($value === null && $default !== null) { // Skip applying defaults during updates to avoid resetting unspecified attributes - if (!$applyDefaults) { + if (! $applyDefaults) { continue; } $value = ($array) ? $default : [$default]; @@ -9567,7 +1923,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) { @@ -9578,7 +1939,7 @@ public function encode(Document $collection, Document $document, bool $applyDefa } } - if (!$array) { + if (! $array) { $value = $value[0]; } $document->setAttribute($key, $value); @@ -9587,40 +1948,105 @@ public function encode(Document $collection, Document $document, bool $applyDefa return $this->removeUnknownAttributes($collection, $document, $known); } + /** + * 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 = []; + $declared = $collection->getAttribute('attributes', []); + foreach (\is_array($declared) ? $declared : [] as $attribute) { + $key = match (true) { + $attribute instanceof Attribute => $attribute->key, + $attribute instanceof Document => $attribute->getId(), + \is_array($attribute) => \is_string($attribute[Document::ID] ?? null) ? $attribute[Document::ID] : '', + default => '', + }; + $known[$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; + } + /** * Decode Document * - * @param Document $collection - * @param Document $document - * @param array $selections - * @return Document + * @param array $selections + * * @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 - ); - - $relationships = \array_filter( - $collection->getAttribute('attributes', []), - fn ($attribute) => $attribute['type'] === self::VAR_RELATIONSHIP - ); + /** @var array|Document> $allAttributes */ + $allAttributes = $collection->getAttribute('attributes', []); + + // Single-pass partition into relationships vs regular attributes. + // Replaces two array_filter passes that each walked the full list. + $attributes = []; + $relationships = []; + $relationshipType = ColumnType::Relationship->value; + foreach ($allAttributes as $attribute) { + if (($attribute['type'] ?? '') === $relationshipType) { + $relationships[] = $attribute; + } else { + $attributes[] = $attribute; + } + } $filteredValue = []; + $relationshipKeys = []; + + if (! empty($relationships)) { + $documentArray = (array) $document; + foreach ($relationships as $relationship) { + /** @var string $key */ + $key = $relationship[Document::ID] ?? ''; + $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); + } } } @@ -9628,161 +2054,297 @@ public function decode(Document $collection, Document $document, array $selectio foreach ($this->getInternalAttributes() as $attribute) { $attributes[] = $attribute; - $internalKeys[$attribute['$id']] = true; + /** @var string $internalKey */ + $internalKey = $attribute[Document::ID] ?? ''; + $internalKeys[$internalKey] = 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'] ?? ''; + /** @var string $key */ + $key = $attribute[Document::ID] ?? ''; + if ($key === Document::PERMISSIONS) { + continue; + } + $array = $attribute['array'] ?? false; + /** @var array $filters */ $filters = $attribute['filters'] ?? []; $value = $document->getAttribute($key); - if ($key === '$permissions') { - continue; - } - // 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. - $document->removeAttribute($filteredKey); - } - } + if ($filteredKey !== $key && $document->offsetExists($filteredKey)) { + $document->removeAttribute($filteredKey); + } + } + + // Skip decoding for Operator objects (shouldn't happen, but safety check) + if ($value instanceof Operator) { + continue; + } + + $value = ($array) ? $value : [$value]; + $value = (is_null($value)) ? [] : $value; + + /** @var array $value */ + $selected = ! $hasSelections + || $selectAll + || ($selectionsMap !== null && isset($selectionsMap[$key])); + + $filterCount = \count($filters); + + if ($filterCount > 0 && ($selected || $hasRelationshipSelections)) { + $filtering ??= $this->filter; + $disabledFilters ??= $this->disabledFilters ?? []; + + 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; + } + } + } + + $resolved = $array ? $value : $value[0]; + $filteredValue[$key] = $resolved; + + if ($selected) { + $document->setAttribute($key, $resolved); + } + } + + if ($hasRelationshipSelections && $selectionsMap !== null) { + foreach ($allAttributes as $attribute) { + /** @var string $key */ + $key = $attribute[Document::ID] ?? ''; + + if (($attribute['type'] ?? '') === $relationshipType || $key === Document::PERMISSIONS) { + continue; + } + + 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->castingAfter($collection, $joined); + $joined = $this->casting($collection, $joined); + $joined = $this->decode($collection, $joined, $keys); - // Skip decoding for Operator objects (shouldn't happen, but safety check) - if ($value instanceof Operator) { - continue; + foreach ($keys as $key) { + $document->setAttribute($alias.'.'.$key, $joined->getAttribute($key)); } + } - $value = ($array) ? $value : [$value]; - $value = (is_null($value)) ? [] : $value; + return $document; + } - $selected = empty($selections) - || \in_array($key, $selections) - || \in_array('*', $selections); + /** + * 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; + } - 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); - } - $value[$index] = $node; - } - } + $encoded = clone $document; + foreach ($rows as $alias => $row) { + $collection = $collections[$alias]; + $keys = \array_map(\strval(...), \array_keys($row)); - $filteredValue[$key] = ($array) ? $value : $value[0]; + $joined = Document::fromRow([...$row, Document::COLLECTION => $collection->getId()]); + $joined = $this->encode($collection, $joined, applyDefaults: false); + $joined = $this->castingBefore($collection, $joined); - if ($selected) { - $document->setAttribute($key, ($array) ? $value : $value[0]); + foreach ($keys as $key) { + $encoded->setAttribute($alias.'.'.$key, $joined->getAttribute($key)); } } - if ($hasRelationshipSelections && !empty($selections) && !\in_array('*', $selections)) { - foreach ($collection->getAttribute('attributes', []) as $attribute) { - $key = $attribute['$id'] ?? ''; + return $encoded; + } - if ($attribute['type'] === self::VAR_RELATIONSHIP || $key === '$permissions') { - continue; - } + /** + * 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 []; + } - if (!in_array($key, $selections) && isset($filteredValue[$key])) { - $document->setAttribute($key, $filteredValue[$key]); - } + $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 $document; + + return \array_filter( + $rows, + static fn (array $row): bool => ! \array_key_exists(Document::ID, $row) || $row[Document::ID] !== null, + ); } /** - * Casting + * Cast document attribute values to their proper PHP types based on the collection schema. * - * @param Document $collection - * @param Document $document - * - * @return Document + * @param Document $collection The collection definition containing attribute type information. + * @param Document $document The document whose attributes will be cast. + * @return Document The document with correctly typed attribute values. */ public function casting(Document $collection, Document $document): Document { - if (!$this->adapter->getSupportForCasting()) { + if (! $this->adapter->supports(Capability::Casting)) { return $document; } + /** @var array> $attributes */ $attributes = $collection->getAttribute('attributes', []); foreach ($this->getInternalAttributes() as $attribute) { $attributes[] = $attribute; } + // Cache the set of types that actually require a cast. Strings, + // datetimes, JSON, etc. all fall through to default in the match + // below, so skipping them entirely avoids the per-attribute + // foreach + setAttribute pair on every document. + $idType = ColumnType::Id->value; + $boolType = ColumnType::Boolean->value; + $intType = ColumnType::Integer->value; + $bigIntType = ColumnType::BigInteger->value; + $floatType = ColumnType::Float->value; + $doubleType = ColumnType::Double->value; + foreach ($attributes as $attribute) { - $key = $attribute['$id'] ?? ''; + /** @var string $key */ + $key = $attribute[Document::ID] ?? ''; + if ($key === Document::PERMISSIONS) { + continue; + } + $type = $attribute['type'] ?? ''; - $signed = $attribute['signed'] ?? true; + $signed = (bool) ($attribute['signed'] ?? true); $array = $attribute['array'] ?? false; - $value = $document->getAttribute($key, null); - if (is_null($value)) { + $normalizedType = $type instanceof ColumnType || \is_string($type) + ? Attribute::tryNormalizeType($type) + : null; + $typeKey = $normalizedType instanceof ColumnType ? $normalizedType->value : ''; + + $needsCast = $array + || $typeKey === $idType + || $typeKey === $boolType + || $typeKey === $intType + || $typeKey === $bigIntType + || $typeKey === $floatType + || $typeKey === $doubleType; + if (! $needsCast) { + // String/datetime/JSON/etc — already in their canonical + // PHP type after PDO fetch. Skip the load/setAttribute + // round trip entirely. continue; } - if ($key === '$permissions') { + $value = $document->getAttribute($key, null); + if (\is_null($value)) { continue; } if ($array) { - $value = !is_string($value) + $value = ! \is_string($value) ? $value - : json_decode($value, true); + : \json_decode($value, true); } else { $value = [$value]; } - 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; - } + /** @var array $value */ + foreach ($value as $index => $node) { + $node = match ($typeKey) { + ColumnType::Id->value => (string) $node, + ColumnType::Boolean->value => (bool) $node, + ColumnType::Integer->value => (int) $node, + ColumnType::BigInteger->value => $this->castBigInteger($node, $signed), + ColumnType::Float->value, + ColumnType::Double->value => (float) $node, + default => $node, + }; - $value[$index] = $node; - } + $value[$index] = $node; } $document->setAttribute($key, ($array) ? $value : $value[0]); @@ -9791,329 +2353,493 @@ public function casting(Document $collection, Document $document): Document return $document; } + private function castBigInteger(mixed $value, bool $signed): mixed + { + if (\is_string($value) && BigInt::fitsPhpInt($value, $signed)) { + return (int) $value; + } + + return $value; + } + /** - * Encode Attribute + * 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; + } + + /** + * Get metadata * - * Passes the attribute $value, and $document context to a predefined filter - * that allow you to manipulate the input format of the given attribute. + * @return array + */ + public function getMetadata(): array + { + return $this->adapter->getMetadata(); + } + + /** + * Clear metadata + */ + public function resetMetadata(): void + { + $this->adapter->resetMetadata(); + } + + /** + * Executes $callback with $timestamp set to $requestTimestamp. + * Scoped to the calling coroutine and the coroutines it starts. * - * @param string $name - * @param mixed $value - * @param Document $document + * @template T * - * @return mixed - * @throws DatabaseException + * @param callable(): T $callback + * @return T */ - protected function encodeAttribute(string $name, mixed $value, Document $document): mixed + public function withRequestTimestamp(?NativeDateTime $requestTimestamp, callable $callback): mixed { - if (!array_key_exists($name, self::$filters) && !array_key_exists($name, $this->instanceFilters)) { - throw new NotFoundException("Filter: {$name} not found"); - } + return $this->requestTimestamp()->with($requestTimestamp, $callback); + } - 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); + /** + * Get getConnection Id + * + * @throws Exception + */ + public function getConnectionId(): string + { + if (! $this->adapter->hasFeature(Feature\ConnectionId::class)) { + throw new DatabaseException('Adapter does not support connection ids'); } - return $value; + return $this->adapter->getConnectionId(); } /** - * 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 + * Ping Database */ - protected function decodeAttribute(string $filter, mixed $value, Document $document, string $attribute): mixed + public function ping(): bool { - if (!$this->filter) { - return $value; - } + return $this->adapter->ping(); + } - if (!\is_null($this->disabledFilters) && isset($this->disabledFilters[$filter])) { - return $value; - } + /** + * Reconnect to the database, re-establishing any dropped connections. + */ + public function reconnect(): void + { + $this->adapter->reconnect(); + } - if (!array_key_exists($filter, self::$filters) && !array_key_exists($filter, $this->instanceFilters)) { - throw new NotFoundException("Filter \"{$filter}\" not found for attribute \"{$attribute}\""); + /** + * Get adapter attribute limit, accounting for internal metadata + * Returns 0 to indicate no limit + */ + public function getLimitForAttributes(): int + { + if ($this->adapter->getLimitForAttributes() === 0) { + return 0; } - if (array_key_exists($filter, $this->instanceFilters)) { - $value = $this->instanceFilters[$filter]['decode']($value, $document, $this); - } else { - $value = self::$filters[$filter]['decode']($value, $document, $this); - } + return $this->adapter->getLimitForAttributes() - $this->adapter->getCountOfDefaultAttributes(); + } - return $value; + /** + * Get adapter index limit + */ + public function getLimitForIndexes(): int + { + return $this->adapter->getLimitForIndexes() - $this->adapter->getCountOfDefaultIndexes(); } /** - * 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::DefinedAttributes) + && $this->adapter->supports(Capability::Objects); - $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)); - $selections[] = '$id'; - $selections[] = '$sequence'; - $selections[] = '$collection'; - $selections[] = '$createdAt'; - $selections[] = '$updatedAt'; - $selections[] = '$permissions'; + continue; + } + + $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 + * Build an `id => Document` map of the collection's attributes plus + * shared internal attribute Documents, and of each joined collection's under + * `alias.id`. Hoisted out so it's computed once per `convertQueries` call + * rather than per query / per attribute. * - * @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; + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + + $attributesById = []; + foreach ($attributes as $attr) { + $attributesById[$attr->getId()] = $attr; + } + foreach (self::internalAttributeDocuments() as $internal) { + $attributesById[$internal->getId()] = $internal; } - return $this->adapter->getLimitForAttributes() - $this->adapter->getCountOfDefaultAttributes(); + foreach ($joinedCollections as $alias => $joined) { + /** @var array $joinedAttributes */ + $joinedAttributes = $joined->getAttribute('attributes', []); + foreach ([...$joinedAttributes, ...self::internalAttributeDocuments()] as $attribute) { + $attributesById[$alias.'.'.$attribute->getId()] ??= $attribute; + } + } + + 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->getValue(''); + if (! $method->isAggregate() || ! \is_string($alias) || $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->convertQueryWithMap($query, $attributesById, $isNestedQueryAttributeSupported); + + $queries[$index] = $query; + } + + return $queries; + } + + /** + * @param Document $collection + * @param Query $query + * @return Query + * + * @throws QueryException + * @throws \Utopia\Database\Exception + */ + public function convertQuery(Document $collection, Query $query): Query + { + $attributesById = $this->buildAttributeMap($collection); + $isNestedQueryAttributeSupported = $this->adapter->supports(Capability::DefinedAttributes) + && $this->adapter->supports(Capability::Objects); + + return $this->convertQueryWithMap($query, $attributesById, $isNestedQueryAttributeSupported); + } + + /** + * @param array $attributesById + * @return Query + * @throws QueryException + * @throws \Utopia\Database\Exception + */ + private function convertQueryWithMap(Query $query, array $attributesById, bool $isNestedQueryAttributeSupported): Query + { + $queryAttribute = $query->getAttribute(); + $isNestedQueryAttribute = $isNestedQueryAttributeSupported && \str_contains($queryAttribute, '.'); + + $attribute = $attributesById[$queryAttribute] ?? null; + + if ($attribute === null && $isNestedQueryAttribute) { + $baseAttribute = \explode('.', $queryAttribute, 2)[0]; + $base = $attributesById[$baseAttribute] ?? null; + if ($base !== null && $base->getAttribute('type') === ColumnType::Object->value) { + $query->setAttributeType(ColumnType::Object->value); + } + } + + if ($attribute !== null) { + /** @var bool $isArray */ + $isArray = $attribute->getAttribute('array', false); + $rawAttrType = $attribute->getAttribute('type'); + $attrType = $rawAttrType instanceof ColumnType || \is_string($rawAttrType) + ? Attribute::normalizeType($rawAttrType)->value + : ''; + $query->setOnArray($isArray); + $query->setAttributeType($attrType); + + if ($attrType == ColumnType::Datetime->value) { + $values = $query->getValues(); + foreach ($values as $valueIndex => $value) { + try { + /** @var string $value */ + $values[$valueIndex] = $this->adapter->hasFeature(Feature\UTCCasting::class) + ? $this->adapter->setUTCDatetime($value) + : DateTime::setTimezone($value); + } catch (Throwable $e) { + throw new QueryException($e->getMessage(), $e->getCode(), $e); + } + } $query->setValues($values); } + } elseif (! $this->adapter->supports(Capability::DefinedAttributes)) { + $values = $query->getValues(); + // setting attribute type to properly apply filters in the adapter level + if ($this->adapter->supports(Capability::Objects) && $this->isCompatibleObjectValue($values)) { + $query->setAttributeType(ColumnType::Object->value); + } + } + + return $query; + } + + /** + * Parent collection definition used for system and custom collections. + * + * @return array + */ + public static function collectionDefinition(): array + { + /** @var array $data */ + $data = (new Collection( + id: self::METADATA, + name: 'collections', + documentSecurity: false, + attributes: [ + Attribute::string(key: 'name', size: 256, required: true), + Attribute::string(key: 'attributes', size: 1_000_000, filters: ['json']), + Attribute::string(key: 'indexes', size: 1_000_000, filters: ['json']), + Attribute::boolean(key: 'documentSecurity', required: true), + ], + ))->getArrayCopy(); + $data[Document::COLLECTION] = self::METADATA; + $data['attributes'] = self::documentArrays($data['attributes'] ?? []); + $data['indexes'] = self::documentArrays($data['indexes'] ?? []); - $query = $this->convertQuery($collection, $query); + return $data; + } - $queries[$index] = $query; + /** + * @return array + */ + protected static function collectionMeta(): array + { + $collection = self::collectionDefinition(); + $attributes = []; + foreach (self::documentArrays($collection['attributes'] ?? []) as $attribute) { + $attributes[] = Attribute::fromArray($attribute); } + $collection['attributes'] = $attributes; - return $queries; + return $collection; } /** - * @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 + * @return list> */ - private function isCompatibleObjectValue(array $values): bool + private static function documentArrays(mixed $values): array { - if (empty($values)) { - return false; + if (! \is_array($values)) { + return []; } + $arrays = []; foreach ($values as $value) { - if (!\is_array($value)) { - return false; + if ($value instanceof Attribute || $value instanceof Index) { + $arrays[] = $value->getArrayCopy(); + continue; } - - // Check associative array (hashmap) or nested structure - if (empty($value)) { + if ($value instanceof Document) { + $arrays[] = $value->getArrayCopy(); continue; } - - // simple indexed array => not an object - if (\array_keys($value) === \range(0, \count($value) - 1)) { - return false; + if (! \is_array($value)) { + continue; } - foreach ($value as $nestedValue) { - if (\is_array($nestedValue)) { - continue; + $typed = []; + foreach ($value as $key => $item) { + if (\is_string($key)) { + $typed[$key] = $item; } } + $arrays[] = $typed; } - return true; + return $arrays; } - public function convertQuery(Document $collection, Query $query): Query + /** + * Get the list of internal attribute definitions (e.g., $id, $createdAt, $permissions) as typed Attribute objects. + * + * @return array + */ + public static function internalAttributes(): array { - /** - * @var array $attributes - */ - $attributes = $collection->getAttribute('attributes', []); - - foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { - $attributes[] = new Document($attribute); - } - - $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 (self::$internalAttributes === null) { + self::$internalAttributes = \array_map( + fn (array $attr): Attribute => Attribute::fromArray($attr), + self::INTERNAL_ATTRIBUTES + ); } - if (!$attribute->isEmpty()) { - $query->setOnArray($attribute->getAttribute('array', false)); - $query->setAttributeType($attribute->getAttribute('type')); + return self::$internalAttributes; + } - if ($attribute->getAttribute('type') == Database::VAR_DATETIME) { - $values = $query->getValues(); - foreach ($values as $valueIndex => $value) { - try { - $values[$valueIndex] = $this->adapter->getSupportForUTCCasting() - ? $this->adapter->setUTCDatetime($value) - : DateTime::setTimezone($value); - } catch (\Throwable $e) { - throw new QueryException($e->getMessage(), $e->getCode(), $e); - } - } - $query->setValues($values); - } - } elseif (!$this->adapter->getSupportForAttributes()) { - $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); + /** + * Get the internal attribute definitions as Document instances. Reuses a + * process-lifetime cache to avoid re-allocating these on every read. + * + * Returned Documents MUST NOT be mutated by callers — they are shared singletons. + * + * @return array + */ + private static function internalAttributeDocuments(): array + { + if (self::$internalAttributeDocuments === null) { + self::$internalAttributeDocuments = []; + foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { + self::$internalAttributeDocuments[] = new Document($attribute); } } - return $query; + return self::$internalAttributeDocuments; } /** - * @return array> + * Get the internal attribute definitions for the current adapter, excluding tenant if shared tables are disabled. + * + * @return array> The internal attribute configurations. */ public function getInternalAttributes(): array { - if ($this->adapter->getSharedTables()) { - return self::INTERNAL_ATTRIBUTES; + if (self::$internalAttributeArrays === null) { + $withTenant = self::INTERNAL_ATTRIBUTES; + $withoutTenant = \array_values(\array_filter( + self::INTERNAL_ATTRIBUTES, + static fn (array $attribute): bool => $attribute[Document::ID] !== Document::TENANT, + )); + self::$internalAttributeArrays = [ + 0 => $withoutTenant, + 1 => $withTenant, + ]; } - return self::$tenantlessInternalAttributes ??= \array_values(\array_filter( - self::INTERNAL_ATTRIBUTES, - fn (array $attribute): bool => $attribute['$id'] !== '$tenant', - )); + return self::$internalAttributeArrays[$this->adapter->getSharedTables() ? 1 : 0]; } /** * Get Schema Attributes * - * @param string $collection * @return array + * * @throws DatabaseException */ public function getSchemaAttributes(string $collection): array { + if (! $this->adapter->hasFeature(Feature\SchemaAttributes::class)) { + return []; + } + return $this->adapter->getSchemaAttributes($collection); } /** - * @param string $collection + * Get the physical schema indexes for a collection from the database engine. + * + * @param string $collection The collection identifier. * @return array */ public function getSchemaIndexes(string $collection): array { + if (! $this->adapter->hasFeature(Feature\SchemaIndexes::class)) { + 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()) { + if ($this->adapter->supports(Capability::Hostname)) { $hostname = $this->adapter->getHostname(); } @@ -10122,15 +2848,17 @@ public function getCacheBaseKeys(string $collectionId, ?string $documentId = nul if ( $collectionId === self::METADATA && $this->adapter->getSharedTables() && + $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 @@ -10140,9 +2868,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 @@ -10153,64 +2879,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() + $hostname = $this->adapter->supports(Capability::Hostname) ? $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->adapter->getDatabase(), $namespace ?? $this->getNamespace(), $this->adapter->getTenant(), $collectionId, ); } + protected function getQueryCacheScope(?string $namespace = null): Scope + { + return new Scope( + hostname: $this->adapter->supports(Capability::Hostname) ? $this->adapter->getHostname() : '', + database: $this->adapter->getDatabase(), + namespace: $namespace ?? $this->adapter->getNamespace(), + tenant: $this->adapter->getTenant(), + ); + } + /** * 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 + * @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); @@ -10225,18 +2962,17 @@ 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. + if ($collection !== null && ! $collection->isEmpty()) { $schemaHash = \md5( - \json_encode($collection->getAttribute('attributes', [])) - . \json_encode($collection->getAttribute('indexes', [])) - . \json_encode($collection->getAttribute('$permissions', [])) - . \json_encode($collection->getAttribute('documentSecurity', false)) + (\json_encode($collection->getAttribute('attributes', [])) ?: '') + .(\json_encode($collection->getAttribute('indexes', [])) ?: '') + .(\json_encode($collection->getAttribute(Document::PERMISSIONS, [])) ?: '') + .(\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') ); } @@ -10254,7 +2990,7 @@ public function getQueryCacheField( private function serializeQueryCacheQuery(Query $query): array { $serialized = [ - 'method' => $query->getMethod(), + 'method' => $query->getMethod()->value, ]; if ($query->getAttribute() !== '') { @@ -10276,741 +3012,366 @@ private function serializeQueryCacheQuery(Query $query): array return $serialized; } - private function normalizeQueryCacheQueryValue(mixed $value): mixed - { - if ($value instanceof Document) { - $value = $value->getArrayCopy(); - } - - if (!\is_array($value)) { - return $value; - } - - foreach ($value as $key => $item) { - $value[$key] = $this->normalizeQueryCacheQueryValue($item); - } - - return $value; - } - - /** - * @return array - */ - private function getActiveFilterSignatures(): array - { - if (!$this->filter) { - 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']; - } - - \ksort($signatures); - - $this->filterSignatures = $signatures; - $this->filterSignaturesEncoded = \json_encode($signatures) ?: ''; - $this->filterSignaturesVersion = self::$filtersVersion; - $this->filterSignaturesSource = $this->instanceFilters; - } - - private function getFilterSignatureKey(): string - { - if (!$this->filter) { - return ''; - } - - if ($this->disabledFilters) { - return \json_encode($this->getActiveFilterSignatures()) ?: ''; - } - - $this->refreshFilterSignatures(); - - return $this->filterSignaturesEncoded; - } - - private static function computeCallableSignature(callable $callable): string - { - 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(); - } - - /** - * @param array $queries - * @return void - * @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()); - } - } - } - - /** - * Process relationship queries, extracting nested selections. - * - * @param array $relationships - * @param array $queries - * @return array> $selects - */ - private function processRelationshipQueries( - array $relationships, - array $queries, - ): array { - $nestedSelections = []; - - foreach ($queries as $query) { - if ($query->getMethod() !== Query::TYPE_SELECT) { - 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 = ['*']; - } - } - $query->setValues($finalValues); - } - - return $nestedSelections; - } - - /** - * 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. - * - * 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] - * - * @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 - */ - private function processNestedRelationshipPath(string $startCollection, array $queries): ?array - { - // 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; - } - } + private function normalizeQueryCacheQueryValue(mixed $value): mixed + { + if ($value instanceof Document) { + $value = $value->getArrayCopy(); + } - $allMatchingIds = \array_merge($allMatchingIds, $matchingIds); + if (! \is_array($value)) { + return $value; + } + + foreach ($value as $key => $item) { + $value[$key] = $this->normalizeQueryCacheQueryValue($item); } - return \array_unique($allMatchingIds); + 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; } /** - * 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 - * - * 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 + * @return array */ - 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; - } + private function getActiveFilterSignatures(): array + { + if (! $this->filter) { + return []; } - if (!$hasRelationshipQuery) { - return $queries; + $signatures = []; + + foreach (self::$filters as $name => $callbacks) { + $signatures[$name] = $callbacks['signature']; } - $relationshipsByKey = []; - foreach ($relationships as $relationship) { - $relationshipsByKey[$relationship->getAttribute('key')] = $relationship; + foreach ($this->typeRegistry?->all() ?? [] as $name => $type) { + $signatures[$name] = $type::class; } - $additionalQueries = []; - $groupedQueries = []; - $indicesToRemove = []; + foreach ($this->instanceFilters as $name => $callbacks) { + $signatures[$name] = $callbacks['signature']; + } - // Handle containsAll queries first - foreach ($queries as $index => $query) { - if ($query->getMethod() !== Query::TYPE_CONTAINS_ALL) { - continue; - } + $signatures = \array_diff_key($signatures, $this->disabledFilters ?? []); + \ksort($signatures); - $attribute = $query->getAttribute(); + return $signatures; + } - if (!\str_contains($attribute, '.')) { - continue; // Non-relationship containsAll handled by adapter - } + /** + * Fire an event to mandatory cache invalidation and registered lifecycle hooks. + * Mandatory invalidation is never silenced and failures are propagated. + */ + protected function trigger(Event $event, mixed $data = null): void + { + $this->invalidate($event, $data); + $this->triggerHooks($event, $data); + } - $parts = \explode('.', $attribute); - $relationshipKey = \array_shift($parts); - $nestedAttribute = \implode('.', $parts); - $relationship = $relationshipsByKey[$relationshipKey] ?? null; + /** + * Run mandatory cache invalidation for a lifecycle event. + */ + protected function invalidate(Event $event, mixed $data = null): void + { + $invalidator = $this->queryCacheInvalidator; + if ($invalidator === null || ! $invalidator->isMutation($event)) { + return; + } - if (!$relationship) { - continue; - } + $tokens = $this->getInvalidationTokens($event, $data); + $invalidator->block($tokens); + $invalidator->activate($tokens); + } - // 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); + /** + * @return array + */ + protected function getInvalidationTokens(Event $event, mixed $data = null): array + { + return $this->queryCacheInvalidator?->tokens( + $event, + $data, + $this->getQueryCacheScope(), + $this->adapter->getSharedTables() && $this->adapter->getTenantPerDocument(), + ) ?? []; + } - if ($result === null) { - return null; - } + /** + * @param array $tokens + */ + protected function blockInvalidation(array $tokens): void + { + $this->queryCacheInvalidator?->block($tokens); + } - $resolvedAttribute = $result['attribute']; - $parentIdSets[] = $result['ids']; - } + /** + * @param array $tokens + */ + protected function activateInvalidation(array $tokens): void + { + $this->queryCacheInvalidator?->activate($tokens); + } - $ids = \count($parentIdSets) > 1 - ? \array_values(\array_intersect(...$parentIdSets)) - : ($parentIdSets[0] ?? []); + /** + * Fire suppressible user lifecycle hooks after mandatory invalidation succeeds. + * + * Whether a hook's exception reaches the caller depends on the event + * ({@see propagatesHookFailures()}); an \Error always does. + */ + protected function triggerHooks(Event $event, mixed $data = null): void + { + $propagates = $this->propagatesHookFailures($event); - if (empty($ids)) { - return null; - } + foreach ($this->getActiveLifecycleHooks($event) as $hook) { + try { + $hook->handle($event, $data); + } catch (Exception $exception) { + if ($propagates) { + throw $exception; + } + } + } + } + + /** + * Fire suppressible user lifecycle hooks and let the first hook exception reach the + * caller whatever the event's default. Document writes and purgeCachedDocument() fire + * Event::DocumentPurge through it; the schema changes that purge a collection fire it + * through triggerHooks(), isolated. + */ + protected function triggerPropagatingHooks(Event $event, mixed $data = null): void + { + foreach ($this->getActiveLifecycleHooks($event) as $hook) { + $hook->handle($event, $data); + } + } + + /** + * 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::DocumentRead, + Event::DocumentCreate, + Event::DocumentsCreate, + Event::DocumentUpdate, + Event::DocumentsUpdate, + Event::DocumentsUpsert, + Event::DocumentIncrease, + Event::DocumentDecrease, + Event::DocumentDelete, + Event::DocumentsDelete, + Event::DocumentFind, + Event::DocumentCount, + Event::DocumentSum => true, + Event::All, + Event::DatabaseList, + Event::DatabaseCreate, + 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::AttributeDelete, + Event::IndexRename, + Event::IndexDelete => false, + }; + } - $additionalQueries[] = Query::equal($resolvedAttribute, $ids); - $indicesToRemove[] = $index; + /** + * @return array + */ + private function getActiveLifecycleHooks(Event $event): array + { + if ($this->lifecycleHooks === [] || $this->areEventsSilenced()) { + return []; } - // 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, '.')) { + $silenced = $this->silencedListeners()->get(); + $active = []; + foreach ($this->lifecycleHooks as $hook) { + if ($hook instanceof Named && isset($silenced[$hook->getName()])) { continue; } - - $parts = \explode('.', $attribute); - $relationshipKey = \array_shift($parts); - $nestedAttribute = \implode('.', $parts); - $relationship = $relationshipsByKey[$relationshipKey] ?? null; - - if (!$relationship) { + if ($hook instanceof Selective && ! $hook->handles($event)) { continue; } + $active[] = $hook; + } - if (!isset($groupedQueries[$relationshipKey])) { - $groupedQueries[$relationshipKey] = [ - 'relationship' => $relationship, - 'queries' => [], - 'indices' => [] - ]; - } + return $active; + } - $groupedQueries[$relationshipKey]['queries'][] = [ - 'method' => $query->getMethod(), - 'attribute' => $nestedAttribute, - 'values' => $query->getValues() - ]; + /** + * 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. + * + * @param string $collection Collection ID + * @param array $data Document data + */ + protected function createDocumentInstance(string $collection, array $data): Document + { + $className = $this->documentTypes[$collection] ?? null; + if ($className === null) { + return Document::fromStorage($data); + } - $groupedQueries[$relationshipKey]['indices'][] = $index; + try { + return $className::fromArray($data); + } catch (StructureException) { + return $className::fromArray(self::withStringPermissions($data)); } + } - // 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; - } - } + /** + * 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(...))); + } - $relatedQueries = []; - foreach ($group['queries'] as $queryData) { - $relatedQueries[] = new Query( - $queryData['method'], - $queryData['attribute'], - $queryData['values'] - ); + foreach ($data as $key => $value) { + if (! \is_array($value)) { + continue; } - try { - $result = $this->resolveRelationshipGroupToIds($relationship, $relatedQueries, $collection); - - if ($result === null) { - return null; - } + if (isset($value[Document::ID]) || isset($value[Document::COLLECTION])) { + /** @var array $value */ + $data[$key] = self::withStringPermissions($value); - $additionalQueries[] = Query::equal($result['attribute'], $result['ids']); + continue; + } - foreach ($group['indices'] as $originalIndex) { - $indicesToRemove[] = $originalIndex; + 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); } - } catch (QueryException $e) { - throw $e; - } catch (\Exception $e) { - return null; } + $data[$key] = $value; } - // Remove the original queries - foreach ($indicesToRemove as $index) { - unset($queries[$index]); - } - - // Merge additional queries - return \array_merge(\array_values($queries), $additionalQueries); + return $data; } /** - * Resolve a group of relationship queries to matching document IDs. + * Encode Attribute * - * @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 - */ - 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; - } - } - - if ($hasNestedPaths) { - $matchingIds = $this->processNestedRelationshipPath( - $relatedCollection, - $relatedQueries - ); - - if ($matchingIds === null || empty($matchingIds)) { - return null; + * Passes the attribute $value, and $document context to a predefined filter + * that allow you to manipulate the input format of the given attribute. + * + * + * @throws DatabaseException + */ + protected function encodeAttribute(string $name, mixed $value, Document $document): mixed + { + try { + if (\array_key_exists($name, $this->instanceFilters)) { + return $this->instanceFilters[$name]['encode']($value, $document, $this); } - $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; + $type = $this->typeRegistry?->get($name); + if ($type !== null) { + return $type->encode($value); } - $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; - } + if (\array_key_exists($name, self::$filters)) { + return self::$filters[$name]['encode']($value, $document, $this); } + } catch (Throwable $th) { + throw new DatabaseException($th->getMessage(), $th->getCode(), $th); + } - 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 = []; + throw new NotFoundException("Filter: {$name} not found"); + } - foreach ($matchingDocs as $doc) { - $parentId = $doc->getAttribute($twoWayKey); + /** + * 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. + * + * @throws NotFoundException + */ + protected function decodeAttribute(string $filter, mixed $value, Document $document, string $attribute): mixed + { + if (\array_key_exists($filter, $this->instanceFilters)) { + return $this->instanceFilters[$filter]['decode']($value, $document, $this, $attribute); + } - 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; - } - } - } + $type = $this->typeRegistry?->get($filter); + if ($type !== null) { + return $type->decode($value); + } - 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]; + if (\array_key_exists($filter, self::$filters)) { + return self::$filters[$filter]['decode']($value, $document, $this, $attribute); } + + throw new NotFoundException("Filter \"{$filter}\" not found for attribute \"{$attribute}\""); } /** * 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)) { + $validator = new SpatialValidator($type); + if (! $validator->isValid($value)) { throw new StructureException($validator->getDescription()); } + /** @var array|array>> $value */ switch ($type) { - case self::VAR_POINT: + case ColumnType::Point->value: + /** @var array{0: float|int, 1: float|int} $value */ return "POINT({$value[0]} {$value[1]})"; - case self::VAR_LINESTRING: + case ColumnType::Linestring->value: $points = []; + /** @var array $value */ foreach ($value as $point) { $points[] = "{$point[0]} {$point[1]}"; } - return 'LINESTRING(' . implode(', ', $points) . ')'; - case self::VAR_POLYGON: + return 'LINESTRING('.implode(', ', $points).')'; + + case ColumnType::Polygon->value: + /** @var array $value */ // 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]); @@ -11021,29 +3382,68 @@ protected function encodeSpatialData(mixed $value, string $type): string } $rings = []; + /** @var array> $value */ foreach ($value as $ring) { $points = []; foreach ($ring as $point) { $points[] = "{$point[0]} {$point[1]}"; } - $rings[] = '(' . implode(', ', $points) . ')'; + $rings[] = '('.implode(', ', $points).')'; } - return 'POLYGON(' . implode(', ', $rings) . ')'; + + return 'POLYGON('.implode(', ', $rings).')'; default: - throw new DatabaseException('Unknown spatial type: ' . $type); + throw new DatabaseException('Unknown spatial type: '.$type); + } + } + + /** + * Check if values are compatible with object attribute type (hashmap/multi-dimensional array) + * + * @param array $values + */ + private function isCompatibleObjectValue(array $values): bool + { + 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; + } + } } + + 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 + * @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, @@ -11053,13 +3453,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++; @@ -11073,21 +3478,36 @@ private function withRetries( \usleep($delayMs * 1000); } - $delayMs = (int)($delayMs * $multiplier); + $delayMs = (int) ($delayMs * $multiplier); } } throw $lastException; } + private function isRetryable(Throwable $error): bool + { + if ($this->failedAfterCommit($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 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 + * * @throws DatabaseException If cleanup fails after all retries */ private function cleanup( @@ -11098,34 +3518,12 @@ 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 * @@ -11134,13 +3532,16 @@ private function cleanupIndex( * 2. Rolling back database operations if metadata persistence fails * 3. Providing detailed error messages for both success and failure scenarios * - * @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 + * A failure raised after the metadata write committed (its cache invalidation or events) is + * rethrown unchanged and rolls nothing back: the definition it reports on is stored. + * + * @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 a failed rollback is reported after the persistence error (true) or fails the call as a cleanup failure (false) + * * @throws DatabaseException If metadata persistence fails after all retries */ private function updateMetadata( @@ -11157,32 +3558,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->failedAfterCommit($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 ); } @@ -11190,26 +3593,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..a131a68f4c 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -7,51 +7,95 @@ 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'; + + public const string SKIP_PERMISSIONS_UPDATE = '$skipPermissionsUpdate'; + + public const string INTERNAL_ID = '$internalId'; + + /** @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::internalAttributes() as $attr) { + self::$internalKeySet[$attr->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,76 +110,216 @@ public function __construct(array $input = []) } /** - * @return string + * @param array $data + */ + public static function fromArray(array $data): self + { + $class = static::class; + + return new $class($data); + } + + /** + * Construct from a raw PDO row. + * + * Fast path that skips nested-Document detection. Raw PDO rows from + * `$stmt->fetch()` only contain scalars or JSON-encoded strings — there + * are never nested arrays carrying `$id`/`$collection`, so the + * nested-detection foreach in the constructor is pure waste per row. + * + * Callers that build documents from relationship-resolved trees or + * arbitrary user input must continue to use the regular constructor. + * + * @param array $row + * + * @throws DatabaseException + */ + public static function fromRow(array $row): self + { + foreach (\array_keys($row) as $key) { + if (\is_int($key)) { + unset($row[$key]); + } + } + + 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'); + } + } + + 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; + } + + /** + * Construct from a document read back from storage. + * + * Builds nested documents like the constructor, but drops non-string permissions instead of + * rejecting them, as fromRow() does. + * + * @param array $data + * + * @throws StructureException When $id is not a string or $permissions is not an array + */ + public static function fromStorage(array $data): self + { + 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; + } + + /** + * Get the document's unique identifier. + * + * @return string The document ID, or empty string if not set. */ public function getId(): string { - return $this->getAttribute('$id', ''); + /** @var string $id */ + $id = $this->getAttribute(self::ID, ''); + return $id; } /** - * @return string|null + * Get the document's auto-generated sequence identifier. + * + * @return string|null The sequence value, or null if not set. */ public function getSequence(): ?string { - $sequence = $this->getAttribute('$sequence'); + $sequence = $this->getAttribute(self::SEQUENCE); if ($sequence === null) { return null; } + /** @var string $sequence */ return $sequence; } /** - * @return string + * Get the collection ID this document belongs to. + * + * @return string The collection ID, or empty string if not set. */ public function getCollection(): string { - return $this->getAttribute('$collection', ''); + /** @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 getPermissions(): array { - return \array_values(\array_unique($this->getAttribute('$permissions', []))); + return $this->parsePermissions()['permissions']; } /** + * Get roles with read permission on this document. + * * @return array */ public function getRead(): array { - return $this->getPermissionsByType(Database::PERMISSION_READ); + return $this->getPermissionsByType(PermissionType::Read); } /** + * Get roles with create permission on this document. + * * @return array */ public function getCreate(): array { - return $this->getPermissionsByType(Database::PERMISSION_CREATE); + return $this->getPermissionsByType(PermissionType::Create); } /** + * Get roles with update permission on this document. + * * @return array */ public function getUpdate(): array { - return $this->getPermissionsByType(Database::PERMISSION_UPDATE); + return $this->getPermissionsByType(PermissionType::Update); } /** + * Get roles with delete permission on this document. + * * @return array */ public function getDelete(): array { - return $this->getPermissionsByType(Database::PERMISSION_DELETE); + return $this->getPermissionsByType(PermissionType::Delete); } /** + * Get roles with full write permission (create, update, and delete) on this document. + * * @return array */ public function getWrite(): array @@ -148,50 +332,119 @@ public function getWrite(): array } /** - * @return array + * Get roles for a specific permission type from this document's permissions. + * + * @param PermissionType $type The permission type. + * @return list + * + * @throws StructureException When the stored permissions are not an array of strings */ - public function getPermissionsByType(string $type): array + public function getPermissionsByType(PermissionType $type): array { - $typePermissions = []; + return $this->parsePermissions()['roles'][$type->value] ?? []; + } - foreach ($this->getPermissions() as $permission) { - if (!\str_starts_with($permission, $type)) { - continue; + /** + * @return array{source: array, permissions: list, roles: array>} + * + * @throws StructureException + */ + private function parsePermissions(): array + { + $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) { + foreach (PermissionType::cases() as $type) { + if (\str_starts_with($permission, $type->value)) { + $roles[$type->value][] = \str_replace([$type->value.'(', ')', '"', ' '], '', $permission); + break; + } } - $typePermissions[] = \str_replace([$type . '(', ')', '"', ' '], '', $permission); } - return \array_unique($typePermissions); + return $this->parsedPermissions = [ + 'source' => $source, + 'permissions' => $permissions, + 'roles' => \array_map(static fn (array $names): array => \array_values(\array_unique($names)), $roles), + ]; } /** - * @return string|null + * @return list + * + * @phpstan-assert array $permissions + * + * @throws StructureException + */ + private static function normalizePermissions(mixed $permissions): array + { + if (! \is_array($permissions)) { + throw new StructureException(self::PERMISSIONS.' must be of type array'); + } + + $strings = []; + foreach ($permissions as $permission) { + if (! \is_string($permission)) { + throw new StructureException('Every permission must be of type string'); + } + $strings[] = $permission; + } + + return \array_values(\array_unique($strings)); + } + + /** + * 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 +455,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 +472,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 +482,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,45 +582,41 @@ 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 + * @param mixed $find */ public function find(string $key, $find, string $subject = ''): mixed { - $subject = $this[$subject] ?? null; - $subject = (empty($subject)) ? $this : $subject; + /** @var array|self $resolved */ + $resolved = $this->resolveSubject($subject); - if (is_array($subject)) { - foreach ($subject as $i => $value) { - if (isset($value[$key]) && $value[$key] === $find) { + if (is_array($resolved)) { + foreach ($resolved as $i => $value) { + if (\is_array($value) && isset($value[$key]) && $value[$key] === $find) { + return $value; + } + if ($value instanceof self && isset($value[$key]) && $value[$key] === $find) { return $value; } } + return false; } - if (isset($subject[$key]) && $subject[$key] === $find) { - return $subject; + if (isset($resolved[$key]) && $resolved[$key] === $find) { + return $resolved; } + return false; } @@ -334,33 +625,36 @@ public function find(string $key, $find, string $subject = ''): mixed * * Get array child by key and value match * - * @param string $key - * @param mixed $find - * @param mixed $replace - * @param string $subject - * - * @return bool + * @param mixed $find + * @param mixed $replace */ public function findAndReplace(string $key, $find, $replace, string $subject = ''): bool { - $subject = &$this[$subject] ?? null; - $subject = (empty($subject)) ? $this : $subject; + $target = $this->resolveSubject($subject); - if (is_array($subject)) { - foreach ($subject as $i => &$value) { - if (isset($value[$key]) && $value[$key] === $find) { + if (\is_array($target)) { + /** @var array $subjectArray */ + $subjectArray = &$this[$subject]; + foreach ($subjectArray as $i => &$value) { + if (\is_array($value) && isset($value[$key]) && $value[$key] === $find) { $value = $replace; return true; } + if ($value instanceof self && isset($value[$key]) && $value[$key] === $find) { + $subjectArray[$i] = $replace; + return true; + } } return false; } - if (isset($subject[$key]) && $subject[$key] === $find) { - $subject[$key] = $replace; - return true; + if (! $target instanceof self || ! isset($target[$key]) || $target[$key] !== $find) { + return false; } - return false; + + $target[$key] = $replace; + + return true; } /** @@ -368,50 +662,54 @@ public function findAndReplace(string $key, $find, $replace, string $subject = ' * * Get array child by key and value match * - * @param string $key - * @param mixed $find - * @param string $subject - * - * @return bool + * @param mixed $find */ public function findAndRemove(string $key, $find, string $subject = ''): 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) { - unset($subject[$i]); + $target = $this->resolveSubject($subject); + + if (\is_array($target)) { + /** @var array $subjectArray */ + $subjectArray = &$this[$subject]; + foreach ($subjectArray as $i => &$value) { + if (\is_array($value) && isset($value[$key]) && $value[$key] === $find) { + unset($subjectArray[$i]); + return true; + } + if ($value instanceof self && isset($value[$key]) && $value[$key] === $find) { + unset($subjectArray[$i]); return true; } } return false; } - if (isset($subject[$key]) && $subject[$key] === $find) { - unset($subject[$key]); - return true; + if (! $target instanceof self || ! isset($target[$key]) || $target[$key] !== $find) { + return false; } - return false; + + unset($target[$key]); + + return true; + } + + private function resolveSubject(string $subject): mixed + { + $value = $subject !== '' ? ($this[$subject] ?? null) : null; + + return empty($value) ? $this : $value; } /** * Checks if document has data. - * - * @return bool */ public function isEmpty(): bool { - return !\count($this); + return ! \count($this); } /** * Checks if a document key is set. - * - * @param string $key - * - * @return bool */ public function isSet(string $key): bool { @@ -423,9 +721,8 @@ public function isSet(string $key): bool * * Outputs entity as a PHP array * - * @param array $allow - * @param array $disallow - * + * @param array $allow + * @param array $disallow * @return array */ public function getArrayCopy(array $allow = [], array $disallow = []): array @@ -435,23 +732,21 @@ public function getArrayCopy(array $allow = [], array $disallow = []): array $output = []; foreach ($array as $key => $value) { - if (!empty($allow) && !\in_array($key, $allow)) { // Export only allow fields + if (! empty($allow) && ! \in_array($key, $allow)) { // Export only allow fields continue; } - if (!empty($disallow) && \in_array($key, $disallow)) { // Don't export disallowed fields + if (! empty($disallow) && \in_array($key, $disallow)) { // Don't export disallowed fields continue; } if ($value instanceof self) { $output[$key] = $value->getArrayCopy($allow, $disallow); } elseif (\is_array($value)) { - $value = \array_map( + $output[$key] = \array_map( fn ($item) => $item instanceof self ? $item->getArrayCopy($allow, $disallow) : $item, $value ); - - $output[$key] = $value; } else { $output[$key] = $value; } @@ -460,6 +755,9 @@ 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) { diff --git a/src/Database/Event.php b/src/Database/Event.php new file mode 100644 index 0000000000..2c8605fa54 --- /dev/null +++ b/src/Database/Event.php @@ -0,0 +1,43 @@ +> */ + private array $listeners = []; + + private ?object $psr14Dispatcher; + + public function __construct(?object $psr14Dispatcher = null) + { + $this->psr14Dispatcher = $psr14Dispatcher; + } + + public function on(string $eventClass, callable $listener): void + { + $this->listeners[$eventClass][] = $listener; + } + + public function handles(Event $event): bool + { + $class = match ($event) { + Event::DocumentCreate => DocumentCreated::class, + Event::DocumentUpdate => DocumentUpdated::class, + Event::DocumentDelete => DocumentDeleted::class, + Event::DocumentsCreate => DocumentsCreated::class, + Event::DocumentsUpdate => DocumentsUpdated::class, + Event::DocumentsDelete => DocumentsDeleted::class, + Event::CollectionCreate => CollectionCreated::class, + Event::CollectionDelete => CollectionDeleted::class, + default => null, + }; + + return $class !== null + && (isset($this->listeners[$class]) || ($this->psr14Dispatcher !== null && \method_exists($this->psr14Dispatcher, 'dispatch'))); + } + + public function handle(Event $event, mixed $data): void + { + $domainEvent = $this->createDomainEvent($event, $data); + + if ($domainEvent === null) { + return; + } + + $failure = null; + + foreach ($this->listeners[$domainEvent::class] ?? [] as $listener) { + try { + $listener($domainEvent); + } catch (Exception $exception) { + $failure ??= $exception; + } + } + + if ($this->psr14Dispatcher !== null && \method_exists($this->psr14Dispatcher, 'dispatch')) { + try { + $this->psr14Dispatcher->dispatch($domainEvent); + } catch (Exception $exception) { + $failure ??= $exception; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + private function createDomainEvent(Event $event, mixed $data): ?Domain + { + return match ($event) { + Event::DocumentCreate => $data instanceof Document + ? new DocumentCreated($data->getCollection(), $data) + : null, + Event::DocumentUpdate => $data instanceof Document + ? new DocumentUpdated($data->getCollection(), $data) + : null, + Event::DocumentDelete => $data instanceof Document + ? new DocumentDeleted($data->getCollection(), $data->getId()) + : ($data instanceof \stdClass + && \is_string($data->collection ?? null) + && \is_string($data->id ?? null) + ? new DocumentDeleted($data->collection, $data->id) + : null), + Event::DocumentsCreate => $this->createBulkEvent(DocumentsCreated::class, $data), + Event::DocumentsUpdate => $this->createBulkEvent(DocumentsUpdated::class, $data), + Event::DocumentsDelete => $this->createBulkEvent(DocumentsDeleted::class, $data), + Event::CollectionCreate => $data instanceof Document + ? new CollectionCreated($data->getId(), $data) + : null, + Event::CollectionDelete => match (true) { + $data instanceof Document => new CollectionDeleted($data->getId()), + \is_string($data) => new CollectionDeleted($data), + default => null, + }, + default => null, + }; + } + + /** + * @param class-string $class + */ + private function createBulkEvent(string $class, mixed $data): ?Domain + { + if (! $data instanceof Document) { + return null; + } + + $count = $data->getAttribute('modified'); + + return \is_int($count) ? new $class($data->getCollection(), $count) : null; + } +} diff --git a/src/Database/Event/Document/Created.php b/src/Database/Event/Document/Created.php new file mode 100644 index 0000000000..d573f0ce08 --- /dev/null +++ b/src/Database/Event/Document/Created.php @@ -0,0 +1,17 @@ +occurredAt = $occurredAt ?? new \DateTimeImmutable(); + } +} diff --git a/src/Database/Exception.php b/src/Database/Exception.php index d86e94c2bb..f9bd10a9ff 100644 --- a/src/Database/Exception.php +++ b/src/Database/Exception.php @@ -2,10 +2,19 @@ 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 { + /** + * @param string $message The exception message + * @param int|string $code The exception code (strings are cast to int) + * @param Throwable|null $previous The previous throwable for chaining + */ public function __construct(string $message, int|string $code = 0, ?Throwable $previous = null) { if (\is_string($code)) { 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..e308ca36d6 100644 --- a/src/Database/Exception/Character.php +++ b/src/Database/Exception/Character.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a value contains invalid or unsupported characters. + */ class Character extends Exception { } 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; parent::__construct($message, $code, $previous); } + + /** + * Get the attribute that caused the ordering error. + * + * @return string|null + */ 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/Relationship.php b/src/Database/Exception/Relationship.php index bcb296579c..ff831e50a4 100644 --- a/src/Database/Exception/Relationship.php +++ b/src/Database/Exception/Relationship.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a relationship operation fails or a relationship constraint is violated. + */ class Relationship extends Exception { } diff --git a/src/Database/Exception/Restricted.php b/src/Database/Exception/Restricted.php index 1ef9fefd72..b6c23d127e 100644 --- a/src/Database/Exception/Restricted.php +++ b/src/Database/Exception/Restricted.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when an operation is restricted due to a relationship constraint (e.g. restrict on delete). + */ class Restricted extends Exception { } diff --git a/src/Database/Exception/Structure.php b/src/Database/Exception/Structure.php index 26e9ce1fdb..47901cf2ae 100644 --- a/src/Database/Exception/Structure.php +++ b/src/Database/Exception/Structure.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a document does not conform to its collection's structure requirements. + */ class Structure extends Exception { } diff --git a/src/Database/Exception/Timeout.php b/src/Database/Exception/Timeout.php index 613e74e55d..3079baa53c 100644 --- a/src/Database/Exception/Timeout.php +++ b/src/Database/Exception/Timeout.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a database operation exceeds the configured timeout duration. + */ class Timeout extends Exception { } diff --git a/src/Database/Exception/Transaction.php b/src/Database/Exception/Transaction.php index 3a3ddf0afa..2abe9ebfb5 100644 --- a/src/Database/Exception/Transaction.php +++ b/src/Database/Exception/Transaction.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a database transaction fails to begin, commit, or rollback. + */ class Transaction extends Exception { } diff --git a/src/Database/Exception/Truncate.php b/src/Database/Exception/Truncate.php index 9bd0ffb129..d567876f7c 100644 --- a/src/Database/Exception/Truncate.php +++ b/src/Database/Exception/Truncate.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a value exceeds the maximum allowed length and would be truncated. + */ class Truncate extends Exception { } diff --git a/src/Database/Exception/Type.php b/src/Database/Exception/Type.php index 045ec5af9e..28226a3a25 100644 --- a/src/Database/Exception/Type.php +++ b/src/Database/Exception/Type.php @@ -4,6 +4,9 @@ use Utopia\Database\Exception; +/** + * Thrown when a value has an incompatible or unsupported type for the target attribute. + */ class Type extends Exception { } diff --git a/src/Database/Exception/Unique.php b/src/Database/Exception/Unique.php index d8e2fe5014..603e847da0 100644 --- a/src/Database/Exception/Unique.php +++ b/src/Database/Exception/Unique.php @@ -2,6 +2,10 @@ namespace Utopia\Database\Exception; +/** + * Thrown when a write violates a unique index; a conflicting document ID throws the parent Duplicate instead. + */ class Unique extends Duplicate { + public const string MESSAGE = 'Document with the requested unique attributes already exists'; } diff --git a/src/Database/Helpers/ID.php b/src/Database/Helpers/ID.php index 3a690a7b1d..90a406ebd5 100644 --- a/src/Database/Helpers/ID.php +++ b/src/Database/Helpers/ID.php @@ -2,14 +2,20 @@ namespace Utopia\Database\Helpers; +use Exception; use Utopia\Database\Exception as DatabaseException; +/** + * Helper class for generating and creating document identifiers. + */ class ID { /** - * Create a new unique ID + * Create a new unique ID using uniqid with optional random padding. * - * @throws DatabaseException + * @param int $padding Number of random hex characters to append for uniqueness + * @return string The generated unique identifier + * @throws DatabaseException If random bytes generation fails */ public static function unique(int $padding = 7): string { @@ -17,8 +23,8 @@ public static function unique(int $padding = 7): string if ($padding > 0) { try { - $bytes = \random_bytes(\max(1, (int)\ceil(($padding / 2)))); // one byte expands to two chars - } catch (\Exception $e) { + $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); } @@ -29,7 +35,10 @@ public static function unique(int $padding = 7): string } /** - * Create a new ID from a string + * 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 { diff --git a/src/Database/Helpers/Permission.php b/src/Database/Helpers/Permission.php index 18c4fe5a94..962065be5c 100644 --- a/src/Database/Helpers/Permission.php +++ b/src/Database/Helpers/Permission.php @@ -3,9 +3,12 @@ namespace Utopia\Database\Helpers; use Exception; -use Utopia\Database\Database; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\PermissionType; +/** + * Represents a database permission binding a permission type to a role. + */ class Permission { private Role $role; @@ -15,12 +18,18 @@ class Permission */ private static array $aggregates = [ 'write' => [ - Database::PERMISSION_CREATE, - Database::PERMISSION_UPDATE, - Database::PERMISSION_DELETE, - ] + 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 +40,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 +60,8 @@ public function getPermission(): string } /** + * Get the role name associated with this permission. + * * @return string */ public function getRole(): string @@ -58,6 +70,8 @@ public function getRole(): string } /** + * Get the role identifier associated with this permission. + * * @return string */ public function getIdentifier(): string @@ -66,6 +80,8 @@ public function getIdentifier(): string } /** + * Get the role dimension associated with this permission. + * * @return string */ public function getDimension(): string @@ -74,24 +90,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 +116,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 +138,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 +161,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,14 +202,15 @@ 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 { @@ -191,14 +220,15 @@ public static function read(Role $role): string $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 { @@ -208,14 +238,15 @@ public static function create(Role $role): string $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 { @@ -225,14 +256,15 @@ public static function update(Role $role): string $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 { @@ -242,14 +274,15 @@ public static function delete(Role $role): string $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 { @@ -259,6 +292,7 @@ public static function write(Role $role): string $role->getIdentifier(), $role->getDimension() ); + return $permission->toString(); } } diff --git a/src/Database/Helpers/Role.php b/src/Database/Helpers/Role.php index 1682cb5471..951271443e 100644 --- a/src/Database/Helpers/Role.php +++ b/src/Database/Helpers/Role.php @@ -2,8 +2,18 @@ namespace Utopia\Database\Helpers; +use Exception; + +/** + * Represents a role used for permission checks, consisting of a role type, identifier, and dimension. + */ class Role { + /** + * @param string $role The role type (e.g. user, users, team, any, guests, member, label) + * @param string $identifier The role identifier (e.g. user ID, team ID) + * @param string $dimension The role dimension (e.g. user status, team role) + */ public function __construct( private string $role, private string $identifier = '', @@ -12,23 +22,26 @@ public function __construct( } /** - * Create a role string from this Role instance + * Create a role string from this Role instance. * - * @return string + * @return string The formatted role string (e.g. 'user:123/verified') */ public function toString(): string { $str = $this->role; if ($this->identifier) { - $str .= ':' . $this->identifier; + $str .= ':'.$this->identifier; } if ($this->dimension) { - $str .= '/' . $this->dimension; + $str .= '/'.$this->dimension; } + return $str; } /** + * Get the role type. + * * @return string */ public function getRole(): string @@ -37,6 +50,8 @@ public function getRole(): string } /** + * Get the role identifier. + * * @return string */ public function getIdentifier(): string @@ -45,6 +60,8 @@ public function getIdentifier(): string } /** + * Get the role dimension. + * * @return string */ public function getDimension(): string @@ -53,11 +70,11 @@ public function getDimension(): string } /** - * Parse a role string into a Role object + * Parse a role string into a Role object. * - * @param string $role + * @param string $role The role string to parse (e.g. 'user:123/verified') * @return self - * @throws \Exception + * @throws Exception If the dimension format is invalid */ public static function parse(string $role): self { @@ -66,51 +83,54 @@ 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') + * @return Role */ public static function user(string $identifier, string $status = ''): Role { @@ -118,9 +138,9 @@ public static function user(string $identifier, string $status = ''): Role } /** - * Create a users role + * Create a users role representing all authenticated users. * - * @param string $status + * @param string $status The user status dimension (e.g. 'verified') * @return self */ public static function users(string $status = ''): self @@ -129,10 +149,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 + * @param string $identifier The team ID + * @param string $dimension The team role dimension (e.g. 'admin', 'member') * @return self */ public static function team(string $identifier, string $dimension = ''): self @@ -141,9 +161,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 + * @param string $identifier The label identifier * @return self */ public static function label(string $identifier): self @@ -152,9 +172,9 @@ public static function label(string $identifier): self } /** - * Create an any satisfy role + * Create a role that matches any user, authenticated or not. * - * @return self + * @return Role */ public static function any(): Role { @@ -162,7 +182,7 @@ public static function any(): Role } /** - * Create a guests role + * Create a role representing unauthenticated guest users. * * @return self */ @@ -171,6 +191,12 @@ public static function guests(): self return new self('guests'); } + /** + * Create a member role from the given identifier. + * + * @param string $identifier The member ID + * @return self + */ public static function member(string $identifier): self { return new self('member', $identifier); diff --git a/src/Database/Hook/AllowNullColumn.php b/src/Database/Hook/AllowNullColumn.php new file mode 100644 index 0000000000..889933d365 --- /dev/null +++ b/src/Database/Hook/AllowNullColumn.php @@ -0,0 +1,51 @@ +filter->filter($table), $this->column, $this->quoteChar); + } + + public static function wrap(Condition $condition, string $column, string $quoteChar = '`'): Condition + { + if (! \preg_match(self::IDENTIFIER_PATTERN, $column)) { + throw new DatabaseException('Invalid column name: '.$column); + } + + return new Condition( + '('.$condition->expression.' OR '.self::quote($column, $quoteChar).' IS NULL)', + $condition->bindings, + ); + } + + public static function quote(string $identifier, string $quoteChar = '`'): string + { + $parts = \explode('.', $identifier); + $quoted = \array_map( + fn (string $part): string => $quoteChar.\str_replace($quoteChar, $quoteChar.$quoteChar, $part).$quoteChar, + $parts, + ); + + return \implode('.', $quoted); + } +} diff --git a/src/Database/Hook/Decorator.php b/src/Database/Hook/Decorator.php new file mode 100644 index 0000000000..bc94457733 --- /dev/null +++ b/src/Database/Hook/Decorator.php @@ -0,0 +1,44 @@ + $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 the way the builder hands + * them to join filters: by alias, or by table without one + */ + 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) { + $alias = $query->getJoinAlias(); + $joins[$alias !== '' ? $alias : $query->getAttribute()] = $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/Hook/Lifecycle.php b/src/Database/Hook/Lifecycle.php new file mode 100644 index 0000000000..fb1eef754c --- /dev/null +++ b/src/Database/Hook/Lifecycle.php @@ -0,0 +1,37 @@ + $filters The current MongoDB filter array + * @param string $collection The collection being queried + * @param PermissionType $forPermission The permission type to filter for + * @return array The modified filter array with permission constraints + */ + 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/TenantFilter.php b/src/Database/Hook/Mongo/TenantFilter.php new file mode 100644 index 0000000000..fdd2e2e185 --- /dev/null +++ b/src/Database/Hook/Mongo/TenantFilter.php @@ -0,0 +1,46 @@ +=): (int|string|null|array>) $getTenantFilters Closure that returns tenant filter values for a collection + */ + public function __construct( + private bool $sharedTables, + private Closure $getTenantFilters, + ) { + } + + /** + * Add a _tenant filter to restrict results to the current tenant. + * + * @param array $filters The current MongoDB filter array + * @param string $collection The collection being queried + * @return array The modified filter array with tenant constraints + */ + public function applyFilters(array $filters, string $collection): array + { + if (! $this->sharedTables) { + return $filters; + } + + $filters[Storage::TENANT] = ($this->getTenantFilters)($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 @@ + $conditions The condition each joined table's rows must meet, by alias + */ + public function __construct( + private JoinChain $chain, + private array $conditions, + private string $quoteChar = '`', + ) { + } + + 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 = AllowNullColumn::wrap($this->conditions[$alias], $alias.'.'.Storage::UID, $this->quoteChar); + $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/Hook/OuterJoinPermissionFilter.php b/src/Database/Hook/OuterJoinPermissionFilter.php new file mode 100644 index 0000000000..e5a51df1a5 --- /dev/null +++ b/src/Database/Hook/OuterJoinPermissionFilter.php @@ -0,0 +1,67 @@ + $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 $quoteChar = '`', + ) { + } + + 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[] = AllowNullColumn::wrap( + $this->conditions[$this->source], + $this->source.'.'.Storage::UID, + $this->quoteChar, + ); + } + 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/Hook/OuterJoinTenantFilter.php b/src/Database/Hook/OuterJoinTenantFilter.php new file mode 100644 index 0000000000..dbaebb7bb3 --- /dev/null +++ b/src/Database/Hook/OuterJoinTenantFilter.php @@ -0,0 +1,49 @@ +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/Hook/PermissionAllowNullUid.php b/src/Database/Hook/PermissionAllowNullUid.php new file mode 100644 index 0000000000..464f7d46f7 --- /dev/null +++ b/src/Database/Hook/PermissionAllowNullUid.php @@ -0,0 +1,24 @@ +inner = new AllowNullColumn($filter, $documentColumn, $quoteChar); + } + + public function filter(string $table): Condition + { + return $this->inner->filter($table); + } +} diff --git a/src/Database/Hook/PermissionFilter.php b/src/Database/Hook/PermissionFilter.php new file mode 100644 index 0000000000..8cb99f2a8d --- /dev/null +++ b/src/Database/Hook/PermissionFilter.php @@ -0,0 +1,148 @@ + $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 Filter|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 = 'read', + protected ?array $columns = null, + protected string $documentColumn = 'id', + protected string $permDocumentColumn = 'document_id', + protected string $permRoleColumn = 'role', + protected string $permTypeColumn = 'type', + protected string $permColumnColumn = 'column', + protected ?Filter $subqueryFilter = null, + protected string $quoteChar = '`', + protected bool $semiJoin = true, + ) { + foreach ([$documentColumn, $permDocumentColumn, $permRoleColumn, $permTypeColumn, $permColumnColumn] as $col) { + if (! \preg_match(self::IDENTIFIER_PATTERN, $col)) { + throw new InvalidArgumentException('Invalid column name: '.$col); + } + } + } + + /** + * Generate a SQL condition that filters documents by permission role membership. + * + * @param string $table The base table name being queried + * @return Condition A condition with an IN subquery against the permissions table + * @throws DatabaseException If the permissions table name is invalid + */ + public function filter(string $table): Condition + { + if (empty($this->roles)) { + return new Condition('1 = 0'); + } + + /** @var string $permTable */ + $permTable = ($this->permissionsTable)($table); + + if (! \preg_match(self::QUOTED_IDENTIFIER_PATTERN, $permTable)) { + throw new DatabaseException('Invalid permissions table name: '.$permTable); + } + + $quotedPermTable = AllowNullColumn::quote($permTable, $this->quoteChar); + $quotedDocumentColumn = AllowNullColumn::quote($this->documentColumn, $this->quoteChar); + + $rolePlaceholders = \implode(', ', \array_fill(0, \count($this->roles), '?')); + + $columnClause = ''; + $columnBindings = []; + + if ($this->columns !== null) { + if (empty($this->columns)) { + $columnClause = " AND {$this->permColumnColumn} IS NULL"; + } else { + $colPlaceholders = \implode(', ', \array_fill(0, \count($this->columns), '?')); + $columnClause = " AND ({$this->permColumnColumn} IS NULL OR {$this->permColumnColumn} 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->permDocumentColumn} FROM {$quotedPermTable} WHERE {$this->permRoleColumn} IN ({$rolePlaceholders}) AND {$this->permTypeColumn} = ?{$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); + } + + $filter = clone $this; + $filter->documentCollation = ' COLLATE '.$collation; + + return $filter; + } + + public function withoutSemiJoin(): static + { + $filter = clone $this; + $filter->semiJoin = false; + + return $filter; + } + + /** + * Per-join-table permission checks are applied via separate PermissionFilter hooks + * registered by the SQL adapter for each joined table. This hook only handles the + * primary table's WHERE clause, so filterJoin returns null. + */ + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + return null; + } +} diff --git a/src/Database/Hook/PermissionJoinFilter.php b/src/Database/Hook/PermissionJoinFilter.php new file mode 100644 index 0000000000..d9fcecf9e7 --- /dev/null +++ b/src/Database/Hook/PermissionJoinFilter.php @@ -0,0 +1,55 @@ +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 = AllowNullColumn::wrap( + $condition, + $this->alias.'.'.Storage::UID, + $this->quoteChar, + ); + } + + return new JoinCondition($condition, $placement); + } +} diff --git a/src/Database/Hook/Permissions.php b/src/Database/Hook/Permissions.php new file mode 100644 index 0000000000..2531330253 --- /dev/null +++ b/src/Database/Hook/Permissions.php @@ -0,0 +1,586 @@ + $documents The created documents + * @param WriteContext $context The write context providing builder and execution closures + */ + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void + { + $permBuilder = ($context->createBuilder)()->into(($context->getTableRaw)(Storage::permissionsTable($collection))); + $hasPermissions = false; + + foreach ($documents as $document) { + foreach ($this->buildPermissionRows($document, $context) as $row) { + $permBuilder->set($row); + $hasPermissions = true; + } + } + + if ($hasPermissions) { + if ($context->skipDuplicates) { + if (! $permBuilder instanceof InsertOrIgnoreFeature) { + throw new DatabaseException('Insert-or-ignore is not supported on this dialect'); + } + + $result = $permBuilder->insertOrIgnore(); + } else { + $result = $permBuilder->insert(); + } + $stmt = ($context->executeResult)($result, Event::PermissionsCreate); + ($context->execute)($stmt); + } + } + + /** + * Diff current vs. new permissions and apply additions/removals for a single document. + * + * @param string $collection The collection name + * @param Document $document The updated document with new permissions + * @param bool $skipPermissions Whether to skip permission syncing + * @param WriteContext $context The write context providing builder and execution closures + */ + public function afterDocumentUpdate(string $collection, Document $document, bool $skipPermissions, WriteContext $context): void + { + if ($skipPermissions) { + return; + } + + $previousId = $context->lookupId; + if ($previousId !== null && $previousId !== '' && $previousId !== $document->getId()) { + $this->movePermissions($collection, $previousId, $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::PERM_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 string $collection The collection name + * @param Document $updates The update document containing new permission values + * @param array $documents The documents being updated + * @param WriteContext $context The write context providing builder and execution closures + */ + public function afterDocumentBatchUpdate(string $collection, Document $updates, array $documents, WriteContext $context): void + { + if (! $updates->offsetExists(Document::PERMISSIONS)) { + return; + } + + $removeConditions = []; + $addBuilder = ($context->createBuilder)()->into(($context->getTableRaw)(Storage::permissionsTable($collection))); + $hasAdditions = false; + + $eligible = []; + foreach ($documents as $document) { + if ($document->getAttribute(Document::SKIP_PERMISSIONS_UPDATE, false)) { + continue; + } + $eligible[] = $document; + } + + if (empty($eligible)) { + return; + } + + [$permissionsMap, $storedIds] = $this->readCurrentPermissionsBatch($collection, $eligible, $context); + $updatesByType = []; + foreach (self::PERM_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::PERM_TYPES as $type) { + $diff = \array_diff($permissions[$type->value], $updatesByType[$type->value]); + if (! empty($diff)) { + $removeConditions[] = Query::and([ + Query::equal(Storage::PERM_DOCUMENT, [$permissionDocumentId]), + Query::equal(Storage::PERM_TYPE, [$type->value]), + Query::equal(Storage::PERM_PERMISSION, \array_values($diff)), + ]); + } + } + + $metadata = $this->documentMetadata($document); + foreach (self::PERM_TYPES as $type) { + $diff = $this->uniqueAdditions($updatesByType[$type->value], $permissions[$type->value]); + if (! empty($diff)) { + foreach ($diff as $permission) { + $row = ($context->decorateRow)([ + Storage::PERM_DOCUMENT => $permissionDocumentId, + Storage::PERM_TYPE => $type->value, + Storage::PERM_PERMISSION => $permission, + ], $metadata); + $addBuilder->set($row); + $hasAdditions = true; + } + } + } + } + + if (! empty($removeConditions)) { + $removeBuilder = ($context->newBuilder)(Storage::permissionsTable($collection)); + $removeBuilder->filter([Query::or($removeConditions)]); + $deleteResult = $removeBuilder->delete(); + $deleteStmt = ($context->executeResult)($deleteResult, Event::PermissionsDelete); + ($context->execute)($deleteStmt); + } + + if ($hasAdditions) { + $addResult = $addBuilder->insert(); + $addStmt = ($context->executeResult)($addResult, Event::PermissionsCreate); + ($context->execute)($addStmt); + } + } + + /** + * Diff old vs. new permissions from upsert change sets and apply additions/removals. + * + * @param string $collection The collection name + * @param array<\Utopia\Database\Change> $changes The upsert change objects containing old and new documents + * @param WriteContext $context The write context providing builder and execution closures + */ + public function afterDocumentUpsert(string $collection, array $changes, WriteContext $context): void + { + $removeConditions = []; + $addBuilder = ($context->createBuilder)()->into(($context->getTableRaw)(Storage::permissionsTable($collection))); + $hasAdditions = false; + + foreach ($changes as $change) { + $old = $change->getOld(); + $document = $change->getNew(); + $metadata = $this->documentMetadata($document); + $tenantScope = $this->tenantScope($metadata, $context); + + $current = []; + foreach (self::PERM_TYPES as $type) { + $current[$type->value] = $old->getPermissionsByType($type); + } + + foreach (self::PERM_TYPES as $type) { + $toRemove = \array_diff($current[$type->value], $document->getPermissionsByType($type)); + if (! empty($toRemove)) { + $removeConditions[] = Query::and([ + Query::equal(Storage::PERM_DOCUMENT, [$document->getId()]), + ...$tenantScope, + Query::equal(Storage::PERM_TYPE, [$type->value]), + Query::equal(Storage::PERM_PERMISSION, \array_values($toRemove)), + ]); + } + } + + foreach (self::PERM_TYPES as $type) { + $toAdd = $this->uniqueAdditions($document->getPermissionsByType($type), $current[$type->value]); + foreach ($toAdd as $permission) { + $row = ($context->decorateRow)([ + Storage::PERM_DOCUMENT => $document->getId(), + Storage::PERM_TYPE => $type->value, + Storage::PERM_PERMISSION => $permission, + ], $metadata); + $addBuilder->set($row); + $hasAdditions = true; + } + } + } + + if (! empty($removeConditions)) { + $removeBuilder = ($context->createBuilder)()->from(($context->getTableRaw)(Storage::permissionsTable($collection))); + $removeBuilder->filter([Query::or($removeConditions)]); + $deleteResult = $removeBuilder->delete(); + $deleteStmt = ($context->executeResult)($deleteResult, Event::PermissionsDelete); + ($context->execute)($deleteStmt); + } + + if ($hasAdditions) { + $addResult = $addBuilder->insert(); + $addStmt = ($context->executeResult)($addResult, Event::PermissionsCreate); + ($context->execute)($addStmt); + } + } + + /** + * An upsert batch can hold documents of several tenants, none of them the adapter's, so its + * removals cannot take newBuilder()'s filter on the adapter's tenant: each one is scoped to + * the tenant decorateRow() stores its own document's rows under instead. + * + * @param array $metadata + * @return list + */ + private function tenantScope(array $metadata, WriteContext $context): array + { + $row = ($context->decorateRow)([], $metadata); + 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 string $collection The collection name + * @param list $documentIds The IDs of deleted documents + * @param WriteContext $context The write context providing builder and execution closures + * @throws DatabaseException If the permission deletion fails + */ + public function afterDocumentDelete(string $collection, array $documentIds, WriteContext $context): void + { + if (empty($documentIds)) { + return; + } + + $permsBuilder = ($context->newBuilder)(Storage::permissionsTable($collection)); + $permsBuilder->filter([Query::equal(Storage::PERM_DOCUMENT, $documentIds)]); + $permsResult = $permsBuilder->delete(); + $stmtPermissions = ($context->executeResult)($permsResult, Event::PermissionsDelete); + + if (! ($context->execute)($stmtPermissions)) { + 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, $context); + if ($documentIds === []) { + return [[], []]; + } + + $readBuilder = ($context->newBuilder)(Storage::permissionsTable($collection)); + $readBuilder->select([Storage::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_PERMISSION]); + $readBuilder->filter([Query::equal(Storage::PERM_DOCUMENT, $documentIds)]); + + $readResult = $readBuilder->build(); + $readStmt = ($context->executeResult)($readResult, Event::PermissionsRead); + ($context->execute)($readStmt); + /** @var array> $rows */ + $rows = (array) $readStmt->fetchAll(); + $readStmt->closeCursor(); + + return [ + $this->groupPermissionRows($documentIds, $rows), + $this->storedDocumentIds($documentIds, $rows), + ]; + } + + /** + * @param array $documents + * @return list + */ + private function permissionReadIds(array $documents, WriteContext $context): array + { + $documentIds = []; + foreach ($documents as $document) { + $id = $document->getId(); + if ($id !== '') { + $documentIds[] = $id; + } + } + + if ($context->lookupId !== null && $context->lookupId !== '') { + $documentIds[] = $context->lookupId; + } + + 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::PERM_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::PERM_DOCUMENT] ?? null; + $type = $row[Storage::PERM_TYPE] ?? null; + $permission = $row[Storage::PERM_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::PERM_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->newBuilder)(Storage::permissionsTable($collection)); + $removeBuilder->filter([Query::equal(Storage::PERM_DOCUMENT, [$previousId])]); + $deleteStmt = ($context->executeResult)($removeBuilder->delete(), Event::PermissionsDelete); + ($context->execute)($deleteStmt); + + $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 => $perms) { + $removeConditions[] = Query::and([ + Query::equal(Storage::PERM_DOCUMENT, [$documentId]), + Query::equal(Storage::PERM_TYPE, [$type]), + Query::equal(Storage::PERM_PERMISSION, $perms), + ]); + } + + $removeBuilder = ($context->newBuilder)(Storage::permissionsTable($collection)); + $removeBuilder->filter([Query::or($removeConditions)]); + $deleteResult = $removeBuilder->delete(); + $deleteStmt = ($context->executeResult)($deleteResult, Event::PermissionsDelete); + ($context->execute)($deleteStmt); + } + + /** + * @param array> $additions + */ + private function insertPermissions(string $collection, Document $document, string $documentId, array $additions, WriteContext $context): void + { + if (empty($additions)) { + return; + } + + $addBuilder = ($context->createBuilder)()->into(($context->getTableRaw)(Storage::permissionsTable($collection))); + $metadata = $this->documentMetadata($document); + + foreach ($additions as $type => $perms) { + foreach (\array_values(\array_unique($perms)) as $permission) { + $row = ($context->decorateRow)([ + Storage::PERM_DOCUMENT => $documentId, + Storage::PERM_TYPE => $type, + Storage::PERM_PERMISSION => $permission, + ], $metadata); + $addBuilder->set($row); + } + } + + $addResult = $addBuilder->insert(); + $addStmt = ($context->executeResult)($addResult, Event::PermissionsCreate); + ($context->execute)($addStmt); + } + + /** + * Build permission rows for a document, applying decorateRow for tenant etc. + * + * @return list> + */ + private function buildPermissionRows(Document $document, WriteContext $context): array + { + $rows = []; + $metadata = $this->documentMetadata($document); + + foreach (self::PERM_TYPES as $type) { + foreach ($document->getPermissionsByType($type) as $permission) { + $row = [ + Storage::PERM_DOCUMENT => $document->getId(), + Storage::PERM_TYPE => $type->value, + Storage::PERM_PERMISSION => \str_replace('"', '', $permission), + ]; + $rows[] = ($context->decorateRow)($row, $metadata); + } + } + + return $rows; + } + + /** + * @return array + */ + private function documentMetadata(Document $document): array + { + return [ + 'id' => $document->getId(), + 'tenant' => $document->getTenant(), + ]; + } +} diff --git a/src/Database/Hook/RawOuterJoinTenantFilter.php b/src/Database/Hook/RawOuterJoinTenantFilter.php new file mode 100644 index 0000000000..52895f3f2e --- /dev/null +++ b/src/Database/Hook/RawOuterJoinTenantFilter.php @@ -0,0 +1,31 @@ +filter->outerJoin($table, $joinType), Placement::On); + } +} diff --git a/src/Database/Hook/RawTenantFilter.php b/src/Database/Hook/RawTenantFilter.php new file mode 100644 index 0000000000..aa220bf390 --- /dev/null +++ b/src/Database/Hook/RawTenantFilter.php @@ -0,0 +1,142 @@ + 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 $quoteChar, + ) { + } + + public function reset(): void + { + $this->joins = []; + } + + 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 + */ + public function filter(string $table): Condition + { + $preserving = (new JoinChain($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 JoinChain($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 OuterJoinChainFilter($chain, $earlier, $this->quoteChar))->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 AllowNullColumn::wrap($condition, $table.'.'.Storage::UID, $this->quoteChar); + } + + private function column(string $table): string + { + return AllowNullColumn::quote($table, $this->quoteChar).'.'.Storage::TENANT; + } +} diff --git a/src/Database/Hook/Read.php b/src/Database/Hook/Read.php new file mode 100644 index 0000000000..24fc3c24a1 --- /dev/null +++ b/src/Database/Hook/Read.php @@ -0,0 +1,25 @@ + $filters The current MongoDB filter array + * @param string $collection The collection being queried + * @param PermissionType $forPermission The permission the caller must hold on every matched document + * @return array The modified filter array + */ + public function applyFilters(array $filters, string $collection, PermissionType $forPermission): array; +} diff --git a/src/Database/Hook/Relationships.php b/src/Database/Hook/Relationships.php new file mode 100644 index 0000000000..4879eb8fa4 --- /dev/null +++ b/src/Database/Hook/Relationships.php @@ -0,0 +1,2818 @@ + + */ + 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 = []; + + /** + * @param Database $db The database instance used for relationship operations + */ + public function __construct( + private Database $db, + ) { + $this->enabled = new Value(true); + $this->checkExist = new Value(true); + $this->inBatchPopulation = new Value(false); + } + + /** + * Effective per-query chunk size for relationship fan-out reads/writes. + * + * 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->db->getMaxQueryValues())); + } + + /** + * Run one read per chunk and return their documents in chunk order. The reads run at the same time only where + * each can borrow its own connection: inside a coroutine, on a pooled adapter, outside a transaction. 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 + { + if ($this->readsConcurrently(\count($reads))) { + $snapshot = $this->db->snapshot(); + $tasks = []; + foreach ($reads as $read) { + $tasks[] = fn (): array => $this->db->withSnapshot($snapshot, $read); + } + + /** @var array> $chunks */ + $chunks = Promise::map($tasks)->await(); + \ksort($chunks); + } else { + $chunks = []; + foreach ($reads as $read) { + $chunks[] = $read(); + } + } + + $documents = []; + foreach ($chunks as $chunk) { + \array_push($documents, ...$chunk); + } + + return $documents; + } + + private function readsConcurrently(int $reads): bool + { + if ($reads < 2 || ! \extension_loaded('swoole') || Coroutine::getCid() <= 0) { + return false; + } + + $adapter = $this->db->getAdapter(); + + return $adapter instanceof Pool && ! $adapter->inTransaction(); + } + + /** + * @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; + } + + /** + * {@inheritDoc} + */ + public function isEnabled(): bool + { + return $this->enabled->get(); + } + + /** + * {@inheritDoc} + */ + 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 + */ + public function withEnabled(bool $enabled, callable $callback): mixed + { + return $this->enabled->with($enabled, $callback); + } + + /** + * {@inheritDoc} + */ + public function shouldCheckExist(): bool + { + return $this->checkExist->get(); + } + + /** + * {@inheritDoc} + */ + public function setCheckExist(bool $check): void + { + $this->checkExist->set($check); + } + + /** + * 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 + */ + public function withCheckExist(bool $check, callable $callback): mixed + { + return $this->checkExist->with($check, $callback); + } + + /** + * {@inheritDoc} + */ + public function getWriteStackCount(): int + { + return \count($this->writeStacks[$this->coroutine()] ?? []); + } + + /** + * {@inheritDoc} + */ + public function getFetchDepth(): int + { + return $this->fetchDepth; + } + + /** + * {@inheritDoc} + */ + 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 + */ + 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), + ), + ); + } + + /** + * {@inheritDoc} + * + * @throws DuplicateException If a related document already exists + * @throws RelationshipException If a relationship constraint is violated + */ + public function afterDocumentCreate(Document $collection, Document $document): Document + { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + + /** @var array $relationships */ + $relationships = \array_filter( + $attributes, + Attribute::isRelationship(...) + ); + + $coroutine = $this->coroutine(); + $writeStack = $this->writeStacks[$coroutine] ?? []; + $stackCount = \count($writeStack); + + foreach ($relationships as $relationship) { + /** @var string $key */ + $key = $relationship->getAttribute('key', $relationship->getId()); + $value = $document->getAttribute($key); + $rel = RelationshipVO::fromArray(['collection' => $collection->getId()] + $relationship->getArrayCopy()); + $relatedCollection = $this->db->getCollection($rel->relatedCollection); + $relationType = $rel->type; + $twoWay = $rel->twoWay; + $twoWayKey = $rel->twoWayKey; + $side = $rel->side; + + 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 === RelationType::OneToOne && ! $twoWay && $side === RelationSide::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 === RelationType::ManyToOne && $side === RelationSide::Parent) || + ($relationType === RelationType::OneToMany && $side === RelationSide::Child) || + ($relationType === RelationType::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, + ); + } elseif (\is_string($related)) { + $this->relateDocumentsById( + $collection, + $relatedCollection, + $key, + $document->getId(), + $related, + $relationType, + $twoWay, + $twoWayKey, + $side, + ); + } 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 === RelationType::OneToOne && ! $twoWay && $side === RelationSide::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 === RelationType::OneToMany && $side === RelationSide::Parent) || + ($relationType === RelationType::ManyToOne && $side === RelationSide::Child) || + ($relationType === RelationType::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, + ); + $document->setAttribute($key, $relatedId); + } elseif (\is_string($value)) { + if ($relationType === RelationType::OneToOne && $twoWay === false && $side === RelationSide::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 === RelationType::OneToMany && $side === RelationSide::Parent) || + ($relationType === RelationType::ManyToOne && $side === RelationSide::Child) || + ($relationType === RelationType::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, + ); + } elseif ($value === null) { + if ( + !(($relationType === RelationType::OneToMany && $side === RelationSide::Child) || + ($relationType === RelationType::ManyToOne && $side === RelationSide::Parent) || + ($relationType === RelationType::OneToOne && $side === RelationSide::Parent) || + ($relationType === RelationType::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; + } + + /** + * {@inheritDoc} + * + * @throws DuplicateException If a related document already exists + * @throws RelationshipException If a relationship constraint is violated + * @throws RestrictedException If a restricted relationship is violated + */ + public function afterDocumentUpdate(Document $collection, Document $old, Document $document): Document + { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + + /** @var array $relationships */ + $relationships = \array_filter( + $attributes, + Attribute::isRelationship(...) + ); + + $coroutine = $this->coroutine(); + $writeStack = $this->writeStacks[$coroutine] ?? []; + $stackCount = \count($writeStack); + + foreach ($relationships as $index => $relationship) { + /** @var string $key */ + $key = $relationship->getAttribute('key', $relationship->getId()); + $value = $document->getAttribute($key); + + $value = $this->coerceToDocument($document, $key, $value); + + $oldValue = $old->getAttribute($key); + $rel = RelationshipVO::fromArray(['collection' => $collection->getId()] + $relationship->getArrayCopy()); + $relatedCollection = $this->db->getCollection($rel->relatedCollection); + $relationType = $rel->type; + $twoWay = $rel->twoWay; + $twoWayKey = $rel->twoWayKey; + $side = $rel->side; + + 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 === RelationType::OneToOne + || ($relationType === RelationType::ManyToOne && $side === RelationSide::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 RelationType::OneToOne: + if (! $twoWay) { + if ($side === RelationSide::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->db->skipRelationships(fn () => $this->db->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, + ); + $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->db->skipRelationships( + fn () => $this->db->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->db->skipRelationships(fn () => $this->db->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $related->setAttribute($twoWayKey, $document->getId()) + )); + } + } elseif ($value instanceof Document) { + $related = $this->db->skipRelationships(fn () => $this->db->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->db->createDocument( + $relatedCollection->getId(), + $value->setAttribute($twoWayKey, $document->getId()) + ); + } else { + $related = $this->db->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->db->skipRelationships( + fn () => $this->db->getDocument($relatedCollection->getId(), $oldValueDocNull->getId()) + ); + $this->db->skipRelationships(fn () => $this->db->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 RelationType::OneToMany: + case RelationType::ManyToOne: + if ( + ($relationType === RelationType::OneToMany && $side === RelationSide::Parent) || + ($relationType === RelationType::ManyToOne && $side === RelationSide::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->db->getAuthorization()->skip(fn () => $this->db->skipRelationships(fn () => $this->db->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->db->skipRelationships( + fn () => $this->db->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); + } + + foreach ($documentRelations as $relation) { + $related = $this->db->skipRelationships( + fn () => $this->db->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->db->createDocument( + $relatedCollection->getId(), + $relation->setAttribute($twoWayKey, $document->getId()) + ); + } else { + $this->db->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $relation->setAttribute($twoWayKey, $document->getId()) + ); + } + } + + $document->removeAttribute($key); + break; + } + + if (\is_string($value)) { + $related = $this->db->skipRelationships( + fn () => $this->db->getDocument($relatedCollection->getId(), $value, [Query::select([Document::ID])]) + ); + + if ($related->isEmpty()) { + $document->setAttribute($key, null); + } + $this->db->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->db->skipRelationships( + fn () => $this->db->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->db->createDocument( + $relatedCollection->getId(), + $value + ); + } elseif ($related->getAttributes() != $value->getAttributes()) { + $this->db->updateDocument( + $relatedCollection->getId(), + $related->getId(), + $value + ); + $this->db->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 RelationType::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->db->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->db->getAuthorization()->skip(fn () => $this->db->deleteDocuments( + $junction, + [Query::equal(Document::ID, $chunk)], + )); + } + } + } + + foreach ($value as $relation) { + if (\is_string($relation)) { + if (\in_array($relation, $oldIds)) { + continue; + } + + $related = $this->db->getDocument($relatedCollection->getId(), $relation, [Query::select([Document::ID])]); + + if ($related->isEmpty()) { + continue; + } + + $this->authorizeLink($relatedCollection, $related); + } elseif ($relation instanceof Document) { + $related = $this->db->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->db->createDocument( + $relatedCollection->getId(), + $relation + ); + } elseif ($related->getAttributes() != $relation->getAttributes()) { + $related = $this->db->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->db->skipRelationships(fn () => $this->db->createDocument( + $this->getJunctionCollection($collection, $relatedCollection, $side), + new Document([ + $key => $relation, + $twoWayKey => $document->getId(), + Document::PERMISSIONS => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]) + )); + } + + $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 + */ + public function beforeDocumentDelete(Document $collection, Document $document, bool $report = false): array + { + /** @var array> $changed */ + $changed = []; + $cascaded = false; + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + + /** @var array $relationships */ + $relationships = \array_filter( + $attributes, + Attribute::isRelationship(...) + ); + + foreach ($relationships as $relationship) { + $key = $relationship->getId(); + $value = $document->getAttribute($key); + $rel = RelationshipVO::fromArray(['collection' => $collection->getId()] + $relationship->getArrayCopy()); + $relatedCollection = $this->db->getCollection($rel->relatedCollection); + $relationType = $rel->type; + $twoWay = $rel->twoWay; + $twoWayKey = $rel->twoWayKey; + $onDelete = $rel->onDelete; + $side = $rel->side; + + $relationship->setAttribute('collection', $collection->getId()); + $relationship->setAttribute('document', $document->getId()); + + $holdsKey = ($relationType === RelationType::OneToMany && $side === RelationSide::Child) + || ($relationType === RelationType::ManyToOne && $side === RelationSide::Parent); + $unwritten = false; + + switch ($onDelete) { + case ForeignKeyAction::Restrict: + $this->deleteRestrict($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side); + $unwritten = true; + break; + case ForeignKeyAction::SetNull: + $written = $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side); + + if ($twoWay) { + foreach ($written as $related) { + $changed[$relatedCollection->getId()][$related->getId()] = $related; + } + } + + $unwritten = $holdsKey || $relationType === RelationType::ManyToMany; + break; + case ForeignKeyAction::Cascade: + $unwritten = $holdsKey || ($relationType === RelationType::ManyToMany && $side === RelationSide::Child); + + foreach ($this->deleteStacks[$this->coroutine()] ?? [] as $processedRelationship) { + /** @var string $existingKey */ + $existingKey = $processedRelationship['key']; + /** @var string $existingCollection */ + $existingCollection = $processedRelationship['collection']; + $existingRel = RelationshipVO::fromArray(['collection' => $existingCollection] + $processedRelationship->getArrayCopy()); + $existingRelatedCollection = $existingRel->relatedCollection; + $existingTwoWayKey = $existingRel->twoWayKey; + $existingSide = $existingRel->side; + + $reflexive = $processedRelationship == $relationship; + + $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, $relationship); + break; + } + + foreach (\is_array($value) ? $value : [$value] as $related) { + if (! $related instanceof Document || $related->isEmpty()) { + continue; + } + + if ($onDelete === ForeignKeyAction::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->db->getAuthorization()->skip(fn () => $this->db->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 + */ + 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) { + $docs = $item['documents']; + $coll = $item['collection']; + $sels = $item['selects']; + $skipKey = $item['skipKey'] ?? null; + $parentHasExplicitSelects = $item['hasExplicitSelects']; + + if (empty($docs)) { + continue; + } + + /** @var array $popAttributes */ + $popAttributes = $coll->getAttribute('attributes', []); + /** @var array $relationships */ + $relationships = []; + + foreach ($popAttributes as $attribute) { + // Avoid the Attribute::fromDocument allocation — + // we only need the type and key here, both of + // which are plain Document attributes. + if (! Attribute::isRelationship($attribute)) { + continue; + } + + /** @var string $popKey */ + $popKey = $attribute->getAttribute('key', $attribute->getId()); + + if ($popKey === $skipKey) { + continue; + } + + if (! $parentHasExplicitSelects || \array_key_exists($popKey, $sels)) { + $relationships[] = $attribute; + } + } + + foreach ($relationships as $relationship) { + /** @var string $key */ + $key = $relationship->getAttribute('key', $relationship->getId()); + $queries = $sels[$key] ?? []; + $relationship->setAttribute('collection', $coll->getId()); + $isAtMaxDepth = ($currentDepth + 1) >= Database::RELATION_MAX_DEPTH; + + if ($isAtMaxDepth) { + foreach ($docs as $doc) { + $doc->removeAttribute($key); + } + + continue; + } + + $relVO = RelationshipVO::fromArray(['collection' => $coll->getId()] + $relationship->getArrayCopy()); + + $relatedDocs = $this->populateSingleRelationshipBatch( + $docs, + $relVO, + $queries + ); + + $twoWay = $relVO->twoWay; + $twoWayKey = $relVO->twoWayKey; + + $hasNestedSelectsForThisRel = isset($sels[$key]); + $shouldQueue = ! empty($relatedDocs) && + ($hasNestedSelectsForThisRel || ! $parentHasExplicitSelects); + + if ($shouldQueue) { + $relatedCollectionId = $relVO->relatedCollection; + $relatedCollection = $this->db->silent(fn () => $this->db->getCollection($relatedCollectionId)); + + if (! $relatedCollection->isEmpty()) { + $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; + + /** @var array $relatedCollectionRelationships */ + $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); + /** @var array $relatedCollectionRelationships */ + $relatedCollectionRelationships = \array_filter( + $relatedCollectionRelationships, + Attribute::isRelationship(...) + ); + + $nextSelects = $this->processQueries($relatedCollectionRelationships, $relationshipQueries); + + $childHasExplicitSelects = $parentHasExplicitSelects; + + $nextQueue[] = [ + 'documents' => $relatedDocs, + 'collection' => $relatedCollection, + 'depth' => $currentDepth + 1, + 'selects' => $nextSelects, + 'skipKey' => $twoWay ? $twoWayKey : null, + 'hasExplicitSelects' => $childHasExplicitSelects, + ]; + } + } + + if ($twoWay && ! empty($relatedDocs)) { + foreach ($relatedDocs as $relatedDoc) { + $relatedDoc->removeAttribute($twoWayKey); + } + } + } + } + + $queue = $nextQueue; + $currentDepth++; + } + + return $documents; + }); + } + + /** + * @param array $relationships + * @param array $queries + * @return array> + */ + 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 = []; + /** @var Document $relationship */ + foreach ($relationships as $relationship) { + /** @var string $relKey */ + $relKey = $relationship->getAttribute('key', $relationship->getId()); + $relationshipsByKey[$relKey] = $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); + + $relationship = $relationshipsByKey[$selectedKey] ?? null; + + if (! $relationship) { + continue; + } + + $nestingPath = \implode('.', $nesting); + + if (empty($nestingPath)) { + $nestedSelections[$selectedKey][] = Query::select(['*']); + } else { + $nestedSelections[$selectedKey][] = Query::select([$nestingPath]); + } + + $relVO = RelationshipVO::fromArray(['collection' => ''] + $relationship->getArrayCopy()); + + switch ($relVO->type) { + case RelationType::ManyToMany: + unset($values[$valueIndex]); + break; + case RelationType::OneToMany: + if ($relVO->side === RelationSide::Parent) { + unset($values[$valueIndex]); + } else { + $values[$valueIndex] = $selectedKey; + } + break; + case RelationType::ManyToOne: + if ($relVO->side === RelationSide::Parent) { + $values[$valueIndex] = $selectedKey; + } else { + unset($values[$valueIndex]); + } + break; + case RelationType::OneToOne: + $values[$valueIndex] = $selectedKey; + break; + } + } + + $finalValues = \array_values($values); + if (empty($finalValues)) { + $finalValues = ['*']; + } + $query->setValues($finalValues); + } + + return $nestedSelections; + } + + /** + * @param array $relationships + * @param array $queries + * @return array|null + * + * @throws QueryException If a relationship query references an invalid attribute + */ + 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; + } + + $collectionId = $collection?->getId() ?? ''; + + /** @var array $relationshipsByKey */ + $relationshipsByKey = []; + foreach ($relationships as $relationship) { + $relVO = RelationshipVO::fromArray(['collection' => $collectionId] + $relationship->getArrayCopy()); + $relationshipsByKey[$relVO->key] = $relVO; + } + + $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, + RelationType $relationType, + bool $twoWay, + string $twoWayKey, + RelationSide $side, + ): string { + switch ($relationType) { + case RelationType::OneToOne: + if ($twoWay) { + $relation->setAttribute($twoWayKey, $document->getId()); + } + break; + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { + $relation->setAttribute($twoWayKey, $document->getId()); + } + break; + case RelationType::ManyToOne: + if ($side === RelationSide::Child) { + $relation->setAttribute($twoWayKey, $document->getId()); + } + break; + } + + $related = $this->db->getDocument($relatedCollection->getId(), $relation->getId()); + + if ($relationType === RelationType::ManyToMany && ! $related->isEmpty()) { + $this->authorizeLink($relatedCollection, $related); + } + + if ($related->isEmpty()) { + if (! isset($relation[Document::PERMISSIONS])) { + $relation->setAttribute(Document::PERMISSIONS, $document->getPermissions()); + } + + $related = $this->db->createDocument($relatedCollection->getId(), $relation); + } elseif ($related->getAttributes() != $relation->getAttributes()) { + foreach ($relation->getAttributes() as $attribute => $value) { + $related->setAttribute($attribute, $value); + } + + $related = $this->db->updateDocument($relatedCollection->getId(), $related->getId(), $related); + } + + if ($relationType === RelationType::ManyToMany) { + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $this->db->createDocument($junction, new Document([ + $key => $related->getId(), + $twoWayKey => $document->getId(), + Document::PERMISSIONS => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + } + + return $related->getId(); + } + + private function relateDocumentsById( + Document $collection, + Document $relatedCollection, + string $key, + string $documentId, + string $relationId, + RelationType $relationType, + bool $twoWay, + string $twoWayKey, + RelationSide $side, + ): void { + $related = $this->db->skipRelationships(fn () => $this->db->getDocument($relatedCollection->getId(), $relationId)); + + if ($related->isEmpty() && $this->checkExist->get()) { + return; + } + + switch ($relationType) { + case RelationType::OneToOne: + if ($twoWay) { + $related->setAttribute($twoWayKey, $documentId); + $this->db->skipRelationships(fn () => $this->db->updateDocument($relatedCollection->getId(), $relationId, $related)); + } + break; + case RelationType::OneToMany: + if ($side === RelationSide::Parent) { + $related->setAttribute($twoWayKey, $documentId); + $this->db->skipRelationships(fn () => $this->db->updateDocument($relatedCollection->getId(), $relationId, $related)); + } + break; + case RelationType::ManyToOne: + if ($side === RelationSide::Child) { + $related->setAttribute($twoWayKey, $documentId); + $this->db->skipRelationships(fn () => $this->db->updateDocument($relatedCollection->getId(), $relationId, $related)); + } + break; + case RelationType::ManyToMany: + if (! $related->isEmpty()) { + $this->authorizeLink($relatedCollection, $related); + } + + $this->db->purgeCachedDocument($relatedCollection->getId(), $relationId); + + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $this->db->skipRelationships(fn () => $this->db->createDocument($junction, new Document([ + $key => $relationId, + $twoWayKey => $documentId, + Document::PERMISSIONS => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]))); + break; + } + } + + private function getJunctionCollection(Document $collection, Document $relatedCollection, RelationSide $side): string + { + return $side === RelationSide::Parent + ? '_'.$collection->getSequence().'_'.$relatedCollection->getSequence() + : '_'.$relatedCollection->getSequence().'_'.$collection->getSequence(); + } + + private function isLinkedElsewhere(Document $collection, string $key, string $relatedId, Document $document): bool + { + return ! $this->db->getAuthorization()->skip(fn () => $this->db->skipRelationships(fn () => $this->db->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 + * @param array $queries + * @return array + */ + private function populateSingleRelationshipBatch(array $documents, RelationshipVO $relationship, array $queries): array + { + return match ($relationship->type) { + RelationType::OneToOne => $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries), + RelationType::OneToMany => $this->populateOneToManyRelationshipsBatch($documents, $relationship, $queries), + RelationType::ManyToOne => $this->populateManyToOneRelationshipsBatch($documents, $relationship, $queries), + RelationType::ManyToMany => $this->populateManyToManyRelationshipsBatch($documents, $relationship, $queries), + }; + } + + /** + * @param array $documents + * @param array $queries + * @return array + */ + private function populateOneToOneRelationshipsBatch(array $documents, RelationshipVO $relationship, array $queries): array + { + $key = $relationship->key; + $relatedCollection = $this->db->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->db->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->db->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, RelationshipVO $relationship, array $queries): array + { + $key = $relationship->key; + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey; + $side = $relationship->side; + $relatedCollection = $this->db->getCollection($relationship->relatedCollection); + + if ($side === RelationSide::Child) { + if (! $twoWay) { + foreach ($documents as $document) { + $document->removeAttribute($key); + } + + return []; + } + + return $this->populateOneToOneRelationshipsBatch($documents, $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->db->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->db->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, RelationshipVO $relationship, array $queries): array + { + $key = $relationship->key; + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey; + $side = $relationship->side; + $relatedCollection = $this->db->getCollection($relationship->relatedCollection); + + if ($side === RelationSide::Parent) { + return $this->populateOneToOneRelationshipsBatch($documents, $relationship, $queries); + } + + 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() === Method::Select) { + $selectQueries[] = $query; + } else { + $otherQueries[] = $query; + } + } + + $collectionId = $relatedCollection->getId(); + $relatedDocuments = $this->readChunks(\array_map( + fn (array $chunk): Closure => fn (): array => $this->db->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->db->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, RelationshipVO $relationship, array $queries): array + { + $key = $relationship->key; + $twoWay = $relationship->twoWay; + $twoWayKey = $relationship->twoWayKey; + $side = $relationship->side; + $relatedCollection = $this->db->getCollection($relationship->relatedCollection); + $collection = $this->db->getCollection($relationship->collection); + + if (! $twoWay && $side === RelationSide::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->db->skipRelationships(fn (): array => $this->db->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->db->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->db->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, + RelationType $relationType, + bool $twoWay, + string $twoWayKey, + RelationSide $side + ): void { + if ( + $relationType !== RelationType::ManyToOne + && $side === RelationSide::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 === RelationType::OneToOne + && $side === RelationSide::Child + && ! $twoWay + ) { + $this->db->getAuthorization()->skip(function () use ($document, $relatedCollection, $twoWayKey) { + $related = $this->db->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + ]); + + if ($related->isEmpty()) { + return; + } + + $this->db->skipRelationships(fn () => $this->db->updateDocument( + $relatedCollection->getId(), + $related->getId(), + new Document([ + $twoWayKey => null, + ]) + )); + }); + } + + if ( + $relationType === RelationType::ManyToOne + && $side === RelationSide::Child + ) { + $related = $this->db->getAuthorization()->skip(fn () => $this->db->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, RelationType $relationType, bool $twoWay, string $twoWayKey, RelationSide $side): bool + { + $authorization = $this->db->getAuthorization(); + + if ($relationType === RelationType::OneToMany) { + return ! $authorization->skip(fn () => $this->db->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->db->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, RelationType $relationType, bool $twoWay, string $twoWayKey, RelationSide $side): array + { + return match ($relationType) { + RelationType::OneToOne => $side === RelationSide::Parent || $twoWay + ? $this->findStoredRelatedIds($collection, $document, $key) + : [], + RelationType::OneToMany => $side === RelationSide::Parent + ? $this->findReferencingIds($relatedCollection, $document, $twoWayKey) + : [], + RelationType::ManyToOne => $side === RelationSide::Child + ? $this->findReferencingIds($relatedCollection, $document, $twoWayKey) + : [], + RelationType::ManyToMany => $this->findJunctionRelatedIds($collection, $relatedCollection, $document, $key, $twoWayKey, $side), + }; + } + + /** + * @return list + */ + private function findStoredRelatedIds(Document $collection, Document $document, string $key): array + { + $stored = $this->db->getAuthorization()->skip(fn () => $this->db->skipRelationships( + fn () => $this->db->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, RelationSide $side): array + { + $junctions = $this->db->getAuthorization()->skip(fn () => $this->db->skipRelationships(fn () => $this->db->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->db->getAuthorization()->skip(fn () => $this->db->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 + */ + private function clearReferences(Document $relatedCollection, Document $document, string $twoWayKey): 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->db->getAuthorization()->skip(fn () => $this->db->skipRelationships(fn () => $this->db->updateDocuments( + $relatedCollection->getId(), + new Document([$twoWayKey => null]), + [Query::equal(Document::ID, $chunk)], + onNext: $collect, + ))); + } + + return $cleared; + } + + /** + * @return list The documents the delete wrote, as the write left them + */ + private function deleteSetNull(Document $collection, Document $relatedCollection, Document $document, RelationType $relationType, bool $twoWay, string $twoWayKey, RelationSide $side): array + { + switch ($relationType) { + case RelationType::OneToOne: + if (! $twoWay && $side === RelationSide::Parent) { + return []; + } + + $written = $this->db->getAuthorization()->skip(function () use ($document, $relatedCollection, $twoWayKey): ?Document { + $related = $this->db->findOne($relatedCollection->getId(), [ + Query::select([Document::ID]), + Query::equal($twoWayKey, [$document->getId()]), + ]); + + if ($related->isEmpty()) { + return null; + } + + return $this->db->skipRelationships(fn () => $this->db->updateDocument( + $relatedCollection->getId(), + $related->getId(), + new Document([ + $twoWayKey => null, + ]) + )); + }); + + return $written === null || $written->isEmpty() ? [] : [$written]; + + case RelationType::OneToMany: + if ($side === RelationSide::Child) { + return []; + } + + return $this->clearReferences($relatedCollection, $document, $twoWayKey); + + case RelationType::ManyToOne: + if ($side === RelationSide::Parent) { + return []; + } + + return $this->clearReferences($relatedCollection, $document, $twoWayKey); + + case RelationType::ManyToMany: + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $junctions = $this->db->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->db->skipRelationships(fn () => $this->deleteRelatedDocuments($junction, $junctionIds)); + break; + } + + return []; + } + + private function deleteCascade(Document $collection, Document $relatedCollection, Document $document, string $key, RelationType $relationType, bool $twoWay, string $twoWayKey, RelationSide $side, Document $relationship): void + { + switch ($relationType) { + case RelationType::OneToOne: + case RelationType::OneToMany: + case RelationType::ManyToOne: + $relatedIds = $this->findRelatedIds($collection, $relatedCollection, $document, $key, $relationType, $twoWay, $twoWayKey, $side); + + if ($relatedIds !== []) { + $this->cascade($relationship, fn () => $this->deleteRelatedDocuments($relatedCollection->getId(), $relatedIds)); + } + + break; + case RelationType::ManyToMany: + $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); + + $junctions = $this->db->skipRelationships(fn () => $this->db->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 === RelationSide::Parent) { + $relatedAttribute = $junctionDocument->getAttribute($key); + $relatedId = $relatedAttribute instanceof Document ? $relatedAttribute->getId() : (\is_string($relatedAttribute) ? $relatedAttribute : null); + if ($relatedId !== null) { + $relatedIds[] = $relatedId; + } + } + } + + $this->cascade($relationship, function () use ($relatedCollection, $relatedIds, $junction, $junctionIds): void { + $this->deleteRelatedDocuments($relatedCollection->getId(), $relatedIds); + $this->deleteRelatedDocuments($junction, $junctionIds); + }); + break; + } + } + + /** + * @param callable(): mixed $callback + */ + private function cascade(Document $relationship, callable $callback): void + { + $coroutine = $this->coroutine(); + $this->deleteStacks[$coroutine][] = $relationship; + + 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->db->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->db->skipRelationships(fn () => $this->db->updateDocuments( + $collection->getId(), + new Document([$twoWayKey => $documentId]), + [Query::equal(Document::ID, $chunk)], + )); + + if ($linked === \count($chunk)) { + continue; + } + + $unlinked = $this->db->getAuthorization()->skip(fn () => $this->db->skipRelationships(fn () => $this->db->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->db->getAuthorization(); + + $related = $authorization->skip(fn () => $this->db->skipRelationships( + fn () => $this->db->getDocument($collection->getId(), $id, forUpdate: true) + )); + + if ($related->isEmpty() || $related->getAttribute($twoWayKey) === $documentId) { + return; + } + + $this->authorizeLink($collection, $related); + + $this->db->skipRelationships(fn () => $this->db->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->db->getAuthorization(); + + if (! $authorization->isValid(new Input(PermissionType::Update, [ + ...$collection->getUpdate(), + ...($collection->getAttribute('documentSecurity', false) ? $related->getUpdate() : []), + ]))) { + 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 */ + $relationshipChain = []; + + foreach ($pathParts as $relationshipKey) { + $collectionDoc = $this->db->silent(fn () => $this->db->getCollection($currentCollection)); + /** @var array> $attributes */ + $attributes = $collectionDoc->getAttribute('attributes', []); + $relationships = \array_filter( + $attributes, + function (mixed $attr): bool { + if ($attr instanceof Document) { + $type = $attr->getAttribute('type', ''); + } else { + $type = $attr['type'] ?? ''; + } + return \is_string($type) && ColumnType::tryFrom($type) === ColumnType::Relationship; + } + ); + + /** @var array|null $relationship */ + $relationship = null; + foreach ($relationships as $rel) { + /** @var array $rel */ + if ($rel['key'] === $relationshipKey) { + $relationship = $rel; + break; + } + } + + if (! $relationship) { + return null; + } + + /** @var Document $relationship */ + $nestedRel = RelationshipVO::fromArray(['collection' => $currentCollection] + $relationship->getArrayCopy()); + $relationshipChain[] = [ + 'key' => $relationshipKey, + 'fromCollection' => $currentCollection, + 'toCollection' => $nestedRel->relatedCollection, + 'relationType' => $nestedRel->type, + 'side' => $nestedRel->side, + 'twoWayKey' => $nestedRel->twoWayKey, + ]; + + $currentCollection = $nestedRel->relatedCollection; + } + + $leafQueries = []; + foreach ($queryGroup as $q) { + $leafQueries[] = new Query($q['method'], $q['attribute'], $q['values']); + } + + /** @var array $matchingDocs */ + $matchingDocs = $this->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->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--) { + $link = $relationshipChain[$i]; + $relationType = $link['relationType']; + $side = $link['side']; + $linkKey = $link['key']; + $linkFromCollection = $link['fromCollection']; + $linkToCollection = $link['toCollection']; + $linkTwoWayKey = $link['twoWayKey']; + + $needsReverseLookup = ( + ($relationType === RelationType::OneToMany && $side === RelationSide::Parent) || + ($relationType === RelationType::ManyToOne && $side === RelationSide::Child) || + ($relationType === RelationType::ManyToMany) + ); + + if ($needsReverseLookup) { + if ($relationType === RelationType::ManyToMany) { + $fromCollectionDoc = $this->db->silent(fn () => $this->db->getCollection($linkFromCollection)); + $toCollectionDoc = $this->db->silent(fn () => $this->db->getCollection($linkToCollection)); + $junction = $this->getJunctionCollection($fromCollectionDoc, $toCollectionDoc, $side); + + $junctionDocs = $this->readByIds($matchingIds, fn (array $chunk): array => $this->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->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->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->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->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->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( + RelationshipVO $relationship, + array $relatedQueries, + ?Document $collection = null, + ): ?array { + $relatedCollection = $relationship->relatedCollection; + $relationType = $relationship->type; + $side = $relationship->side; + $twoWayKey = $relationship->twoWayKey; + $relationshipKey = $relationship->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 === RelationType::OneToMany && $side === RelationSide::Parent) || + ($relationType === RelationType::ManyToOne && $side === RelationSide::Child) || + ($relationType === RelationType::ManyToMany) + ); + + if ($relationType === RelationType::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->db->silent(fn () => $this->db->getCollection($relatedCollection)); + $junction = $this->getJunctionCollection($collection, $relatedCollectionDoc, $side); + + $junctionDocs = $this->readByIds($matchingIds, fn (array $chunk): array => $this->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->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->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->find($relatedCollection, \array_merge($relatedQueries, $queries)))); + } + + return $this->readByIds($pathIds, fn (array $chunk): array => $this->db->silent(fn () => $this->db->skipRelationships(fn () => $this->db->find( + $relatedCollection, + \array_merge($relatedQueries, [Query::equal(Document::ID, $chunk)], $queries) + )))); + } +} diff --git a/src/Database/Hook/Selective.php b/src/Database/Hook/Selective.php new file mode 100644 index 0000000000..a36b6d6205 --- /dev/null +++ b/src/Database/Hook/Selective.php @@ -0,0 +1,14 @@ +tenant; + } + + public function decorateRow(array $row, array $metadata = []): array + { + $row[$this->column] = $metadata['tenant'] ?? $this->tenant; + + return $row; + } +} diff --git a/src/Database/Hook/TenantFilter.php b/src/Database/Hook/TenantFilter.php new file mode 100644 index 0000000000..3ccdc2a5e9 --- /dev/null +++ b/src/Database/Hook/TenantFilter.php @@ -0,0 +1,125 @@ + + */ + 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 $quoteChar The adapter's identifier quote: tables are named quoted with it, as the + * builder declares them + */ + public function __construct( + int|string|null|array $tenant, + private string $metadataCollection = '', + private string $collection = '', + private string $allowNullColumn = '', + private string $quoteChar = '`', + ) { + if (! \is_array($tenant)) { + $tenant = [$tenant]; + } + + $this->tenants = $tenant === [] ? [null] : $tenant; + } + + private function placeholders(): string + { + return \implode(', ', \array_fill(0, \count($this->tenants), '?')); + } + + public function filter(string $table): Condition + { + $prefix = (! \str_contains($table, '.') && ! \str_contains($table, $this->quoteChar)) + ? AllowNullColumn::quote($table, $this->quoteChar).'.' + : ''; + + $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 AllowNullColumn::wrap($condition, $this->allowNullColumn, $this->quoteChar); + } + + /** + * 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. + */ + public function filterJoin(string $table, JoinType $joinType): ?JoinCondition + { + $placement = match ($joinType) { + JoinType::Left, JoinType::Inner => Placement::On, + default => Placement::Where, + }; + + $condition = $this->joined($table); + + if ($placement === Placement::Where && ($joinType === JoinType::FullOuter || $this->allowNullColumn !== '')) { + $condition = AllowNullColumn::wrap( + $condition, + $table.'.'.Storage::UID, + $this->quoteChar, + ); + } + + 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 = AllowNullColumn::quote($table, $this->quoteChar).'.'.Storage::TENANT; + + return new Condition("{$column} IN ({$this->placeholders()})", $this->tenants); + } +} 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 + * @param array $metadata + * @return array + */ + public function decorateRow(array $row, array $metadata = []): array; + + /** + * Execute after documents are created (e.g. insert permission rows). + * + * @param array $documents + */ + public function afterDocumentCreate(string $collection, array $documents, WriteContext $context): void; + + /** + * Execute after a document is updated (e.g. sync permission rows). + */ + public function afterDocumentUpdate(string $collection, Document $document, bool $skipPermissions, WriteContext $context): void; + + /** + * Execute after documents are updated in batch (e.g. sync permission rows). + * + * @param array $documents + */ + public function afterDocumentBatchUpdate(string $collection, Document $updates, array $documents, WriteContext $context): void; + + /** + * Execute after documents are upserted (e.g. sync permission rows from old→new diffs). + * + * @param array $changes + */ + public function afterDocumentUpsert(string $collection, array $changes, WriteContext $context): void; + + /** + * Execute after documents are deleted (e.g. clean up permission rows). + * + * @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..b811a073cc --- /dev/null +++ b/src/Database/Hook/WriteContext.php @@ -0,0 +1,38 @@ +, array): array $decorateRow Apply all write hooks' decorateRow to a row + * @param Closure(): \Utopia\Query\Builder\SQL $createBuilder Create a raw builder (no hooks, no table) + * @param Closure(string): string $getTableRaw Get the raw SQL table name with namespace prefix + * @param bool $skipDuplicates Whether duplicate-key errors should be swallowed by this write + * @param string|null $lookupId The document id used to load/update, when it may differ in casing from getId() + */ + public function __construct( + public Closure $newBuilder, + public Closure $executeResult, + public Closure $execute, + public Closure $decorateRow, + public Closure $createBuilder, + public Closure $getTableRaw, + public bool $skipDuplicates = false, + public ?string $lookupId = null, + ) { + } +} diff --git a/src/Database/Index.php b/src/Database/Index.php new file mode 100644 index 0000000000..67c2d31993 --- /dev/null +++ b/src/Database/Index.php @@ -0,0 +1,584 @@ + $attributes + * @property array $lengths + * @property array $orders + * @property int $ttl + */ +class Index extends Document +{ + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public function __construct( + string $key, + IndexType $type, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ) { + parent::__construct([ + self::ID => $key, + 'key' => $key, + 'type' => $type->value, + 'attributes' => $attributes, + 'lengths' => $lengths, + 'orders' => self::encodeOrders($orders), + 'ttl' => $ttl, + ]); + } + + /** + * @return ( + * $name is 'key' ? string : + * $name is 'type' ? IndexType : + * $name is 'attributes' ? array : + * $name is 'lengths' ? array : + * $name is 'orders' ? array : + * $name is 'ttl' ? int : + * mixed + * ) + */ + public function __get(string $name): mixed + { + switch ($name) { + case 'key': + /** @var string $key */ + $key = $this->getAttribute('key', $this->getId()); + + return $key; + case 'type': + $type = $this->getAttribute('type', IndexType::Key->value); + if ($type instanceof IndexType) { + return $type; + } + + return IndexType::from(\is_string($type) ? $type : IndexType::Key->value); + case 'attributes': + $attributes = $this->getAttribute('attributes', []); + if (! \is_array($attributes)) { + return []; + } + /** @var array $attributes */ + + return $attributes; + case 'lengths': + $lengths = $this->getAttribute('lengths', []); + if (! \is_array($lengths)) { + return []; + } + /** @var array $lengths */ + + return $lengths; + case 'orders': + $stored = $this->getAttribute('orders', []); + + return self::decodeOrders(\is_array($stored) ? $stored : []); + case 'ttl': + /** @var int $ttl */ + $ttl = $this->getAttribute('ttl', 1); + + return $ttl; + default: + return $this->getAttribute($name); + } + } + + public function __set(string $name, mixed $value): void + { + match ($name) { + 'key' => $this->setAttribute('key', $value)->setAttribute(self::ID, $value), + 'type' => $this->setAttribute('type', $value instanceof IndexType ? $value->value : $value), + 'attributes' => $this->setAttribute('attributes', $value), + 'lengths' => $this->setAttribute('lengths', $value), + 'orders' => $this->setAttribute('orders', self::encodeOrders(\is_array($value) ? $value : [])), + 'ttl' => $this->setAttribute('ttl', $value), + default => $this->setAttribute($name, $value), + }; + } + + public function __isset(string $name): bool + { + return match ($name) { + 'key', 'type', 'attributes', 'lengths', 'orders', 'ttl' => true, + default => $this->offsetExists($name), + }; + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function key( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function index( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Index, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function unique( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Unique, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function fullText( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Fulltext, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function spatial( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Spatial, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function object( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Object, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function hnswEuclidean( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::HnswEuclidean, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function hnswCosine( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::HnswCosine, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function hnswDot( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::HnswDot, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function trigram( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Trigram, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + public static function ttl( + string $key, + array $attributes = [], + array $lengths = [], + array $orders = [], + int $ttl = 1, + ): self { + return new self( + key: $key, + type: IndexType::Ttl, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ); + } + + /** + * Convert this index to a Document representation. + * + * @return Document + */ + public function toDocument(): Document + { + return new Document([ + Document::ID => ID::custom($this->key), + 'key' => $this->key, + 'type' => $this->type->value, + 'attributes' => $this->attributes, + 'lengths' => $this->lengths, + 'orders' => $this->getAttribute('orders', []), + 'ttl' => $this->ttl, + ]); + } + + /** + * Create from an associative array (used by collection config files). + * + * @param array $data + */ + public static function fromArray(array $data): self + { + /** @var IndexType|string $type */ + $type = $data['type'] ?? 'key'; + /** @var string $key */ + $key = $data[Document::ID] ?? $data['key'] ?? ''; + /** @var array $attributes */ + $attributes = $data['attributes'] ?? []; + /** @var array $lengths */ + $lengths = $data['lengths'] ?? []; + /** @var array $orders */ + $orders = $data['orders'] ?? []; + /** @var int $ttl */ + $ttl = $data['ttl'] ?? 1; + + return self::make( + key: $key, + type: $type instanceof IndexType ? $type : IndexType::from((string) $type), + attributes: $attributes, + lengths: $lengths, + orders: self::decodeOrders($orders), + ttl: $ttl, + ); + } + + public static function fromDocument(Document $document): self + { + /** @var string $key */ + $key = $document->getAttribute('key', $document->getId()); + $type = $document->getAttribute('type', IndexType::Key->value); + /** @var array $attributes */ + $attributes = $document->getAttribute('attributes', []); + /** @var array $lengths */ + $lengths = $document->getAttribute('lengths', []); + /** @var array $orders */ + $orders = $document->getAttribute('orders', []); + /** @var int $ttl */ + $ttl = $document->getAttribute('ttl', 1); + + return self::make( + key: $key, + type: $type instanceof IndexType ? $type : IndexType::tryFrom(\is_string($type) ? $type : '') ?? IndexType::Key, + attributes: $attributes, + lengths: $lengths, + orders: self::decodeOrders($orders), + ttl: $ttl, + ); + } + + /** + * @param array $attributes + * @param array $lengths + * @param array $orders + */ + private static function make( + string $key, + IndexType $type, + array $attributes, + array $lengths, + array $orders, + int $ttl, + ): self { + return match ($type) { + IndexType::Key => self::key( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Index => self::index( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Unique => self::unique( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Fulltext => self::fullText( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Spatial => self::spatial( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Object => self::object( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::HnswEuclidean => self::hnswEuclidean( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::HnswCosine => self::hnswCosine( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::HnswDot => self::hnswDot( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Trigram => self::trigram( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + IndexType::Ttl => self::ttl( + key: $key, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl, + ), + }; + } + + public static function direction(?Order $order): string + { + return $order === null ? '' : $order->value; + } + + /** + * @param array $orders + * @return array + */ + private static function encodeOrders(array $orders): array + { + $encoded = []; + + foreach ($orders as $order) { + if ($order instanceof Order) { + $encoded[] = $order->value; + continue; + } + + if ($order === null) { + $encoded[] = null; + continue; + } + + throw new \InvalidArgumentException('Index order must be Order or null'); + } + + return $encoded; + } + + /** + * @param array $orders + * @return array + */ + private static function decodeOrders(array $orders): array + { + $decoded = []; + + foreach ($orders as $order) { + if ($order instanceof Order || $order === null) { + $decoded[] = $order; + continue; + } + + if (\is_string($order)) { + $decoded[] = Order::from(\strtoupper($order)); + continue; + } + + throw new \InvalidArgumentException('Index order must be Order or null'); + } + + return $decoded; + } +} diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92f..a437cd0f1f 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -2,15 +2,35 @@ 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\QueryCache; use Utopia\Database\Exception\Duplicate as DuplicateException; use Utopia\Database\Exception\Limit; use Utopia\Database\Helpers\ID; +use Utopia\Database\Hook\Lifecycle; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\Hook\Write; use Utopia\Database\Mirroring\Filter; +use Utopia\Database\Type\TypeRegistry; use Utopia\Database\Validator\Authorization; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKeyAction; +use Utopia\Query\Schema\Order; +/** + * Wraps a source Database and replicates write operations to an optional destination Database. + */ class Mirror extends Database { protected Database $source; + protected ?Database $destination; /** @@ -23,7 +43,7 @@ class Mirror extends Database /** * Callbacks to run when an error occurs on the destination database * - * @var array + * @var array */ protected array $errorCallbacks = []; @@ -35,29 +55,75 @@ class Mirror extends Database ]; /** - * @param Database $source - * @param ?Database $destination - * @param array $filters + * The last queued replication of each document, by collection and document id, until it finishes + * + * @var array> + */ + protected array $documentReplications = []; + + /** + * The last queued replication that can reach every document of a collection, by collection, until it finishes + * + * @var array + */ + protected array $collectionReplications = []; + + /** + * @param array $filters */ public function __construct( Database $source, ?Database $destination = null, array $filters = [], ) { + $this->source = $source; + $this->destination = $destination; + $this->writeFilters = $filters; parent::__construct( $source->getAdapter(), $source->getCache() ); - $this->source = $source; - $this->destination = $destination; - $this->writeFilters = $filters; } + /** + * Get the source database instance. + * + * @return Database + */ 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 + */ + protected function getDocumentsValidator(Document $collection, array $joinedCollections = []): Validator\Queries\Documents + { + return $this->source->getDocumentsValidator($collection, $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. + */ + public function getCollection(string $id): Collection + { + return $this->source->getCollection($id); + } + + /** + * Get the destination database instance, if configured. + * + * @return Database|null + */ public function getDestination(): ?Database { return $this->destination; @@ -72,8 +138,7 @@ public function getWriteFilters(): array } /** - * @param callable(string, \Throwable): void $callback - * @return void + * @param callable(string, Throwable): void $callback */ public function onError(callable $callback): void { @@ -81,55 +146,315 @@ public function onError(callable $callback): void } /** - * @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->destination->{$method}(...$args); + } catch (Throwable $err) { $this->logError($method, $err); } - return $result; + return $sourceResult; } + /** + * {@inheritdoc} + */ 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; } + /** + * {@inheritdoc} + */ 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; } + /** + * {@inheritdoc} + */ 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; } + /** + * {@inheritdoc} + */ public function setTenant(int|string|null $tenant): static + { + parent::setTenant($tenant); + $this->source->setTenant($tenant); + $this->destination?->setTenant($tenant); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setMaxQueryValues(int $max): self + { + parent::setMaxQueryValues($max); + $this->source->setMaxQueryValues($max); + $this->destination?->setMaxQueryValues($max); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setCache(Cache $cache): static + { + parent::setCache($cache); + $this->source->setCache($cache); + $this->destination?->setCache($cache); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setQueryCache(?QueryCache $queryCache): static + { + parent::setQueryCache($queryCache); + $this->source->setQueryCache($queryCache); + $this->destination?->setQueryCache($queryCache); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setCacheName(string $name): static + { + parent::setCacheName($name); + $this->source->setCacheName($name); + $this->destination?->setCacheName($name); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setCacheWriterTimeout(int $seconds): static + { + parent::setCacheWriterTimeout($seconds); + $this->source->setCacheWriterTimeout($seconds); + $this->destination?->setCacheWriterTimeout($seconds); + + return $this; + } + + /** + * {@inheritdoc} + */ + 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. + * + * {@inheritdoc} + */ + public function setTimeout(int $milliseconds, Event $event = Event::All): static { $this->delegate(__FUNCTION__, \func_get_args()); return $this; } + /** + * {@inheritdoc} + */ + public function clearTimeout(Event $event = Event::All): void + { + $this->delegate(__FUNCTION__, \func_get_args()); + } + + /** + * {@inheritdoc} + */ + public function setGlobalCollections(array $collections): static + { + parent::setGlobalCollections($collections); + $this->source->setGlobalCollections($collections); + $this->destination?->setGlobalCollections($collections); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function resetGlobalCollections(): void + { + parent::resetGlobalCollections(); + $this->source->resetGlobalCollections(); + $this->destination?->resetGlobalCollections(); + } + + /** + * {@inheritdoc} + */ + public function setMetadata(string $key, mixed $value): static + { + parent::setMetadata($key, $value); + $this->source->setMetadata($key, $value); + $this->destination?->setMetadata($key, $value); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function resetMetadata(): void + { + parent::resetMetadata(); + $this->source->resetMetadata(); + $this->destination?->resetMetadata(); + } + + /** + * {@inheritdoc} + */ + public function setMigrating(bool $migrating): self + { + parent::setMigrating($migrating); + $this->source->setMigrating($migrating); + $this->destination?->setMigrating($migrating); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setTypeRegistry(?TypeRegistry $typeRegistry): static + { + parent::setTypeRegistry($typeRegistry); + $this->source->setTypeRegistry($typeRegistry); + $this->destination?->setTypeRegistry($typeRegistry); + + return $this; + } + + /** + * A destination that cannot apply the setting is reported through onError(), like setTimeout(). + * + * {@inheritdoc} + */ + public function enableLocks(bool $enabled): static + { + parent::enableLocks($enabled); + $this->delegate(__FUNCTION__, \func_get_args()); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function enableFilters(): static + { + parent::enableFilters(); + $this->source->enableFilters(); + $this->destination?->enableFilters(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function disableFilters(): static + { + parent::disableFilters(); + $this->source->disableFilters(); + $this->destination?->disableFilters(); + + return $this; + } + + /** + * Opens the scope on the mirror, its source and its destination. + * + * {@inheritdoc} + */ + public function skipFilters(callable $callback, ?array $filters = null): mixed + { + $skip = fn (): mixed => parent::skipFilters($callback, $filters); + $destination = $this->destination; + + return $this->source->skipFilters( + fn (): mixed => $destination === null ? $skip() : $destination->skipFilters($skip, $filters), + $filters, + ); + } + + /** + * The mirror queries through its source's adapter, so it reports to the source's profiler. + * + * {@inheritdoc} + */ + public function enableProfiling(): static + { + $this->source->enableProfiling(); + $this->destination?->enableProfiling(); + $this->profiler = $this->source->getProfiler(); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function disableProfiling(): static + { + $this->source->disableProfiling(); + $this->destination?->disableProfiling(); + + return $this; + } + + /** + * {@inheritdoc} + */ public function setDropUnknownAttributes(bool $drop): static { $this->delegate(__FUNCTION__, \func_get_args()); @@ -139,6 +464,9 @@ public function setDropUnknownAttributes(bool $drop): static return $this; } + /** + * {@inheritdoc} + */ public function setPreserveDates(bool $preserve): static { $this->delegate(__FUNCTION__, \func_get_args()); @@ -148,6 +476,9 @@ public function setPreserveDates(bool $preserve): static return $this; } + /** + * {@inheritdoc} + */ public function setPreserveSequence(bool $preserve): static { $this->delegate(__FUNCTION__, \func_get_args()); @@ -157,6 +488,9 @@ public function setPreserveSequence(bool $preserve): static return $this; } + /** + * {@inheritdoc} + */ public function enableValidation(): static { $this->delegate(__FUNCTION__); @@ -166,6 +500,9 @@ public function enableValidation(): static return $this; } + /** + * {@inheritdoc} + */ public function disableValidation(): static { $this->delegate(__FUNCTION__); @@ -175,240 +512,429 @@ public function disableValidation(): static return $this; } - public function on(string $event, string $name, ?callable $callback): static + /** + * {@inheritdoc} + */ + public function skipValidation(callable $callback): mixed { - $this->source->on($event, $name, $callback); + return parent::skipValidation(function () use ($callback) { + if ($this->destination === null) { + return $this->source->skipValidation($callback); + } + + $destination = $this->destination; + + return $this->source->skipValidation( + fn () => $destination->skipValidation($callback) + ); + }); + } + + /** + * 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()). + * + * {@inheritdoc} + */ + 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)); + } + + /** + * Opens the scope on the mirror, its source and its destination. + * + * {@inheritdoc} + */ + public function withPreserveDates(callable $callback): mixed + { + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withPreserveDates($callback); + + return parent::withPreserveDates(fn (): mixed => $this->source->withPreserveDates($scoped)); + } + + /** + * Opens the scope on the mirror, its source and its destination. + * + * {@inheritdoc} + */ + public function withPreserveSequence(callable $callback): mixed + { + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withPreserveSequence($callback); + + return parent::withPreserveSequence(fn (): mixed => $this->source->withPreserveSequence($scoped)); + } + + /** + * {@inheritdoc} + */ + public function skipRelationships(callable $callback): mixed + { + return parent::skipRelationships(fn (): mixed => $this->source->skipRelationships($callback)); + } + + /** + * {@inheritdoc} + */ + public function skipRelationshipsExistCheck(callable $callback): mixed + { + return parent::skipRelationshipsExistCheck(fn (): mixed => $this->source->skipRelationshipsExistCheck($callback)); + } + + /** + * {@inheritdoc} + */ + public function addLifecycleHook(Lifecycle $hook): static + { + if ($hook instanceof Invalidator) { + parent::addHook($hook); + } + + $this->source->addHook($hook); return $this; } - protected function trigger(string $event, mixed $args = null): void + /** + * Invalidates the mirror's own query cache, then lets the source invalidate its own and run + * the lifecycle hooks, which are registered there (see addLifecycleHook()). + */ + protected function trigger(Event $event, mixed $data = null): void + { + parent::trigger($event, $data); + $this->source->trigger($event, $data); + } + + /** + * Also invalidates a query cache the source holds that is not the mirror's own. + */ + protected function invalidate(Event $event, mixed $data = null): void + { + parent::invalidate($event, $data); + + if ($this->source->getQueryCache() !== $this->queryCache) { + $this->source->invalidate($event, $data); + } + } + + /** + * Lifecycle hooks are registered on the source (see addLifecycleHook()). + */ + protected function triggerPropagatingHooks(Event $event, mixed $data = null): void { - $this->source->trigger($event, $args); + $this->source->triggerPropagatingHooks($event, $data); } + /** + * Silences the source, where lifecycle hooks are registered, and the mirror itself, + * where decorators are. + * + * {@inheritdoc} + */ public function silent(callable $callback, ?array $listeners = null): mixed { - return $this->source->silent($callback, $listeners); + return parent::silent(fn () => $this->source->silent($callback, $listeners), $listeners); } - public function withRequestTimestamp(?\DateTime $requestTimestamp, callable $callback): mixed + /** + * Scoped to the mirror and its source only: the source checks the timestamp, and the destination applies + * what the source accepted. + * + * {@inheritdoc} + */ + public function withRequestTimestamp(?DateTime $requestTimestamp, callable $callback): mixed { - return $this->delegate(__FUNCTION__, \func_get_args()); + 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); + } + + /** + * {@inheritdoc} + */ public function exists(?string $database = null, ?string $collection = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritdoc} + */ public function create(?string $database = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + $result = $this->source->create($database); + + if ($this->destination !== null) { + $this->destination->create($database); + } + + return $result; } + /** + * {@inheritdoc} + */ public function delete(?string $database = null): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitEveryReplication(); + + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document + /** + * {@inheritdoc} + */ + 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), + ])); + + $this->trigger(Event::CollectionList, $result); + + $collections = []; + foreach ($result as $doc) { + $collections[] = $doc instanceof Collection ? $doc : Collection::fromArray($doc->getArrayCopy()); + } + + return $collections; + } + + /** + * {@inheritdoc} + */ + public function createCollection(Collection $collection): Collection + { + $collectionId = $collection->id; + + $result = $this->source->createCollection($collection); if ($this->destination === null) { return $result; } try { + $filtered = $result; foreach ($this->writeFilters as $filter) { - $result = $filter->beforeCreateCollection( + $filtered = $filter->beforeCreateCollection( source: $this->source, destination: $this->destination, - collectionId: $id, - collection: $result, + collectionId: $collectionId, + collection: $filtered, ); + if ($filtered === null) { + return $result; + } } + $result = $filtered; - $this->destination->createCollection( - $id, - $attributes, - $indexes, - $permissions, - $documentSecurity - ); + $this->destination->createCollection($collection); - $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) { + } catch (Throwable $err) { $this->logError('createCollection', $err); } - return $result; + + return $result instanceof Collection ? $result : Collection::fromArray($result->getArrayCopy()); } + /** + * {@inheritdoc} + */ public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document { $result = $this->source->updateCollection($id, $permissions, $documentSecurity); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($id); + try { + $filtered = $result; foreach ($this->writeFilters as $filter) { - $result = $filter->beforeUpdateCollection( + $filtered = $filter->beforeUpdateCollection( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $id, - collection: $result, + collection: $filtered, ); + if ($filtered === null) { + return $result; + } } + $result = $filtered; - $this->destination->updateCollection($id, $permissions, $documentSecurity); - } catch (\Throwable $err) { + $destination->updateCollection($id, $permissions, $documentSecurity); + } catch (Throwable $err) { $this->logError('updateCollection', $err); } return $result; } + /** + * {@inheritdoc} + */ public function deleteCollection(string $id): bool { $result = $this->source->deleteCollection($id); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($id); + try { - $this->destination->deleteCollection($id); + $destination->deleteCollection($id); foreach ($this->writeFilters as $filter) { $filter->beforeDeleteCollection( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $id, ); } - } catch (\Throwable $err) { + } catch (Throwable $err) { $this->logError('deleteCollection', $err); } 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 + /** + * {@inheritdoc} + */ + public function createAttribute(string $collection, Attribute $attribute): bool { - $result = $this->source->createAttribute( - $collection, - $id, - $type, - $size, - $required, - $default, - $signed, - $array, - $format, - $formatOptions, - $filters - ); + $result = $this->source->createAttribute($collection, $attribute); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($collection); + try { - $document = new Document([ - '$id' => $id, - 'type' => $type, - 'size' => $size, - 'required' => $required, - 'default' => $default, - 'signed' => $signed, - 'array' => $array, - 'format' => $format, - 'formatOptions' => $formatOptions, - 'filters' => $filters, - ]); + // Round-trip through Document is required: Filter interface accepts/returns Document, + // so we must serialize to Document for filter processing, then deserialize back. + $document = $attribute->toDocument(); foreach ($this->writeFilters as $filter) { $document = $filter->beforeCreateAttribute( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, - attributeId: $id, + attributeId: $attribute->key, attribute: $document, ); + if ($document === null) { + break; + } } - $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) { + if ($document !== null) { + $filteredAttribute = Attribute::fromDocument($document); + $result = $destination->createAttribute($collection, $filteredAttribute); + } + } catch (Throwable $err) { $this->logError('createAttribute', $err); } return $result; } + /** + * {@inheritdoc} + */ public function createAttributes(string $collection, array $attributes): bool { $result = $this->source->createAttributes($collection, $attributes); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($collection); + try { - foreach ($attributes as &$attribute) { + $filteredAttributes = []; + foreach ($attributes as $attribute) { + // Round-trip through Document is required: Filter interface accepts/returns Document, + // so we must serialize to Document for filter processing, then deserialize back. + $document = $attribute->toDocument(); + foreach ($this->writeFilters as $filter) { $document = $filter->beforeCreateAttribute( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, - attributeId: $attribute['$id'], - attribute: new Document($attribute), + attributeId: $attribute->key, + attribute: $document, ); + if ($document === null) { + break; + } + } - $attribute = $document->getArrayCopy(); + if ($document !== null) { + $filteredAttributes[] = Attribute::fromDocument($document); } } - $result = $this->destination->createAttributes( - $collection, - $attributes, - ); - } catch (\Throwable $err) { + if ($filteredAttributes !== []) { + $result = $destination->createAttributes( + $collection, + $filteredAttributes, + ); + } + } catch (Throwable $err) { $this->logError('createAttributes', $err); } 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 + /** + * {@inheritdoc} + */ + public function updateAttribute(string $collection, string $id, ColumnType|string|null $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 { $document = $this->source->updateAttribute( $collection, @@ -425,151 +951,177 @@ public function updateAttribute(string $collection, string $id, ?string $type = $newKey, ); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $document; } + $this->awaitReplications($collection); + try { + $filtered = $document; foreach ($this->writeFilters as $filter) { - $document = $filter->beforeUpdateAttribute( + $filtered = $filter->beforeUpdateAttribute( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, attributeId: $id, - attribute: $document, + attribute: $filtered, ); + if ($filtered === null) { + return $document; + } } + $document = $filtered; - $this->destination->updateAttribute( + $typedAttr = Attribute::fromDocument($document); + + $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'), + $typedAttr->type, + $typedAttr->size, + $typedAttr->required, + $typedAttr->default, + $typedAttr->signed, + $typedAttr->array, + $typedAttr->format ?: null, + $typedAttr->formatOptions ?: null, + $typedAttr->filters ?: null, $newKey, ); - } catch (\Throwable $err) { + } catch (Throwable $err) { $this->logError('updateAttribute', $err); } return $document; } + /** + * {@inheritdoc} + */ public function deleteAttribute(string $collection, string $id): bool { $result = $this->source->deleteAttribute($collection, $id); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($collection); + try { foreach ($this->writeFilters as $filter) { $filter->beforeDeleteAttribute( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, attributeId: $id, ); } - $this->destination->deleteAttribute($collection, $id); - } catch (\Throwable $err) { + $destination->deleteAttribute($collection, $id); + } catch (Throwable $err) { $this->logError('deleteAttribute', $err); } return $result; } - public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = [], int $ttl = 1): bool + /** + * {@inheritdoc} + */ + public function createIndex(string $collection, Index $index): bool { - $result = $this->source->createIndex($collection, $id, $type, $attributes, $lengths, $orders, $ttl); + $result = $this->source->createIndex($collection, $index); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($collection); + try { - $document = new Document([ - '$id' => $id, - 'type' => $type, - 'attributes' => $attributes, - 'lengths' => $lengths, - 'orders' => $orders, - ]); + // Round-trip through Document is required: Filter interface accepts/returns Document, + // so we must serialize to Document for filter processing, then deserialize back. + $document = $index->toDocument(); foreach ($this->writeFilters as $filter) { $document = $filter->beforeCreateIndex( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, - indexId: $id, + indexId: $index->key, index: $document, ); + if ($document === null) { + break; + } } - $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) { + if ($document !== null) { + $filteredIndex = Index::fromDocument($document); + $result = $destination->createIndex($collection, $filteredIndex); + } + } catch (Throwable $err) { $this->logError('createIndex', $err); } return $result; } + /** + * {@inheritdoc} + */ public function deleteIndex(string $collection, string $id): bool { $result = $this->source->deleteIndex($collection, $id); - if ($this->destination === null) { + $destination = $this->destination; + if ($destination === null) { return $result; } + $this->awaitReplications($collection); + try { - $this->destination->deleteIndex($collection, $id); + $destination->deleteIndex($collection, $id); foreach ($this->writeFilters as $filter) { $filter->beforeDeleteIndex( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, indexId: $id, ); } - } catch (\Throwable $err) { + } catch (Throwable $err) { $this->logError('deleteIndex', $err); } return $result; } + /** + * {@inheritdoc} + */ 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 { @@ -578,31 +1130,33 @@ public function createDocument(string $collection, Document $document): Document foreach ($this->writeFilters as $filter) { $clone = $filter->beforeCreateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } - $this->destination->setPreserveDates(true); - $document = $this->destination->createDocument($collection, $clone); - $this->destination->setPreserveDates(false); + $this->awaitReplications($collection, [$document->getId()]); + $destination->withPreserveDates(fn (): Document => $destination->createDocument($collection, $clone)); foreach ($this->writeFilters as $filter) { $filter->afterCreateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } - } catch (\Throwable $err) { + } catch (Throwable $err) { $this->logError('createDocument', $err); } - return $document; + return $this->decorate(Event::DocumentCreate, $collection, $document); } + /** + * {@inheritdoc} + */ public function createDocuments( string $collection, array $documents, @@ -610,6 +1164,7 @@ public function createDocuments( ?callable $onNext = null, ?callable $onError = null, ): int { + $onNext = $this->decorating(Event::DocumentsCreate, $collection, $onNext); $modified = $this->skipDuplicates ? $this->source->skipDuplicates( fn () => $this->source->createDocuments($collection, $documents, $batchSize, $onNext, $onError) @@ -632,31 +1187,46 @@ public function createDocuments( // 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. + $clones = []; + $destination = $this->destination; + try { - $clones = []; foreach ($documents as $document) { $clone = clone $document; + foreach ($this->writeFilters as $filter) { $clone = $filter->beforeCreateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } + $clones[] = $clone; } + } catch (Throwable $err) { + $this->logError('createDocuments', $err); + + return $modified; + } - if ($this->skipDuplicates) { - $this->destination->skipDuplicates( - fn () => $this->destination->withPreserveDates( - fn () => $this->destination->createDocuments($collection, $clones, $batchSize) + $skipDuplicates = $this->skipDuplicates; + + $this->replicate('createDocuments', $collection, self::documentIds($documents), function () use ($destination, $collection, $clones, $batchSize, $skipDuplicates): void { + if ($skipDuplicates) { + $destination->skipDuplicates( + fn () => $destination->withPreserveDates( + fn () => $destination->createDocuments( + $collection, + $clones, + $batchSize, + ) ) ); } else { - $this->destination->withPreserveDates( - fn () => - $this->destination->createDocuments( + $destination->withPreserveDates( + fn () => $destination->createDocuments( $collection, $clones, $batchSize, @@ -668,34 +1238,36 @@ public function createDocuments( 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; } + /** + * {@inheritdoc} + */ 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 { @@ -704,31 +1276,33 @@ public function updateDocument(string $collection, string $id, Document $documen foreach ($this->writeFilters as $filter) { $clone = $filter->beforeUpdateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } - $this->destination->setPreserveDates(true); - $this->destination->updateDocument($collection, $id, $clone); - $this->destination->setPreserveDates(false); + $this->awaitReplications($collection, [$id]); + $destination->withPreserveDates(fn (): Document => $destination->updateDocument($collection, $id, $clone)); foreach ($this->writeFilters as $filter) { $filter->afterUpdateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); } - } catch (\Throwable $err) { + } catch (Throwable $err) { $this->logError('updateDocument', $err); } - return $document; + return $this->decorate(Event::DocumentUpdate, $collection, $document); } + /** + * {@inheritdoc} + */ public function updateDocuments( string $collection, Document $updates, @@ -737,6 +1311,7 @@ public function updateDocuments( ?callable $onNext = null, ?callable $onError = null, ): int { + $onNext = $this->decorating(Event::DocumentsUpdate, $collection, $onNext); $modified = $this->source->updateDocuments( $collection, $updates, @@ -758,22 +1333,28 @@ public function updateDocuments( return $modified; } - try { - $clone = clone $updates; + $clone = clone $updates; + $destination = $this->destination; + try { foreach ($this->writeFilters as $filter) { $clone = $filter->beforeUpdateDocuments( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, updates: $clone, queries: $queries, ); } + } catch (Throwable $err) { + $this->logError('updateDocuments', $err); + + return $modified; + } - $this->destination->withPreserveDates( - fn () => - $this->destination->updateDocuments( + $this->replicate('updateDocuments', $collection, null, function () use ($destination, $collection, $clone, $queries, $batchSize): void { + $destination->withPreserveDates( + fn () => $destination->updateDocuments( $collection, $clone, $queries, @@ -784,32 +1365,39 @@ 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; } - public function upsertDocuments( + /** + * upsertDocument() and upsertDocuments() upsert through this method, so each of them writes, + * and fires its events, once on the source. + * + * {@inheritdoc} + */ + public function upsertDocumentsWithIncrease( string $collection, + string $attribute, array $documents, - int $batchSize = Database::INSERT_BATCH_SIZE, ?callable $onNext = null, ?callable $onError = null, + int $batchSize = self::INSERT_BATCH_SIZE, ): int { - $modified = $this->source->upsertDocuments( + $onNext = $this->decorating(Event::DocumentsUpsert, $collection, $onNext); + $modified = $this->source->upsertDocumentsWithIncrease( $collection, + $attribute, $documents, - $batchSize, $onNext, $onError, + $batchSize, ); if ( @@ -824,16 +1412,18 @@ public function upsertDocuments( return $modified; } - try { - $clones = []; + $clones = []; + $destination = $this->destination; + $action = $attribute === '' ? 'upsertDocuments' : 'upsertDocumentsWithIncrease'; + try { foreach ($documents as $document) { $clone = clone $document; foreach ($this->writeFilters as $filter) { $clone = $filter->beforeCreateOrUpdateDocument( source: $this->source, - destination: $this->destination, + destination: $destination, collectionId: $collection, document: $clone, ); @@ -841,13 +1431,19 @@ public function upsertDocuments( $clones[] = $clone; } + } catch (Throwable $err) { + $this->logError($action, $err); - $this->destination->withPreserveDates( - fn () => - $this->destination->upsertDocuments( + return $modified; + } + + $this->replicate($action, $collection, self::documentIds($documents), function () use ($destination, $collection, $attribute, $clones, $batchSize): void { + $destination->withPreserveDates( + fn () => $destination->upsertDocumentsWithIncrease( $collection, + $attribute, $clones, - $batchSize, + batchSize: $batchSize, ) ); @@ -855,19 +1451,20 @@ 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; } + /** + * {@inheritdoc} + */ public function deleteDocument(string $collection, string $id): bool { $result = $this->source->deleteDocument($collection, $id); @@ -893,24 +1490,32 @@ public function deleteDocument(string $collection, string $id): bool documentId: $id, ); } + } catch (Throwable $err) { + $this->logError('deleteDocument', $err); + + return $result; + } - $this->destination->deleteDocument($collection, $id); + $destination = $this->destination; + $this->replicate('deleteDocument', $collection, [$id], function () use ($destination, $collection, $id): void { + $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; } + /** + * {@inheritdoc} + */ public function deleteDocuments( string $collection, array $queries = [], @@ -947,8 +1552,15 @@ public function deleteDocuments( queries: $queries, ); } + } catch (Throwable $err) { + $this->logError('deleteDocuments', $err); + + return $modified; + } - $this->destination->deleteDocuments( + $destination = $this->destination; + $this->replicate('deleteDocuments', $collection, null, function () use ($destination, $collection, $queries, $batchSize): void { + $destination->deleteDocuments( $collection, $queries, $batchSize, @@ -957,93 +1569,171 @@ 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; } + /** + * {@inheritdoc} + */ public function updateAttributeRequired(string $collection, string $id, bool $required): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritdoc} + */ public function updateAttributeFormat(string $collection, string $id, string $format): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritdoc} + */ public function updateAttributeFormatOptions(string $collection, string $id, array $formatOptions): Document { - return $this->delegate(__FUNCTION__, [$collection, $id, $formatOptions]); + $this->awaitReplications($collection); + + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, [$collection, $id, $formatOptions]); + return $result; } + /** + * {@inheritdoc} + */ public function updateAttributeFilters(string $collection, string $id, array $filters): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritdoc} + */ public function updateAttributeDefault(string $collection, string $id, mixed $default = null): Document { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var Document $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritdoc} + */ public function renameAttribute(string $collection, string $old, string $new): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - 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()); + /** + * {@inheritdoc} + */ + public function createRelationship(Relationship $relationship): bool + { + $this->awaitReplications($relationship->collection); + $this->awaitReplications($relationship->relatedCollection); + + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, [$relationship]); + return $result; } + /** + * {@inheritdoc} + */ public function updateRelationship( string $collection, string $id, ?string $newKey = null, ?string $newTwoWayKey = null, ?bool $twoWay = null, - ?string $onDelete = null + ?ForeignKeyAction $onDelete = null ): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } + /** + * {@inheritdoc} + */ public function deleteRelationship(string $collection, string $id): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); - } + $this->awaitReplications($collection); + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; + } + /** + * {@inheritdoc} + */ public function renameIndex(string $collection, string $old, string $new): bool { - return $this->delegate(__FUNCTION__, \func_get_args()); + $this->awaitReplications($collection); + + /** @var bool $result */ + $result = $this->delegate(__FUNCTION__, \func_get_args()); + return $result; } - public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value = 1, int|float|null $max = null): Document + /** + * {@inheritdoc} + */ + 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()); + $this->awaitReplications($collection, [$id]); + + /** @var Document $result */ + $result = $this->delegate(__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 + /** + * {@inheritdoc} + */ + 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()); + $this->awaitReplications($collection, [$id]); + + /** @var Document $result */ + $result = $this->delegate(__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 @@ -1052,53 +1742,17 @@ public function createUpgrades(): void { $collection = $this->source->getCollection('upgrades'); - if (!$collection->isEmpty()) { + if (! $collection->isEmpty()) { 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(new Collection(id: 'upgrades', attributes: [ + Attribute::string(key: 'collectionId', required: true), + Attribute::string(key: 'status'), + ], indexes: [ + Index::unique(key: '_unique_collection', attributes: ['collectionId'], lengths: [Database::LENGTH_KEY]), + Index::key(key: '_status_index', attributes: ['status'], lengths: [Database::LENGTH_KEY], orders: [Order::Asc]), + ])); } /** @@ -1113,53 +1767,252 @@ 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, once every earlier replication that can reach the same documents has finished; outside one it runs + * before this returns, since a task that yields outside a scheduler never resumes. + * + * @param array|null $documentIds The documents the write can reach, or null for every document of the collection + * @param Closure(): void $write + */ + private function replicate(string $action, string $collection, ?array $documentIds, 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 (! \extension_loaded('swoole') || Coroutine::getCid() <= 0) { + $apply(); + + return; + } + + $earlier = $this->replicationsBefore($collection, $documentIds); + $finished = new Channel(1); + $this->queueReplication($collection, $documentIds, $finished); + + Promise::async(function () use ($apply, $earlier, $collection, $documentIds, $finished): void { + try { + foreach ($earlier as $replication) { + $replication->pop(); + } + + $apply(); + } finally { + $this->releaseReplication($collection, $documentIds, $finished); + } + }); + } + + /** + * Waits until every queued replication that can reach these documents has finished, so a write or schema + * change the caller replicates itself reaches the destination after them. + * + * @param array|null $documentIds The documents the caller's change reaches, or null for the whole collection + */ + private function awaitReplications(string $collection, ?array $documentIds = null): void + { + foreach ($this->replicationsBefore($collection, $documentIds) as $replication) { + $replication->pop(); + } + } + + private function awaitEveryReplication(): void + { + $collections = \array_keys($this->documentReplications + $this->collectionReplications); + foreach ($collections as $collection) { + $this->awaitReplications((string) $collection); + } + } + + /** + * @param array|null $documentIds + * @return array + */ + private function replicationsBefore(string $collection, ?array $documentIds): array + { + $pending = $this->documentReplications[$collection] ?? []; + $earlier = $documentIds === null + ? \array_values($pending) + : \array_values(\array_intersect_key($pending, \array_flip($documentIds))); + + if (isset($this->collectionReplications[$collection])) { + $earlier[] = $this->collectionReplications[$collection]; + } + + $unique = []; + foreach ($earlier as $replication) { + $unique[\spl_object_id($replication)] = $replication; + } + + return $unique; + } + + /** + * @param array|null $documentIds + */ + private function queueReplication(string $collection, ?array $documentIds, Channel $finished): void + { + if ($documentIds === null) { + unset($this->documentReplications[$collection]); + $this->collectionReplications[$collection] = $finished; + + return; + } + + foreach ($documentIds as $id) { + $this->documentReplications[$collection][$id] = $finished; + } + } + + /** + * @param array|null $documentIds + */ + private function releaseReplication(string $collection, ?array $documentIds, Channel $finished): void + { + $finished->close(); + + if ($documentIds === null) { + if (($this->collectionReplications[$collection] ?? null) === $finished) { + unset($this->collectionReplications[$collection]); + } + + return; + } + + foreach ($documentIds as $id) { + if (($this->documentReplications[$collection][$id] ?? null) === $finished) { + unset($this->documentReplications[$collection][$id]); + } + } + + if (($this->documentReplications[$collection] ?? null) === []) { + unset($this->documentReplications[$collection]); + } + } + + /** + * @param array $documents + * @return array + */ + private static function documentIds(array $documents): array + { + return \array_map(static fn (Document $document): string => $document->getId(), $documents); + } + + protected function logError(string $action, Throwable $err): void { foreach ($this->errorCallbacks as $callback) { $callback($action, $err); } } + /** + * {@inheritdoc} + */ public function setAuthorization(Authorization $authorization): self { parent::setAuthorization($authorization); - if (isset($this->source)) { - $this->source->setAuthorization($authorization); - } - if (isset($this->destination)) { + $this->source->setAuthorization($authorization); + + if ($this->destination !== null) { $this->destination->setAuthorization($authorization); } return $this; } + /** + * {@inheritdoc} + */ + public function addHook(\Utopia\Query\Hook $hook): static + { + if ($hook instanceof Lifecycle) { + $this->addLifecycleHook($hook); + } else { + parent::addHook($hook); + } + + if ($hook instanceof Relationships) { + $this->source->addHook(new Relationships($this->source)); + $this->destination?->addHook(new Relationships($this->destination)); + } + + if ($hook instanceof Write) { + $this->destination?->getAdapter()->addWriteHook($hook); + } + + return $this; + } + /** * 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 */ public function setDocumentType(string $collection, string $className): static { $this->delegate(__FUNCTION__, \func_get_args()); $this->documentTypes[$collection] = $className; + return $this; } /** * 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 { @@ -1171,8 +2024,6 @@ public function clearDocumentType(string $collection): static /** * Clear all document type mappings - * - * @return static */ public function clearAllDocumentTypes(): static { @@ -1181,5 +2032,4 @@ public function clearAllDocumentTypes(): static return $this; } - } diff --git a/src/Database/Mirroring/Filter.php b/src/Database/Mirroring/Filter.php index 2da00534b1..b1e61b2714 100644 --- a/src/Database/Mirroring/Filter.php +++ b/src/Database/Mirroring/Filter.php @@ -6,13 +6,16 @@ use Utopia\Database\Document; use Utopia\Database\Query; +/** + * Abstract filter for intercepting and transforming mirrored database operations between source and destination. + */ abstract class Filter { /** * Called before any action is executed, when the filter is constructed. * - * @param Database $source - * @param ?Database $destination + * @param Database $source The source database instance + * @param Database|null $destination The destination database instance, or null if unavailable * @return void */ public function init( @@ -24,8 +27,8 @@ public function init( /** * Called after all actions are executed, when the filter is destructed. * - * @param Database $source - * @param ?Database $destination + * @param Database $source The source database instance + * @param Database|null $destination The destination database instance, or null if unavailable * @return void */ public function shutdown( @@ -35,13 +38,13 @@ public function shutdown( } /** - * Called before collection is created in the destination database + * Called before a collection is created in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param ?Document $collection - * @return ?Document + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document|null $collection The collection document, or null to skip creation + * @return Document|null The possibly transformed collection document, or null to skip */ public function beforeCreateCollection( Database $source, @@ -53,13 +56,13 @@ public function beforeCreateCollection( } /** - * Called before collection is updated in the destination database + * Called before a collection is updated in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param ?Document $collection - * @return ?Document + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document|null $collection The collection document, or null to skip update + * @return Document|null The possibly transformed collection document, or null to skip */ public function beforeUpdateCollection( Database $source, @@ -71,11 +74,11 @@ public function beforeUpdateCollection( } /** - * Called after collection is deleted in the destination database + * Called before a collection is deleted in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier * @return void */ public function beforeDeleteCollection( @@ -86,12 +89,14 @@ public function beforeDeleteCollection( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $attributeId - * @param ?Document $attribute - * @return ?Document + * Called before an attribute is created 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 $attributeId The attribute identifier + * @param Document|null $attribute The attribute document, or null to skip creation + * @return Document|null The possibly transformed attribute document, or null to skip */ public function beforeCreateAttribute( Database $source, @@ -104,12 +109,14 @@ public function beforeCreateAttribute( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $attributeId - * @param ?Document $attribute - * @return ?Document + * Called before an attribute is 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 string $attributeId The attribute identifier + * @param Document|null $attribute The attribute document, or null to skip update + * @return Document|null The possibly transformed attribute document, or null to skip */ public function beforeUpdateAttribute( Database $source, @@ -122,10 +129,12 @@ public function beforeUpdateAttribute( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $attributeId + * Called before an attribute 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 $attributeId The attribute identifier * @return void */ public function beforeDeleteAttribute( @@ -136,15 +145,15 @@ public function beforeDeleteAttribute( ): void { } - // Indexes - /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $indexId - * @param ?Document $index - * @return ?Document + * Called before an index is created 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 $indexId The index identifier + * @param Document|null $index The index document, or null to skip creation + * @return Document|null The possibly transformed index document, or null to skip */ public function beforeCreateIndex( Database $source, @@ -157,12 +166,14 @@ public function beforeCreateIndex( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $indexId - * @param ?Document $index - * @return ?Document + * Called before an index is 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 string $indexId The index identifier + * @param Document|null $index The index document, or null to skip update + * @return Document|null The possibly transformed index document, or null to skip */ public function beforeUpdateIndex( Database $source, @@ -175,10 +186,12 @@ public function beforeUpdateIndex( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $indexId + * Called before an index 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 $indexId The index identifier * @return void */ public function beforeDeleteIndex( @@ -190,13 +203,13 @@ public function beforeDeleteIndex( } /** - * Called before document is created in the destination database + * Called before a document is created in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document + * @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 create + * @return Document The possibly transformed document */ public function beforeCreateDocument( Database $source, @@ -208,13 +221,13 @@ public function beforeCreateDocument( } /** - * Called after document is created in the destination database + * Called after a document is created in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document $document The created document + * @return Document The possibly transformed document */ public function afterCreateDocument( Database $source, @@ -226,13 +239,13 @@ public function afterCreateDocument( } /** - * Called before document is updated in the destination database + * Called before a document is updated in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document + * @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 update + * @return Document The possibly transformed document */ public function beforeUpdateDocument( Database $source, @@ -244,13 +257,13 @@ public function beforeUpdateDocument( } /** - * Called after document is updated in the destination database + * Called after a document is updated in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document + * @param Database $source The source database instance + * @param Database $destination The destination database instance + * @param string $collectionId The collection identifier + * @param Document $document The updated document + * @return Document The possibly transformed document */ public function afterUpdateDocument( Database $source, @@ -262,12 +275,14 @@ public function afterUpdateDocument( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $updates - * @param array $queries - * @return Document + * Called before 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 to update + * @return Document The possibly transformed updates document */ public function beforeUpdateDocuments( Database $source, @@ -280,11 +295,13 @@ public function beforeUpdateDocuments( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $updates - * @param array $queries + * 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( @@ -297,12 +314,12 @@ public function afterUpdateDocuments( } /** - * Called before document is deleted in the destination database + * Called before a document is deleted in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $documentId + * @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( @@ -314,12 +331,12 @@ public function beforeDeleteDocument( } /** - * Called after document is deleted in the destination database + * Called after a document is deleted in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param string $documentId + * @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( @@ -331,10 +348,12 @@ public function afterDeleteDocument( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param array $queries + * 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( @@ -346,10 +365,12 @@ public function beforeDeleteDocuments( } /** - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param array $queries + * 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( @@ -361,13 +382,13 @@ public function afterDeleteDocuments( } /** - * Called before document is upserted in the destination database + * Called before a document is upserted in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document + * @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, @@ -379,13 +400,13 @@ public function beforeCreateOrUpdateDocument( } /** - * Called after document is upserted in the destination database + * Called after a document is upserted in the destination database. * - * @param Database $source - * @param Database $destination - * @param string $collectionId - * @param Document $document - * @return Document + * @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, diff --git a/src/Database/Operator.php b/src/Database/Operator.php index c43d767097..a59365a7df 100644 --- a/src/Database/Operator.php +++ b/src/Database/Operator.php @@ -13,132 +13,37 @@ */ class Operator { - // Numeric operation types - public const TYPE_INCREMENT = 'increment'; - public const TYPE_DECREMENT = 'decrement'; - public const TYPE_MODULO = 'modulo'; - public const TYPE_POWER = 'power'; - public const TYPE_MULTIPLY = 'multiply'; - public const TYPE_DIVIDE = 'divide'; - - // Array operation types - public const TYPE_ARRAY_APPEND = 'arrayAppend'; - public const TYPE_ARRAY_PREPEND = 'arrayPrepend'; - public const TYPE_ARRAY_INSERT = 'arrayInsert'; - public const TYPE_ARRAY_REMOVE = 'arrayRemove'; - public const TYPE_ARRAY_UNIQUE = 'arrayUnique'; - public const TYPE_ARRAY_INTERSECT = 'arrayIntersect'; - public const TYPE_ARRAY_DIFF = 'arrayDiff'; - public const TYPE_ARRAY_FILTER = 'arrayFilter'; - - // String operation types - public const TYPE_STRING_CONCAT = 'stringConcat'; - public const TYPE_STRING_REPLACE = 'stringReplace'; - - // Boolean operation types - public const TYPE_TOGGLE = 'toggle'; - - // Date operation types - public const TYPE_DATE_ADD_DAYS = 'dateAddDays'; - public const TYPE_DATE_SUB_DAYS = 'dateSubDays'; - public const TYPE_DATE_SET_NOW = 'dateSetNow'; - - public const TYPES = [ - 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, - ]; - /** * Maximum number of values a single array operator (append/prepend/intersect/diff) may carry, * to guard against memory exhaustion. Enforced consistently across all adapters. */ - public const MAX_ARRAY_OPERATOR_SIZE = 10000; + public const int MAX_ARRAY_OPERATOR_SIZE = 10_000; /** * Conditions accepted by the arrayFilter operator. */ - public const ARRAY_FILTER_CONDITIONS = [ + public const array ARRAY_FILTER_CONDITIONS = [ 'equal', 'notEqual', // comparison 'greaterThan', 'greaterThanEqual', 'lessThan', 'lessThanEqual', // numeric 'isNull', 'isNotNull', // null checks ]; - - protected const NUMERIC_TYPES = [ - self::TYPE_INCREMENT, - self::TYPE_DECREMENT, - self::TYPE_MULTIPLY, - self::TYPE_DIVIDE, - self::TYPE_MODULO, - self::TYPE_POWER, - ]; - - protected const ARRAY_TYPES = [ - 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, - ]; - - protected const STRING_TYPES = [ - self::TYPE_STRING_CONCAT, - self::TYPE_STRING_REPLACE, - ]; - - protected const BOOLEAN_TYPES = [ - self::TYPE_TOGGLE, - ]; - - - protected const DATE_TYPES = [ - self::TYPE_DATE_ADD_DAYS, - self::TYPE_DATE_SUB_DAYS, - self::TYPE_DATE_SET_NOW, - ]; - - protected string $method = ''; - protected string $attribute = ''; - - /** - * @var array - */ - 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..56f40270e4 100644 --- a/src/Database/PDO.php +++ b/src/Database/PDO.php @@ -2,25 +2,71 @@ 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 ?string $hostname = null; /** - * @param string $dsn - * @param ?string $username - * @param ?string $password - * @param array $config + * Whether a reconnect dropped the caller's open transaction and 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. + */ + private bool $lostTransaction = false; + + /** + * 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 +74,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 +100,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 +119,45 @@ 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 && \strcasecmp($method, 'rollBack') === 0) { + $this->lostTransaction = false; + + 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); } + + $this->lostTransaction = true; } throw $e; @@ -116,13 +165,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. * - * @return void + * 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. + * + * @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 = false; + } + + /** + * 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) { + return; + } + + if (\strcasecmp(\trim($statement), self::ROLLBACK) === 0) { + $this->lostTransaction = false; + + return; + } + + throw new PDOException('The transaction was lost with the connection: roll it back before running another statement'); + } + + 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 +264,15 @@ public function reconnect(): void ); } + public function inTransaction(): bool + { + return $this->lostTransaction || $this->pdo->inTransaction(); + } + /** * Get the hostname from the DSN. * - * @return string - * @throws \Exception + * @throws Exception */ public function getHostname(): string { @@ -144,7 +282,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 +294,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.'); } @@ -171,6 +310,7 @@ private function parseDsn(string $dsn): array // Handle “path only” DSNs like sqlite:/path/to.db if (\in_array($driver, ['sqlite'], true) && $parameterString !== '') { $parsed['path'] = \ltrim($parameterString, '/'); + return $parsed; } @@ -179,7 +319,7 @@ 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 diff --git a/src/Database/PDOStatement.php b/src/Database/PDOStatement.php index 5dbfc4d689..af734ca04e 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 @@ -139,7 +141,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 +186,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/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 @@ + $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/Profiler/QueryProfiler.php b/src/Database/Profiler/QueryProfiler.php new file mode 100644 index 0000000000..473560bd9c --- /dev/null +++ b/src/Database/Profiler/QueryProfiler.php @@ -0,0 +1,199 @@ + */ + 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 callable|null */ + private $onSlowQuery = null; + + public function enable(): void + { + $this->enabled = true; + } + + public function disable(): void + { + $this->enabled = false; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setSlowThreshold(float $milliseconds): void + { + $this->slowThreshold = $milliseconds; + } + + /** + * Keep at most this many of the newest entries. + * + * @throws InvalidArgumentException + */ + public function setCapacity(int $capacity): void + { + 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; + } + + public function getCapacity(): int + { + return $this->capacity; + } + + public function enableBacktrace(bool $enabled = true): void + { + $this->captureBacktrace = $enabled; + } + + public function onSlowQuery(callable $callback): void + { + $this->onSlowQuery = $callback; + } + + /** + * @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 QueryLog( + 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 (QueryLog $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(QueryLog $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/Query.php b/src/Database/Query.php index 147c463ad0..15717ea3b7 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -2,427 +2,196 @@ namespace Utopia\Database; -use JsonException; use Utopia\Database\Exception\Query as QueryException; - -class Query +use Utopia\Query\CursorDirection; +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 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 const array LOGICAL_TYPES = [Method::And, Method::Or, Method::ElemMatch]; - public function __clone(): void - { - foreach ($this->values as $index => $value) { - if ($value instanceof self) { - $this->values[$index] = clone $value; - } - } - } + public const TYPE_ELEM_MATCH = '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 = []) { - 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); } /** - * Sets method - * - * @param string $method - * @return self + * @throws QueryException */ - public function setMethod(string $method): self + 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()); + } 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 + 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()); + } 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 $query + * @return array * - * @param array $values - * @return self + * @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)); + } + + $values[$index] = self::parse($value, $allowRaw)->toArray(); + } + + $query['values'] = $values; - return $this; + return $query; } /** - * Check if method is supported - * - * @param string $value - * @return bool + * @param Document $value */ - public static function isMethod(string $value): bool + public static function cursorAfter(mixed $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 Document $value */ - public function isSpatialQuery(): bool + public static function cursorBefore(mixed $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 + 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 + 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 + 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; } /** @@ -476,6 +245,8 @@ 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 */ public function shape(): string { @@ -487,6 +258,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 +278,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 +290,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)]; @@ -526,20 +301,25 @@ public function shape(): string */ 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 (\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])) { + if ($value instanceof Document && in_array($this->method, [Method::CursorAfter, Method::CursorBefore])) { $value = $value->getId(); } $array['values'][] = $value; @@ -550,838 +330,103 @@ 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 + * Iterates through queries and groups them by type, + * returning the result in the Database-specific array format + * with string order types and cursor directions. * - * @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 - * - * @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 string $start - * @param string $end - * @return Query - */ - public static function updatedBetween(string $start, string $end): self - { - 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); - } - - /** - * @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; - } - } - - 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, + * aggregations: array, + * groupBy: array, + * having: array, + * joins: array, + * distinct: bool, * limit: int|null, * offset: int|null, * orderAttributes: array, - * orderTypes: array, + * orderTypes: array, * cursor: Document|null, - * cursorDirection: string|null + * cursorDirection: CursorDirection|null * } */ - public static function groupByType(array $queries): array - { - $filters = []; - $selections = []; - $limit = null; - $offset = null; + public static function groupForDatabase(array $queries): array + { + $grouped = parent::groupByType($queries); + + /** @var array $filters */ + $filters = $grouped->filters; + /** @var array $selections */ + $selections = $grouped->selections; + /** @var array $aggregations */ + $aggregations = $grouped->aggregations; + /** @var array $having */ + $having = $grouped->having; + /** @var array $joins */ + $joins = $grouped->joins; + /** @var Document|null $cursor */ + $cursor = $grouped->cursor; + + // The base library's groupByType no longer tracks order attributes on + // ParsedQuery — order clauses are consumed directly by the compiler. + // Database adapters still take orderAttributes/orderTypes, so rebuild + // them here from the pending query list. $orderAttributes = []; $orderTypes = []; - $cursor = null; - $cursorDirection = null; - foreach ($queries as $query) { - if (!$query instanceof Query) { + $direction = match ($query->getMethod()) { + Method::OrderAsc => OrderDirection::Asc, + Method::OrderDesc => OrderDirection::Desc, + Method::OrderRandom => OrderDirection::Random, + default => null, + }; + + if ($direction === null) { 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; - } - - $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; - } + $orderAttributes[] = $query->getAttribute(); + $orderTypes[] = $direction; } + /** @var list $groupBy */ + $groupBy = $grouped->groupBy; return [ 'filters' => $filters, 'selections' => $selections, - 'limit' => $limit, - 'offset' => $offset, + 'aggregations' => $aggregations, + 'groupBy' => $groupBy, + 'having' => $having, + 'joins' => $joins, + 'distinct' => $grouped->distinct, + 'limit' => $grouped->limit, + 'offset' => $grouped->offset, 'orderAttributes' => $orderAttributes, 'orderTypes' => $orderTypes, 'cursor' => $cursor, - 'cursorDirection' => $cursorDirection, + 'cursorDirection' => $grouped->cursorDirection, ]; } /** - * Is this query able to contain other queries + * Check whether this query targets a spatial attribute type (point, linestring, or polygon). * - * @return bool - */ - public function isNested(): bool - { - if (in_array($this->getMethod(), self::LOGICAL_TYPES)) { - return true; - } - - 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 + * @return bool True if the attribute type is spatial. */ 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]); + $type = ColumnType::tryFrom($this->attributeType); + return in_array($type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true); } /** - * Helper method to create Query with notCrosses method + * Check whether this query targets an object (JSON/hashmap) attribute type. * - * @param string $attribute - * @param array $values - * @return Query + * @return bool True if the attribute type is object. */ - 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]); - } - - /** - * Helper method to create Query with exists method - * - * @param array $attributes - * @return Query - */ - public static function exists(array $attributes): self - { - return new self(self::TYPE_EXISTS, '', $attributes); - } - - /** - * Helper method to create Query with notExists method - * - * @param string|int|float|bool|array $attribute - * @return Query - */ - 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/RelationSide.php b/src/Database/RelationSide.php new file mode 100644 index 0000000000..1c0abacbd1 --- /dev/null +++ b/src/Database/RelationSide.php @@ -0,0 +1,12 @@ + $key, + 'key' => $key, + 'collection' => $collection, + 'relatedCollection' => $relatedCollection, + 'relationType' => $type->value, + 'twoWay' => $twoWay, + 'twoWayKey' => $twoWayKey, + 'onDelete' => $onDelete->value, + 'side' => $side->value, + ]); + } + + /** + * @return ( + * $name is 'collection' ? string : + * $name is 'relatedCollection' ? string : + * $name is 'type' ? RelationType : + * $name is 'twoWay' ? bool : + * $name is 'key' ? string : + * $name is 'twoWayKey' ? string : + * $name is 'onDelete' ? ForeignKeyAction : + * $name is 'side' ? RelationSide : + * mixed + * ) + */ + public function __get(string $name): mixed + { + switch ($name) { + case 'collection': + /** @var string $collection */ + $collection = $this->getAttribute('collection', ''); + + return $collection; + case 'relatedCollection': + /** @var string $relatedCollection */ + $relatedCollection = $this->getAttribute('relatedCollection', ''); + + return $relatedCollection; + case 'type': + $type = $this->getAttribute('relationType', RelationType::OneToOne->value); + if ($type instanceof RelationType) { + return $type; + } + + return RelationType::from(\is_string($type) ? $type : RelationType::OneToOne->value); + case 'twoWay': + return (bool) $this->getAttribute('twoWay', false); + case 'key': + /** @var string $key */ + $key = $this->getAttribute('key', $this->getId()); + + return $key; + case 'twoWayKey': + /** @var string $twoWayKey */ + $twoWayKey = $this->getAttribute('twoWayKey', ''); + + return $twoWayKey; + case 'onDelete': + $onDelete = $this->getAttribute('onDelete', ForeignKeyAction::Restrict->value); + if ($onDelete instanceof ForeignKeyAction) { + return $onDelete; + } + + return ForeignKeyAction::from(\is_string($onDelete) ? $onDelete : ForeignKeyAction::Restrict->value); + case 'side': + $side = $this->getAttribute('side', RelationSide::Parent->value); + if ($side instanceof RelationSide) { + return $side; + } + + return RelationSide::from(\is_string($side) ? $side : RelationSide::Parent->value); + default: + return $this->getAttribute($name); + } + } + + public function __set(string $name, mixed $value): void + { + match ($name) { + 'collection' => $this->setAttribute('collection', $value), + 'relatedCollection' => $this->setAttribute('relatedCollection', $value), + 'type' => $this->setAttribute('relationType', $value instanceof RelationType ? $value->value : $value), + 'twoWay' => $this->setAttribute('twoWay', $value), + 'key' => $this->setAttribute('key', $value)->setAttribute(self::ID, $value), + 'twoWayKey' => $this->setAttribute('twoWayKey', $value), + 'onDelete' => $this->setAttribute('onDelete', $value instanceof ForeignKeyAction ? $value->value : $value), + 'side' => $this->setAttribute('side', $value instanceof RelationSide ? $value->value : $value), + default => $this->setAttribute($name, $value), + }; + } + + public function __isset(string $name): bool + { + return match ($name) { + 'collection', 'relatedCollection', 'type', 'twoWay', 'key', 'twoWayKey', 'onDelete', 'side' => true, + default => $this->offsetExists($name), + }; + } + + public static function oneToOne( + string $collection, + string $relatedCollection, + bool $twoWay = false, + string $key = '', + string $twoWayKey = '', + ForeignKeyAction $onDelete = ForeignKeyAction::Restrict, + RelationSide $side = RelationSide::Parent, + ): self { + return new self( + collection: $collection, + relatedCollection: $relatedCollection, + type: RelationType::OneToOne, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ); + } + + public static function oneToMany( + string $collection, + string $relatedCollection, + bool $twoWay = false, + string $key = '', + string $twoWayKey = '', + ForeignKeyAction $onDelete = ForeignKeyAction::Restrict, + RelationSide $side = RelationSide::Parent, + ): self { + return new self( + collection: $collection, + relatedCollection: $relatedCollection, + type: RelationType::OneToMany, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ); + } + + public static function manyToOne( + string $collection, + string $relatedCollection, + bool $twoWay = false, + string $key = '', + string $twoWayKey = '', + ForeignKeyAction $onDelete = ForeignKeyAction::Restrict, + RelationSide $side = RelationSide::Parent, + ): self { + return new self( + collection: $collection, + relatedCollection: $relatedCollection, + type: RelationType::ManyToOne, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ); + } + + public static function manyToMany( + string $collection, + string $relatedCollection, + bool $twoWay = false, + string $key = '', + string $twoWayKey = '', + ForeignKeyAction $onDelete = ForeignKeyAction::Restrict, + RelationSide $side = RelationSide::Parent, + ): self { + return new self( + collection: $collection, + relatedCollection: $relatedCollection, + type: RelationType::ManyToMany, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ); + } + + /** + * @return Document + */ + public function toDocument(): Document + { + return new Document([ + 'relatedCollection' => $this->relatedCollection, + 'relationType' => $this->type->value, + 'twoWay' => $this->twoWay, + 'twoWayKey' => $this->twoWayKey, + 'onDelete' => $this->onDelete->value, + 'side' => $this->side->value, + ]); + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $options = $data['options'] ?? []; + if ($options instanceof Document) { + $options = $options->getArrayCopy(); + } + if (! \is_array($options)) { + $options = []; + } + + /** @var string $key */ + $key = $data[self::ID] ?? $data['key'] ?? ''; + /** @var string $collection */ + $collection = $data['collection'] ?? ''; + /** @var string $relatedCollection */ + $relatedCollection = $data['relatedCollection'] ?? $options['relatedCollection'] ?? ''; + /** @var RelationType|string $type */ + $type = $data['relationType'] ?? $options['relationType'] ?? RelationType::OneToOne->value; + /** @var bool $twoWay */ + $twoWay = $data['twoWay'] ?? $options['twoWay'] ?? false; + /** @var string $twoWayKey */ + $twoWayKey = $data['twoWayKey'] ?? $options['twoWayKey'] ?? ''; + /** @var ForeignKeyAction|string $onDelete */ + $onDelete = $data['onDelete'] ?? $options['onDelete'] ?? ForeignKeyAction::Restrict; + /** @var RelationSide|string $side */ + $side = $data['side'] ?? $options['side'] ?? RelationSide::Parent; + + $relationship = self::make( + collection: $collection, + relatedCollection: $relatedCollection, + type: $type instanceof RelationType ? $type : RelationType::from((string) $type), + twoWay: (bool) $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete instanceof ForeignKeyAction ? $onDelete : ForeignKeyAction::from((string) $onDelete), + side: $side instanceof RelationSide ? $side : RelationSide::from((string) $side), + ); + + foreach ($data as $name => $value) { + if (\in_array($name, [ + self::ID, + 'key', + 'collection', + 'relatedCollection', + 'relationType', + 'twoWay', + 'twoWayKey', + 'onDelete', + 'side', + 'options', + ], true)) { + continue; + } + $relationship->setAttribute($name, $value); + } + + return $relationship; + } + + /** + * Create a Relationship instance from a collection ID and attribute Document. + * + * @param string $collection The parent collection ID + * @param Attribute|Document $attribute The relationship attribute + * @return self + */ + public static function fromDocument(string $collection, Attribute|Document $attribute): self + { + $data = $attribute->getArrayCopy(); + $data['collection'] = $collection; + + return self::fromArray($data); + } + + private static function make( + string $collection, + string $relatedCollection, + RelationType $type, + bool $twoWay, + string $key, + string $twoWayKey, + ForeignKeyAction $onDelete, + RelationSide $side, + ): self { + return match ($type) { + RelationType::OneToOne => self::oneToOne( + collection: $collection, + relatedCollection: $relatedCollection, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ), + RelationType::OneToMany => self::oneToMany( + collection: $collection, + relatedCollection: $relatedCollection, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ), + RelationType::ManyToOne => self::manyToOne( + collection: $collection, + relatedCollection: $relatedCollection, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ), + RelationType::ManyToMany => self::manyToMany( + collection: $collection, + relatedCollection: $relatedCollection, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: $side, + ), + }; + } +} 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 -1 outside coroutines + * @param T $value + * @param Scope|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 ?Scope $outer, + ) { + } +} diff --git a/src/Database/State/Snapshot.php b/src/Database/State/Snapshot.php new file mode 100644 index 0000000000..e041930771 --- /dev/null +++ b/src/Database/State/Snapshot.php @@ -0,0 +1,49 @@ + $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 $skipDuplicates 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 $skipDuplicates, + public ?DateTime $requestTimestamp, + ) { + } +} diff --git a/src/Database/State/Value.php b/src/Database/State/Value.php new file mode 100644 index 0000000000..4b34491684 --- /dev/null +++ b/src/Database/State/Value.php @@ -0,0 +1,173 @@ +> 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) + { + 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->open === 0) { + $this->value = $value; + + return; + } + + $writer = self::coroutine(); + $coroutine = $writer; + while (! isset($this->scopes[$coroutine])) { + if ($coroutine === self::OUTSIDE) { + $this->value = $value; + + return; + } + + $coroutine = self::parent($coroutine); + } + + $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 Scope($coroutine, $value, $this->scopes[$coroutine] ?? null); + $this->scopes[$coroutine] = $scope; + $this->open++; + + try { + return $callback(); + } finally { + $this->open--; + + 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 Scope $scope + * @return T + */ + private static function inherited(Scope $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; + } + + 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..59af64cac6 --- /dev/null +++ b/src/Database/Storage.php @@ -0,0 +1,116 @@ + + */ + 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::PERMS_SUFFIX; + } + + /** + * The alias a join that declares none has its values returned under: the prefix and the join's + * position among the query's joins, or the next number no other alias of the query takes. + * + * @param array $taken Lower-cased aliases in use; the alias returned is added + */ + public static function joinAlias(int $position, array &$taken): string + { + do { + $alias = self::JOIN_ALIAS_PREFIX.$position++; + } while (isset($taken[$alias])); + + $taken[$alias] = true; + + return $alias; + } +} diff --git a/src/Database/Traits/Attributes.php b/src/Database/Traits/Attributes.php new file mode 100644 index 0000000000..8031797eb1 --- /dev/null +++ b/src/Database/Traits/Attributes.php @@ -0,0 +1,1456 @@ + + */ + private const array COLUMN_TYPE_SPELLINGS = [ + '/\s+/' => ' ', + '/ (NOT )?NULL$/' => '', + '/^(POINT|LINESTRING|POLYGON)\b.*$/' => '$1', + '/\b(TINYINT|SMALLINT|MEDIUMINT|INT|INTEGER|BIGINT)\(\d+\)/' => '$1', + ]; + + /** + * Create Attribute + * + * @param string $collection The collection identifier + * @param Attribute $attribute The attribute definition to create + * @return bool True if the attribute was created successfully + * + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + * @throws Exception + */ + public function createAttribute(string $collection, Attribute $attribute): bool + { + $attribute = clone $attribute; + $id = $attribute->key; + $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; + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + if (in_array($type, Database::ATTRIBUTE_FILTER_COLUMN_TYPES, true)) { + $filters[] = $type->value; + $filters = array_unique($filters); + $attribute->filters = $filters; + $attribute->setAttribute('filters', $filters); + } + + $existsInSchema = false; + + $schemaAttributes = $this->adapter->hasFeature(Feature\SchemaAttributes::class) + ? $this->getSchemaAttributes($collection->getId()) + : []; + + try { + $attribute = $this->validateAttribute( + $collection, + $id, + $type->value, + $size, + $required, + $default, + $signed, + $array, + $format, + $formatOptions, + $filters, + $schemaAttributes + ); + } catch (DuplicateException $e) { + $existsInSchema = $this->reconcileSchemaOnlyColumn($collection, $attribute, $schemaAttributes, $e); + } + + $created = false; + + if (! $existsInSchema) { + try { + $created = $this->adapter->createAttribute($collection->getId(), $attribute); + + if (! $created) { + throw new DatabaseException('Failed to create attribute'); + } + } catch (MismatchException $e) { + throw $e; + } 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, SetType::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())); + + $this->triggerHooks(Event::DocumentPurge, new Document([ + Document::ID => $collection->getId(), + Document::COLLECTION => self::METADATA, + ])); + + $this->triggerHooks( + Event::AttributeCreate, + $attribute->toDocument()->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return true; + } + + /** + * Create Attributes + * + * @param string $collection The collection identifier + * @param array $attributes The attribute definitions to create + * @return bool True if the attributes were created successfully + * + * @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'); + } + + $attributes = \array_map(static fn (Attribute $attribute): Attribute => clone $attribute, $attributes); + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + $schemaAttributes = $this->adapter->hasFeature(Feature\SchemaAttributes::class) + ? $this->getSchemaAttributes($collection->getId()) + : []; + + $attributeModels = []; + $attributesToCreate = []; + foreach ($attributes as $attribute) { + if (empty($attribute->key)) { + throw new DatabaseException('Missing attribute key'); + } + + if (in_array($attribute->type, Database::ATTRIBUTE_FILTER_COLUMN_TYPES, true)) { + $attribute->filters = array_values( + array_unique(array_merge($attribute->filters, [$attribute->type->value])) + ); + } + + $existsInSchema = false; + + try { + $attribute = $this->validateAttribute( + $collection, + $attribute->key, + $attribute->type->value, + $attribute->size, + $attribute->required, + $attribute->default, + $attribute->signed, + $attribute->array, + $attribute->format, + $attribute->formatOptions, + $attribute->filters, + $schemaAttributes + ); + } catch (DuplicateException $e) { + $existsInSchema = $this->reconcileSchemaOnlyColumn($collection, $attribute, $schemaAttributes, $e); + } + + $attributeModels[] = $attribute; + if (! $existsInSchema) { + $attributesToCreate[] = $attribute; + } + } + + $createdAttributes = []; + + if (! empty($attributesToCreate)) { + try { + if (! $this->adapter->createAttributes($collection->getId(), $attributesToCreate)) { + throw new DatabaseException('Failed to create attributes'); + } + $createdAttributes = $attributesToCreate; + } catch (MismatchException $e) { + throw $e; + } 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 + ); + $createdAttributes[] = $attr; + } catch (MismatchException $e) { + throw $e; + } catch (DuplicateException) { + // Column already exists in schema — skip + } + } + } + } + + foreach ($attributeModels as $attributeModel) { + $collection->setAttribute('attributes', $attributeModel, SetType::Append); + } + + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupAttributes($collection->getId(), $createdAttributes), + shouldRollback: $createdAttributes !== [], + operationDescription: 'attributes creation', + rollbackReturnsErrors: true + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + + $this->triggerHooks(Event::DocumentPurge, new Document([ + Document::ID => $collection->getId(), + Document::COLLECTION => self::METADATA, + ])); + + $createdAttributes = \array_map( + static fn (Attribute $attribute): Document => $attribute->toDocument() + ->setAttribute(Document::COLLECTION, $collection->getId()), + $attributeModels, + ); + + foreach ($createdAttributes as $createdAttribute) { + $this->triggerHooks(Event::AttributeCreate, $createdAttribute); + } + + $this->triggerHooks(Event::AttributesCreate, $createdAttributes); + + 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 array $schemaAttributes + * @return bool True when the existing column is reused + * + * @throws DuplicateException + */ + private function reconcileSchemaOnlyColumn( + Document $collection, + Attribute $attribute, + array $schemaAttributes, + DuplicateException $duplicate, + ): bool { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + foreach ($attributes as $existing) { + if (\strtolower($existing->key) === \strtolower($attribute->key)) { + throw $duplicate; + } + } + + if (! $this->adapter->hasFeature(Feature\ColumnTypes::class)) { + return true; + } + + $expected = $this->adapter->getColumnType( + $attribute->type->value, + $attribute->size, + $attribute->signed, + $attribute->array, + $attribute->required, + ); + if ($expected === '') { + return true; + } + + $filteredId = \strtolower($this->adapter->filter($attribute->key)); + foreach ($schemaAttributes as $column) { + if (\strtolower($column->getId()) !== $filteredId) { + continue; + } + + $columnType = $column->getAttribute('columnType', ''); + if (self::canonicalColumnType(\is_string($columnType) ? $columnType : '') === self::canonicalColumnType($expected)) { + return true; + } + + if ($this->getSharedTables()) { + throw new DuplicateException('Attribute exists in the shared table with another type', previous: $duplicate); + } + + $this->adapter->deleteAttribute($collection->getId(), $attribute->key); + + return false; + } + + return true; + } + + /** + * Engines report integer display widths (int(11)), spatial types without their SRID or + * nullability, and MariaDB's JSON as LONGTEXT. + */ + private static function canonicalColumnType(string $columnType): string + { + $canonical = \preg_replace( + \array_keys(self::COLUMN_TYPE_SPELLINGS), + \array_values(self::COLUMN_TYPE_SPELLINGS), + \strtoupper(\trim($columnType)), + ) ?? $columnType; + + return $canonical === 'JSON' ? 'LONGTEXT' : $canonical; + } + + /** + * @param array $formatOptions + * @param array $filters + * @param array|null $schemaAttributes Pre-fetched schema attributes, or null to fetch internally + * + * @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 + ): Attribute { + $type = Attribute::normalizeType($type)->value; + if ($type === ColumnType::BigInteger->value) { + $size = 0; + } + + $attribute = Attribute::fromArray([ + Document::ID => $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, SetType::Append); + + /** @var array $existingAttributes */ + $existingAttributes = $collection->getAttribute('attributes', []); + + $resolvedSchemaAttributes = $schemaAttributes ?? ($this->adapter->hasFeature(Feature\SchemaAttributes::class) + ? $this->getSchemaAttributes($collection->getId()) + : []); + $typedSchemaAttrs = []; + foreach ($resolvedSchemaAttributes as $schemaAttribute) { + $typedSchemaAttrs[] = Attribute::fromArray($schemaAttribute->getArrayCopy()); + } + + $validator = new AttributeValidator( + attributes: $existingAttributes, + schemaAttributes: $typedSchemaAttrs, + 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->hasFeature(Feature\SchemaAttributes::class), + supportForVectors: $this->adapter->supports(Capability::Vectors), + supportForSpatialAttributes: $this->adapter->hasFeature(Feature\Spatial::class), + supportForObject: $this->adapter->supports(Capability::Objects), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt), + attributeCountCallback: fn (Document $attrDoc) => $this->adapter->getCountOfAttributes($collectionClone), + attributeWidthCallback: fn (Document $attrDoc) => $this->adapter->getAttributeWidth($collectionClone), + filterCallback: fn (string $filterId) => $this->adapter->filter($filterId), + 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) { + ColumnType::Datetime->value => ['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 + * + * @throws DatabaseException + */ + protected function validateDefaultTypes(string $type, mixed $default, bool $signed = true): void + { + $type = Attribute::normalizeType($type); + $defaultType = \gettype($default); + + if ($defaultType === 'NULL') { + // Disable null. No validation required + return; + } + + if ($defaultType === 'array') { + if (Attribute::isSpatialType($type)) { + $spatial = new SpatialValidator($type->value); + if (! $spatial->isValid($default)) { + throw new DatabaseException('Invalid default value: '.$spatial->getDescription()); + } + + return; + } + + if ($type !== ColumnType::Object) { + /** @var array $defaultArr */ + $defaultArr = $default; + foreach ($defaultArr as $value) { + $this->validateDefaultTypes($type->value, $value, $signed); + } + } + + return; + } + + $defaultStr = \is_scalar($default) ? (string) $default : '[non-scalar]'; + + switch ($type) { + case ColumnType::String: + case ColumnType::Varchar: + case ColumnType::Text: + case ColumnType::MediumText: + case ColumnType::LongText: + if ($defaultType !== 'string') { + throw new DatabaseException('Default value '.$defaultStr.' does not match given type '.Attribute::persistedType($type)); + } + break; + case ColumnType::Integer: + case ColumnType::Boolean: + if ($type->value !== $defaultType) { + throw new DatabaseException('Default value '.$defaultStr.' does not match given type '.Attribute::persistedType($type)); + } + break; + case ColumnType::BigInteger: + if (! (new BigInt($signed, $this->adapter->supports(Capability::UnsignedBigInt)))->isValid($default)) { + throw new DatabaseException('Default value '.$defaultStr.' does not match given type '.Attribute::persistedType($type)); + } + break; + case ColumnType::Float: + case ColumnType::Double: + if ($defaultType !== 'double') { + throw new DatabaseException('Default value '.$defaultStr.' does not match given type '.Attribute::persistedType($type)); + } + break; + case ColumnType::Datetime: + if ($defaultType !== ColumnType::String->value) { + throw new DatabaseException('Default value '.$defaultStr.' does not match given type '.Attribute::persistedType($type)); + } + break; + case ColumnType::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: + throw \in_array($type, Attribute::TYPES, true) + ? new DatabaseException('Default value '.$defaultStr.' does not match given type '.Attribute::persistedType($type)) + : $this->unknownType($type->value); + } + } + + private function unknownType(string $type): DatabaseException + { + $availableTypes = Attribute::availableTypes( + objects: $this->adapter->supports(Capability::Objects), + spatial: $this->adapter->hasFeature(Feature\Spatial::class), + vectors: $this->adapter->supports(Capability::Vectors), + ); + + return new DatabaseException('Unknown attribute type: '.$type.'. Must be one of '.\implode(', ', \array_map( + Attribute::persistedType(...), + $availableTypes, + ))); + } + + private function typeValidator(): AttributeValidator + { + return new AttributeValidator( + attributes: [], + maxStringLength: $this->adapter->getLimitForString(), + maxVarcharLength: $this->adapter->getMaxVarcharLength(), + maxIntLength: $this->adapter->getLimitForInt(), + maxBigIntLength: $this->adapter->getLimitForBigInt(), + supportForVectors: $this->adapter->supports(Capability::Vectors), + supportForSpatialAttributes: $this->adapter->hasFeature(Feature\Spatial::class), + supportForObject: $this->adapter->supports(Capability::Objects), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt), + ); + } + + /** + * Update attribute metadata. Utility method for update attribute methods. + * + * @param callable(Attribute, Document, int|string): void $updateCallback + * + * @throws ConflictException + * @throws DatabaseException + */ + protected function updateAttributeMeta(string $collection, string $id, callable $updateCallback, bool $triggerEvent = true): Attribute + { + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->getId() === self::METADATA) { + throw new DatabaseException('Cannot update metadata attributes'); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + $index = \array_search($id, \array_map(fn (Attribute $attribute) => $attribute->key, $attributes), true); + + if ($index === false) { + throw new NotFoundException('Attribute not found'); + } + + $attribute = $attributes[$index]; + + $updateCallback($attribute, $collection, $index); + $attributes[$index] = $attribute; + + $collection->setAttribute('attributes', $attributes); + + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "attribute metadata update '{$id}'" + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + + if ($triggerEvent) { + $this->triggerHooks( + Event::AttributeUpdate, + $attribute->toDocument()->setAttribute(Document::COLLECTION, $collection->getId()), + ); + } + + return $attribute; + } + + /** + * Update required status of attribute. + * + * @param string $collection The collection identifier + * @param string $id The attribute identifier + * @param bool $required Whether the attribute should be required + * @return Document The updated attribute + * + * @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); + })->toDocument(); + } + + /** + * Update format of attribute. + * + * @param string $collection The collection identifier + * @param string $id The attribute identifier + * @param string $format Validation format of attribute + * @return Document The updated attribute + * + * @throws Exception + */ + public function updateAttributeFormat(string $collection, string $id, string $format): Document + { + return $this->updateAttributeMeta($collection, $id, function ($attribute) use ($format) { + $rawType = $attribute->getAttribute('type'); + if (! $rawType instanceof ColumnType && ! \is_string($rawType)) { + throw new DatabaseException('Unknown attribute type'); + } + $attrType = Attribute::normalizeType($rawType); + if (! Structure::hasFormat($format, $attrType)) { + throw new DatabaseException('Format "'.$format.'" not available for attribute type "'.$attrType->value.'"'); + } + + $attribute->setAttribute('format', $format); + })->toDocument(); + } + + /** + * Update format options of attribute. + * + * @param string $collection The collection identifier + * @param string $id The attribute identifier + * @param array $formatOptions Assoc array with custom options for format validation + * @return Document The updated attribute + * + * @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); + })->toDocument(); + } + + /** + * Update filters of attribute. + * + * @param string $collection The collection identifier + * @param string $id The attribute identifier + * @param array $filters Filter names to apply to the attribute + * @return Document The updated attribute + * + * @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); + })->toDocument(); + } + + /** + * Update default value of attribute. + * + * @param string $collection The collection identifier + * @param string $id The attribute identifier + * @param mixed $default The new default value + * @return Document The updated attribute + * + * @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'); + } + + $rawAttrType = $attribute->getAttribute('type'); + $this->validateDefaultTypes( + $rawAttrType instanceof ColumnType || \is_string($rawAttrType) ? Attribute::normalizeType($rawAttrType)->value : '', + $default, + (bool) $attribute->getAttribute('signed', true), + ); + + $attribute->setAttribute('default', $default); + })->toDocument(); + } + + /** + * 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 The collection identifier + * @param string $id The attribute identifier + * @param ColumnType|string|null $type New column type, or null to keep existing + * @param int|null $size New utf8mb4 chars length, or null to keep existing + * @param bool|null $required New required status, or null to keep existing + * @param mixed $default New default value + * @param bool|null $signed New signed status, or null to keep existing + * @param bool|null $array New array status, or null to keep existing + * @param string|null $format New validation format, or null to keep existing + * @param array|null $formatOptions New format options, or null to keep existing + * @param array|null $filters New filters, or null to keep existing + * @param string|null $newKey New attribute key for renaming, or null to keep existing + * @return Document The updated attribute + * + * @throws Exception + */ + public function updateAttribute(string $collection, string $id, ColumnType|string|null $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 + { + $type = $type === null ? null : Attribute::normalizeType($type)->value; + $collectionDoc = $this->silent(fn () => $this->getCollection($collection)); + + if ($collectionDoc->getId() === self::METADATA) { + throw new DatabaseException('Cannot update metadata attributes'); + } + + /** @var array $attributes */ + $attributes = $collectionDoc->getAttribute('attributes', []); + $attributeIndex = \array_search($id, \array_map(fn (Attribute $attribute) => $attribute->key, $attributes), true); + + if ($attributeIndex === false) { + throw new NotFoundException('Attribute not found'); + } + + $attribute = $attributes[$attributeIndex]; + + $rawOriginalType = $attribute->getAttribute('type'); + if (! $rawOriginalType instanceof ColumnType && ! \is_string($rawOriginalType)) { + throw new DatabaseException('Unknown attribute type'); + } + $originalType = Attribute::normalizeType($rawOriginalType)->value; + /** @var int $originalSize */ + $originalSize = $attribute->getAttribute('size'); + $originalSigned = (bool) $attribute->getAttribute('signed'); + $originalArray = (bool) $attribute->getAttribute('array'); + $originalRequired = (bool) $attribute->getAttribute('required'); + /** @var string $originalKey */ + $originalKey = $attribute->getAttribute('key'); + + $originalIndexes = []; + /** @var array $collectionIndexes */ + $collectionIndexes = $collectionDoc->getAttribute('indexes', []); + foreach ($collectionIndexes as $index) { + $originalIndexes[] = clone $index; + } + + $altering = ! \is_null($type) + || ! \is_null($size) + || ! \is_null($signed) + || ! \is_null($array) + || ! \is_null($newKey); + if ($type === null) { + $rawType = $attribute->getAttribute('type'); + if (! $rawType instanceof ColumnType && ! \is_string($rawType)) { + throw new DatabaseException('Unknown attribute type'); + } + $type = Attribute::normalizeType($rawType)->value; + } + if ($size === null) { + /** @var int $size */ + $size = $attribute->getAttribute('size'); + } + $signed ??= (bool) $attribute->getAttribute('signed'); + $required ??= (bool) $attribute->getAttribute('required'); + $default ??= $attribute->getAttribute('default'); + $array ??= (bool) $attribute->getAttribute('array'); + if ($format === null) { + $rawFormat = $attribute->getAttribute('format'); + $format = \is_string($rawFormat) ? $rawFormat : null; + } + if ($formatOptions === null) { + $rawFormatOptions = $attribute->getAttribute('formatOptions'); + /** @var array|null $formatOptions */ + $formatOptions = \is_array($rawFormatOptions) ? $rawFormatOptions : null; + } + if ($filters === null) { + $rawFilters = $attribute->getAttribute('filters'); + /** @var array|null $filters */ + $filters = \is_array($rawFilters) ? $rawFilters : null; + } + + if ($type === ColumnType::BigInteger->value) { + $size = 0; + } + + 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->supports(Capability::SpatialIndexNull) && in_array($type, [ColumnType::Point->value, ColumnType::Linestring->value, ColumnType::Polygon->value])) { + $altering = true; + } + + switch ($type) { + case ColumnType::String->value: + 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 ColumnType::Varchar->value: + 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 ColumnType::Text->value: + case ColumnType::MediumText->value: + case ColumnType::LongText->value: + // Text types don't require size validation as they have fixed max sizes + break; + + case ColumnType::Integer->value: + $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 ColumnType::Id->value: + case ColumnType::BigInteger->value: + break; + case ColumnType::Float->value: + case ColumnType::Double->value: + case ColumnType::Boolean->value: + case ColumnType::Datetime->value: + if (! empty($size)) { + throw new DatabaseException('Size must be empty'); + } + break; + case ColumnType::Object->value: + if (! $this->adapter->supports(Capability::Objects)) { + 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 ColumnType::Point->value: + case ColumnType::Linestring->value: + case ColumnType::Polygon->value: + if (! $this->adapter->hasFeature(Feature\Spatial::class)) { + 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 ColumnType::Vector->value: + 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 ($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; + case ColumnType::Relationship->value: + throw new DatabaseException('Cannot update relationship as an attribute'); + default: + throw $this->unknownType($type); + } + + /** Ensure required filters for the attribute are passed */ + $requiredFilters = $this->getRequiredFilters($type); + if (! empty(array_diff($requiredFilters, (array) $filters))) { + throw new DatabaseException("Attribute of type: $type requires the following filters: ".implode(',', $requiredFilters)); + } + + if ($format) { + if (! Structure::hasFormat($format, Attribute::normalizeType($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, $signed); + } + + $attribute + ->setAttribute(Document::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); + + /** @var array $attributes */ + $attributes = $collectionDoc->getAttribute('attributes', []); + $attributes[$attributeIndex] = $attribute; + $collectionDoc->setAttribute('attributes', $attributes, SetType::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, [ColumnType::Point->value, ColumnType::Linestring->value, ColumnType::Polygon->value], true) && ! $this->adapter->supports(Capability::SpatialIndexNull)) { + /** @var array $typedAttributeMap */ + $typedAttributeMap = []; + foreach ($attributes as $typedAttr) { + $typedAttributeMap[\strtolower($typedAttr->key)] = $typedAttr; + } + + /** @var array $spatialIndexes */ + $spatialIndexes = $collectionDoc->getAttribute('indexes', []); + foreach ($spatialIndexes as $typedIndex) { + if ($typedIndex->type !== IndexType::Spatial) { + continue; + } + foreach ($typedIndex->attributes as $attributeName) { + $lookup = \strtolower($attributeName); + if (! isset($typedAttributeMap[$lookup])) { + continue; + } + $typedAttr = $typedAttributeMap[$lookup]; + + if (in_array($typedAttr->type, [ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], true) && ! $typedAttr->required) { + 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) { + /** @var array $indexes */ + $indexes = $collectionDoc->getAttribute('indexes', []); + + if (! \is_null($newKey) && $id !== $newKey) { + foreach ($indexes as $index) { + /** @var array $indexAttrList */ + $indexAttrList = (array) $index['attributes']; + if (in_array($id, $indexAttrList)) { + $index['attributes'] = array_map(fn ($attribute) => $attribute === $id ? $newKey : $attribute, $indexAttrList); + } + } + + /** + * Check index dependency if we are changing the key + */ + /** @var array $depIndexes */ + $depIndexes = $collectionDoc->getAttribute('indexes', []); + $validator = new IndexDependencyValidator( + $depIndexes, + $this->adapter->supports(Capability::CastIndexArray), + ); + + 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->supports(Capability::IndexArray), + $this->adapter->supports(Capability::SpatialIndexNull), + $this->adapter->supports(Capability::SpatialIndexOrder), + $this->adapter->supports(Capability::Vectors), + $this->adapter->supports(Capability::DefinedAttributes), + $this->adapter->supports(Capability::MultipleFulltextIndexes), + $this->adapter->supports(Capability::IdenticalIndexes), + $this->adapter->supports(Capability::ObjectIndexes), + $this->adapter->supports(Capability::TrigramIndex), + $this->adapter->hasFeature(Feature\Spatial::class), + $this->adapter->supports(Capability::Index), + $this->adapter->supports(Capability::UniqueIndex), + $this->adapter->supports(Capability::Fulltext), + $this->adapter->supports(Capability::TTLIndexes), + $this->adapter->supports(Capability::Objects) + ); + + foreach ($indexes as $index) { + if (! $validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } + } + } + + $updateAttrModel = new Attribute( + key: $id, + type: Attribute::normalizeType($type), + size: $size, + required: $required, + default: $default, + signed: $signed, + array: $array, + format: $format, + formatOptions: $formatOptions ?? [], + filters: $filters ?? [], + ); + $updated = $this->adapter->updateAttribute($collection, $updateAttrModel, $newKey); + + if (! $updated) { + throw new DatabaseException('Failed to update attribute'); + } + } elseif ($originalRequired && ! $required) { + // The alter above already applies the new nullability. A required-only change relaxes the + // column on its own instead, because the column rewrite re-casts datetime columns on Postgres. + if (! $this->adapter->relaxAttributeRequired($collection, $id)) { + throw new DatabaseException('Failed to update attribute'); + } + } + + $collectionDoc->setAttribute('attributes', $attributes); + + $rollbackAttrModel = new Attribute( + key: $newKey ?? $id, + type: Attribute::normalizeType($originalType), + size: $originalSize, + required: $originalRequired, + signed: $originalSigned, + array: $originalArray, + ); + $this->updateMetadata( + collection: $collectionDoc, + rollbackOperation: fn () => $this->adapter->updateAttribute( + $collection, + $rollbackAttrModel, + $originalKey + ), + shouldRollback: $updated, + operationDescription: "attribute update '{$id}'", + silentRollback: true + ); + + if ($altering) { + $this->withRetries(fn () => $this->purgeCachedCollection($collection)); + } + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection)); + + $this->triggerHooks(Event::DocumentPurge, new Document([ + Document::ID => $collection, + Document::COLLECTION => self::METADATA, + ])); + + $attributeDocument = $attribute->toDocument(); + + $this->triggerHooks( + Event::AttributeUpdate, + (clone $attributeDocument)->setAttribute(Document::COLLECTION, $collection), + ); + + return $attributeDocument; + } + + /** + * Checks if attribute can be added to collection without exceeding limits. + * + * @param Document $collection The collection document + * @param Attribute $attribute The attribute to check + * @return bool True if the attribute can be added + * + * @throws LimitException + */ + public function checkAttribute(Document $collection, Attribute $attribute): bool + { + $collection = clone $collection; + + $collection->setAttribute('attributes', $attribute, SetType::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 The collection identifier + * @param string $id The attribute identifier to delete + * @return bool True if the attribute was deleted successfully + * + * @throws ConflictException + * @throws DatabaseException + */ + public function deleteAttribute(string $collection, string $id): bool + { + $collection = $this->silent(fn () => $this->getCollection($collection)); + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + /** @var Attribute|null $attribute */ + $attribute = null; + + foreach ($attributes as $key => $value) { + if ($value->getId() === $id) { + $attribute = $value; + unset($attributes[$key]); + break; + } + } + + if (\is_null($attribute)) { + throw new NotFoundException('Attribute not found'); + } + + if (Attribute::isRelationship($attribute)) { + throw new DatabaseException('Cannot delete relationship as an attribute'); + } + + if ($this->validate) { + /** @var array $depIndexes */ + $depIndexes = $collection->getAttribute('indexes', []); + $validator = new IndexDependencyValidator( + $depIndexes, + $this->adapter->supports(Capability::CastIndexArray), + ); + + if (! $validator->isValid($attribute)) { + throw new DependencyException($validator->getDescription()); + } + } + + foreach ($indexes as $indexKey => $index) { + /** @var array $indexAttributes */ + $indexAttributes = $index->getAttribute('attributes', []); + + $indexAttributes = \array_filter($indexAttributes, fn ($attr) => $attr !== $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 + } + + $rawAttrTypeForRollback = $attribute->getAttribute('type'); + $rawAttrSizeForRollback = $attribute->getAttribute('size'); + /** @var string $rollbackAttrType */ + $rollbackAttrType = \is_string($rawAttrTypeForRollback) ? $rawAttrTypeForRollback : ''; + /** @var int $rollbackAttrSize */ + $rollbackAttrSize = \is_int($rawAttrSizeForRollback) ? $rawAttrSizeForRollback : 0; + $rollbackAttr = new Attribute( + key: $id, + type: Attribute::normalizeType($rollbackAttrType), + size: $rollbackAttrSize, + required: (bool) ($attribute->getAttribute('required') ?? false), + signed: (bool) ($attribute->getAttribute('signed') ?? true), + array: (bool) ($attribute->getAttribute('array') ?? false), + ); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->createAttribute( + $collection->getId(), + $rollbackAttr + ), + shouldRollback: $shouldRollback, + operationDescription: "attribute deletion '{$id}'", + silentRollback: true + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + + $this->triggerHooks(Event::DocumentPurge, new Document([ + Document::ID => $collection->getId(), + Document::COLLECTION => self::METADATA, + ])); + + $this->triggerHooks( + Event::AttributeDelete, + (clone $attribute)->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return true; + } + + /** + * Rename Attribute + * + * @param string $collection The collection identifier + * @param string $old Current attribute ID + * @param string $new New attribute ID + * @return bool True if the attribute was renamed successfully + * + * @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 = null; + + foreach ($attributes as $value) { + if ($value->getId() === $old) { + $attribute = $value; + } + + if ($value->getId() === $new) { + throw new DuplicateException('Attribute name already used'); + } + } + + if ($attribute === null) { + throw new NotFoundException('Attribute not found'); + } + + if ($this->validate) { + /** @var array $renameDepIndexes */ + $renameDepIndexes = $collection->getAttribute('indexes', []); + $validator = new IndexDependencyValidator( + $renameDepIndexes, + $this->adapter->supports(Capability::CastIndexArray), + ); + + if (! $validator->isValid($attribute)) { + throw new DependencyException($validator->getDescription()); + } + } + + $attribute->setAttribute(Document::ID, $new); + $attribute->setAttribute('key', $new); + + foreach ($indexes as $index) { + /** @var array $indexAttributes */ + $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 (DuplicateException $e) { + throw $e; + } catch (Throwable $e) { + 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())); + + $this->triggerHooks( + Event::AttributeUpdate, + (clone $attribute)->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + 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 + * + * @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 $attributes The attributes 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 $attributes, + int $maxAttempts = 3 + ): array { + $errors = []; + + foreach ($attributes as $attribute) { + try { + $this->cleanupAttribute($collectionId, $attribute->getId(), $maxAttempts); + } catch (Exception $e) { + $errors[] = $e->getMessage(); + } + } + + return $errors; + } + + /** + * Rollback metadata state by removing specified attributes from collection + * + * @param Document $collection The collection document + * @param array $attributeIds Attribute IDs to remove + */ + private function rollbackAttributeMetadata(Document $collection, array $attributeIds): void + { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + $filteredAttributes = \array_filter( + $attributes, + fn (Attribute $attr) => ! \in_array($attr->getId(), $attributeIds) + ); + $collection->setAttribute('attributes', \array_values($filteredAttributes)); + } +} diff --git a/src/Database/Traits/Collections.php b/src/Database/Traits/Collections.php new file mode 100644 index 0000000000..737a6bb98d --- /dev/null +++ b/src/Database/Traits/Collections.php @@ -0,0 +1,579 @@ +id; + $name = $collection->name !== '' ? $collection->name : $collection->id; + $attributes = \array_map(static fn (Attribute $attribute): Attribute => clone $attribute, $collection->attributes); + $indexes = \array_map(static fn (Index $index): Index => clone $index, $collection->indexes); + $permissions = $collection->permissions ?? [Permission::create(Role::any())]; + $documentSecurity = $collection->documentSecurity; + $metadata = $collection->metadata; + + foreach ($attributes as $attribute) { + if (in_array($attribute->type, Database::ATTRIBUTE_FILTER_COLUMN_TYPES, true)) { + $existingFilters = $attribute->filters; + $attribute->filters = array_values( + array_unique(array_merge($existingFilters, [$attribute->type->value])) + ); + $attribute->setAttribute('filters', $attribute->filters); + } + } + + $typeValidator = $this->typeValidator(); + foreach ($attributes as $attribute) { + $typeValidator->checkType($attribute); + } + + 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->adapter->supports(Capability::TTLIndexes)) { + $ttlIndexes = array_filter($indexes, fn (Index $idx) => $idx->type === IndexType::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->lengths; + $orders = $index->orders; + + foreach ($index->attributes as $i => $attr) { + foreach ($attributes as $collectionAttribute) { + if ($collectionAttribute->key === $attr) { + /** + * mysql does not save length in collection when length = attributes size + */ + if ($collectionAttribute->type === ColumnType::String) { + if (! empty($lengths[$i]) && $lengths[$i] === $collectionAttribute->size && $this->adapter->getMaxIndexLength() > 0) { + $lengths[$i] = null; + } + } + + $isArray = $collectionAttribute->array; + if ($isArray) { + if ($this->adapter->getMaxIndexLength() > 0) { + $lengths[$i] = self::MAX_ARRAY_INDEX_LENGTH; + } + $orders[$i] = null; + } + break; + } + } + } + + $index->lengths = $lengths; + $index->orders = $orders; + $indexes[$key] = $index; + } + + $collection = new Document(\array_merge([ + Document::ID => ID::custom($id), + Document::PERMISSIONS => $permissions, + 'name' => $name, + 'attributes' => $attributes, + 'indexes' => $indexes, + 'documentSecurity' => $documentSecurity, + ], $metadata)); + + if ($this->validate) { + $validator = new IndexValidator( + $attributes, + [], + $this->adapter->getMaxIndexLength(), + $this->adapter->getInternalIndexesKeys(), + $this->adapter->supports(Capability::IndexArray), + $this->adapter->supports(Capability::SpatialIndexNull), + $this->adapter->supports(Capability::SpatialIndexOrder), + $this->adapter->supports(Capability::Vectors), + $this->adapter->supports(Capability::DefinedAttributes), + $this->adapter->supports(Capability::MultipleFulltextIndexes), + $this->adapter->supports(Capability::IdenticalIndexes), + $this->adapter->supports(Capability::ObjectIndexes), + $this->adapter->supports(Capability::TrigramIndex), + $this->adapter->hasFeature(Feature\Spatial::class), + $this->adapter->supports(Capability::Index), + $this->adapter->supports(Capability::UniqueIndex), + $this->adapter->supports(Capability::Fulltext), + $this->adapter->supports(Capability::TTLIndexes), + $this->adapter->supports(Capability::Objects) + ); + 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.'); + } + } + + $created = false; + + try { + $this->adapter->createCollection($id, $attributes, $indexes); + $created = 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 $this->hydrateCollectionModels(Collection::fromArray(self::collectionMeta())); + } + + 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 ($this->failedAfterCommit($e)) { + throw $e; + } + + if ($created) { + 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}': ".$e->getMessage(), previous: $e); + } + + $this->triggerHooks(Event::CollectionCreate, $createdCollection); + + return $this->hydrateCollectionModels($createdCollection); + } + + /** + * Update Collections Permissions. + * + * @param string $id The collection identifier + * @param array $permissions New permission strings + * @param bool $documentSecurity Whether to enable document-level security + * @return Document The updated collection metadata document + * + * @throws ConflictException + * @throws DatabaseException + */ + public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document + { + 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(Document::PERMISSIONS, $permissions) + ->setAttribute('documentSecurity', $documentSecurity); + + $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + + $this->triggerHooks(Event::CollectionUpdate, $collection); + + return $collection; + } + + /** + * Get Collection + * + * @param string $id The collection identifier + * @return Collection The collection metadata document, or an empty Collection if not found + * + * @throws DatabaseException + */ + public function getCollection(string $id): Collection + { + $definition = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); + + if ( + $id !== self::METADATA + && $this->adapter->getSharedTables() + && $definition->getTenant() !== null + && $definition->getTenant() !== $this->adapter->getTenant() + ) { + return new Collection(); + } + + $collection = $this->hydrateCollectionModels($definition); + $this->attachCollectionCacheEpoch($collection, $this->getCollectionCacheEpoch($definition)); + + $this->trigger(Event::CollectionRead, $collection); + + return $collection; + } + + private function hydrateCollectionModels(Document $collection): Collection + { + if ($collection->isEmpty()) { + return $collection instanceof Collection ? $collection : new Collection(); + } + + if (! $collection instanceof Collection) { + $collection = Collection::fromArray($collection->getArrayCopy()); + } + + $attributes = $collection->getAttribute('attributes', []); + if (\is_array($attributes)) { + $hydrated = []; + $changed = false; + foreach ($attributes as $attr) { + if ($attr instanceof Attribute) { + $hydrated[] = $attr; + continue; + } + if (! \is_array($attr)) { + throw new DatabaseException('Collection attributes must be Attribute models'); + } + $changed = true; + $typed = []; + foreach ($attr as $name => $item) { + if (\is_string($name)) { + $typed[$name] = $item; + } + } + $hydrated[] = Attribute::fromArray($typed); + } + if ($changed) { + $collection->setAttribute('attributes', $hydrated); + } + } + + $indexes = $collection->getAttribute('indexes', []); + if (\is_array($indexes)) { + $hydrated = []; + $changed = false; + foreach ($indexes as $idx) { + if ($idx instanceof Index) { + $hydrated[] = $idx; + continue; + } + if (! \is_array($idx)) { + throw new DatabaseException('Collection indexes must be Index models'); + } + $changed = true; + $typed = []; + foreach ($idx as $name => $item) { + if (\is_string($name)) { + $typed[$name] = $item; + } + } + $hydrated[] = Index::fromArray($typed); + } + if ($changed) { + $collection->setAttribute('indexes', $hydrated); + } + } + + return $collection; + } + + /** + * List Collections + * + * @param int $limit Maximum number of collections to return + * @param int $offset Number of collections to skip + * @return array + * + * @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), + ])); + + foreach ($result as $i => $listed) { + $result[$i] = $this->hydrateCollectionModels($listed); + } + + $this->trigger(Event::CollectionList, $result); + + return $result; + } + + /** + * Get Collection Size + * + * @param string $collection The collection identifier + * @return int The number of documents in the collection + * + * @throws Exception + */ + public function getSizeOfCollection(string $collection): int + { + $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()); + } + + /** + * Get Collection Size on disk + * + * @param string $collection The collection identifier + * @return int The collection size in bytes on disk + * + * @throws DatabaseException + * @throws NotFoundException + */ + public function getSizeOfCollectionOnDisk(string $collection): int + { + 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'); + } + + return $this->adapter->getSizeOfCollectionOnDisk($collection->getId()); + } + + /** + * Analyze a collection updating its metadata on the database engine. + * + * @param string $collection The collection identifier + * @return bool True if the analysis completed successfully + */ + public function analyzeCollection(string $collection): bool + { + return $this->adapter->analyzeCollection($collection); + } + + /** + * Delete Collection + * + * @param string $id The collection identifier + * @return bool True if the collection was successfully deleted + * + * @throws DatabaseException + */ + public function deleteCollection(string $id): bool + { + $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'); + } + + /** @var array $allAttributes */ + $allAttributes = $collection->getAttribute('attributes', []); + $relationships = \array_filter( + $allAttributes, + fn (Attribute $attribute) => $attribute->type === ColumnType::Relationship + ); + + foreach ($relationships as $relationship) { + $this->deleteRelationship($collection->getId(), $relationship->key); + } + + // Re-fetch collection to get current state after relationship deletions + $currentCollection = $this->silent(fn () => $this->getCollection($id)); + /** @var array $currentAttributes */ + $currentAttributes = $currentCollection->isEmpty() ? [] : $currentCollection->getAttribute('attributes', []); + /** @var array $currentIndexes */ + $currentIndexes = $currentCollection->isEmpty() ? [] : $currentCollection->getAttribute('indexes', []); + + if ($id === self::METADATA) { + $this->purgeCachedCollection($id); + } + + $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 ($id !== self::METADATA) { + $this->purgeCachedCollection($id); + } + + if ($deleted) { + $this->triggerHooks(Event::CollectionDelete, $collection); + } + + return $deleted; + } + + /** + * Cleanup (delete) a collection with retry logic + * + * @param string $collectionId The collection ID + * @param int $maxAttempts Maximum retry attempts + * + * @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 + ); + } +} diff --git a/src/Database/Traits/Databases.php b/src/Database/Traits/Databases.php new file mode 100644 index 0000000000..0951a3b227 --- /dev/null +++ b/src/Database/Traits/Databases.php @@ -0,0 +1,92 @@ +adapter->getDatabase(); + + $this->adapter->create($database); + + /** @var array $attributes */ + $attributes = self::collectionMeta()['attributes']; + + $this->silent(fn () => $this->createCollection(new Collection( + id: self::METADATA, + attributes: $attributes, + ))); + + $this->trigger(Event::DatabaseCreate, $database); + + return true; + } + + /** + * Check if database exists, and optionally check if a collection exists in the database. + * + * @param string|null $database Database name, defaults to the adapter's configured database + * @param string|null $collection Collection name to check for within the database + * @return bool True if the database (and optionally the collection) exists + */ + public function exists(?string $database = null, ?string $collection = null): bool + { + $database ??= $this->adapter->getDatabase(); + + return $this->adapter->exists($database, $collection); + } + + /** + * List Databases + * + * @return array + */ + public function list(): array + { + $databases = $this->adapter->list(); + + $this->trigger(Event::DatabaseList, $databases); + + return $databases; + } + + /** + * Delete Database + * + * @param string|null $database Database name, defaults to the adapter's configured database + * @return bool True if the database was deleted successfully + * + * @throws DatabaseException + */ + public function delete(?string $database = null): bool + { + $database = $database ?? $this->adapter->getDatabase(); + + $deleted = $this->adapter->delete($database); + + $this->cache->flush(); + + $this->trigger(Event::DatabaseDelete, [ + 'name' => $database, + 'deleted' => $deleted, + ]); + + return $deleted; + } +} diff --git a/src/Database/Traits/Documents.php b/src/Database/Traits/Documents.php new file mode 100644 index 0000000000..9c9e954942 --- /dev/null +++ b/src/Database/Traits/Documents.php @@ -0,0 +1,5025 @@ +> Definition keys of the collections the open invalidation scope wrote, by coroutine id and collection key. */ + private array $documentCacheDefinitions = []; + + /** @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::isIntegerType($attribute->type)) { + 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((string) $current)) { + throw new TypeException('Attribute value must be an integer.'); + } + if ((! \is_int($value) && ! \is_string($value)) || ! BigInt::isIntegerString((string) $value)) { + throw new TypeException('Change value must be an integer.'); + } + + $result = $increase + ? BigInt::add($current, $value) + : BigInt::subtract($current, $value); + $bounds = Attribute::getNumericBounds($attribute->type, $attribute->signed); + 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::getNumericBounds($attribute->type, $attribute->signed); + + 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; + } + + private function isDeclaredInteger(?Attribute $attribute): bool + { + return $attribute !== null && ! $attribute->array && Attribute::isIntegerType($attribute->type); + } + + private function assertIntegerChange(int|float|string $value): void + { + if ((! \is_int($value) && ! \is_string($value)) || ! BigInt::isIntegerString((string) $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; + + /** + * 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 + { + $supportForJoins = $this->adapter->supports(Capability::Joins); + $supportForAggregations = $this->adapter->supports(Capability::Aggregations); + + if ($joinedCollections !== []) { + return $this->createDocumentsValidator($collection, $supportForJoins, $supportForAggregations); + } + + $context = $this->getCollectionMetadataCacheKey($collection->getId()); + $key = $this->documentsValidatorCacheKey($collection, $context, $supportForJoins, $supportForAggregations); + + if (isset($this->documentsValidatorCache[$key])) { + return $this->documentsValidatorCache[$key]; + } + + $validator = $this->createDocumentsValidator($collection, $supportForJoins, $supportForAggregations); + + if (\count($this->documentsValidatorCache) >= self::DOCUMENTS_VALIDATOR_CACHE_LIMIT) { + $this->documentsValidatorCache = []; + } + $this->documentsValidatorCache[$key] = $validator; + + return $validator; + } + + private function createDocumentsValidator(Document $collection, bool $supportForJoins, bool $supportForAggregations): DocumentsValidator + { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + return new DocumentsValidator( + $attributes, + $indexes, + $this->adapter->getIdAttributeType(), + $this->maxQueryValues, + $this->adapter->getMaxUIDLength(), + $this->adapter->getMinDateTime(), + $this->adapter->getMaxDateTime(), + $this->adapter->supports(Capability::DefinedAttributes), + $this->adapter->supports(Capability::UnsignedBigInt), + $supportForJoins, + $supportForAggregations, + $this->adapter->getSharedTables(), + $this->adapter->supports(Capability::OrderRandom), + ); + } + + /** + * Build the composite cache key for the DocumentsValidator cache. Scoping + * by namespace + tenant + max-query-values + the join and aggregation + * grammar keeps two collections that share an id (different tenant + * schemas, different namespace prefixes, different per-request limits or + * adapters with different capabilities) from aliasing onto the same + * validator. + */ + private function documentsValidatorCacheKey(Document $collection, string $context, bool $supportForJoins, bool $supportForAggregations): string + { + $fingerprint = \hash('sha256', \serialize([ + 'attributes' => $this->normalizeQueryCacheQueryValue($collection->getAttribute('attributes', [])), + 'indexes' => $this->normalizeQueryCacheQueryValue($collection->getAttribute('indexes', [])), + 'permissions' => $this->normalizeQueryCacheQueryValue($collection->getAttribute(Document::PERMISSIONS, [])), + 'documentSecurity' => (bool) $collection->getAttribute('documentSecurity', false), + ])); + + return $context.'::'.$this->maxQueryValues.'::'.(int) $supportForJoins.(int) $supportForAggregations.(int) $this->adapter->getSharedTables().'::'.$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; + } + + /** + * Get Document + * + * @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 ($collection === self::METADATA && $id === self::METADATA) { + return new Document(self::collectionMeta()); + } + + 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'); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + + $this->checkQueryTypes($queries); + + $joinedCollections = null; + + if ($this->validate) { + $joinedCollections = $this->resolveJoinedCollections($queries); + $supportForAttributes = $this->adapter->supports(Capability::DefinedAttributes); + $supportForJoins = $this->adapter->supports(Capability::Joins); + $validator = $joinedCollections === [] + ? new DocumentValidator($attributes, $supportForAttributes, sharedTables: $this->adapter->getSharedTables(), supportForJoins: $supportForJoins) + : new DocumentValidator( + attributes: $attributes, + supportForAttributes: $supportForAttributes, + idAttributeType: $this->adapter->getIdAttributeType(), + maxValuesCount: $this->maxQueryValues, + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt), + sharedTables: $this->adapter->getSharedTables(), + supportForJoins: $supportForJoins, + ); + $validator->setJoinedCollections($joinedCollections); + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + /** @var array $allAttributes */ + $allAttributes = $collection->getAttribute('attributes', []); + $relationships = \array_filter( + $allAttributes, + fn (Attribute|Document $attribute) => Attribute::isRelationship($attribute) + ); + + $grouped = Query::groupForDatabase($queries); + $selects = $grouped['selections']; + $joins = $grouped['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; + 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) { + Console::warning('Warning: Failed to get document from cache: '.$e->getMessage()); + } + + if (\is_array($cached) && isset($cached[self::CACHE_EMPTY_MARKER])) { + return $this->createDocumentInstance($collection->getId(), []); + } + + if ($cached) { + /** @var array $cached */ + $document = $this->createDocumentInstance($collection->getId(), $cached); + $document = $this->casting($collection, $document); + + if ($collection->getId() !== self::METADATA) { + + if (! $this->authorization->isValid(new Input(PermissionType::Read, [ + ...$collection->getRead(), + ...($documentSecurity ? $document->getRead() : []), + ]))) { + return $this->createDocumentInstance($collection->getId(), []); + } + } + + $document = $this->decorateDocument(Event::DocumentRead, $collection, $document); + + $this->trigger(Event::DocumentRead, $document); + + if ($this->isTtlExpired($collection, $document)) { + return $this->createDocumentInstance($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()); + } + } + + $collectionGranted = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getRead())); + $skipAuth = empty($joins) + && $collection->getId() !== self::METADATA + && $collectionGranted; + + $getDocument = fn () => $this->adapter->getDocument( + $this->withJoinAttributes($this->withJoinAuthorization($collection, $joinDocumentSecurity, $collectionGranted || $collection->getId() === self::METADATA), $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->createDocumentInstance($collection->getId(), []); + } + + if ($this->isTtlExpired($collection, $document)) { + return $this->createDocumentInstance($collection->getId(), []); + } + + $collectionState = $cacheable && $definition + ? $this->loadDocumentCacheState($this->getCacheBaseKeys($id)[0]) + : new Epoch(); + + $document = $this->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(Document::COLLECTION, $collection->getId()); + + if ($collection->getId() !== self::METADATA) { + if (! $this->authorization->isValid(new Input(PermissionType::Read, [ + ...$collection->getRead(), + ...($documentSecurity ? $document->getRead() : []), + ]))) { + return $this->createDocumentInstance($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]; + } + + /** @var array $cacheCheckAttrs */ + $cacheCheckAttrs = $collection->getAttribute('attributes', []); + $relationships = \array_filter( + $cacheCheckAttrs, + fn (Attribute|Document $attribute) => Attribute::isRelationship($attribute) + ); + + 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($this->getCacheBaseKeys($id)[0])->value === $collectionState->value, + ); + } + } catch (Exception $e) { + Console::warning('Failed to save document to cache: '.$e->getMessage()); + } + + $document = $this->decorateDocument(Event::DocumentRead, $collection, $document); + + $this->trigger(Event::DocumentRead, $document); + + $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); + } + + /** + * 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 && $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::TTLIndexes)) { + return false; + } + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + foreach ($indexes as $index) { + if ($index->type !== IndexType::Ttl) { + continue; + } + $ttlSeconds = $index->ttl; + $ttlAttr = $index->attributes[0] ?? null; + if ($ttlSeconds <= 0 || ! $ttlAttr) { + return false; + } + /** @var string $ttlAttrStr */ + $ttlAttrStr = $ttlAttr; + $val = $document->getAttribute($ttlAttrStr); + if (is_string($val)) { + try { + $start = new PhpDateTime($val); + + return (new PhpDateTime()) > (clone $start)->modify("+{$ttlSeconds} seconds"); + } catch (Throwable) { + return false; + } + } + } + + return false; + } + + /** + * Strip non-selected attributes from documents based on select queries. + * + * @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(fn (array $attr) => $attr[Document::ID] ?? '', $this->getInternalAttributes()); + 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); + } + } + } + } + + /** + * Create Document + * + * @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 + { + 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(PermissionType::Create, $collection->getCreate())); + 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->preserveDates) ? $time : $createdAt) + ->setAttribute(Document::UPDATED_AT, ($updatedAt === null || ! $this->preserveDates) ? $time : $updatedAt); + + if (empty($document->getPermissions())) { + $document->setAttribute(Document::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(Document::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: $collection, + idAttributeType: $this->adapter->getIdAttributeType(), + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->supports(Capability::DefinedAttributes), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt) + ); + if (! $structure->isValid($document)) { + throw new StructureException($structure->getDescription()); + } + } + + $document = $this->castingBefore($collection, $document); + + $document = $this->withMutation(Event::DocumentCreate, $document, function () use ($collection, $document) { + $hook = $this->relationshipHook; + if ($hook?->isEnabled()) { + $document = $this->silent(fn () => $hook->afterDocumentCreate($collection, $document)); + } + + $document = $this->adapter->createDocument($collection, $document); + $this->withDocumentTenant( + $document, + fn () => $this->purgeCachedDocumentInternal($collection->getId(), $document->getId()) + ); + + return $document; + }); + + $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->castingAfter($collection, $document); + $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()); + } + + $document = $this->decorateDocument(Event::DocumentCreate, $collection, $document); + + $this->triggerHooks(Event::DocumentCreate, $document); + + return $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 + * @param (callable(Document): void)|null $onNext Callback invoked for each created document + * @param (callable(Throwable): void)|null $onError Callback invoked on per-document errors + * @return int The number of documents created + * + * @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() + && 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.'); + } + + 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(PermissionType::Create, $collection->getCreate()))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + $time = DateTime::now(); + $modified = 0; + $hasRelationships = ! empty(\array_filter( + $collection->attributes, + static fn (Attribute $attribute): bool => $attribute->type === ColumnType::Relationship, + )); + + // 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->validate + ? new Structure( + collection: $collection, + idAttributeType: $this->adapter->getIdAttributeType(), + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->supports(Capability::DefinedAttributes), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt) + ) + : 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->preserveDates) ? $time : $createdAt) + ->setAttribute(Document::UPDATED_AT, ($updatedAt === null || ! $this->preserveDates) ? $time : $updatedAt); + + if (empty($document->getPermissions())) { + $document->setAttribute(Document::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(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->castingBefore($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->skipDuplicates + ? $this->adapter->skipDuplicates($insert) + : $insert(); + + if ($onNext !== null || $hasRelationships) { + $batch = $this->adapter->getSequences($collection->getId(), $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, + $this->castingAfter($collection, $document) + ) + ), + $batch + ); + + $batch = $this->decorateDocuments(Event::DocumentsCreate, $collection, $batch); + + foreach ($batch as $document) { + try { + $onNext && $onNext($document); + } catch (Throwable $e) { + $onError ? $onError($e) : throw $e; + } + + $modified++; + } + } + + $this->triggerHooks(Event::DocumentsCreate, new Document([ + Document::COLLECTION => $collection->getId(), + 'modified' => $modified, + ])); + + return $modified; + } + + /** + * Update Document + * + * @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->getDocument($collection->getId(), $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->preserveDates) ? $old->getCreatedAt() : $createdAt; + + if ($this->adapter->getSharedTables()) { + $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); + + /** @var array $updateAttrs */ + $updateAttrs = $collection->getAttribute('attributes', []); + $relationships = \array_filter($updateAttrs, function (Attribute|Document $attribute) { + return Attribute::isRelationship($attribute); + }); + + $shouldUpdate = false; + + if ($collection->getId() !== self::METADATA) { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + + foreach ($relationships as $relationship) { + $relationships[$relationship->getId()] = $relationship; + } + + foreach ($document as $key => $value) { + if (Operator::isOperator($value)) { + $shouldUpdate = true; + break; + } + } + + $internalKeys = [Document::INTERNAL_ID, 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; + } + + if (\array_key_exists($key, $relationships)) { + $rel = Relationship::fromArray(['collection' => $collection->getId()] + $relationships[$key]->getArrayCopy()); + $relationType = $rel->type; + $side = $rel->side; + $storesKey = $relationType === RelationType::OneToOne + || ($relationType === RelationType::ManyToOne && $side === RelationSide::Parent) + || ($relationType === RelationType::OneToMany && $side === RelationSide::Child); + + if (! $storesKey && $this->relationshipHook !== null && $this->relationshipHook->getWriteStackCount() >= Database::RELATION_MAX_DEPTH - 1) { + continue; + } + + switch ($relationType) { + case RelationType::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 RelationType::OneToMany: + case RelationType::ManyToOne: + case RelationType::ManyToMany: + if ( + ($relationType === RelationType::ManyToOne && $side === RelationSide::Parent) || + ($relationType === RelationType::OneToMany && $side === RelationSide::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->getUpdate(), + ...($documentSecurity ? $old->getUpdate() : []), + ]; + + $readPermissions = [ + ...$collection->getRead(), + ...($documentSecurity ? $old->getRead() : []), + ]; + + 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->preserveDates) ? $time : $newUpdatedAt); + } + + // Check if document was updated after the request timestamp + $oldUpdatedAt = new PhpDateTime($old->getUpdatedAt() ?? 'now'); + if (! is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + + $storedAttributes = []; + if ($this->validate && $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->validate) { + $structureValidator = new Structure( + collection: $collection, + idAttributeType: $this->adapter->getIdAttributeType(), + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->supports(Capability::DefinedAttributes), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt), + 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->castingBefore($collection, $document); + + $this->authorization->skip(fn () => $this->adapter->updateDocument($collection, $old->getId(), $document, $skipPermissionsUpdate)); + + $document = $this->castingAfter($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->createDocumentInstance($collection->getId(), $document->getArrayCopy()); + } + + $document = $this->decorateDocument(Event::DocumentUpdate, $collection, $document); + + $this->triggerHooks(Event::DocumentUpdate, $document); + + return $document; + } + + /** + * Update documents + * + * 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 + * @param (callable(Document $updated, Document $old): void)|null $onNext Callback invoked for each updated document + * @param (callable(Throwable): void)|null $onError Callback invoked on per-document errors + * @return int The number of documents updated + * + * @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 { + $this->rejectJoins($queries, 'Join queries are not supported for bulk updates'); + + 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(PermissionType::Update, $collection->getUpdate())); + + if (! $skipAuth && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + $this->checkQueryTypes($queries); + + if ($this->validate) { + $validator = $this->getDocumentsValidator($collection); + + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + $grouped = Query::groupForDatabase($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[Document::ID]); + unset($updates[Document::TENANT]); + + if (($updates->getCreatedAt() === null || ! $this->preserveDates)) { + unset($updates[Document::CREATED_AT]); + } else { + $updates[Document::CREATED_AT] = $updates->getCreatedAt(); + } + + if ($this->adapter->getSharedTables()) { + $updates[Document::TENANT] = $this->adapter->getTenant(); + } + + $updatedAt = $updates->getUpdatedAt(); + $updates[Document::UPDATED_AT] = ($updatedAt === null || ! $this->preserveDates) ? DateTime::now() : $updatedAt; + + $decodedUpdates = clone $updates; + $updates = $this->encode( + $collection, + $updates, + applyDefaults: false + ); + + if ($this->validate) { + $validator = new PartialStructure( + collection: $collection, + idAttributeType: $this->adapter->getIdAttributeType(), + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->supports(Capability::DefinedAttributes), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt), + currentDocument: null + ); + + 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, $grouped['selections']); + $decodedKeys = $selections === [] + ? [] + : \array_values(\array_unique([...$selections, ...\array_map(\strval(...), \array_keys($adapterData))])); + $adapterUpdates = $this->castingBefore($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->find( + $collection->getId(), + array_merge($new, $queries), + forPermission: PermissionType::Update + )); + + 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) { + 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); + } + + $document->setAttribute(Document::SKIP_PERMISSIONS_UPDATE, $skipPermissionsUpdate); + + $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); + } + + if (! is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { + throw new ConflictException('Document was updated after the request timestamp'); + } + + $document = $new; + + $encoded = $this->encode($collection, $document); + $batch[$index] = $this->castingBefore($collection, $encoded); + } + + $this->adapter->updateDocuments( + $collection, + $adapterUpdates, + $batch + ); + + 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, $grouped['selections']); + } + + // The operator refetch goes through find(), which already decoded every document; + // decoding again would run each decode filter twice. + /** @var array $batch */ + $batch = \array_map( + fn (Document $doc) => $hasOperators + ? $this->castingAfter($collection, $doc) + : $this->decode($collection, $this->castingAfter($collection, $doc), $decodedKeys), + $batch + ); + + $batch = $this->decorateDocuments(Event::DocumentsUpdate, $collection, $batch); + + foreach ($batch as $index => $doc) { + $doc->removeAttribute(Document::SKIP_PERMISSIONS_UPDATE); + 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; + } + + /** @var Document|false $last */ + $last = \end($batch); + } + + $this->triggerHooks(Event::DocumentsUpdate, new Document([ + Document::COLLECTION => $collection->getId(), + 'modified' => $modified, + ])); + + 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 + * + * @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 The collection identifier + * @param array $documents The documents to create or update + * @param int $batchSize Number of documents per batch + * @param (callable(Document, ?Document): void)|null $onNext Callback invoked for each upserted document with optional old document + * @param (callable(Throwable): void)|null $onError Callback invoked on per-document errors + * @return int The number of documents created or updated + * + * @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 The collection identifier + * @param string $attribute The attribute to increment on update + * @param array $documents The documents to create or update + * @param (callable(Document, ?Document): void)|null $onNext Callback invoked for each upserted document with optional old document + * @param (callable(Throwable): void)|null $onError Callback invoked on per-document errors + * @param int $batchSize Number of documents per batch + * @return int The number of documents created or updated + * + * @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 (! $this->adapter->hasFeature(Feature\Upserts::class)) { + throw new DatabaseException('Adapter does not support upserts'); + } + + if ( + $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.'); + } + + 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); + /** @var array $collectionAttributes */ + $collectionAttributes = $collection->getAttribute('attributes', []); + $time = DateTime::now(); + $created = 0; + $updated = 0; + $operatorIds = []; + $seenIds = []; + $hasRelationships = ! empty(\array_filter( + $collection->attributes, + static fn (Attribute $attribute): bool => $attribute->type === ColumnType::Relationship, + )); + $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']; + + $internalKeys = \array_map( + fn (Attribute $attr) => $attr->key, + self::internalAttributes() + ); + + $regularUpdatesUserOnly = \array_diff_key($regularUpdates, \array_flip($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 (! 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 (Attribute $attr) => $attr->key, + self::internalAttributes() + ); + + $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(PermissionType::Create, $collection->getCreate()))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } elseif (! $this->authorization->isValid(new Input(PermissionType::Update, \array_merge( + $collection->getUpdate(), + ((bool) $documentSecurity ? $old->getUpdate() : []) + )))) { + 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->preserveDates) ? $time : $updatedAt); + + if (! $this->preserveSequence) { + $document->removeAttribute(Document::SEQUENCE); + } + + $createdAt = $document->getCreatedAt(); + if ($createdAt === null || ! $this->preserveDates) { + $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 $attr) { + /** @var string $attrId */ + $attrId = $attr[Document::ID]; + if (! $attr->getAttribute('required') && ! \array_key_exists($attrId, (array) $document)) { + $document->setAttribute( + $attrId, + $old->getAttribute($attrId, ($attr['default'] ?? null)) + ); + } + } + + if ($skipPermissionsUpdate) { + $document->setAttribute(Document::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(Document::TENANT, $this->adapter->getTenant()); + } + } + + $document = $this->encode($collection, $document); + + if ($this->validate) { + $validator = new Structure( + collection: $collection, + idAttributeType: $this->adapter->getIdAttributeType(), + minAllowedDate: $this->adapter->getMinDateTime(), + maxAllowedDate: $this->adapter->getMaxDateTime(), + supportForAttributes: $this->adapter->supports(Capability::DefinedAttributes), + supportUnsignedBigInt: $this->adapter->supports(Capability::UnsignedBigInt), + 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); + } + + if (! \is_null($this->timestamp) && $oldUpdatedAt > $this->timestamp) { + 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->castingBefore($collection, $old); + $document = $this->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 + */ + $hasOperators = false; + foreach ($chunk as $change) { + if (isset($operatorIds[$this->getDocumentIdentity($change->getNew())])) { + $hasOperators = true; + break; + } + } + + $batch = $this->withMutation( + Event::DocumentsUpsert, + \array_map(static fn (Change $change): Document => $change->getNew(), $chunk), + function () use ($collection, $attribute, $chunk): array { + if (! $this->adapter->hasFeature(Feature\Upserts::class)) { + throw new DatabaseException('Adapter does not support upserts'); + } + + $adapter = $this->adapter; + $batch = $this->authorization->skip(fn () => $adapter->upsertDocuments( + $collection, + $attribute, + $chunk + )); + + 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]->getOld()->getSequence())) { + $document->setAttribute(Document::SEQUENCE, $chunk[$index]->getOld()->getSequence()); + } + } + + if ($onNext !== null || $hasRelationships) { + $batch = $this->adapter->getSequences($collection->getId(), $batch); + } + + foreach ($chunk as $change) { + if ($change->getOld()->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 = \array_map( + fn (Document $doc) => $hasOperators + ? $this->castingAfter($collection, $doc) + : $this->decode($collection, $this->castingAfter($collection, $doc)), + $batch + ); + + $batch = $this->decorateDocuments(Event::DocumentsUpsert, $collection, $batch); + + foreach ($batch as $index => $doc) { + $old = $chunk[$index]->getOld(); + + if (! $old->isEmpty()) { + $old = $this->castingAfter($collection, $old); + } + + try { + $onNext && $onNext($doc, $old->isEmpty() ? null : $old); + } catch (Throwable $th) { + $onError ? $onError($th) : throw $th; + } + } + } + + $this->triggerHooks(Event::DocumentsUpsert, new Document([ + Document::COLLECTION => $collection->getId(), + 'created' => $created, + 'updated' => $updated, + ])); + + 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->getSharedTables() && $this->getTenantPerDocument(); + + $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->getSharedTables() && $this->getTenantPerDocument() + ? $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, can be a float + * @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 { + if (! \is_numeric($value) || (\is_string($value) && BigInt::isIntegerString($value) + ? BigInt::compare($value, 0) <= 0 + : (float) $value <= 0)) { + throw new InvalidArgumentException('Value must be numeric and greater than 0'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $numericAttribute = null; + if ($this->adapter->supports(Capability::DefinedAttributes)) { + /** @var array $allAttrs */ + $allAttrs = $collection->getAttribute('attributes', []); + $matchedAttrs = \array_filter($allAttrs, function (Attribute $a) use ($attribute) { + return $a->key === $attribute; + }); + + if (empty($matchedAttrs)) { + throw new NotFoundException('Attribute not found'); + } + + /** @var Attribute $matchedAttr */ + $matchedAttr = \end($matchedAttrs); + if (! Attribute::isNumericType($matchedAttr->type) || $matchedAttr->array) { + throw new TypeException('Attribute must be an integer or float and can not be an array.'); + } + $numericAttribute = $matchedAttr; + } + + 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->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(PermissionType::Update, \array_merge( + $collection->getUpdate(), + ((bool) $documentSecurity ? $document->getUpdate() : []) + )))) { + 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 instanceof Attribute && Attribute::isIntegerType($numericAttribute->type) + ? 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->preserveDates) ? $time : DateTime::setTimezone($updatedAt); + if ($max !== null) { + $max = $numericAttribute instanceof Attribute && Attribute::isIntegerType($numericAttribute->type) + ? BigInt::subtract($max, $value) + : $this->getNativeNumber($max) - $this->getNativeNumber($value); + } + + $this->adapter->increaseDocumentAttribute( + $collection->getId(), + $id, + $attribute, + $numericAttribute instanceof Attribute && Attribute::isIntegerType($numericAttribute->type) + ? BigInt::toNative($value) + : $this->getNativeNumber($value), + $updatedAt, + max: $max + ); + + $this->purgeCachedDocumentInternal($collection->getId(), $id); + $this->queueDocumentPurge($collection->getId(), $id); + + return $document->setAttribute($attribute, $result); + }); + + $this->triggerHooks(Event::DocumentIncrease, $document); + + return $document; + } + + /** + * 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, must be positive + * @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 + */ + public function decreaseDocumentAttribute( + string $collection, + string $id, + string $attribute, + int|float|string $value = 1, + int|float|string|null $min = null + ): Document { + if (! \is_numeric($value) || (\is_string($value) && BigInt::isIntegerString($value) + ? BigInt::compare($value, 0) <= 0 + : (float) $value <= 0)) { + throw new InvalidArgumentException('Value must be numeric and greater than 0'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $numericAttribute = null; + if ($this->adapter->supports(Capability::DefinedAttributes)) { + /** @var array $decAllAttrs */ + $decAllAttrs = $collection->getAttribute('attributes', []); + $matchedDecAttrs = \array_filter($decAllAttrs, function (Attribute $a) use ($attribute) { + return $a->key === $attribute; + }); + + if (empty($matchedDecAttrs)) { + throw new NotFoundException('Attribute not found'); + } + + /** @var Attribute $matchedDecAttr */ + $matchedDecAttr = \end($matchedDecAttrs); + if (! Attribute::isNumericType($matchedDecAttr->type) || $matchedDecAttr->array) { + throw new TypeException('Attribute must be an integer or float and can not be an array.'); + } + $numericAttribute = $matchedDecAttr; + } + + 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->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(PermissionType::Update, \array_merge( + $collection->getUpdate(), + ((bool) $documentSecurity ? $document->getUpdate() : []) + )))) { + 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 instanceof Attribute && Attribute::isIntegerType($numericAttribute->type) + ? 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->preserveDates) ? $time : DateTime::setTimezone($updatedAt); + if ($min !== null) { + $min = $numericAttribute instanceof Attribute && Attribute::isIntegerType($numericAttribute->type) + ? BigInt::add($min, $value) + : $this->getNativeNumber($min) + $this->getNativeNumber($value); + } + + $this->adapter->increaseDocumentAttribute( + $collection->getId(), + $id, + $attribute, + $numericAttribute instanceof Attribute && Attribute::isIntegerType($numericAttribute->type) + ? 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); + }); + + $this->triggerHooks(Event::DocumentDecrease, $document); + + return $document; + } + + /** + * Delete 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(); + $report = $this->getActiveLifecycleHooks(Event::DocumentUpdate) !== []; + $changed = []; + $deleted = $this->withMutation(Event::DocumentDelete, $cacheTarget, function () use ($collection, $id, $report, &$changed): ?Document { + $changed = []; + $document = $this->authorization->skip(fn () => $this->silent( + fn () => $this->getDocument($collection->getId(), $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->getDelete(), + ...($documentSecurity ? $document->getDelete() : []), + ]))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + } + + try { + $oldUpdatedAt = new PhpDateTime($document->getUpdatedAt() ?? 'now'); + } 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->relationshipHook?->isEnabled()) { + $changed = $this->silent(fn () => $this->relationshipHook->beforeDocumentDelete($collection, $document, $report)); + } + + $result = $this->authorization->skip(fn () => $this->adapter->deleteDocument($collection->getId(), $id)); + + $this->purgeCachedDocumentInternal($collection->getId(), $id); + + if ($result) { + $this->queueDocumentPurge($collection->getId(), $id); + } + + return $result ? $document : null; + }); + + if ($deleted === null) { + return false; + } + + $this->triggerDeleteHooks($deleted, $changed); + + 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 + */ + private function triggerDeleteHooks(Document $document, array $changed): void + { + $failure = null; + + try { + $this->triggerHooks(Event::DocumentDelete, $document); + } catch (Throwable $error) { + $failure = $error; + } + + foreach ($changed as $related) { + try { + $this->triggerHooks(Event::DocumentUpdate, $related); + } catch (Throwable $error) { + $failure ??= $error; + } + } + + if ($failure !== null) { + throw $failure; + } + } + + /** + * Delete Documents + * + * 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 + * @param (callable(Document, Document): void)|null $onNext Callback invoked for each deleted document + * @param (callable(Throwable): void)|null $onError Callback invoked on per-document errors + * @return int The number of documents deleted + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws QueryException + * @throws RestrictedException + * @throws Throwable + */ + public function deleteDocuments( + string $collection, + array $queries = [], + int $batchSize = self::DELETE_BATCH_SIZE, + ?callable $onNext = null, + ?callable $onError = null, + ): int { + $this->rejectJoins($queries, 'Join queries are not supported for bulk deletes'); + + 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(PermissionType::Delete, $collection->getDelete())); + + if (! $skipAuth && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + $this->checkQueryTypes($queries); + + if ($this->validate) { + $validator = $this->getDocumentsValidator($collection); + + if (! $validator->isValid($queries)) { + throw new QueryException($validator->getDescription()); + } + } + + $grouped = Query::groupForDatabase($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: PermissionType::Delete + )); + + if (empty($batch)) { + break; + } + + $old = array_map(fn ($doc) => clone $doc, $batch); + $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); + } + + 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 $document) { + $this->withDocumentTenant( + $document, + fn () => $this->advanceCollectionCacheEpoch($collection->getId(), $document->getId()) + ); + } + + $this->queueDocumentPurges($collection->getId(), $batch); + }); + + foreach ($batch as $index => $document) { + 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->triggerHooks(Event::DocumentsDelete, new Document([ + Document::COLLECTION => $collection->getId(), + 'modified' => $modified, + ])); + + return $modified; + } + + /** + * Cleans all of the collection's documents from the cache and all related cached documents. + * + * @param string $collectionId The collection identifier + * @return bool True if the cache was purged successfully + */ + public function purgeCachedCollection(string $collectionId): bool + { + if ($collectionId === self::METADATA) { + $this->purgeCachedDefinitions(); + $this->queryCache?->invalidateCollection($this->getQueryCacheScope(), $collectionId); + + return true; + } + + [$collectionKey] = $this->getCacheKeys($collectionId); + + $purged = $this->advanceDocumentCacheEpoch($collectionKey, $this->getDefinitionCacheKey($collectionId)); + $this->queryCache?->invalidateCollection($this->getQueryCacheScope(), $collectionId); + + return $purged; + } + + /** + * 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; + } + + $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(fn () => $this->foreach( + self::METADATA, + function (Document $definition): void { + $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->getSharedTables() && $this->getTenantPerDocument() && $tenant !== null) { + $this->withTenant($tenant, $callback); + + return; + } + + $callback(); + } + + private function getDocumentIdentity(Document $document): string + { + if (! $this->adapter->getTenantPerDocument()) { + 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. + * + * @param string $collectionId The collection identifier + * @param string|null $id The document identifier, or null to skip + * @return bool True if the cache was purged successfully + * + * @throws Exception + */ + public function purgeCachedDocument(string $collectionId, ?string $id): bool + { + $result = $this->purgeCachedDocumentInternal($collectionId, $id); + + if ($id !== null) { + $purged = new Document([ + Document::ID => $id, + Document::COLLECTION => $collectionId, + ]); + $this->invalidate(Event::DocumentPurge, $purged); + $this->triggerPropagatingHooks(Event::DocumentPurge, $purged); + } + + return $result; + } + + /** + * 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 + { + $document = new Document([ + Document::ID => $id, + Document::COLLECTION => $collectionId, + ]); + + if (! $this->adapter->inTransaction()) { + $this->triggerPropagatingHooks(Event::DocumentPurge, $document); + + return; + } + + if ($this->areEventsSilenced()) { + return; + } + + $context = $this->getEventContext(); + $tenant = $this->getTenant(); + $silenced = \array_keys($this->silencedListeners()->get()); + $announce = fn () => $this->triggerPropagatingHooks(Event::DocumentPurge, $document); + + $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 + { + $collectionDocument = $this->silent(fn () => $this->getCollection($collection)); + $collection = $collectionDocument->isEmpty() ? $collection : $collectionDocument->getId(); + $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->getCollection($collectionId)); + + if ($collection->isEmpty()) { + $decoded = false; + } else { + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $skipAuth = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getRead())); + + 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->createDocumentInstance($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->getRead()))) { + 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 + * @param PermissionType $forPermission The permission type to check for authorization + * @return array + * + * @param array $queries + * @return array + * + * @throws DatabaseException + * @throws QueryException + * @throws TimeoutException + * @throws Exception + */ + public function find(string $collection, array $queries = [], PermissionType $forPermission = PermissionType::Read): array + { + $queryCacheQueries = $queries; + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + $this->checkQueryTypes($queries); + + $joinedCollectionsById = null; + + if ($this->validate) { + $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()); + } + + /** @var array $relationships */ + $relationships = \array_filter( + $attributes, + fn (Attribute|Document $attribute) => Attribute::isRelationship($attribute) + ); + + $grouped = Query::groupForDatabase($queries); + $filters = $grouped['filters']; + $selects = $grouped['selections']; + $aggregations = $grouped['aggregations']; + $groupByAttrs = $grouped['groupBy']; + $having = $grouped['having']; + $joins = $grouped['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 = $grouped['distinct']; + $limit = $grouped['limit']; + $offset = $grouped['offset']; + $orderAttributes = $grouped['orderAttributes']; + $orderTypes = $grouped['orderTypes']; + $cursor = $grouped['cursor']; + $cursorDirection = $grouped['cursorDirection'] ?? CursorDirection::After; + + $isAggregation = ! empty($aggregations) || ! empty($groupByAttrs); + + 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'); + } + + foreach ($aggregations as $aggregation) { + $method = $aggregation->getMethod(); + $capability = match ($method) { + Method::Stddev, Method::StddevPop, Method::StddevSamp, Method::Variance, Method::VarPop, Method::VarSamp => Capability::StatisticalAggregates, + Method::BitAnd, Method::BitOr, Method::BitXor => Capability::BitwiseAggregates, + default => null, + }; + + if ($capability !== null && ! $this->adapter->supports($capability)) { + throw new QueryException('Aggregate '.$method->value.' is 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)); + } + + if (! empty($cursor)) { + if ($isAggregation) { + throw new QueryException('Cursor pagination is not supported with aggregation queries'); + } + + if ($joins === [] && ! $distinct && $this->validate && $cursor->getId() === '') { + throw new QueryException('Invalid query: Invalid cursor: '.(new UID($this->adapter->getMaxUIDLength()))->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->castingBefore($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->withJoinAttributes($this->withJoinAuthorization($collection, $joinDocumentSecurity, $collectionGranted), $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) { + $this->trigger(Event::DocumentFind, $results); + + 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)); + } + } + + // Hoist invariants out of the per-document loop. Collection id and + // documentType lookup don't change per result row, but were being + // re-evaluated for every document on every find. + $collectionId = $collection->getId(); + $hasCustomType = isset($this->documentTypes[$collectionId]); + + foreach ($results as $index => $node) { + $node = $this->castingAfter($collection, $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); + } + } + + // Convert to custom document type if mapped + if ($hasCustomType) { + $node = $this->createDocumentInstance($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->hydrateCollectionModels($node); + } + } + + $this->trigger(Event::DocumentFind, $results); + + return $results; + } + + /** + * Execute a raw query bypassing the query builder. The statement runs as written, with no + * permission or tenant scope, so like from() and execute() 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->adapter->hasFeature(Feature\RawQuery::class)) { + throw new DatabaseException('Raw queries are not supported by this adapter'); + } + + return $this->adapter->rawQuery($query, $bindings); + } + + /** + * Iterate documents in collection using a callback pattern. + * + * @param string $collection The collection identifier + * @param callable(Document): void $callback Callback invoked for each matching document + * @param array $queries Queries for filtering, sorting, and pagination + * @param PermissionType $forPermission The permission type to check for authorization + * + * @throws DatabaseException + */ + public function foreach(string $collection, callable $callback, array $queries = [], PermissionType $forPermission = PermissionType::Read): void + { + foreach ($this->iterate($collection, $queries, $forPermission) as $document) { + $callback($document); + } + } + + /** + * Return a generator yielding each document of the given collection that matches the given queries. + * + * @param string $collection The collection identifier + * @param array $queries Queries for filtering, sorting, and pagination + * @param PermissionType $forPermission The permission type to check for authorization + * @return Generator + * + * @throws DatabaseException + */ + public function iterate(string $collection, array $queries = [], PermissionType $forPermission = PermissionType::Read): Generator + { + $grouped = Query::groupForDatabase($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 === CursorDirection::Before) { + throw new DatabaseException('Cursor '.CursorDirection::Before->value.' not supported in this method.'); + } + + $sum = $limit; + $latestDocument = null; + $check = 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); + $latestDocument = $results[array_key_last($results)]; + + if ($sum === $limit) { + $check ??= $this->nextPageCheck($collection, $queries); + $check($latestDocument); + } + + foreach ($results as $document) { + yield $document; + } + } + } + + /** + * 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 + * + * @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(Event::DocumentFind, $found); + + if (! $found) { + return new Document(); + } + + 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 Maximum count to return, null for unlimited + * @return int The document count + * + * @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'); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + $this->checkQueryTypes($queries); + + $joinedCollections = null; + + if ($this->validate) { + $joinedCollections = $this->resolveJoinedCollections($queries); + $this->validateDocumentsQueries($collection, $queries, $joinedCollections); + } + + $documentSecurity = $collection->getAttribute('documentSecurity', false); + $collectionGranted = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getRead())); + + if (! $collectionGranted && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + /** @var array $relationships */ + $relationships = \array_filter( + $attributes, + fn (Attribute|Document $attribute) => Attribute::isRelationship($attribute) + ); + + $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(); + + $this->trigger(Event::DocumentCount, $count); + + return $count; + } + + /** + * Sum an attribute + * + * Sum an attribute for all matching documents. Pass $max=0 for unlimited. + * + * @param string $collection The collection identifier + * @param string $attribute The attribute to sum + * @param array $queries Queries for filtering + * @param int|null $max Maximum number of documents to include in the sum + * @return float|int The sum of the attribute values + * + * @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'); + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + $this->checkQueryTypes($queries); + + $joinedCollections = null; + + if ($this->validate) { + $joinedCollections = $this->resolveJoinedCollections($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->getRead())); + + if (! $collectionGranted && ! $documentSecurity && $collection->getId() !== self::METADATA) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + /** @var array $relationships */ + $relationships = \array_filter( + $attributes, + fn (Attribute|Document $attribute) => Attribute::isRelationship($attribute) + ); + + $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(); + + $this->trigger(Event::DocumentSum, $sum); + + return $sum; + } + + /** + * 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 given an alias reads, by that 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() && $query->getJoinAlias() !== '', + )); + + 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) { + /** @var array $joinedAttributes */ + $joinedAttributes = $joined->getAttribute('attributes', []); + foreach ($joinedAttributes as $declared) { + if ($declared->getId() === $attribute && ! Attribute::isRelationship($declared)) { + $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::internalAttributes() as $internal) { + if ($internal->key === $attribute) { + return true; + } + } + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + foreach ($attributes as $declared) { + if ($declared->getId() === $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 + { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + $validator = new Aggregate($attributes, $this->adapter->supports(Capability::DefinedAttributes), $this->adapter->getSharedTables()); + + if (\str_contains($attribute, '.') || ! $this->declaresSumAttribute($collection, $attribute)) { + $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()); + } + } + + /** + * 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 + */ + public function cursor(string $collection, array $queries = [], int $batchSize = 100): Generator + { + $grouped = Query::groupForDatabase($queries); + $remaining = $grouped['limit']; + $offset = $grouped['offset']; + $cursor = $grouped['cursor']; + + if ($cursor !== null && $grouped['cursorDirection'] === CursorDirection::Before) { + throw new DatabaseException('Cursor '.CursorDirection::Before->value.' not supported in this method.'); + } + + $queries = \array_values(\array_filter( + $queries, + static fn (Query $query): bool => ! \in_array($query->getMethod(), [Method::Limit, Method::Offset, Method::CursorAfter, Method::CursorBefore], true), + )); + $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]); + $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; + } + } + + /** + * Execute aggregation queries (count, sum, avg, min, max, groupBy) and return results. + * + * @param array $queries Must include at least one aggregation query (Query::count(), Query::sum(), etc.) + * @return array + */ + public function aggregate(string $collection, array $queries): array + { + return $this->find($collection, $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. + * + * @param array $orderAttributes + * @param array $orderTypes + * @param array $filters + * @param array $joins + * @param array $joinedCollections + * @return array{array, array} + */ + private function addTieBreaks(array $orderAttributes, array $orderTypes, array $filters, array $joins, array $joinedCollections, bool $paged): 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; + } + + $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 + { + $grouped = Query::groupForDatabase($queries); + $joins = $grouped['joins']; + $distinct = $grouped['distinct']; + if (($joins === [] && ! $distinct) || $grouped['aggregations'] !== [] || $grouped['groupBy'] !== []) { + return static function (Document $cursor): void { + }; + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + $joinedCollections = $this->joinedCollectionsByAlias($joins, $this->resolveJoinedCollections($joins)); + $selects = $grouped['selections']; + $filters = $grouped['filters']; + $orderAttributes = $grouped['orderAttributes']; + $orderTypes = $grouped['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); + } + + $this->assertCursorHasOrderValues($cursor, $orders); + }; + } + + /** + * 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 + { + if (! \in_array($join->getMethod(), [Method::Join, Method::LeftJoin], true) || $join->isNestedJoin()) { + return false; + } + + [$left, $operator, $right] = \array_pad($join->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->getDocumentsValidator($collection, $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->getCollection($id)); + if ($collection->isEmpty()) { + throw new QueryException("Joined collection '{$id}' not found"); + } + + $collections[$id] = $collection; + } + + return $collections; + } + + /** + * @param array $queries + * @param array $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 { + $grouped = Query::groupForDatabase($queries); + $filters = $grouped['filters']; + $joins = $grouped['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->withJoinAuthorization( + $collection, + $this->authorizeJoins($joins, PermissionType::Read, $joinedCollections), + $collectionGranted, + ); + } + + $queries = $this->convertQueries( + $collection, + \array_merge($filters, $joins), + $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] ?? new Document(); + + if ($joinCollection->isEmpty()) { + 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; + } + + $joinCollection = $joinedCollections[$joinCollectionId] ?? new Document(); + /** @var array $attributes */ + $attributes = $joinCollection->getAttribute('attributes', []); + $keys = []; + foreach ($attributes as $attribute) { + if (! Attribute::isRelationship($attribute)) { + $keys[] = $attribute->getId(); + } + } + $joinAttributes[$joinCollectionId] = $keys; + } + + $adapterCollection = clone $collection; + $adapterCollection->setAttribute(self::JOIN_ATTRIBUTES, $joinAttributes); + + return $adapterCollection; + } + + /** + * The collection each join reads, by the alias its values come back under: the alias the join + * declares, or the one generated for it. + * + * @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); + $taken = []; + foreach ($joins as $join) { + $alias = $join->getJoinAlias(); + if ($alias !== '') { + $taken[\strtolower($alias)] = true; + } + } + + $collections = []; + foreach (\array_values($joins) as $position => $join) { + $alias = $join->getJoinAlias(); + if ($alias === '') { + $alias = Storage::joinAlias($position, $taken); + } + + $collections[$alias] = $joinedCollections[$join->getAttribute()] ?? new Document(); + } + + return $collections; + } + + /** + * The `alias.$id` of each join whose attributes a select names without it, 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])) { + $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( + fn (array $attribute) => $attribute[Document::ID] ?? '', + $this->getInternalAttributes() + ); + + /** @var array $collAttrs */ + $collAttrs = $collection->getAttribute('attributes', []); + foreach ($collAttrs as $attribute) { + if (Attribute::isRelationship($attribute)) { + continue; + } + /** @var string $attrKey */ + $attrKey = $attribute->getAttribute('key', $attribute->getId()); + $keys[] = $attrKey; + } + 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 castingBefore(Document $collection, Document $document): Document + { + if ($this->adapter->hasFeature(Feature\InternalCasting::class)) { + return $this->adapter->castingBefore($collection, $document); + } + + return $document; + } + + private function castingAfter(Document $collection, Document $document): Document + { + if ($this->adapter->hasFeature(Feature\InternalCasting::class)) { + return $this->adapter->castingAfter($collection, $document); + } + + return $document; + } +} diff --git a/src/Database/Traits/Indexes.php b/src/Database/Traits/Indexes.php new file mode 100644 index 0000000000..c4404fc1e1 --- /dev/null +++ b/src/Database/Traits/Indexes.php @@ -0,0 +1,538 @@ +key; + $type = $index->type; + $attributes = $index->attributes; + $lengths = $index->lengths; + $orders = $index->orders; + $ttl = $index->ttl; + + 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 $existingIndex) { + if (\strtolower($existingIndex->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]; + } + + foreach ($collectionAttributes as $typedAttr) { + if ($typedAttr->key === $baseAttr) { + + $indexAttributesWithTypes[$attr] = $typedAttr->type->value; + + /** + * mysql does not save length in collection when length = attributes size + */ + if ($typedAttr->type === ColumnType::String) { + if (! empty($lengths[$i]) && $lengths[$i] === $typedAttr->size && $this->adapter->getMaxIndexLength() > 0) { + $lengths[$i] = null; + } + } + + if ($typedAttr->array) { + if ($this->adapter->getMaxIndexLength() > 0) { + $lengths[$i] = self::MAX_ARRAY_INDEX_LENGTH; + } + $orders[$i] = null; + } + break; + } + } + } + + // Update the index model with potentially modified lengths/orders + $index = new Index( + key: $id, + type: $type, + attributes: $attributes, + lengths: $lengths, + orders: $orders, + ttl: $ttl + ); + + if ($this->validate) { + /** @var array $collectionAttrsForValidation */ + $collectionAttrsForValidation = $collection->getAttribute('attributes', []); + /** @var array $collectionIdxsForValidation */ + $collectionIdxsForValidation = $collection->getAttribute('indexes', []); + + $validator = new IndexValidator( + $collectionAttrsForValidation, + $collectionIdxsForValidation, + $this->adapter->getMaxIndexLength(), + $this->adapter->getInternalIndexesKeys(), + $this->adapter->supports(Capability::IndexArray), + $this->adapter->supports(Capability::SpatialIndexNull), + $this->adapter->supports(Capability::SpatialIndexOrder), + $this->adapter->supports(Capability::Vectors), + $this->adapter->supports(Capability::DefinedAttributes), + $this->adapter->supports(Capability::MultipleFulltextIndexes), + $this->adapter->supports(Capability::IdenticalIndexes), + $this->adapter->supports(Capability::ObjectIndexes), + $this->adapter->supports(Capability::TrigramIndex), + $this->adapter->hasFeature(Feature\Spatial::class), + $this->adapter->supports(Capability::Index), + $this->adapter->supports(Capability::UniqueIndex), + $this->adapter->supports(Capability::Fulltext), + $this->adapter->supports(Capability::TTLIndexes), + $this->adapter->supports(Capability::Objects) + ); + if (! $validator->isValid($index)) { + throw new IndexException($validator->getDescription()); + } + } + + $created = false; + + if (! $this->reconcileSchemaOnlyIndex($collection->getId(), $index)) { + try { + $created = $this->adapter->createIndex($collection->getId(), $index, $indexAttributesWithTypes); + + if (! $created) { + throw new DatabaseException('Failed to create index'); + } + } catch (DuplicateException $e) { + // 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, SetType::Append); + + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->cleanupIndex($collection->getId(), $id), + shouldRollback: $created, + operationDescription: "index creation '{$id}'" + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + + $this->triggerHooks( + Event::IndexCreate, + $index->toDocument()->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return true; + } + + /** + * 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->hasFeature(Feature\SchemaIndexes::class) + || ($this->getSharedTables() && $this->isMigrating())) { + return false; + } + + $id = \strtolower($this->adapter->filter($index->key)); + foreach ($this->adapter->getInternalIndexesKeys() as $internal) { + if (\strtolower($this->adapter->filter($internal)) === $id) { + return false; + } + } + + foreach ($this->getSchemaIndexes($collection) as $schemaIndex) { + if (\strtolower($schemaIndex->getId()) !== $id) { + continue; + } + + if ($this->schemaIndexMatches($schemaIndex, $index)) { + return true; + } + + if ($this->getSharedTables()) { + 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(Document $schemaIndex, Index $index): bool + { + $rawColumns = $schemaIndex->getAttribute('columns', []); + $rawLengths = $schemaIndex->getAttribute('lengths', []); + $schemaLengths = \is_array($rawLengths) ? \array_values($rawLengths) : []; + + $columns = []; + $lengths = []; + foreach (\is_array($rawColumns) ? \array_values($rawColumns) : [] as $position => $column) { + $length = $schemaLengths[$position] ?? null; + $columns[] = \is_string($column) ? \strtolower($column) : ''; + $lengths[] = \is_numeric($length) ? (int) $length : 0; + } + + if ($this->getSharedTables() && ($columns[0] ?? '') === Storage::TENANT) { + \array_shift($columns); + \array_shift($lengths); + } + + if (\count($columns) !== \count($index->attributes)) { + return false; + } + + foreach (\array_values($index->attributes) as $position => $attribute) { + if ($columns[$position] === '') { + continue; + } + if ($columns[$position] !== \strtolower($this->adapter->filter(Storage::column($attribute)))) { + return false; + } + if ($lengths[$position] !== (int) ($index->lengths[$position] ?? 0)) { + return false; + } + } + + $indexType = $schemaIndex->getAttribute('indexType', ''); + $nonUnique = $schemaIndex->getAttribute('nonUnique', 1); + $schemaType = match (\is_string($indexType) ? \strtoupper($indexType) : '') { + 'FULLTEXT' => IndexType::Fulltext, + 'SPATIAL' => IndexType::Spatial, + default => \is_numeric($nonUnique) && (int) $nonUnique === 0 ? IndexType::Unique : IndexType::Key, + }; + $requestedType = $index->type === IndexType::Index ? IndexType::Key : $index->type; + + return $schemaType === $requestedType; + } + + /** + * Rename Index + * + * @param string $collection The collection identifier + * @param string $old Current index ID + * @param string $new New index ID + * @return bool True if the index was renamed successfully + * + * @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)); + + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + $index = \in_array($old, \array_map(fn ($idx) => $idx[Document::ID], $indexes)); + + if ($index === false) { + throw new NotFoundException('Index not found'); + } + + $indexNewExists = \in_array($new, \array_map(fn ($idx) => $idx[Document::ID], $indexes)); + + if ($indexNewExists !== false) { + throw new DuplicateException('Index name already used'); + } + + /** @var Document|null $indexNew */ + $indexNew = null; + foreach ($indexes as $key => $value) { + if ($value->getId() === $old) { + $value->setAttribute('key', $new); + $value->setAttribute(Document::ID, $new); + $indexNew = $value; + $indexes[$key] = $value; + break; + } + } + + if ($indexNew === null) { + throw new NotFoundException('Index not found'); + } + + $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 { + if (! $this->adapter->renameIndex($collection->getId(), $new, $old)) { + throw new DatabaseException('Failed to rename index'); + } + // Reverse succeeded — index was at $new. Re-rename to complete. + $renamed = $this->adapter->renameIndex($collection->getId(), $old, $new); + if (! $renamed) { + throw new DatabaseException('Failed to rename index'); + } + } 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())); + + $this->triggerHooks( + Event::IndexRename, + (clone $indexNew)->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return true; + } + + /** + * Delete Index + * + * @param string $collection The collection identifier + * @param string $id The index identifier to delete + * @return bool True if the index was deleted successfully + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws StructureException + */ + public function deleteIndex(string $collection, string $id): bool + { + $collection = $this->silent(fn () => $this->getCollection($collection)); + + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + + /** @var Index|null $indexDeleted */ + $indexDeleted = null; + foreach ($indexes as $key => $value) { + if ($value->getId() === $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', []); + $typedDeletedIndex = $indexDeleted; + /** @var array $indexAttributeTypes */ + $indexAttributeTypes = []; + foreach ($typedDeletedIndex->attributes as $attr) { + $baseAttr = \str_contains($attr, '.') ? \explode('.', $attr, 2)[0] : $attr; + foreach ($collectionAttributes as $collectionAttribute) { + if ($collectionAttribute->key === $baseAttr) { + $indexAttributeTypes[$attr] = $collectionAttribute->type->value; + break; + } + } + } + + $rollbackIndex = new Index( + key: $id, + type: $typedDeletedIndex->type, + attributes: $typedDeletedIndex->attributes, + lengths: $typedDeletedIndex->lengths, + orders: $typedDeletedIndex->orders, + ttl: $typedDeletedIndex->ttl + ); + $this->updateMetadata( + collection: $collection, + rollbackOperation: fn () => $this->adapter->createIndex( + $collection->getId(), + $rollbackIndex, + $indexAttributeTypes, + ), + shouldRollback: $shouldRollback, + operationDescription: "index deletion '{$id}'", + silentRollback: true + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + + $this->triggerHooks( + Event::IndexDelete, + $indexDeleted->toDocument()->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return $deleted; + } + + /** + * Update index metadata. Utility method for update index methods. + * + * @param callable(Index, Document, int|string): void $updateCallback + * + * @throws ConflictException + * @throws DatabaseException + */ + protected function updateIndexMeta(string $collection, string $id, callable $updateCallback): Index + { + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->getId() === self::METADATA) { + throw new DatabaseException('Cannot update metadata indexes'); + } + + /** @var array $indexes */ + $indexes = $collection->getAttribute('indexes', []); + $index = \array_search($id, \array_map(fn (Index $idx) => $idx->key, $indexes), true); + + if ($index === false) { + throw new NotFoundException('Index not found'); + } + + $indexModel = $indexes[$index]; + + $updateCallback($indexModel, $collection, $index); + $indexes[$index] = $indexModel; + + $collection->setAttribute('indexes', $indexes); + + $this->updateMetadata( + collection: $collection, + rollbackOperation: null, + shouldRollback: false, + operationDescription: "index metadata update '{$id}'" + ); + + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); + + return $indexModel; + } + + /** + * Cleanup an index that was created in the adapter but whose metadata + * persistence failed. + * + * @param string $collectionId The collection ID + * @param string $indexId The index ID + * @param int $maxAttempts Maximum retry attempts + * + * @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 + ); + } +} diff --git a/src/Database/Traits/Relationships.php b/src/Database/Traits/Relationships.php new file mode 100644 index 0000000000..477eb5e266 --- /dev/null +++ b/src/Database/Traits/Relationships.php @@ -0,0 +1,1004 @@ +relationshipHook === null) { + return $callback(); + } + + return $this->relationshipHook->withEnabled(false, $callback); + } + + /** + * Skip relationship existence checks for all calls inside the 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); + } + + /** + * Cleanup a relationship on failure + * + * @param string $collectionId The collection ID + * @param string $relatedCollectionId The related collection ID + * @param RelationType $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 RelationSide $side The relationship side + * @param int $maxAttempts Maximum retry attempts + * + * @throws DatabaseException If cleanup fails after all retries + */ + private function cleanupRelationship( + string $collectionId, + string $relatedCollectionId, + RelationType $type, + bool $twoWay, + string $key, + string $twoWayKey, + RelationSide $side = RelationSide::Parent, + int $maxAttempts = 3 + ): void { + $adapter = $this->adapter; + if (! $adapter->hasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + + $relationshipModel = new Relationship( + collection: $collectionId, + relatedCollection: $relatedCollectionId, + type: $type, + twoWay: $twoWay, + key: $key, + twoWayKey: $twoWayKey, + side: $side, + ); + $this->cleanup( + fn () => $adapter->deleteRelationship($relationshipModel), + 'relationship', + $key, + $maxAttempts + ); + } + + /** + * Create a relationship attribute between two collections. + * + * @param Relationship $relationship The relationship definition + * @return bool True if the relationship was created successfully + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + * @throws StructureException + */ + public function createRelationship( + Relationship $relationship + ): bool { + if (! $this->adapter->hasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + + $collection = $this->silent(fn () => $this->getCollection($relationship->collection)); + $relatedCollection = $this->silent(fn () => $this->getCollection($relationship->relatedCollection)); + + /** @var Document $collection */ + /** @var Document $relatedCollection */ + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + if ($relatedCollection->isEmpty()) { + throw new NotFoundException('Related collection not found'); + } + + $type = $relationship->type; + $twoWay = $relationship->twoWay; + $id = ! empty($relationship->key) ? $relationship->key : $this->adapter->filter($relatedCollection->getId()); + $twoWayKey = ! empty($relationship->twoWayKey) ? $relationship->twoWayKey : $this->adapter->filter($collection->getId()); + $onDelete = $relationship->onDelete; + + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + foreach ($attributes as $attribute) { + if (\strtolower($attribute->key) === \strtolower($id)) { + throw new DuplicateException('Attribute already exists'); + } + + if ($attribute->type === ColumnType::Relationship) { + $existingRel = Relationship::fromArray(['collection' => $collection->getId()] + $attribute->getArrayCopy()); + if ( + \strtolower($existingRel->twoWayKey) === \strtolower($twoWayKey) + && $existingRel->relatedCollection === $relatedCollection->getId() + ) { + throw new DuplicateException('Related attribute already exists'); + } + } + } + + $relationship = Attribute::relationship( + key: $id, + options: [ + 'relatedCollection' => $relatedCollection->getId(), + 'relationType' => $type->value, + 'twoWay' => $twoWay, + 'twoWayKey' => $twoWayKey, + 'onDelete' => $onDelete->value, + 'side' => RelationSide::Parent->value, + ], + ); + + $twoWayRelationship = Attribute::relationship( + key: $twoWayKey, + options: [ + 'relatedCollection' => $collection->getId(), + 'relationType' => $type->value, + 'twoWay' => $twoWay, + 'twoWayKey' => $id, + 'onDelete' => $onDelete->value, + 'side' => RelationSide::Child->value, + ], + ); + + $this->checkAttribute($collection, $relationship); + $this->checkAttribute($relatedCollection, $twoWayRelationship); + + /** @var ?string $junctionCollection */ + $junctionCollection = null; + if ($type === RelationType::ManyToMany) { + $junctionCollection = '_'.$collection->getSequence().'_'.$relatedCollection->getSequence(); + $junctionAttributes = [ + Attribute::string(key: $id, required: true), + Attribute::string(key: $twoWayKey, required: true), + ]; + $junctionIndexes = [ + Index::key(key: '_index_'.$id, attributes: [$id]), + Index::key(key: '_index_'.$twoWayKey, attributes: [$twoWayKey]), + ]; + try { + $this->silent(fn () => $this->createCollection(new Collection(id: $junctionCollection, attributes: $junctionAttributes, indexes: $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; + + $adapterRelationship = new Relationship( + collection: $collection->getId(), + relatedCollection: $relatedCollection->getId(), + type: $type, + twoWay: $twoWay, + key: $id, + twoWayKey: $twoWayKey, + onDelete: $onDelete, + side: RelationSide::Parent, + ); + + try { + $created = $this->adapter->createRelationship($adapterRelationship); + + 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, SetType::Append); + $relatedCollection->setAttribute('attributes', $twoWayRelationship, SetType::Append); + + $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $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 $e) { + if (! $this->failedAfterCommit($e)) { + $this->rollbackAttributeMetadata($collection, [$id]); + $this->rollbackAttributeMetadata($relatedCollection, [$twoWayKey]); + + if ($created) { + try { + $this->cleanupRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + RelationSide::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: '.$e->getMessage(), previous: $e); + } + + $committedFailure = $e; + } + + $indexKey = '_index_'.$id; + $twoWayIndexKey = '_index_'.$twoWayKey; + $indexes = match ($type) { + RelationType::OneToOne => $twoWay + ? [ + [$collection->getId(), Index::unique(key: $indexKey, attributes: [$id])], + [$relatedCollection->getId(), Index::unique(key: $twoWayIndexKey, attributes: [$twoWayKey])], + ] + : [[$collection->getId(), Index::unique(key: $indexKey, attributes: [$id])]], + RelationType::OneToMany => [[$relatedCollection->getId(), Index::key(key: $twoWayIndexKey, attributes: [$twoWayKey])]], + RelationType::ManyToOne => [[$collection->getId(), Index::key(key: $indexKey, attributes: [$id])]], + RelationType::ManyToMany => [], + }; + $indexesCreated = []; + + try { + foreach ($indexes as [$indexCollection, $index]) { + try { + $this->createIndex($indexCollection, $index); + } catch (Throwable $e) { + if (! $this->failedAfterCommit($e)) { + throw $e; + } + + $committedFailure ??= $e; + } + $indexesCreated[] = ['collection' => $indexCollection, 'index' => $index->key]; + } + } 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()); + } + } + + $definitionsRemoved = true; + try { + $this->withTransaction(function () use ($collection, $relatedCollection, $id, $twoWayKey) { + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + $collection->setAttribute('attributes', array_filter($attributes, fn (Attribute $attr) => $attr->getId() !== $id)); + $this->updateDocument(self::METADATA, $collection->getId(), $collection); + + /** @var array $relatedAttributes */ + $relatedAttributes = $relatedCollection->getAttribute('attributes', []); + $relatedCollection->setAttribute('attributes', array_filter($relatedAttributes, fn (Attribute $attr) => $attr->getId() !== $twoWayKey)); + $this->updateDocument(self::METADATA, $relatedCollection->getId(), $relatedCollection); + }); + } catch (Throwable $cleanupError) { + $definitionsRemoved = $this->failedAfterCommit($cleanupError); + Console::error("Failed to cleanup metadata for relationship '{$id}': ".$cleanupError->getMessage()); + } + + if ($definitionsRemoved) { + try { + $this->cleanupRelationship( + $collection->getId(), + $relatedCollection->getId(), + $type, + $twoWay, + $id, + $twoWayKey, + RelationSide::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(), previous: $e); + } + + if ($committedFailure !== null) { + throw $committedFailure; + } + }); + + $this->triggerHooks( + Event::AttributeCreate, + (clone $relationship)->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return true; + } + + /** + * Update a relationship attribute's keys, two-way status, or on-delete behavior. + * + * @param string $collection The collection identifier + * @param string $id The relationship attribute identifier + * @param string|null $newKey New key for the relationship attribute + * @param string|null $newTwoWayKey New key for the two-way relationship attribute + * @param bool|null $twoWay Whether the relationship should be two-way + * @param ForeignKeyAction|null $onDelete Action to take on related document deletion + * @return bool True if the relationship was updated successfully + * + * @throws ConflictException + * @throws DatabaseException + */ + public function updateRelationship( + string $collection, + string $id, + ?string $newKey = null, + ?string $newTwoWayKey = null, + ?bool $twoWay = null, + ?ForeignKeyAction $onDelete = null + ): bool { + if (! $this->adapter->hasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + + if ( + $newKey === null + && $newTwoWayKey === null + && $twoWay === null + && $onDelete === null + ) { + return true; + } + + $collection = $this->getCollection($collection); + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + + if ( + $newKey !== null + && \in_array($newKey, \array_map(fn (Attribute $attribute) => $attribute->key, $attributes), true) + ) { + throw new DuplicateException('Relationship already exists'); + } + + $attributeIndex = array_search($id, array_map(fn (Attribute $attribute) => $attribute->key, $attributes), true); + + if ($attributeIndex === false) { + throw new NotFoundException('Relationship not found'); + } + + $attribute = $attributes[$attributeIndex]; + $oldRel = Relationship::fromArray(['collection' => $collection->getId()] + $attribute->getArrayCopy()); + + $relatedCollectionId = $oldRel->relatedCollection; + $relatedCollection = $this->getCollection($relatedCollectionId); + + // Determine if we need to alter the database (rename columns/indexes) + $oldTwoWayKey = $oldRel->twoWayKey; + $altering = ($newKey !== null && $newKey !== $id) + || ($newTwoWayKey !== null && $newTwoWayKey !== $oldTwoWayKey); + + // Validate new keys don't already exist + /** @var array $relatedAttrs */ + $relatedAttrs = $relatedCollection->getAttribute('attributes', []); + if ( + $newTwoWayKey !== null + && \in_array($newTwoWayKey, \array_map(fn (Attribute $attribute) => $attribute->key, $relatedAttrs), true) + ) { + throw new DuplicateException('Related attribute already exists'); + } + + $actualNewKey = $newKey ?? $id; + $actualNewTwoWayKey = $newTwoWayKey ?? $oldTwoWayKey; + $actualTwoWay = $twoWay ?? $oldRel->twoWay; + $actualOnDelete = $onDelete ?? $oldRel->onDelete; + + $adapterUpdated = false; + if ($altering) { + try { + $current = new Relationship( + collection: $collection->getId(), + relatedCollection: $relatedCollection->getId(), + type: $oldRel->type, + twoWay: $actualTwoWay, + key: $id, + twoWayKey: $oldTwoWayKey, + onDelete: $actualOnDelete, + side: $oldRel->side, + ); + $adapterUpdated = $this->adapter->updateRelationship( + $current, + $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->hasFeature(Feature\SchemaAttributes::class)) { + $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); + } + } + } + + $updatedAttributes = []; + + try { + $updatedAttributes[] = [$collection->getId(), $this->updateAttributeMeta($collection->getId(), $id, function ($attribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete, $relatedCollection, $oldRel) { + $attribute->setAttribute(Document::ID, $actualNewKey); + $attribute->setAttribute('key', $actualNewKey); + $attribute->setAttribute('options', [ + 'relatedCollection' => $relatedCollection->getId(), + 'relationType' => $oldRel->type->value, + 'twoWay' => $actualTwoWay, + 'twoWayKey' => $actualNewTwoWayKey, + 'onDelete' => $actualOnDelete->value, + 'side' => $oldRel->side->value, + ]); + }, triggerEvent: false)]; + + $updatedAttributes[] = [$relatedCollection->getId(), $this->updateAttributeMeta($relatedCollection->getId(), $oldTwoWayKey, function (Document $twoWayAttribute) use ($actualNewKey, $actualNewTwoWayKey, $actualTwoWay, $actualOnDelete) { + /** @var array $options */ + $options = $twoWayAttribute->getAttribute('options', []); + $options['twoWayKey'] = $actualNewKey; + $options['twoWay'] = $actualTwoWay; + $options['onDelete'] = $actualOnDelete->value; + + $twoWayAttribute->setAttribute(Document::ID, $actualNewTwoWayKey); + $twoWayAttribute->setAttribute('key', $actualNewTwoWayKey); + $twoWayAttribute->setAttribute('options', $options); + }, triggerEvent: false)]; + + if ($oldRel->type === RelationType::ManyToMany) { + $junction = $this->getJunctionCollection($collection, $relatedCollection, $oldRel->side); + + $updatedAttributes[] = [$junction, $this->updateAttributeMeta($junction, $id, function ($junctionAttribute) use ($actualNewKey) { + $junctionAttribute->setAttribute(Document::ID, $actualNewKey); + $junctionAttribute->setAttribute('key', $actualNewKey); + }, triggerEvent: false)]; + $updatedAttributes[] = [$junction, $this->updateAttributeMeta($junction, $oldTwoWayKey, function ($junctionAttribute) use ($actualNewTwoWayKey) { + $junctionAttribute->setAttribute(Document::ID, $actualNewTwoWayKey); + $junctionAttribute->setAttribute('key', $actualNewTwoWayKey); + }, triggerEvent: false)]; + + $this->withRetries(fn () => $this->purgeCachedCollection($junction)); + } + } catch (Throwable $e) { + $restores = [ + fn () => $this->updateAttributeMeta($collection->getId(), $actualNewKey, function ($attribute) use ($id, $oldRel) { + $attribute->setAttribute(Document::ID, $id); + $attribute->setAttribute('key', $id); + $attribute->setAttribute('options', $oldRel->toDocument()->getArrayCopy()); + }, triggerEvent: false), + fn () => $this->updateAttributeMeta($relatedCollection->getId(), $actualNewTwoWayKey, function (Document $twoWayAttribute) use ($oldTwoWayKey, $id, $oldRel) { + /** @var array $options */ + $options = $twoWayAttribute->getAttribute('options', []); + $options['twoWayKey'] = $id; + $options['twoWay'] = $oldRel->twoWay; + $options['onDelete'] = $oldRel->onDelete->value; + $twoWayAttribute->setAttribute(Document::ID, $oldTwoWayKey); + $twoWayAttribute->setAttribute('key', $oldTwoWayKey); + $twoWayAttribute->setAttribute('options', $options); + }, triggerEvent: false), + fn () => $this->updateAttributeMeta($this->getJunctionCollection($collection, $relatedCollection, $oldRel->side), $actualNewKey, function ($junctionAttribute) use ($id) { + $junctionAttribute->setAttribute(Document::ID, $id); + $junctionAttribute->setAttribute('key', $id); + }, triggerEvent: false), + fn () => $this->updateAttributeMeta($this->getJunctionCollection($collection, $relatedCollection, $oldRel->side), $actualNewTwoWayKey, function ($junctionAttribute) use ($oldTwoWayKey) { + $junctionAttribute->setAttribute(Document::ID, $oldTwoWayKey); + $junctionAttribute->setAttribute('key', $oldTwoWayKey); + }, triggerEvent: false), + ]; + foreach (\array_slice($restores, 0, \count($updatedAttributes)) as $restore) { + try { + $restore(); + } catch (Throwable) { + // Best effort + } + } + + if ($adapterUpdated && $this->adapter->hasFeature(Feature\Relationships::class)) { + try { + $renamed = new Relationship( + collection: $collection->getId(), + relatedCollection: $relatedCollection->getId(), + type: $oldRel->type, + twoWay: $actualTwoWay, + key: $actualNewKey, + twoWayKey: $actualNewTwoWayKey, + onDelete: $actualOnDelete, + side: $oldRel->side, + ); + $this->adapter->updateRelationship( + $renamed, + $id, + $oldTwoWayKey + ); + } catch (Throwable) { + // 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 ($oldRel->type) { + case RelationType::OneToOne: + 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 RelationType::OneToMany: + if ($oldRel->side === RelationSide::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 RelationType::ManyToOne: + if ($oldRel->side === RelationSide::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 RelationType::ManyToMany: + $junction = $this->getJunctionCollection($collection, $relatedCollection, $oldRel->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) { + if ($adapterUpdated && $this->adapter->hasFeature(Feature\Relationships::class)) { + try { + $renamed = new Relationship( + collection: $collection->getId(), + relatedCollection: $relatedCollection->getId(), + type: $oldRel->type, + twoWay: $oldRel->twoWay, + key: $actualNewKey, + twoWayKey: $actualNewTwoWayKey, + onDelete: $oldRel->onDelete, + side: $oldRel->side, + ); + $this->adapter->updateRelationship( + $renamed, + $id, + $oldTwoWayKey + ); + } catch (Throwable) { + // Best effort + } + } + + foreach (\array_reverse($indexRenamesCompleted) as [$indexedCollection, $from, $to]) { + try { + $renameIndex($indexedCollection, $from, $to); + } catch (Throwable) { + // Best effort + } + } + + try { + $this->updateAttributeMeta($collection->getId(), $actualNewKey, function ($attribute) use ($id, $oldRel) { + $attribute->setAttribute(Document::ID, $id); + $attribute->setAttribute('key', $id); + $attribute->setAttribute('options', $oldRel->toDocument()->getArrayCopy()); + }, triggerEvent: false); + } catch (Throwable) { + // Best effort + } + + try { + $this->updateAttributeMeta($relatedCollection->getId(), $actualNewTwoWayKey, function (Document $twoWayAttribute) use ($oldTwoWayKey, $id, $oldRel) { + /** @var array $options */ + $options = $twoWayAttribute->getAttribute('options', []); + $options['twoWayKey'] = $id; + $options['twoWay'] = $oldRel->twoWay; + $options['onDelete'] = $oldRel->onDelete->value; + $twoWayAttribute->setAttribute(Document::ID, $oldTwoWayKey); + $twoWayAttribute->setAttribute('key', $oldTwoWayKey); + $twoWayAttribute->setAttribute('options', $options); + }, triggerEvent: false); + } catch (Throwable) { + // Best effort + } + + if ($oldRel->type === RelationType::ManyToMany) { + $junctionId = $this->getJunctionCollection($collection, $relatedCollection, $oldRel->side); + try { + $this->updateAttributeMeta($junctionId, $actualNewKey, function ($attr) use ($id) { + $attr->setAttribute(Document::ID, $id); + $attr->setAttribute('key', $id); + }, triggerEvent: false); + } catch (Throwable) { + // Best effort + } + try { + $this->updateAttributeMeta($junctionId, $actualNewTwoWayKey, function ($attr) use ($oldTwoWayKey) { + $attr->setAttribute(Document::ID, $oldTwoWayKey); + $attr->setAttribute('key', $oldTwoWayKey); + }, triggerEvent: false); + } 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())); + + foreach ($updatedAttributes as [$updatedCollection, $attribute]) { + $this->triggerHooks( + Event::AttributeUpdate, + $attribute->toDocument()->setAttribute(Document::COLLECTION, $updatedCollection), + ); + } + + return true; + } + + /** + * Delete a relationship attribute and its inverse from both collections. + * + * @param string $collection The collection identifier + * @param string $id The relationship attribute identifier + * @return bool True if the relationship was deleted successfully + * + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws StructureException + */ + public function deleteRelationship(string $collection, string $id): bool + { + if (! $this->adapter->hasFeature(Feature\Relationships::class)) { + throw new DatabaseException('Adapter does not support relationships'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + /** @var array $attributes */ + $attributes = $collection->getAttribute('attributes', []); + $relationship = null; + + foreach ($attributes as $name => $attribute) { + if ($attribute->key === $id) { + $relationship = $attribute; + unset($attributes[$name]); + break; + } + } + + if ($relationship === null) { + throw new NotFoundException('Relationship not found'); + } + + $collection->setAttribute('attributes', \array_values($attributes)); + + $rel = Relationship::fromArray(['collection' => $collection->getId()] + $relationship->getArrayCopy()); + + $relatedCollection = $this->silent(fn () => $this->getCollection($rel->relatedCollection)); + /** @var array $relatedAttributes */ + $relatedAttributes = $relatedCollection->getAttribute('attributes', []); + + foreach ($relatedAttributes as $name => $attribute) { + if ($attribute->key === $rel->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, $rel, $id, &$deletedIndexes, &$deletedJunction) { + $indexKey = '_index_'.$id; + $twoWayIndexKey = '_index_'.$rel->twoWayKey; + + switch ($rel->type) { + case RelationType::OneToOne: + if ($rel->side === RelationSide::Parent) { + $this->deleteIndex($collection->getId(), $indexKey); + $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => IndexType::Unique, 'attributes' => [$id]]; + if ($rel->twoWay) { + $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); + $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => IndexType::Unique, 'attributes' => [$rel->twoWayKey]]; + } + } + if ($rel->side === RelationSide::Child) { + $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); + $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => IndexType::Unique, 'attributes' => [$rel->twoWayKey]]; + if ($rel->twoWay) { + $this->deleteIndex($collection->getId(), $indexKey); + $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => IndexType::Unique, 'attributes' => [$id]]; + } + } + break; + case RelationType::OneToMany: + if ($rel->side === RelationSide::Parent) { + $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); + $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => IndexType::Key, 'attributes' => [$rel->twoWayKey]]; + } else { + $this->deleteIndex($collection->getId(), $indexKey); + $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => IndexType::Key, 'attributes' => [$id]]; + } + break; + case RelationType::ManyToOne: + if ($rel->side === RelationSide::Parent) { + $this->deleteIndex($collection->getId(), $indexKey); + $deletedIndexes[] = ['collection' => $collection->getId(), 'key' => $indexKey, 'type' => IndexType::Key, 'attributes' => [$id]]; + } else { + $this->deleteIndex($relatedCollection->getId(), $twoWayIndexKey); + $deletedIndexes[] = ['collection' => $relatedCollection->getId(), 'key' => $twoWayIndexKey, 'type' => IndexType::Key, 'attributes' => [$rel->twoWayKey]]; + } + break; + case RelationType::ManyToMany: + $junction = $this->getJunctionCollection( + $collection, + $relatedCollection, + $rel->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); + + $dropped = new Relationship( + collection: $collection->getId(), + relatedCollection: $relatedCollection->getId(), + type: $rel->type, + twoWay: $rel->twoWay, + key: $id, + twoWayKey: $rel->twoWayKey, + side: $rel->side, + ); + + $shouldRollback = false; + try { + $deleted = $this->adapter->deleteRelationship($dropped); + + 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) { + try { + $restored = new Relationship( + collection: $collection->getId(), + relatedCollection: $relatedCollection->getId(), + type: $rel->type, + twoWay: $rel->twoWay, + key: $id, + twoWayKey: $rel->twoWayKey, + onDelete: $rel->onDelete, + side: RelationSide::Parent, + ); + $this->adapter->createRelationship($restored); + } catch (Throwable) { + // Silent rollback — best effort to restore consistency + } + } + + // Restore deleted indexes + foreach ($deletedIndexes as $indexInfo) { + try { + $this->createIndex( + $indexInfo['collection'], + new Index( + key: $indexInfo['key'], + type: $indexInfo['type'], + attributes: $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())); + + $this->triggerHooks( + Event::AttributeDelete, + (clone $relationship)->setAttribute(Document::COLLECTION, $collection->getId()), + ); + + return true; + } + + private function getJunctionCollection(Document $collection, Document $relatedCollection, RelationSide $side): string + { + return $side === RelationSide::Parent + ? '_'.$collection->getSequence().'_'.$relatedCollection->getSequence() + : '_'.$relatedCollection->getSequence().'_'.$collection->getSequence(); + } +} diff --git a/src/Database/Traits/Transactions.php b/src/Database/Traits/Transactions.php new file mode 100644 index 0000000000..77ec192b9f --- /dev/null +++ b/src/Database/Traits/Transactions.php @@ -0,0 +1,245 @@ +> Collection keys of the documents written in the open invalidation scope, by coroutine id and document key. */ + protected array $documentCachePurges = []; + + /** @var array> Lower-cased keys of the documents written in the open invalidation scope, by coroutine id, kept only while that scope owns the adapter's transaction. */ + protected array $transactionWrites = []; + + /** @var array> Document purge events of the open invalidation scope, by coroutine id, fired once its outermost transaction has committed. */ + protected array $documentPurgeEvents = []; + + /** @var WeakMap|null Failures raised after their outermost transaction committed. */ + private ?WeakMap $committedFailures = 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) { + $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; + } + + return $callback(); + })); + } + + /** + * 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. + * + * @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); + }; + + try { + return $this->adapter->withTransaction(function () use ($callback, $discard): mixed { + $discard(); + + return $callback(); + }); + } catch (Throwable $error) { + if ($this->adapter->supports(Capability::NestedTransactions)) { + $discard(); + } + + 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 are dropped with a rollback. + * + * @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]; + unset( + $this->queryCacheMutations[$context], + $this->documentCacheMutations[$context], + $this->documentCachePurges[$context], + $this->transactionWrites[$context], + $this->documentPurgeEvents[$context], + ); + try { + $this->purgeWrittenDocuments($documents); + } catch (Throwable) { + // Rolled back: the cached entries still hold committed rows. + } + 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. + } + } + + throw $error; + } + + if ($outer) { + $queryTokens = $this->queryCacheMutations[$context]; + $documentTokens = $this->documentCacheMutations[$context]; + $documents = $this->documentCachePurges[$context]; + $purgeEvents = $this->documentPurgeEvents[$context]; + unset( + $this->queryCacheMutations[$context], + $this->documentCacheMutations[$context], + $this->documentCachePurges[$context], + $this->transactionWrites[$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; + } + } + + return $result; + } + + /** + * 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]); + } +} diff --git a/src/Database/Type/Custom.php b/src/Database/Type/Custom.php new file mode 100644 index 0000000000..511e5c6d28 --- /dev/null +++ b/src/Database/Type/Custom.php @@ -0,0 +1,12 @@ + */ + private array $types = []; + + /** + * @throws DuplicateException + */ + public function register(Custom $type): void + { + $name = $type->name(); + + if (\in_array($name, Database::DEFAULT_FILTERS, true)) { + throw new DuplicateException("Custom type \"{$name}\" collides with the built-in filter of the same name"); + } + + $this->types[$name] = $type; + } + + public function get(string $name): ?Custom + { + return $this->types[$name] ?? null; + } + + /** + * @return array + */ + public function all(): array + { + return $this->types; + } +} diff --git a/src/Database/Validator/Attribute.php b/src/Database/Validator/Attribute.php index e3dc0044e6..7e4068344c 100644 --- a/src/Database/Validator/Attribute.php +++ b/src/Database/Validator/Attribute.php @@ -2,45 +2,50 @@ namespace Utopia\Database\Validator; +use stdClass; +use Utopia\Database\Attribute as AttributeVO; 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\Limit as LimitException; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; +use ValueError; +/** + * Validates database attribute definitions including type, size, format, and default values. + */ class Attribute extends Validator { + private const string JSON_FILTER = 'json'; + + private const array STRING_TYPES = [ + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText, + ]; + protected string $message = 'Invalid attribute'; /** - * @var array $attributes + * @var array */ protected array $attributes = []; /** - * @var array $schemaAttributes + * @var array */ 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 + * @param array $attributes + * @param array $schemaAttributes + * @param callable|null $attributeCountCallback + * @param callable|null $attributeWidthCallback + * @param callable|null $filterCallback */ public function __construct( array $attributes, @@ -68,12 +73,12 @@ public function __construct( } foreach ($attributes as $attribute) { - $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); - $this->attributes[$key] = $attribute; + $typed = $attribute instanceof AttributeVO ? $attribute : AttributeVO::fromDocument($attribute); + $this->attributes[\strtolower($typed->key)] = $typed; } foreach ($schemaAttributes as $attribute) { - $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); - $this->schemaAttributes[$key] = $attribute; + $typed = $attribute instanceof AttributeVO ? $attribute : AttributeVO::fromDocument($attribute); + $this->schemaAttributes[\strtolower($typed->key)] = $typed; } } @@ -81,8 +86,6 @@ public function __construct( * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { @@ -91,7 +94,6 @@ public function getType(): string /** * Returns validator description - * @return string */ public function getDescription(): string { @@ -102,8 +104,6 @@ public function getDescription(): string * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -114,33 +114,47 @@ public function isArray(): bool * Is valid. * * Returns true if attribute is valid. - * @param Document $value - * @return bool + * + * @param AttributeVO|Document $value + * * @throws DatabaseException * @throws DuplicateException * @throws LimitException */ public function isValid($value): bool { - if (!$this->checkDuplicateId($value)) { + if ($value instanceof AttributeVO) { + $attr = $value; + } else { + try { + $attr = AttributeVO::fromDocument($value); + } catch (ValueError $e) { + /** @var string $rawType */ + $rawType = $value->getAttribute('type', 'unknown'); + $this->message = 'Unknown attribute type: '.$rawType; + throw new DatabaseException($this->message); + } + } + + if (! $this->checkDuplicateId($attr)) { return false; } - if (!$this->checkDuplicateInSchema($value)) { + if (! $this->checkDuplicateInSchema($attr)) { return false; } - if (!$this->checkRequiredFilters($value)) { + if (! $this->checkRequiredFilters($attr)) { return false; } - if (!$this->checkFormat($value)) { + if (! $this->checkFormat($attr)) { return false; } - if (!$this->checkAttributeLimits($value)) { + if (! $this->checkType($attr)) { return false; } - if (!$this->checkType($value)) { + if (! $this->checkAttributeLimits($attr)) { return false; } - if (!$this->checkDefaultValue($value)) { + if (! $this->checkDefaultValue($attr)) { return false; } @@ -150,16 +164,14 @@ public function isValid($value): bool /** * Check for duplicate attribute ID in collection metadata * - * @param Document $attribute - * @return bool * @throws DuplicateException */ - public function checkDuplicateId(Document $attribute): bool + public function checkDuplicateId(AttributeVO $attribute): bool { - $id = $attribute->getAttribute('key', $attribute->getAttribute('$id')); + $id = $attribute->key; foreach ($this->attributes as $existingAttribute) { - if (\strtolower($existingAttribute->getId()) === \strtolower($id)) { + if (\strtolower($existingAttribute->key) === \strtolower($id)) { $this->message = 'Attribute already exists in metadata'; throw new DuplicateException($this->message); } @@ -171,13 +183,11 @@ public function checkDuplicateId(Document $attribute): bool /** * Check for duplicate attribute ID in schema * - * @param Document $attribute - * @return bool * @throws DuplicateException */ - public function checkDuplicateInSchema(Document $attribute): bool + public function checkDuplicateInSchema(AttributeVO $attribute): bool { - if (!$this->supportForSchemaAttributes) { + if (! $this->supportForSchemaAttributes) { return true; } @@ -185,10 +195,11 @@ public function checkDuplicateInSchema(Document $attribute): bool return true; } - $id = $attribute->getAttribute('key', $attribute->getAttribute('$id')); + $id = $attribute->key; foreach ($this->schemaAttributes as $schemaAttribute) { - $schemaId = $this->filterCallback ? ($this->filterCallback)($schemaAttribute->getId()) : $schemaAttribute->getId(); + /** @var string $schemaId */ + $schemaId = $this->filterCallback ? ($this->filterCallback)($schemaAttribute->key) : $schemaAttribute->key; if (\strtolower($schemaId) === \strtolower($id)) { $this->message = 'Attribute already exists in schema'; throw new DuplicateException($this->message); @@ -201,18 +212,13 @@ public function checkDuplicateInSchema(Document $attribute): bool /** * Check if required filters are present for the attribute type * - * @param Document $attribute - * @return bool * @throws DatabaseException */ - public function checkRequiredFilters(Document $attribute): bool + public function checkRequiredFilters(AttributeVO $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); + $requiredFilters = $this->getRequiredFilters($attribute->type); + if (! empty(\array_diff($requiredFilters, $attribute->filters))) { + $this->message = "Attribute of type: {$attribute->type->value} requires the following filters: ".implode(',', $requiredFilters); throw new DatabaseException($this->message); } @@ -222,14 +228,12 @@ public function checkRequiredFilters(Document $attribute): bool /** * 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 + protected function getRequiredFilters(ColumnType $type): array { return match ($type) { - Database::VAR_DATETIME => ['datetime'], + ColumnType::Datetime => ['datetime'], default => [], }; } @@ -237,17 +241,12 @@ protected function getRequiredFilters(?string $type): array /** * Check if format is valid for the attribute type * - * @param Document $attribute - * @return bool * @throws DatabaseException */ - public function checkFormat(Document $attribute): bool + public function checkFormat(AttributeVO $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 . '")'; + if ($attribute->format && ! Structure::hasFormat($attribute->format, $attribute->type)) { + $this->message = 'Format ("'.$attribute->format.'") not available for this attribute type ("'.$attribute->type->value.'")'; throw new DatabaseException($this->message); } @@ -257,26 +256,28 @@ public function checkFormat(Document $attribute): bool /** * Check attribute limits (count and width) * - * @param Document $attribute - * @return bool * @throws LimitException */ - public function checkAttributeLimits(Document $attribute): bool + public function checkAttributeLimits(AttributeVO $attribute): bool { if ($this->attributeCountCallback === null || $this->attributeWidthCallback === null) { return true; } - $attributeCount = ($this->attributeCountCallback)($attribute); - $attributeWidth = ($this->attributeWidthCallback)($attribute); + $attributeDoc = $attribute->toDocument(); + + /** @var int $attributeCount */ + $attributeCount = ($this->attributeCountCallback)($attributeDoc); + /** @var int $attributeWidth */ + $attributeWidth = ($this->attributeWidthCallback)($attributeDoc); 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.'; + $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.'; + $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); } @@ -286,108 +287,105 @@ public function checkAttributeLimits(Document $attribute): bool /** * Check attribute type and type-specific constraints * - * @param Document $attribute - * @return bool * @throws DatabaseException */ - public function checkType(Document $attribute): bool + public function checkType(AttributeVO $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'); + $type = $attribute->type; + $size = $attribute->size; + $signed = $attribute->signed; + $array = $attribute->array; + $default = $attribute->default; switch ($type) { - case Database::VAR_ID: + case ColumnType::Id: break; - case Database::VAR_STRING: + case ColumnType::String: if ($size > $this->maxStringLength) { - $this->message = 'Max size allowed for string is: ' . number_format($this->maxStringLength); + $this->message = 'Max size allowed for string is: '.number_format($this->maxStringLength); throw new DatabaseException($this->message); } break; - case Database::VAR_VARCHAR: + case ColumnType::Varchar: if ($size > $this->maxVarcharLength) { - $this->message = 'Max size allowed for varchar is: ' . number_format($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; + case ColumnType::Text: + if ($size > 65535) { + $this->message = 'Max size allowed for text is: 65535'; 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; + case ColumnType::MediumText: + if ($size > 16777215) { + $this->message = 'Max size allowed for mediumtext is: 16777215'; 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; + case ColumnType::LongText: + if ($size > 4294967295) { + $this->message = 'Max size allowed for longtext is: 4294967295'; throw new DatabaseException($this->message); } break; - case Database::VAR_INTEGER: + case ColumnType::Integer: $limit = ($signed) ? $this->maxIntLength / 2 : $this->maxIntLength; if ($size > $limit) { - $this->message = 'Max size allowed for int is: ' . number_format($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: + case ColumnType::BigInteger: + case ColumnType::Float: + case ColumnType::Double: + case ColumnType::Boolean: + case ColumnType::Datetime: + case ColumnType::Relationship: break; - case Database::VAR_OBJECT: - if (!$this->supportForObject) { + case ColumnType::Object: + if (! $this->supportForObject) { $this->message = 'Object attributes are not supported'; throw new DatabaseException($this->message); } - if (!empty($size)) { + if (! empty($size)) { $this->message = 'Size must be empty for object attributes'; throw new DatabaseException($this->message); } - if (!empty($array)) { + 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) { + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: + if (! $this->supportForSpatialAttributes) { $this->message = 'Spatial attributes are not supported'; throw new DatabaseException($this->message); } - if (!empty($size)) { + if (! empty($size)) { $this->message = 'Size must be empty for spatial attributes'; throw new DatabaseException($this->message); } - if (!empty($array)) { + if (! empty($array)) { $this->message = 'Spatial attributes cannot be arrays'; throw new DatabaseException($this->message); } break; - case Database::VAR_VECTOR: - if (!$this->supportForVectors) { + case ColumnType::Vector: + if (! $this->supportForVectors) { $this->message = 'Vector types are not supported by the current database'; throw new DatabaseException($this->message); } @@ -400,22 +398,22 @@ public function checkType(Document $attribute): bool throw new DatabaseException($this->message); } if ($size > Database::MAX_VECTOR_DIMENSIONS) { - $this->message = 'Vector dimensions cannot exceed ' . 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)) { + 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'; + $this->message = 'Vector default value must have exactly '.$size.' elements'; throw new DatabaseException($this->message); } foreach ($default as $component) { - if (!is_numeric($component)) { + if (! is_numeric($component)) { $this->message = 'Vector default value must contain only numeric elements'; throw new DatabaseException($this->message); } @@ -424,29 +422,15 @@ public function checkType(Document $attribute): bool 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); + $availableTypes = AttributeVO::availableTypes( + objects: $this->supportForObject, + spatial: $this->supportForSpatialAttributes, + vectors: $this->supportForVectors, + ); + $this->message = 'Unknown attribute type: '.$type->value.'. Must be one of '.\implode(', ', \array_map( + AttributeVO::persistedType(...), + $availableTypes, + )); throw new DatabaseException($this->message); } @@ -456,29 +440,31 @@ public function checkType(Document $attribute): bool /** * Check default value constraints and type matching * - * @param Document $attribute - * @return bool * @throws DatabaseException */ - public function checkDefaultValue(Document $attribute): bool + public function checkDefaultValue(AttributeVO $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); + $default = $attribute->default; + $type = $attribute->type; + $signed = $attribute->signed; if (\is_null($default)) { return true; } - if ($required === true) { + if ($attribute->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)) { + 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); } @@ -488,17 +474,42 @@ public function checkDefaultValue(Document $attribute): bool return true; } + /** + * 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(AttributeVO $attribute): bool + { + $default = $attribute->default; + + return \in_array(self::JSON_FILTER, $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(AttributeVO $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); + } + } + /** * 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) + * @param ColumnType $type Type of the attribute + * @param mixed $default Default value of the attribute * - * @return void * @throws DatabaseException */ - protected function validateDefaultTypes(string $type, mixed $default, bool $signed = true): void + protected function validateDefaultTypes(ColumnType $type, mixed $default, bool $signed = true): void { $defaultType = \gettype($default); @@ -508,51 +519,64 @@ protected function validateDefaultTypes(string $type, mixed $default, bool $sign } if ($defaultType === 'array') { - // Spatial types require the array itself - if (!in_array($type, Database::SPATIAL_TYPES) && $type != Database::VAR_OBJECT) { + if (AttributeVO::isSpatialType($type)) { + $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 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: if ($defaultType !== 'string') { - $this->message = 'Default value ' . $default . ' does not match given type ' . $type; + $this->message = 'Default value '.json_encode($default).' does not match given type '.AttributeVO::persistedType($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; + case ColumnType::Integer: + case ColumnType::Boolean: + if ($type->value !== $defaultType) { + $this->message = 'Default value '.json_encode($default).' does not match given type '.AttributeVO::persistedType($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; + case ColumnType::BigInteger: + if (! (new BigInt($signed, $this->supportUnsignedBigInt))->isValid($default)) { + $this->message = 'Default value '.json_encode($default).' does not match given type '.AttributeVO::persistedType($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'; + break; + case ColumnType::Float: + case ColumnType::Double: + if ($defaultType !== 'double') { + $this->message = 'Default value '.json_encode($default).' does not match given type '.AttributeVO::persistedType($type); 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; + case ColumnType::Datetime: + if ($defaultType !== 'string') { + $this->message = 'Default value '.json_encode($default).' does not match given type '.AttributeVO::persistedType($type); throw new DatabaseException($this->message); } break; - case Database::VAR_VECTOR: + case ColumnType::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)'; @@ -561,25 +585,26 @@ protected function validateDefaultTypes(string $type, mixed $default, bool $sign 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 + ColumnType::String->value, + ColumnType::Varchar->value, + ColumnType::Text->value, + ColumnType::MediumText->value, + ColumnType::LongText->value, + ColumnType::Integer->value, + AttributeVO::persistedType(ColumnType::BigInteger), + ColumnType::Float->value, + ColumnType::Double->value, + ColumnType::Boolean->value, + ColumnType::Datetime->value, + ColumnType::Relationship->value, ]; if ($this->supportForVectors) { - $supportedTypes[] = Database::VAR_VECTOR; + $supportedTypes[] = ColumnType::Vector->value; } if ($this->supportForSpatialAttributes) { - \array_push($supportedTypes, ...Database::SPATIAL_TYPES); + \array_push($supportedTypes, ColumnType::Point->value, ColumnType::Linestring->value, ColumnType::Polygon->value); } - $this->message = 'Unknown attribute type: ' . $type . '. Must be one of ' . implode(', ', $supportedTypes); + $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..a0186c7607 100644 --- a/src/Database/Validator/Authorization.php +++ b/src/Database/Validator/Authorization.php @@ -2,101 +2,126 @@ namespace Utopia\Database\Validator; +use Utopia\Database\State\Value; use Utopia\Database\Validator\Authorization\Input; use Utopia\Validator; +/** + * Validates authorization by checking if any of the current roles match the required permissions. + * + * The status and the roles are shared by every caller, except inside skip(), withStatus() and withRoles(): those + * scopes belong to the calling coroutine and the coroutines it starts (see {@see Value}). + */ 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 */ protected bool $statusDefault = true; /** - * @var array + * @var Value> */ - private array $roles = [ - 'any' => true - ]; + private Value $roles; - /** - * @var string - */ protected string $message = 'Authorization Error'; + public function __construct() + { + $this->status = new Value(true); + + /** @var Value> $roles */ + $roles = new Value(['any' => true]); + $this->roles = $roles; + } + + public function __clone() + { + $this->status = new Value($this->status->get()); + $this->roles = new Value($this->roles->get()); + } + /** * Get Description. * * Returns validator description - * - * @return string */ public function getDescription(): string { return $this->message; } - /* - * Validation + /** + * Validate that the given input has the required permissions for the current roles. * - * Returns true if valid or false if not. - */ + * @param mixed $input Authorization\Input instance containing action and permissions + * @return bool + */ public function isValid(mixed $input): bool { - if (!($input instanceof Input)) { + if (! ($input instanceof Input)) { $this->message = 'Invalid input provided'; + return false; } $permissions = $input->getPermissions(); $action = $input->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 + * Add a role to the authorized roles list. + * + * @param string $role Role identifier to add * @return void */ public function addRole(string $role): void { - $this->roles[$role] = true; + $roles = $this->roles->get(); + $roles[$role] = true; + $this->roles->set($roles); } /** - * @param string $role + * Remove a role from the authorized roles list. * + * @param string $role Role identifier to remove * @return void */ public function removeRole(string $role): void { - unset($this->roles[$role]); + $roles = $this->roles->get(); + unset($roles[$role]); + $this->roles->set($roles); } /** @@ -104,59 +129,70 @@ public function removeRole(string $role): void */ public function getRoles(): array { - return \array_keys($this->roles); + return \array_keys($this->roles->get()); + } + + /** + * 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 withRoles(array $roles, callable $callback): mixed + { + return $this->roles->with(\array_fill_keys($roles, true), $callback); } /** + * Remove all roles from the authorized roles list. + * * @return void */ public function cleanRoles(): void { - $this->roles = []; + $this->roles->set([]); } /** - * @param string $role + * Check whether a specific role exists in the authorized roles list. * + * @param string $role Role identifier to check * @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 { $this->statusDefault = $status; - $this->status = $status; + $this->status->set($status); } /** * Change status - * - * @param bool $status - * @return void */ public function setStatus(bool $status): void { - $this->status = $status; + $this->status->set($status); } /** * Get status - * - * @return bool */ public function getStatus(): bool { - return $this->status; + return $this->status->get(); } /** @@ -165,57 +201,56 @@ public function getStatus(): bool * Skips authorization for the code to be executed inside the callback * * @template T - * @param callable(): T $callback + * + * @param callable(): T $callback * @return T */ public function skip(callable $callback): mixed { - $initialStatus = $this->status; - $this->disable(); + return $this->status->with(false, $callback); + } - try { - return $callback(); - } finally { - $this->status = $initialStatus; - } + /** + * Run the callback with the given status for the calling coroutine and the coroutines it starts + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + public function withStatus(bool $status, callable $callback): mixed + { + return $this->status->with($status, $callback); } /** * Enable Authorization checks - * - * @return void */ public function enable(): void { - $this->status = true; + $this->status->set(true); } /** * Disable Authorization checks - * - * @return void */ public function disable(): void { - $this->status = false; + $this->status->set(false); } /** - * Disable Authorization checks - * - * @return void + * Reset the status to the default status */ public function reset(): void { - $this->status = $this->statusDefault; + $this->status->set($this->statusDefault); } /** * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -226,8 +261,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { diff --git a/src/Database/Validator/Authorization/Input.php b/src/Database/Validator/Authorization/Input.php index 8db9e80585..5a021152ec 100644 --- a/src/Database/Validator/Authorization/Input.php +++ b/src/Database/Validator/Authorization/Input.php @@ -2,39 +2,61 @@ 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 + * Create a new authorization input. + * + * @param PermissionType|string $action The action being authorized (e.g., read, write) + * @param string[] $permissions List of permission strings to check against */ - 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 + * Set the permissions to check against. + * + * @param string[] $permissions List of permission strings + * @return self */ public function setPermissions(array $permissions): self { $this->permissions = $permissions; + return $this; } - public function setAction(string $action): self + /** + * Set the action being authorized. + * + * @param PermissionType|string $action The action name + * @return self + */ + public function setAction(PermissionType|string $action): self { - $this->action = $action; + $this->action = $action instanceof PermissionType ? $action->value : $action; + return $this; } /** + * Get the permissions to check against. + * * @return string[] */ public function getPermissions(): array @@ -42,6 +64,11 @@ public function getPermissions(): array return $this->permissions; } + /** + * Get the action being authorized. + * + * @return string + */ public function getAction(): string { return $this->action; diff --git a/src/Database/Validator/BigInt.php b/src/Database/Validator/BigInt.php index fc560694a2..c953fe2d6e 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, @@ -37,7 +38,7 @@ public function isArray(): bool public function getType(): string { - return Database::VAR_BIGINT; + return \Utopia\Query\Schema\ColumnType::BigInteger->value; } public function isValid(mixed $value): bool @@ -123,6 +124,336 @@ public static function compareUnsignedStrings(string $a, string $b): int return $a < $b ? -1 : 1; } + public static function normalizeInteger(mixed $value): string + { + 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 + { + $value = self::normalizeInteger($value); + + return self::fitsPhpInt($value) ? (int) $value : $value; + } + + public static function compare(int|float|string $a, int|float|string $b): int + { + $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 + { + $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 + { + $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/Datetime.php b/src/Database/Validator/Datetime.php index 7950b1e078..3120285b59 100644 --- a/src/Database/Validator/Datetime.php +++ b/src/Database/Validator/Datetime.php @@ -2,61 +2,70 @@ namespace Utopia\Database\Validator; +use DateTime as PhpDateTime; +use Exception; use Utopia\Validator; +/** + * Validates datetime strings against configurable precision, range, and future-date constraints. + */ 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'; /** - * @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 */ 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 { @@ -65,8 +74,8 @@ public function isValid($value): bool } 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 +89,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; } @@ -130,8 +130,6 @@ public function isValid($value): bool * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -142,8 +140,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 8b07db2ce1..6b5a2b4207 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -2,44 +2,53 @@ namespace Utopia\Database\Validator; +use Utopia\Database\Attribute as AttributeVO; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; +use Utopia\Database\Index as IndexVO; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; use Utopia\Validator; +/** + * Validates database index definitions including type support, attribute references, lengths, and constraints. + */ class Index extends Validator { + private const array STRING_TYPES = [ + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText, + ]; + + private const int BIG_INTEGER_SIZE = 8; + protected string $message = 'Invalid index'; /** - * @var array $attributes + * @var array */ 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 + * @var array + */ + protected array $indexes; + + /** + * @param array $attributes + * @param array $indexes + * @param array $reservedKeys + * * @throws DatabaseException */ public function __construct( array $attributes, - protected array $indexes, + array $indexes, protected int $maxLength, protected array $reservedKeys = [], protected bool $supportForArrayIndexes = false, @@ -58,13 +67,19 @@ public function __construct( protected bool $supportForTTLIndexes = false, protected bool $supportForObjects = false ) { + $this->attributes = []; foreach ($attributes as $attribute) { - $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); + $typed = $attribute instanceof AttributeVO ? $attribute : AttributeVO::fromDocument($attribute); + $this->attributes[\strtolower($typed->key)] = $typed; + } + foreach (Database::internalAttributes() as $attribute) { + $key = \strtolower($attribute->key); $this->attributes[$key] = $attribute; } - foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { - $key = \strtolower($attribute['$id']); - $this->attributes[$key] = new Document($attribute); + + $this->indexes = []; + foreach ($indexes as $index) { + $this->indexes[] = $index instanceof IndexVO ? $index : IndexVO::fromDocument($index); } } @@ -72,8 +87,6 @@ public function __construct( * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { @@ -82,7 +95,6 @@ public function getType(): string /** * Returns validator description - * @return string */ public function getDescription(): string { @@ -93,8 +105,6 @@ public function getDescription(): string * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -105,362 +115,430 @@ public function isArray(): bool * Is valid. * * Returns true index if valid. - * @param Document $value - * @return bool + * + * @param IndexVO|Document $value + * * @throws DatabaseException */ public function isValid($value): bool { - if (!$this->checkValidIndex($value)) { + if (! $this->checkStoredDefinition($value)) { return false; } - if (!$this->checkValidAttributes($value)) { + + $index = $value instanceof IndexVO ? $value : IndexVO::fromDocument($value); + + if (! $this->checkValidIndex($index)) { + return false; + } + if (! $this->checkValidAttributes($index)) { return false; } - if (!$this->checkEmptyIndexAttributes($value)) { + if (! $this->checkEmptyIndexAttributes($index)) { return false; } - if (!$this->checkDuplicatedAttributes($value)) { + if (! $this->checkDuplicatedAttributes($index)) { return false; } - if (!$this->checkMultipleFulltextIndexes($value)) { + if (! $this->checkMultipleFulltextIndexes($index)) { return false; } - if (!$this->checkFulltextIndexNonString($value)) { + if (! $this->checkFulltextIndexNonString($index)) { return false; } - if (!$this->checkArrayIndexes($value)) { + if (! $this->checkArrayIndexes($index)) { return false; } - if (!$this->checkIndexLengths($value)) { + if (! $this->checkIndexLengths($index)) { return false; } - if (!$this->checkReservedNames($value)) { + if (! $this->checkReservedNames($index)) { return false; } - if (!$this->checkSpatialIndexes($value)) { + if (! $this->checkSpatialIndexes($index)) { return false; } - if (!$this->checkNonSpatialIndexOnSpatialAttributes($value)) { + if (! $this->checkNonSpatialIndexOnSpatialAttributes($index)) { return false; } - if (!$this->checkVectorIndexes($value)) { + if (! $this->checkVectorIndexes($index)) { return false; } - if (!$this->checkIdenticalIndexes($value)) { + if (! $this->checkIdenticalIndexes($index)) { return false; } - if (!$this->checkObjectIndexes($value)) { + if (! $this->checkObjectIndexes($index)) { return false; } - if (!$this->checkTrigramIndexes($value)) { + if (! $this->checkTrigramIndexes($index)) { return false; } - if (!$this->checkKeyUniqueFulltextSupport($value)) { + if (! $this->checkKeyUniqueFulltextSupport($index)) { return false; } - if (!$this->checkTTLIndexes($value)) { + if (! $this->checkTTLIndexes($index)) { return false; } + return true; } /** - * @param Document $index + * Check that the index type is supported by the current adapter. + * + * @param IndexVO $index The index to validate * @return bool - */ - public function checkValidIndex(Document $index): bool + */ + public function checkValidIndex(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->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)); + $dottedAttributes = array_filter($index->attributes, fn (string $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; - }; + 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 Database::INDEX_KEY: - if (!$this->supportForKeyIndexes) { + case IndexType::Key: + if (! $this->supportForKeyIndexes) { $this->message = 'Key index is not supported'; + return false; } break; - case Database::INDEX_UNIQUE: - if (!$this->supportForUniqueIndexes) { + case IndexType::Unique: + if (! $this->supportForUniqueIndexes) { $this->message = 'Unique index is not supported'; + return false; } break; - case Database::INDEX_FULLTEXT: - if (!$this->supportForFulltextIndexes) { + case IndexType::Fulltext: + if (! $this->supportForFulltextIndexes) { $this->message = 'Fulltext index is not supported'; + return false; } break; - case Database::INDEX_SPATIAL: - if (!$this->supportForSpatialIndexes) { + case IndexType::Spatial: + if (! $this->supportForSpatialIndexes) { $this->message = 'Spatial indexes are not supported'; + return false; } - if (!empty($index->getAttribute('orders')) && !$this->supportForSpatialIndexOrder) { + if (! empty($index->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) { + case IndexType::HnswEuclidean: + case IndexType::HnswCosine: + case IndexType::HnswDot: + if (! $this->supportForVectorIndexes) { $this->message = 'Vector indexes are not supported'; + return false; } break; - case Database::INDEX_OBJECT: - if (!$this->supportForObjectIndexes) { + case IndexType::Object: + if (! $this->supportForObjectIndexes) { $this->message = 'Object indexes are not supported'; + return false; } break; - case Database::INDEX_TRIGRAM: - if (!$this->supportForTrigramIndexes) { + case IndexType::Trigram: + if (! $this->supportForTrigramIndexes) { $this->message = 'Trigram indexes are not supported'; + return false; } break; - case Database::INDEX_TTL: - if (!$this->supportForTTLIndexes) { + case IndexType::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; + $this->message = self::unknownTypeMessage($type->value); + return false; } + + return true; + } + + /** + * Index::fromDocument() reads stored metadata leniently, since every validated query parses + * it, so the stored type and ttl are checked here before the conversion. + */ + 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; + } + /** - * @param Document $index + * Check that all index attributes exist in the collection schema. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkValidAttributes(Document $index): bool + public function checkValidAttributes(IndexVO $index): bool { - if (!$this->supportForAttributes) { + if (! $this->supportForAttributes) { return true; } - foreach ($index->getAttribute('attributes', []) as $attribute) { + foreach ($index->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 (! 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'; + $this->message = 'Invalid index attribute "'.$attribute.'" not found'; + return false; } } + return true; } /** - * @param Document $index + * Check that the index has at least one attribute. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkEmptyIndexAttributes(Document $index): bool + public function checkEmptyIndexAttributes(IndexVO $index): bool { - if (empty($index->getAttribute('attributes', []))) { + if (empty($index->attributes)) { $this->message = 'No attributes provided for index'; + return false; } + return true; } /** - * @param Document $index + * Check that the index does not contain duplicate attributes. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkDuplicatedAttributes(Document $index): bool + public function checkDuplicatedAttributes(IndexVO $index): bool { - $attributes = $index->getAttribute('attributes', []); $stack = []; - foreach ($attributes as $attribute) { + foreach ($index->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 + * Check that fulltext indexes only reference string-type attributes. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkFulltextIndexNonString(Document $index): bool + public function checkFulltextIndexNonString(IndexVO $index): bool { - if (!$this->supportForAttributes) { + 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'; + 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; } /** - * @param Document $index + * Check constraints for indexes on array attributes including type, length, and count limits. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkArrayIndexes(Document $index): bool + public function checkArrayIndexes(IndexVO $index): bool { - if (!$this->supportForAttributes) { + 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(); + foreach ($index->attributes as $attributePosition => $attributeName) { + $attribute = $this->findAttribute($attributeName); - if ($attribute->getAttribute('array', false)) { + if ($attribute !== null && $attribute->array) { // 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'; + if ($index->type !== IndexType::Key) { + $this->message = '"'.ucfirst($index->type->value).'" index is forbidden on array attributes'; + return false; } - if (empty($lengths[$attributePosition])) { + if (empty($index->lengths[$attributePosition])) { $this->message = 'Index length for array not specified'; + return false; } - $arrayAttributes[] = $attribute->getAttribute('key', ''); + $arrayAttributes[] = $attribute->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', '') . '"'; + $direction = $index->orders[$attributePosition] ?? null; + if ($direction !== null) { + $this->message = 'Invalid index order "'.$direction->value.'" on array attribute "'.$attribute->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'; + } elseif (! $this->isStringAttribute($attribute) && ! empty($index->lengths[$attributePosition])) { + $type = $attribute === null ? '' : $attribute->type->value; + $this->message = 'Cannot set a length on "'.$type.'" attributes'; + return false; } } + return true; } /** - * @param Document $index + * Check that index lengths are valid and do not exceed the maximum allowed total. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkIndexLengths(Document $index): bool + public function checkIndexLengths(IndexVO $index): bool { - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { + if ($index->type === IndexType::Fulltext) { return true; } - if (!$this->supportForAttributes) { + if (! $this->supportForAttributes) { return true; } $total = 0; - $lengths = $index->getAttribute('lengths', []); - $attributes = $index->getAttribute('attributes', []); - if (count($lengths) > count($attributes)) { + if (count($index->lengths) > count($index->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)])) { + foreach ($index->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; - } + $attrType = $attribute->type; + $attrSize = $attribute->resolvedSize(); + [$attributeSize, $indexLength] = match ($attrType) { + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText => [ + $attrSize, + ! empty($index->lengths[$attributePosition]) ? $index->lengths[$attributePosition] : $attrSize, + ], + ColumnType::Float, + ColumnType::Double, + ColumnType::BigInteger, + ColumnType::Id => [2, 2], + ColumnType::Integer => $attrSize >= self::BIG_INTEGER_SIZE ? [2, 2] : [1, 1], + default => [1, 1], + }; if ($indexLength < 0) { - $this->message = 'Negative index length provided for ' . $attributeName; + $this->message = 'Negative index length provided for '.$attributeName; + return false; } - if ($attribute->getAttribute('array', 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 . '"'; + $this->message = 'Index length '.$indexLength.' is larger than the size for '.$attributeName.': '.$attributeSize.'"'; + return false; } @@ -468,7 +546,8 @@ public function checkIndexLengths(Document $index): bool } if ($total > $this->maxLength && $this->maxLength > 0) { - $this->message = 'Index length is longer than the maximum: ' . $this->maxLength; + $this->message = 'Index length is longer than the maximum: '.$this->maxLength; + return false; } @@ -476,16 +555,19 @@ public function checkIndexLengths(Document $index): bool } /** - * @param Document $index + * Check that the index key name is not a reserved name. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkReservedNames(Document $index): bool + public function checkReservedNames(IndexVO $index): bool { - $key = $index->getAttribute('key', $index->getAttribute('$id')); + $key = $index->key; foreach ($this->reservedKeys as $reserved) { if (\strtolower($key) === \strtolower($reserved)) { $this->message = 'Index key name is reserved'; + return false; } } @@ -494,48 +576,51 @@ public function checkReservedNames(Document $index): bool } /** - * @param Document $index + * Check spatial index constraints including attribute type and nullability. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkSpatialIndexes(Document $index): bool + public function checkSpatialIndexes(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; - if ($type !== Database::INDEX_SPATIAL) { + if ($type !== IndexType::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) { + if (\count($index->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', ''); + foreach ($index->attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new AttributeVO(); + $attributeType = $attribute->type; + + 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.'"'; - 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.'; + if (! $attribute->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) { + if (! empty($index->orders) && ! $this->supportForSpatialIndexOrder) { $this->message = 'Spatial indexes with explicit orders are not supported. Remove the orders to create this index.'; + return false; } @@ -543,26 +628,27 @@ public function checkSpatialIndexes(Document $index): bool } /** - * @param Document $index + * Check that non-spatial index types are not applied to spatial attributes. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkNonSpatialIndexOnSpatialAttributes(Document $index): bool + public function checkNonSpatialIndexOnSpatialAttributes(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; // Skip check for spatial indexes - if ($type === Database::INDEX_SPATIAL) { + if ($type === IndexType::Spatial) { return true; } - $attributes = $index->getAttribute('attributes', []); + foreach ($index->attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new AttributeVO(); + $attributeType = $attribute->type; - foreach ($attributes as $attributeName) { - $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); + 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.'; - 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; } } @@ -571,44 +657,42 @@ public function checkNonSpatialIndexOnSpatialAttributes(Document $index): bool } /** - * @param Document $index - * @return bool * @throws DatabaseException */ - public function checkVectorIndexes(Document $index): bool + public function checkVectorIndexes(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; if ( - $type !== Database::INDEX_HNSW_DOT && - $type !== Database::INDEX_HNSW_COSINE && - $type !== Database::INDEX_HNSW_EUCLIDEAN + $type !== IndexType::HnswDot && + $type !== IndexType::HnswCosine && + $type !== IndexType::HnswEuclidean ) { return true; } if ($this->supportForVectorIndexes === false) { $this->message = 'Vector indexes are not supported'; + return false; } - $attributes = $index->getAttribute('attributes', []); - - if (\count($attributes) !== 1) { + if (\count($index->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) { + $attribute = $this->attributes[\strtolower($index->attributes[0])] ?? new AttributeVO(); + if ($attribute->type !== ColumnType::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) { + if (! empty($index->orders) || \count(\array_filter($index->lengths)) > 0) { $this->message = 'Vector indexes do not support orders or lengths'; + return false; } @@ -616,45 +700,33 @@ public function checkVectorIndexes(Document $index): bool } /** - * @param Document $index - * @return bool * @throws DatabaseException */ - public function checkTrigramIndexes(Document $index): bool + public function checkTrigramIndexes(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; - if ($type !== Database::INDEX_TRIGRAM) { + if ($type !== IndexType::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)) { + 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; } } - $orders = $index->getAttribute('orders', []); - $lengths = $index->getAttribute('lengths', []); - if (!empty($orders) || \count(\array_filter($lengths)) > 0) { + if (! empty($index->orders) || \count(\array_filter($index->lengths)) > 0) { $this->message = 'Trigram indexes do not support orders or lengths'; + return false; } @@ -662,20 +734,24 @@ public function checkTrigramIndexes(Document $index): bool } /** - * @param Document $index + * Check that key and unique index types are supported by the current adapter. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkKeyUniqueFulltextSupport(Document $index): bool + public function checkKeyUniqueFulltextSupport(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; - if ($type === Database::INDEX_KEY && $this->supportForKeyIndexes === false) { + if ($type === IndexType::Key && $this->supportForKeyIndexes === false) { $this->message = 'Key index is not supported'; + return false; } - if ($type === Database::INDEX_UNIQUE && $this->supportForUniqueIndexes === false) { + if ($type === IndexType::Unique && $this->supportForUniqueIndexes === false) { $this->message = 'Unique index is not supported'; + return false; } @@ -683,22 +759,25 @@ public function checkKeyUniqueFulltextSupport(Document $index): bool } /** - * @param Document $index + * Check that multiple fulltext indexes are not created when unsupported. + * + * @param IndexVO $index The index to validate * @return bool */ - public function checkMultipleFulltextIndexes(Document $index): bool + public function checkMultipleFulltextIndexes(IndexVO $index): bool { if ($this->supportForMultipleFulltextIndexes) { return true; } - if ($index->getAttribute('type') === Database::INDEX_FULLTEXT) { + if ($index->type === IndexType::Fulltext) { foreach ($this->indexes as $existingIndex) { - if ($existingIndex->getId() === $index->getId()) { + if ($existingIndex->key === $index->key) { continue; } - if ($existingIndex->getAttribute('type') === Database::INDEX_FULLTEXT) { + if ($existingIndex->type === IndexType::Fulltext) { $this->message = 'There is already a fulltext index in the collection'; + return false; } } @@ -708,45 +787,47 @@ public function checkMultipleFulltextIndexes(Document $index): bool } /** - * @param Document $index + * 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 IndexVO $index The index to validate * @return bool */ - public function checkIdenticalIndexes(Document $index): bool + public function checkIdenticalIndexes(IndexVO $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', ''); + if (\strtolower($existingIndex->key) === \strtolower($index->key)) { + continue; + } $attributesMatch = false; - if (empty(\array_diff($existingAttributes, $indexAttributes)) && - empty(\array_diff($indexAttributes, $existingAttributes))) { + if (empty(\array_diff($existingIndex->attributes, $index->attributes)) && + empty(\array_diff($index->attributes, $existingIndex->attributes))) { $attributesMatch = true; } $ordersMatch = false; - if (empty(\array_diff($existingOrders, $indexOrders)) && - empty(\array_diff($indexOrders, $existingOrders))) { + $existingOrders = self::orderValues($existingIndex); + $incomingOrders = self::orderValues($index); + if (empty(\array_diff($existingOrders, $incomingOrders)) && + empty(\array_diff($incomingOrders, $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); + $regularTypes = [IndexType::Key, IndexType::Unique]; + $isRegularIndex = \in_array($index->type, $regularTypes); + $isRegularExisting = \in_array($existingIndex->type, $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; } } @@ -756,94 +837,105 @@ public function checkIdenticalIndexes(Document $index): bool } /** - * @param Document $index + * Check object index constraints including single-attribute and top-level requirements. + * + * @param IndexVO $index The index to validate * @return bool - */ - public function checkObjectIndexes(Document $index): bool + */ + public function checkObjectIndexes(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - - if ($type !== Database::INDEX_OBJECT) { + if ($type !== IndexType::Object) { return true; } - if (!$this->supportForObjectIndexes) { + if (! $this->supportForObjectIndexes) { $this->message = 'Object indexes are not supported'; + return false; } - if (count($attributes) !== 1) { + if (count($index->attributes) !== 1) { $this->message = 'Object index can be created on a single object attribute'; + return false; } - if (!empty($orders)) { + if (! empty($index->orders)) { $this->message = 'Object index do not support explicit orders. Remove the orders to create this index.'; + return false; } - $attributeName = $attributes[0] ?? ''; + $attributeName = (string) ($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->attributes[\strtolower($attributeName)] ?? new Document(); - $attributeType = $attribute->getAttribute('type', ''); + $attribute = $this->attributes[\strtolower($attributeName)] ?? new AttributeVO(); + $attributeType = $attribute->type; + + if ($attributeType !== ColumnType::Object) { + $this->message = 'Object index can only be created on object attributes. Attribute "'.$attributeName.'" is of type "'.$attributeType->value.'"'; - 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 + /** + * Check TTL index constraints including single-attribute, datetime type, and uniqueness requirements. + * + * @param IndexVO $index The index to validate + * @return bool + */ + public function checkTTLIndexes(IndexVO $index): bool { - $type = $index->getAttribute('type'); + $type = $index->type; - $attributes = $index->getAttribute('attributes', []); - $orders = $index->getAttribute('orders', []); - $ttl = $index->getAttribute('ttl', 0); - if ($type !== Database::INDEX_TTL) { + if ($type !== IndexType::Ttl) { return true; } - if (count($attributes) !== 1) { + if (count($index->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', ''); + $attributeName = (string) ($index->attributes[0] ?? ''); + $attribute = $this->attributes[\strtolower($attributeName)] ?? new AttributeVO(); + $attributeType = $attribute->type; + + if ($this->supportForAttributes && $attributeType !== ColumnType::Datetime) { + $this->message = 'TTL index can only be created on datetime attributes. Attribute "'.$attributeName.'" is of type "'.$attributeType->value.'"'; - 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) { + if ($index->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()) { + if ($existingIndex->key === $index->key) { continue; } // Check if existing index is also a TTL index - if ($existingIndex->getAttribute('type') === Database::INDEX_TTL) { + if ($existingIndex->type === IndexType::Ttl) { $this->message = 'There can be only one TTL index in a collection'; + return false; } } @@ -851,6 +943,21 @@ public function checkTTLIndexes(Document $index): bool 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): ?AttributeVO + { + return $this->attributes[\strtolower($name)] ?? null; + } + + private function isStringAttribute(?AttributeVO $attribute): bool + { + return $attribute !== null && \in_array($attribute->type, self::STRING_TYPES, true); + } + private function isDottedAttribute(string $attribute): bool { return \str_contains($attribute, '.'); @@ -858,6 +965,17 @@ private function isDottedAttribute(string $attribute): bool private function getBaseAttributeFromDottedAttribute(string $attribute): string { - return $this->isDottedAttribute($attribute) ? \explode('.', $attribute, 2)[0] ?? '' : $attribute; + return $this->isDottedAttribute($attribute) ? \explode('.', $attribute, 2)[0] : $attribute; + } + + /** + * @return array + */ + private static function orderValues(IndexVO $index): array + { + return \array_map( + static fn (?Order $order): ?string => $order?->value, + $index->orders, + ); } } diff --git a/src/Database/Validator/IndexDependency.php b/src/Database/Validator/IndexDependency.php index 7e8453b830..1d218a4936 100644 --- a/src/Database/Validator/IndexDependency.php +++ b/src/Database/Validator/IndexDependency.php @@ -2,9 +2,14 @@ namespace Utopia\Database\Validator; +use Utopia\Database\Attribute as AttributeVO; use Utopia\Database\Document; +use Utopia\Database\Index as IndexVO; use Utopia\Validator; +/** + * Validates that an attribute can be safely deleted or renamed by checking for index dependencies. + */ class IndexDependency extends Validator { protected string $message = "Attribute can't be deleted or renamed because it is used in an index"; @@ -12,18 +17,20 @@ class IndexDependency extends Validator protected bool $castIndexSupport; /** - * @var array + * @var array */ 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 IndexVO ? $index : IndexVO::fromDocument($index); + } } /** @@ -37,7 +44,7 @@ public function getDescription(): string /** * Is valid. * - * @param Document $value + * @param AttributeVO|Document $value */ public function isValid($value): bool { @@ -45,15 +52,16 @@ public function isValid($value): bool return true; } - if (! $value->getAttribute('array', false)) { + $attr = $value instanceof AttributeVO ? $value : AttributeVO::fromDocument($value); + + if (! $attr->array) { return true; } - $key = \strtolower($value->getAttribute('key', $value->getAttribute('$id'))); + $key = \strtolower($attr->key); foreach ($this->indexes as $index) { - $attributes = $index->getAttribute('attributes', []); - foreach ($attributes as $attribute) { + foreach ($index->attributes as $attribute) { if ($key === \strtolower($attribute)) { return false; } diff --git a/src/Database/Validator/IndexedQueries.php b/src/Database/Validator/IndexedQueries.php index a24e0d21da..70b377c314 100644 --- a/src/Database/Validator/IndexedQueries.php +++ b/src/Database/Validator/IndexedQueries.php @@ -3,20 +3,34 @@ namespace Utopia\Database\Validator; use Exception; -use Utopia\Database\Database; +use Throwable; +use Utopia\Database\Attribute as AttributeVO; use Utopia\Database\Document; +use Utopia\Database\Index as IndexVO; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Base; +use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; +use Utopia\Query\Schema\IndexType; +/** + * Validates queries against available indexes, ensuring search queries have matching fulltext indexes. + */ class IndexedQueries extends Queries { + private const string UID_INDEX = '_uid_'; + + private const string CREATED_AT_INDEX = '_created_at_'; + + private const string UPDATED_AT_INDEX = '_updated_at_'; + /** - * @var array + * @var array */ protected array $attributes = []; /** - * @var array + * @var array */ protected array $indexes = []; @@ -25,32 +39,24 @@ class IndexedQueries extends Queries * * This Queries Validator filters indexes for only available indexes * - * @param array $attributes - * @param array $indexes - * @param array $validators + * @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'] - ]); + foreach ($attributes as $attribute) { + $this->attributes[] = $attribute instanceof AttributeVO ? $attribute : AttributeVO::fromDocument($attribute); + } - $this->indexes[] = new Document([ - 'type' => Database::INDEX_KEY, - 'attributes' => ['$updatedAt'] - ]); + $this->indexes[] = new IndexVO(key: self::UID_INDEX, type: IndexType::Unique, attributes: [Document::ID]); + $this->indexes[] = new IndexVO(key: self::CREATED_AT_INDEX, type: IndexType::Key, attributes: [Document::CREATED_AT]); + $this->indexes[] = new IndexVO(key: self::UPDATED_AT_INDEX, type: IndexType::Key, attributes: [Document::UPDATED_AT]); foreach ($indexes as $index) { - $this->indexes[] = $index; + $this->indexes[] = $index instanceof IndexVO ? $index : IndexVO::fromDocument($index); } parent::__construct($validators); @@ -59,20 +65,23 @@ public function __construct(array $attributes = [], array $indexes = [], array $ /** * Count vector queries across entire query tree * - * @param array $queries - * @return int + * @param array $queries */ private function countVectorQueries(array $queries): int { $count = 0; foreach ($queries as $query) { - if (in_array($query->getMethod(), Query::VECTOR_TYPES)) { + if (in_array($query->getMethod(), [Method::VectorDot, Method::VectorCosine, Method::VectorEuclidean])) { $count++; } - if ($query->isNested()) { - $count += $this->countVectorQueries($query->getValues()); + if ($query->isNestedJoin()) { + $count += $this->countVectorQueries($query->getJoinOnQueries()); + } elseif ($query->isNested()) { + /** @var array $nestedValues */ + $nestedValues = $query->getValues(); + $count += $this->countVectorQueries($nestedValues); } } @@ -80,63 +89,118 @@ private function countVectorQueries(array $queries): int } /** - * @param mixed $value - * @return bool + * @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; + } + + $alias = $query->getJoinAlias(); + if ($alias === '') { + continue; + } + + /** @var array $definitions */ + $definitions = $this->getJoinedCollection($query->getAttribute())?->getAttribute('indexes', []) ?? []; + + $indexes[$alias] = []; + foreach ($definitions as $index) { + $indexes[$alias][] = $index instanceof IndexVO ? $index : IndexVO::fromDocument($index); + } + } + + return $indexes; + } + + /** + * @param mixed $value + * * @throws Exception */ public function isValid($value): bool { - if (!parent::isValid($value)) { + /** @var array $value */ + if (! parent::isValid($value)) { return false; } $queries = []; foreach ($value as $query) { if (! $query instanceof Query) { try { - $query = Query::parse($query); - } catch (\Throwable $e) { + $query = Query::parse((string) $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']; + return $this->validateSearchIndexes($queries, $this->joinIndexes($queries)); + } - foreach ($filters as $filter) { + /** + * @param array $queries + * @param array> $joinIndexes + */ + private function validateSearchIndexes(array $queries, array $joinIndexes): bool + { + foreach ($queries as $query) { if ( - $filter->getMethod() === Query::TYPE_SEARCH || - $filter->getMethod() === Query::TYPE_NOT_SEARCH + $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 ($this->indexes as $index) { + foreach ($indexes as $index) { if ( - $index->getAttribute('type') === Database::INDEX_FULLTEXT - && $index->getAttribute('attributes') === [$filter->getAttribute()] + $index->type === IndexType::Fulltext + && $index->attributes === [$column] ) { $matched = true; } } - if (!$matched) { - $this->message = "Searching by attribute \"{$filter->getAttribute()}\" requires a fulltext index."; + 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; } } diff --git a/src/Database/Validator/Key.php b/src/Database/Validator/Key.php index 8434446777..bfebfdf27a 100644 --- a/src/Database/Validator/Key.php +++ b/src/Database/Validator/Key.php @@ -3,8 +3,12 @@ namespace Utopia\Database\Validator; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Validator; +/** + * Validates key strings ensuring they contain only alphanumeric chars, periods, hyphens, and underscores. + */ class Key extends Validator { protected string $message; @@ -13,8 +17,6 @@ class Key extends Validator * Get Description. * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -28,20 +30,17 @@ 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 { - if (!\is_string($value)) { + if (! \is_string($value)) { return false; } @@ -57,12 +56,12 @@ 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); @@ -85,8 +84,6 @@ public function isValid($value): bool * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -97,8 +94,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { diff --git a/src/Database/Validator/Label.php b/src/Database/Validator/Label.php index cf09be0b19..29ff3ab6e5 100644 --- a/src/Database/Validator/Label.php +++ b/src/Database/Validator/Label.php @@ -4,28 +4,37 @@ use Utopia\Database\Database; +/** + * Validates label strings ensuring they contain only alphanumeric characters. + */ class Label extends Key { + /** + * Create a new label validator. + * + * @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 { - if (!parent::isValid($value)) { + if (! parent::isValid($value)) { + return false; + } + + if (! \is_string($value)) { return false; } diff --git a/src/Database/Validator/ObjectValidator.php b/src/Database/Validator/ObjectValidator.php index 1103128010..329d2184f5 100644 --- a/src/Database/Validator/ObjectValidator.php +++ b/src/Database/Validator/ObjectValidator.php @@ -4,6 +4,9 @@ use Utopia\Validator; +/** + * Validates that a value is a valid object (associative array or valid JSON string). + */ class ObjectValidator extends Validator { /** @@ -16,23 +19,24 @@ public function getDescription(): string /** * Is Valid - * - * @param mixed $value */ public function isValid(mixed $value): bool { if (is_string($value)) { - // Check if it's valid JSON - json_decode($value); - return json_last_error() === JSON_ERROR_NONE; + $decoded = json_decode($value); + + if (json_last_error() !== JSON_ERROR_NONE) { + return false; + } + + $value = $decoded; } if ($value instanceof \stdClass) { return true; } - // Allow empty or associative arrays (non-list) - return empty($value) || (is_array($value) && !array_is_list($value)); + return is_array($value) && (count($value) === 0 || ! array_is_list($value)); } /** diff --git a/src/Database/Validator/Operator.php b/src/Database/Validator/Operator.php index 655d5d511d..2bdac4021e 100644 --- a/src/Database/Validator/Operator.php +++ b/src/Database/Validator/Operator.php @@ -2,17 +2,25 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Database; +use Throwable; +use Utopia\Database\Attribute as AttributeVO; use Utopia\Database\Document; use Utopia\Database\Operator as DatabaseOperator; +use Utopia\Database\OperatorType; +use Utopia\Database\RelationSide; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; +/** + * Validates update operators (increment, append, toggle, etc.) against collection attribute types and constraints. + */ class Operator extends Validator { protected Document $collection; /** - * @var array> + * @var array */ protected array $attributes = []; @@ -23,24 +31,26 @@ class Operator extends Validator /** * 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; + /** @var array $collectionAttributes */ + $collectionAttributes = $collection->getAttribute('attributes', []); + foreach ($collectionAttributes as $attribute) { + $typed = $attribute instanceof AttributeVO ? $attribute : AttributeVO::fromDocument($attribute); + $this->attributes[$typed->key] = $typed; } } /** * 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,43 +59,175 @@ 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(AttributeVO $attribute): bool { - $options = $attribute instanceof Document - ? $attribute->getAttribute('options', []) - : ($attribute['options'] ?? []); + $options = $attribute->options ?? []; + + /** @var array $options */ + + $relationTypeRaw = $options['relationType'] ?? ''; + $sideRaw = $options['side'] ?? ''; - $relationType = $options['relationType'] ?? ''; - $side = $options['side'] ?? ''; + $relationType = $relationTypeRaw instanceof RelationType + ? $relationTypeRaw + : (\is_string($relationTypeRaw) && $relationTypeRaw !== '' ? RelationType::from($relationTypeRaw) : null); + $side = $sideRaw instanceof RelationSide + ? $sideRaw + : (\is_string($sideRaw) && $sideRaw !== '' ? RelationSide::from($sideRaw) : null); // Many-to-many is always an array on both sides - if ($relationType === Database::RELATION_MANY_TO_MANY) { + if ($relationType === RelationType::ManyToMany) { return true; } // One-to-many: array on parent side, single on child side - if ($relationType === Database::RELATION_ONE_TO_MANY && $side === Database::RELATION_SIDE_PARENT) { + if ($relationType === RelationType::OneToMany && $side === RelationSide::Parent) { return true; } // Many-to-one: array on child side, single on parent side - if ($relationType === Database::RELATION_MANY_TO_ONE && $side === Database::RELATION_SIDE_CHILD) { + if ($relationType === RelationType::ManyToOne && $side === RelationSide::Child) { return true; } return false; } + /** + * @return array{min: int|float|string, max: int|float|string}|null + */ + private function getNumericBounds(AttributeVO $attribute): ?array + { + return AttributeVO::getNumericBounds($attribute->type, $attribute->signed); + } + + private function isNumericValueInBounds(mixed $value, AttributeVO $attribute): bool + { + $bounds = $this->getNumericBounds($attribute); + if ($bounds === null) { + return false; + } + + if (AttributeVO::isIntegerType($attribute->type)) { + $integer = $this->getIntegerValue($value); + + return $integer !== null + && BigInt::compare($integer, $bounds['min']) >= 0 + && BigInt::compare($integer, $bounds['max']) <= 0; + } + + $numeric = $this->getNumericValue($value); + if ($numeric === null) { + return false; + } + + if (\is_float($numeric) && ! \is_finite($numeric)) { + return false; + } + + return $numeric >= $bounds['min'] && $numeric <= $bounds['max']; + } + + private function isValidLimit(mixed $limit, AttributeVO $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 (! AttributeVO::isIntegerType($attribute->type) || ! $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 = $this->getNumericBounds($attribute); + 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, AttributeVO $attribute, int|float|string $result): bool + { + $bounds = $this->getNumericBounds($attribute); + if ($bounds === null) { + return false; + } + + $aboveMaximum = AttributeVO::isIntegerType($attribute->type) + ? 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 = AttributeVO::isIntegerType($attribute->type) + ? 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; + } + /** * Get Description * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -96,18 +238,17 @@ public function getDescription(): string * Is valid * * Returns true if valid or false if not. - * - * @param $value - * - * @return bool */ public function isValid($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,16 +256,11 @@ 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; } @@ -134,21 +270,17 @@ public function isValid($value): bool /** * Validate operator against attribute configuration - * - * @param DatabaseOperator $operator - * @param Document|array $attribute - * @return bool */ private function validateOperatorForAttribute( DatabaseOperator $operator, - Document|array $attribute + AttributeVO $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 +288,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,138 +303,172 @@ 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: + case OperatorType::Increment: + case OperatorType::Decrement: + case OperatorType::Multiply: + case OperatorType::Divide: + case OperatorType::Modulo: + case OperatorType::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()}'"; + if (! AttributeVO::isNumericType($type)) { + $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; } // 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()); + 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; } // 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"; + $integerType = AttributeVO::isIntegerType($type); + $operatorValue = $integerType + ? $this->getIntegerValue($values[0]) + : $this->getNumericValue($values[0]); + if ($operatorValue === null) { 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 (($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; } - if ($this->currentDocument !== null && $type === Database::VAR_INTEGER && !isset($values[1])) { - $currentValue = $this->currentDocument->getAttribute($operator->getAttribute()) ?? 0; - $operatorValue = $values[0]; + if (\count($values) > 1 && $values[1] !== null && ! $this->isValidLimit($values[1], $attribute, $operator)) { + return false; + } + + 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"; + + return false; + } - // 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, - }; + 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: + case OperatorType::ArrayAppend: + case OperatorType::ArrayPrepend: // 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()}'"; + 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) && AttributeVO::isIntegerType($type)) { $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 = $this->getNumericBounds($attribute); + 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 (AttributeVO::isIntegerType($type) && \is_numeric($insertValue)) { + if (! $this->isNumericValueInBounds($insertValue, $attribute)) { + $bounds = $this->getNumericBounds($attribute); + if ($bounds === null) { + return false; + } + $this->message = "Cannot apply {$methodName} operator: array items must be between {$bounds['min']} and {$bounds['max']}"; + return false; } } @@ -314,179 +480,201 @@ private function validateOperatorForAttribute( $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; 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: + case OperatorType::StringReplace: // 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()}'"; + 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: + case OperatorType::Toggle: // Toggle only works on boolean types - if ($type !== Database::VAR_BOOLEAN) { - $this->message = "Cannot apply {$method} operator to non-boolean field '{$operator->getAttribute()}'"; + 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; @@ -496,8 +684,6 @@ private function validateOperatorForAttribute( * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -508,8 +694,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { diff --git a/src/Database/Validator/PartialStructure.php b/src/Database/Validator/PartialStructure.php index a386a1fa4b..2218235ec6 100644 --- a/src/Database/Validator/PartialStructure.php +++ b/src/Database/Validator/PartialStructure.php @@ -5,6 +5,9 @@ use Utopia\Database\Database; use Utopia\Database\Document; +/** + * Validates partial document structures, only requiring attributes that are both marked required and present in the document. + */ class PartialStructure extends Structure { /** @@ -12,48 +15,53 @@ class PartialStructure extends Structure * * Returns true if valid or false if not. * - * @param mixed $document - * - * @return bool + * @param mixed $document */ public function isValid($document): bool { - if (!$document instanceof Document) { + if (! $document 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', [])); + /** @var array $collectionAttributes */ + $collectionAttributes = $this->collection->getAttribute('attributes', []); + /** @var array $attributes */ + $attributes = \array_merge($this->attributes, $collectionAttributes); foreach ($attributes as $attribute) { - $name = $attribute['$id'] ?? ''; + /** @var array $attribute */ + /** @var string $name */ + $name = $attribute[Document::ID] ?? ''; $keys[$name] = $attribute; } - /** - * @var array $requiredAttributes - */ $requiredAttributes = []; foreach ($this->attributes as $attribute) { - if ($attribute['required'] === true && $document->offsetExists($attribute['$id'])) { + /** @var array $attribute */ + /** @var string $attrId */ + $attrId = $attribute[Document::ID] ?? ''; + if ($attribute['required'] === true && $document->offsetExists($attrId)) { $requiredAttributes[] = $attribute; } } - if (!$this->checkForAllRequiredValues($structure, $requiredAttributes, $keys)) { + if (! $this->checkForAllRequiredValues($structure, $requiredAttributes, $keys)) { return false; } - if (!$this->checkForUnknownAttributes($structure, $keys)) { + if (! $this->checkForUnknownAttributes($structure, $keys)) { return false; } - if (!$this->checkForInvalidAttributeValues($document, $structure, $keys)) { + if (! $this->checkForInvalidAttributeValues($document, $structure, $keys)) { return false; } diff --git a/src/Database/Validator/Permissions.php b/src/Database/Validator/Permissions.php index 13e7372050..5d08137e1c 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -2,9 +2,13 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Database; +use Exception; use Utopia\Database\Helpers\Permission; +use Utopia\Database\PermissionType; +/** + * Validates permission strings ensuring they use valid permission types and role formats. + */ class Permissions extends Roles { protected string $message = 'Permissions Error'; @@ -19,21 +23,19 @@ class Permissions extends Roles /** * 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 */ public function getDescription(): string { @@ -45,35 +47,38 @@ public function getDescription(): string * * Returns true if valid or false if not. * - * @param mixed $permissions - * - * @return bool + * @param mixed $permissions */ public function isValid($permissions): bool { - if (!\is_array($permissions)) { + if (! \is_array($permissions)) { $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.'; + $this->message = 'You can only provide up to '.$this->length.' permissions.'; + return false; } foreach ($permissions as $permission) { - if (!\is_string($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 +89,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,10 +107,11 @@ 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; } @@ -111,8 +119,6 @@ public function isValid($permissions): bool * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -123,8 +129,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 4f91251828..05bf9f07b1 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -2,15 +2,26 @@ namespace Utopia\Database\Validator; +use Throwable; +use Utopia\Database\Document; use Utopia\Database\Query; +use Utopia\Database\Validator\Query\Aggregate; use Utopia\Database\Validator\Query\Base; +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\JoinedCollection; +use Utopia\Database\Validator\Query\Order; +use Utopia\Database\Validator\Query\Select; +use Utopia\Query\Method; use Utopia\Validator; +/** + * Validates an array of query objects by dispatching each to the appropriate method-type validator. + */ class Queries extends Validator { - /** - * @var string - */ protected string $message = 'Invalid queries'; /** @@ -18,15 +29,17 @@ class Queries extends Validator */ protected array $validators; + protected int $length; + /** - * @var int + * @var array */ - protected int $length; + protected array $joinedCollections = []; /** * Queries constructor * - * @param array $validators + * @param array $validators */ public function __construct(array $validators = [], int $length = 0) { @@ -38,8 +51,6 @@ public function __construct(array $validators = [], int $length = 0) * Get Description. * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -47,105 +58,284 @@ public function getDescription(): string } /** - * @param array $value - * @return bool + * 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 */ public function isValid($value): bool { - if (!is_array($value)) { + if (! \is_array($value)) { $this->message = 'Queries must be an array'; + return false; } - - if ($this->length && \count($value) > $this->length) { + /** @var array $value */ + if (! $this->isValidLength($value)) { return false; } - foreach ($value as $query) { - if (!$query instanceof Query) { + // 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() === Base::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 { - $query = Query::parse($query); - } catch (\Throwable $e) { - $this->message = 'Invalid query: ' . $e->getMessage(); + $q = Query::parse($q); + } catch (Throwable $e) { + $this->message = 'Invalid query: '.$e->getMessage(); + return false; } } - if ($query->isNested()) { - if (!self::isValid($query->getValues())) { - return false; + $parsedQueries[] = $q; + + $method = $q->getMethod(); + + if ($method->isAggregate()) { + $alias = $q->getValue(''); + if (\is_string($alias) && $alias !== '') { + $aggregationAliases[] = $alias; + } + } + + if ($method->isJoin()) { + $hasJoins = true; + $alias = $q->getJoinAlias(); + if ($alias !== '') { + $joinAliases[] = $alias; + } + } + } + + 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(); - $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 => '', - }; + + // Route every aggregate method through the single source of truth + // on the base enum. Previously this match hand-listed only half + // of the aggregate methods, silently rejecting stddevPop, varPop, + // bitAnd, etc. with "Invalid query method". + if ($method->isAggregate()) { + $methodType = Base::METHOD_TYPE_AGGREGATE; + } else { + $methodType = match ($method) { + Method::Select => Base::METHOD_TYPE_SELECT, + Method::Limit => Base::METHOD_TYPE_LIMIT, + Method::Offset => Base::METHOD_TYPE_OFFSET, + Method::CursorAfter, + Method::CursorBefore => Base::METHOD_TYPE_CURSOR, + Method::OrderAsc, + Method::OrderDesc, + Method::OrderRandom => Base::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 => Base::METHOD_TYPE_FILTER, + Method::Distinct => Base::METHOD_TYPE_DISTINCT, + Method::GroupBy => Base::METHOD_TYPE_GROUP_BY, + Method::Having => Base::METHOD_TYPE_HAVING, + Method::Join, + Method::LeftJoin, + Method::RightJoin, + Method::CrossJoin, + Method::FullOuterJoin, + Method::NaturalJoin => Base::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(); + if (! $validator->isValid($query)) { + $this->message = 'Invalid query: '.$validator->getDescription(); + return false; } $methodIsValid = true; } - if (!$methodIsValid) { - $this->message = 'Invalid query method: ' . $method; + if (! $methodIsValid) { + $this->message = 'Invalid query method: '.$method->value; + return false; } } @@ -153,12 +343,34 @@ public function isValid($value): bool 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[] = JoinedCollection::of($query->getJoinAlias(), $collection); + } + } + + return $joins; + } + /** * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -169,11 +381,112 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ 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..9065c5afc8 100644 --- a/src/Database/Validator/Queries/Document.php +++ b/src/Database/Validator/Queries/Document.php @@ -2,43 +2,131 @@ namespace Utopia\Database\Validator\Queries; +use DateTime; use Exception; -use Utopia\Database\Database; +use Utopia\Database\Document as BaseDocument; +use Utopia\Database\Query; use Utopia\Database\Validator\Queries; +use Utopia\Database\Validator\Query\Filter; +use Utopia\Database\Validator\Query\Join; use Utopia\Database\Validator\Query\Select; +use Utopia\Query\Schema\ColumnType; +/** + * 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 Queries { /** - * @param array $attributes - * @param bool $supportForAttributes + * @var array + */ + private readonly array $attributes; + + private ?Queries $conditions = null; + + /** + * @param array $attributes + * @param bool $sharedTables Whether the tables hold `$tenant`, as they do under shared tables + * @param bool $supportForJoins Whether join queries are accepted + * * @throws Exception */ - public function __construct(array $attributes, bool $supportForAttributes = true) - { - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$id', - 'key' => '$id', - 'type' => Database::VAR_STRING, + public function __construct( + array $attributes, + private readonly bool $supportForAttributes = true, + private readonly string $idAttributeType = ColumnType::Integer->value, + 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 $supportUnsignedBigInt = true, + bool $sharedTables = false, + bool $supportForJoins = true, + ) { + $attributes[] = new BaseDocument([ + BaseDocument::ID => BaseDocument::ID, + 'key' => BaseDocument::ID, + 'type' => ColumnType::String->value, 'array' => false, ]); - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$createdAt', - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, + $attributes[] = new BaseDocument([ + BaseDocument::ID => BaseDocument::SEQUENCE, + 'key' => BaseDocument::SEQUENCE, + 'type' => ColumnType::Id->value, 'array' => false, ]); - $attributes[] = new \Utopia\Database\Document([ - '$id' => '$updatedAt', - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, + $attributes[] = new BaseDocument([ + BaseDocument::ID => BaseDocument::CREATED_AT, + 'key' => BaseDocument::CREATED_AT, + 'type' => ColumnType::Datetime->value, 'array' => false, ]); + $attributes[] = new BaseDocument([ + BaseDocument::ID => BaseDocument::UPDATED_AT, + 'key' => BaseDocument::UPDATED_AT, + 'type' => ColumnType::Datetime->value, + 'array' => false, + ]); + + $this->attributes = $attributes; + + $validators = [new Select($attributes, $supportForAttributes, $sharedTables)]; - $validators = [ - new Select($attributes, $supportForAttributes), - ]; + if ($supportForJoins) { + $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 + */ + public function isValid($value): bool + { + 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; + } + + $conditions = $this->conditions ??= new Queries([ + new Filter( + $this->attributes, + $this->idAttributeType, + $this->maxValuesCount, + $this->minAllowedDate, + $this->maxAllowedDate, + $this->supportForAttributes, + $this->supportUnsignedBigInt, + ), + new Join($this->attributes, $this->supportForAttributes), + ]); + $conditions->setJoinedCollections(\array_values($this->joinedCollections)); + + if (! $conditions->isValid($joins)) { + $this->message = $conditions->getDescription(); + + return false; + } + + return true; + } } diff --git a/src/Database/Validator/Queries/Documents.php b/src/Database/Validator/Queries/Documents.php index 4959a062cf..9eb17b1ab9 100644 --- a/src/Database/Validator/Queries/Documents.php +++ b/src/Database/Validator/Queries/Documents.php @@ -2,26 +2,34 @@ namespace Utopia\Database\Validator\Queries; -use Utopia\Database\Database; +use DateTime; use Utopia\Database\Document; use Utopia\Database\Validator\IndexedQueries; +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\Schema\ColumnType; +/** + * 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 IndexedQueries { /** - * @param array $attributes - * @param array $indexes - * @param string $idAttributeType - * @param int $maxValuesCount - * @param \DateTime $minAllowedDate - * @param \DateTime $maxAllowedDate - * @param bool $supportForAttributes + * @param array $attributes + * @param array $indexes + * @param bool $sharedTables Whether the tables hold `$tenant`, as they do under shared tables + * @param bool $supportForOrderRandom Whether the adapter can order by random (Capability::OrderRandom) + * * @throws \Utopia\Database\Exception */ public function __construct( @@ -30,33 +38,37 @@ public function __construct( string $idAttributeType, int $maxValuesCount = 5000, int $maxUIDLength = 36, - \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), + DateTime $minAllowedDate = new DateTime('0000-01-01'), + DateTime $maxAllowedDate = new DateTime('9999-12-31'), bool $supportForAttributes = true, - bool $supportUnsignedBigInt = true + bool $supportUnsignedBigInt = true, + bool $supportForJoins = false, + bool $supportForAggregations = false, + bool $sharedTables = false, + bool $supportForOrderRandom = true, ) { $attributes[] = new Document([ - '$id' => '$id', - 'key' => '$id', - 'type' => Database::VAR_STRING, + Document::ID => Document::ID, + 'key' => Document::ID, + 'type' => ColumnType::String->value, 'array' => false, ]); $attributes[] = new Document([ - '$id' => '$sequence', - 'key' => '$sequence', - 'type' => Database::VAR_ID, + Document::ID => Document::SEQUENCE, + 'key' => Document::SEQUENCE, + 'type' => ColumnType::Id->value, 'array' => false, ]); $attributes[] = new Document([ - '$id' => '$createdAt', - 'key' => '$createdAt', - 'type' => Database::VAR_DATETIME, + Document::ID => Document::CREATED_AT, + 'key' => Document::CREATED_AT, + 'type' => ColumnType::Datetime->value, 'array' => false, ]); $attributes[] = new Document([ - '$id' => '$updatedAt', - 'key' => '$updatedAt', - 'type' => Database::VAR_DATETIME, + Document::ID => Document::UPDATED_AT, + 'key' => Document::UPDATED_AT, + 'type' => ColumnType::Datetime->value, 'array' => false, ]); @@ -73,10 +85,24 @@ public function __construct( $supportForAttributes, $supportUnsignedBigInt ), - new Order($attributes, $supportForAttributes), - new Select($attributes, $supportForAttributes), + new Order($attributes, $supportForAttributes, $supportForOrderRandom), + new Select($attributes, $supportForAttributes, $sharedTables), ]; + if ($supportForJoins) { + $validators[] = new Join($attributes, $supportForAttributes); + } + + if ($supportForAggregations) { + \array_push( + $validators, + new Aggregate($attributes, $supportForAttributes, $sharedTables), + new GroupBy($attributes, $supportForAttributes, $sharedTables), + new Having(), + new Distinct(), + ); + } + parent::__construct($attributes, $indexes, $validators); } } diff --git a/src/Database/Validator/Query/Aggregate.php b/src/Database/Validator/Query/Aggregate.php new file mode 100644 index 0000000000..ec74b7af72 --- /dev/null +++ b/src/Database/Validator/Query/Aggregate.php @@ -0,0 +1,357 @@ + + */ + 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) + { + foreach ($attributes as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute(Document::ID)); + + if (\is_string($key)) { + $this->schema[$key] = true; + } + } + + $this->schema += self::internalColumns($sharedTables); + + $this->numeric = self::numericTypes($attributes); + $this->columns = JoinedCollection::columns($attributes); + + foreach ($attributes as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute(Document::ID)); + if (\is_string($key) && ! self::isOrdered($attribute->getAttribute('type'), (bool) $attribute->getAttribute('array', false))) { + $this->unordered[$key] = true; + } + } + } + + /** + * 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->getValue(''); + if (\is_string($alias) && $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; + } + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_AGGREGATE; + } + + 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->getValues()[0] ?? null; + + if ($alias !== null && (! \is_string($alias) || \preg_match(self::ALIAS_PATTERN, $alias) !== 1)) { + $this->message = 'Invalid aggregate alias'; + + return false; + } + + if (\is_string($alias) && \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 (\is_string($alias) && ($this->aliases[$alias] ?? 0) > 1) { + $this->message = 'Aggregate alias "'.$alias.'" is given to more than one aggregate'; + + return false; + } + + if (\is_string($alias) && 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 && ! Attribute::isIntegerType($type)) { + $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; + } + + $ordered = self::isOrdered($definition['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(mixed $type, bool $array): bool + { + if ($array) { + return false; + } + + $type = $type instanceof ColumnType || \is_string($type) ? Attribute::tryNormalizeType($type) : null; + + return ! \in_array($type, self::UNORDERED_TYPES, true); + } + + /** + * The type of each attribute that holds a single number. + * + * @param array $attributes + * @return array + */ + public static function numericTypes(array $attributes): array + { + $types = []; + + foreach ($attributes as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute(Document::ID)); + $type = $attribute->getAttribute('type'); + + if (! \is_string($key) || ! ($type instanceof ColumnType || \is_string($type)) || (bool) $attribute->getAttribute('array', false)) { + continue; + } + + $type = Attribute::tryNormalizeType($type); + + if ($type !== null && Attribute::isNumericType($type)) { + $types[$key] = $type; + } + } + + return $types; + } + + 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..3627ee0f51 100644 --- a/src/Database/Validator/Query/Base.php +++ b/src/Database/Validator/Query/Base.php @@ -2,25 +2,42 @@ 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 METHOD_TYPE_JOIN = 'join'; + + public const METHOD_TYPE_AGGREGATE = 'aggregate'; + + public const METHOD_TYPE_GROUP_BY = 'groupBy'; + + public const METHOD_TYPE_HAVING = 'having'; + + public const METHOD_TYPE_DISTINCT = 'distinct'; + protected string $message = 'Invalid query'; /** * Get Description. * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -31,8 +48,6 @@ public function getDescription(): string * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -43,14 +58,50 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { return self::TYPE_OBJECT; } + /** + * Rejects anything that is not a Query, then defers to the subclass rule. + * + * 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. + * + * @param mixed $value + */ + public function isValid($value): bool + { + if (! $value instanceof Query) { + $this->message = 'Value must be a Query'; + + return false; + } + + return $this->isValidQuery($value); + } + + /** + * 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; + } + /** * Returns what type of query this Validator is for */ diff --git a/src/Database/Validator/Query/Cursor.php b/src/Database/Validator/Query/Cursor.php index 58053fe606..2760bd2096 100644 --- a/src/Database/Validator/Query/Cursor.php +++ b/src/Database/Validator/Query/Cursor.php @@ -6,9 +6,18 @@ use Utopia\Database\Document; use Utopia\Database\Query; use Utopia\Database\Validator\UID; +use Utopia\Query\Method; +/** + * Validates cursor-based pagination queries (cursorAfter and cursorBefore). + */ class Cursor extends Base { + /** + * Create a new cursor query validator. + * + * @param int $maxLength Maximum allowed UID length for cursor values + */ public function __construct(private readonly int $maxLength = Database::MAX_UID_DEFAULT_LENGTH) { } @@ -16,25 +25,30 @@ public function __construct(private readonly int $maxLength = Database::MAX_UID_ /** * Is valid. * - * Returns true if method is cursorBefore or cursorAfter and value is not null + * Returns true if method is cursorBefore or cursorAfter and its value is a valid 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. * * Otherwise, returns false * - * @param Query $value - * @return bool + * @param mixed $value */ public function isValid($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 +56,19 @@ 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; } + /** + * Get the method type this validator handles. + * + * @return string + */ 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..3f84ecc297 --- /dev/null +++ b/src/Database/Validator/Query/Distinct.php @@ -0,0 +1,19 @@ + */ 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(); + /** @var string $attrKey */ + $attrKey = $attribute->getAttribute('key', $attribute->getId()); + $copy = $attribute->getArrayCopy(); + // Convert known type strings to ColumnType enum for typed comparisons. + // Unknown strings are preserved as-is so the downstream switch can + // emit a recoverable "Unknown Data type" error instead of throwing. + if (isset($copy['type']) && \is_string($copy['type'])) { + $copy['type'] = Attribute::tryNormalizeType($copy['type']) ?? $copy['type']; + } + $this->schema[$attrKey] = $copy; } } - /** - * @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, '.')) { + $dot = \strpos($attribute, '.'); + if ($dot !== false) { // Check for special symbol `.` 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); + } + // For relationships, just validate the top level. // will validate each nested level during the recursive calls. - $attribute = \explode('.', $attribute)[0]; + $attribute = $alias; } // Search for attribute in schema - if ($this->supportForAttributes && !isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; + if ($this->supportForAttributes && ! isset($this->schema[$attribute])) { + $this->message = 'Attribute not found in schema: '.$attribute; + return false; } @@ -74,231 +106,318 @@ 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; + /** @var array|null $attributeSchema */ + $attributeSchema = null; + $joined = false; // 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]; - } + $dot = \strpos($attribute, '.'); + if ($dot !== false && ! isset($this->schema[$attribute])) { + $alias = \substr($attribute, 0, $dot); + $column = \substr($attribute, $dot + 1); - // 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); - } + if ($this->isJoinColumnReference($alias, $column)) { + if ($method->isVector()) { + $this->message = 'Vector queries cannot be used on a joined attribute: '.$attribute; - 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; + 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])) { + // 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; + } + + 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: + case ColumnType::Vector: // For vector queries, validate that the value is an array of floats - if (!is_array($value)) { + 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 . '"'; + if ($validator !== null && ! $validator->isValid($value)) { + $this->message = 'Query value is invalid for attribute "'.$attribute.'"'; + return false; } } - if ($attributeSchema['type'] === 'relationship') { + if ($attributeType === ColumnType::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']; + $options = $attributeSchema['options'] ?? []; + + if ($options instanceof Document) { + $options = $options->getArrayCopy(); + } + + /** @var array $options */ + + /** @var string $relationTypeStr */ + $relationTypeStr = $options['relationType'] ?? ''; + /** @var bool $twoWay */ + $twoWay = $options['twoWay'] ?? false; + /** @var string $sideStr */ + $sideStr = $options['side'] ?? ''; - if ($options['relationType'] === Database::RELATION_ONE_TO_ONE && $options['twoWay'] === false && $options['side'] === Database::RELATION_SIDE_CHILD) { + $relationType = $relationTypeStr !== '' ? RelationType::from($relationTypeStr) : null; + $side = $sideStr !== '' ? RelationSide::from($sideStr) : null; + + if ($relationType === RelationType::OneToOne && $twoWay === false && $side === RelationSide::Child) { $this->message = 'Cannot query on virtual relationship attribute'; + return false; } - if ($options['relationType'] === Database::RELATION_ONE_TO_MANY && $options['side'] === Database::RELATION_SIDE_PARENT) { + if ($relationType === RelationType::OneToMany && $side === RelationSide::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) { + if ($relationType === RelationType::ManyToOne && $side === RelationSide::Child) { $this->message = 'Cannot query on virtual relationship attribute'; + return false; } - if ($options['relationType'] === Database::RELATION_MANY_TO_MANY) { + if ($relationType === RelationType::ManyToMany) { $this->message = 'Cannot query on virtual relationship attribute'; + 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 +426,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 +471,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 +494,7 @@ private function isValidObjectQueryValues(mixed $values): bool } foreach ($values as $value) { - if (!$this->isValidObjectQueryValues($value)) { + if (! $this->isValidObjectQueryValues($value)) { return false; } } @@ -374,145 +509,220 @@ private function isValidObjectQueryValues(mixed $values): bool * * Otherwise, returns false * - * @param Query $value - * @return bool + * @param Query $value */ public function isValid($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; } + 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: + case Method::VectorDot: + case Method::VectorCosine: + case Method::VectorEuclidean: // Validate that the attribute is a vector type - if (!$this->isValidAttribute($attribute)) { + 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 = Query::groupForDatabase($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: + case Method::ElemMatch: // elemMatch is not supported when adapter supports attributes (schema mode) 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 = Query::groupForDatabase($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 +730,76 @@ 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 && (JoinedCollection::columns([new Document($definition)])[$attribute] ?? true) === false) { + $this->message = 'Cannot query on virtual relationship attribute'; + + return false; + } + + return true; + } + + 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; } + /** + * Get the method type this validator handles. + */ 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..92f476e16b --- /dev/null +++ b/src/Database/Validator/Query/GroupBy.php @@ -0,0 +1,89 @@ + + */ + 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) + { + foreach ($attributes as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getAttribute(Document::ID)); + + if (\is_string($key)) { + $this->schema[$key] = true; + } + } + + $this->schema += self::internalColumns($sharedTables); + $this->columns = JoinedCollection::columns($attributes); + } + + public function getMethodType(): string + { + return self::METHOD_TYPE_GROUP_BY; + } + + 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; + } + + 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..03914f5d1d --- /dev/null +++ b/src/Database/Validator/Query/Having.php @@ -0,0 +1,253 @@ + + */ + private array $aggregations = []; + + /** + * @var array + */ + private array $groupBy = []; + + /** + * Get the method type this validator handles. + * + * @return string + */ + 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->getValue(''); + if (\is_string($alias) && $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. + */ + 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..036d2e3437 --- /dev/null +++ b/src/Database/Validator/Query/Join.php @@ -0,0 +1,370 @@ +', '<=', '>=', '<>']; + + /** + * 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 : JoinedCollection::columns($attributes); + + $relationships = []; + foreach ($attributes ?? [] as $attribute) { + $key = $attribute->getAttribute('key', $attribute->getId()); + if (\is_string($key) && $key !== '' && Attribute::isRelationship($attribute)) { + $relationships[$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 (\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 + */ + 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. + */ + 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->getJoinAlias(); + $invalidAlias = $alias === '' ? null : 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; + } + + if ($alias !== '') { + $this->declared[$alias] = $join; + } + + return true; + } + + private function isValidConditions(Query $query, string $alias, ?JoinedCollection $join): bool + { + if (! $query->isNestedJoin()) { + $values = $query->getValues(); + + return $this->isValidCondition($values[0] ?? null, $values[1] ?? null, $values[2] ?? null, $alias, $join); + } + + $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, ?JoinedCollection $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, ?JoinedCollection $join): bool + { + $name = $column; + $dot = \strpos($column, '.'); + if ($dot !== false) { + if ($alias === '' || \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(?JoinedCollection $join): ?array + { + if ($join === null) { + return null; + } + + return \array_fill_keys(\array_keys($join->attributes), true) + $join->columns; + } + + private function joinOf(Query $query): ?JoinedCollection + { + foreach ($this->joins as $join) { + if ($join->collection === $query->getAttribute() && $join->alias === $query->getJoinAlias()) { + return $join; + } + } + + return null; + } +} diff --git a/src/Database/Validator/Query/JoinedAttributes.php b/src/Database/Validator/Query/JoinedAttributes.php new file mode 100644 index 0000000000..7c64e59b48 --- /dev/null +++ b/src/Database/Validator/Query/JoinedAttributes.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): ?JoinedCollection + { + $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::internalAttributes() as $attribute) { + if ($attribute->key !== Document::COLLECTION && ($sharedTables || $attribute->key !== Document::TENANT)) { + $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 (JoinedCollection $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/JoinedCollection.php b/src/Database/Validator/Query/JoinedCollection.php new file mode 100644 index 0000000000..f76513bed2 --- /dev/null +++ b/src/Database/Validator/Query/JoinedCollection.php @@ -0,0 +1,126 @@ + $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 + { + /** @var array $definitions */ + $definitions = $collection->getAttribute('attributes', []); + + $attributes = []; + $encrypted = []; + $schema = []; + foreach ($definitions as $definition) { + if (! Attribute::isRelationship($definition)) { + $attributes[$definition->getId()] = true; + $schema[$definition->getId()] = self::definition($definition); + } + + $filters = $definition->getAttribute('filters', []); + if (\is_array($filters) && \in_array('encrypt', $filters, true)) { + $encrypted[$definition->getId()] = true; + } + } + + return new self( + $alias, + $attributes, + Aggregate::numericTypes($definitions), + $encrypted, + self::columns($definitions), + $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; + } + + /** + * @return array + */ + private static function definition(Document $attribute): array + { + $copy = $attribute->getArrayCopy(); + if (isset($copy['type']) && \is_string($copy['type'])) { + $copy['type'] = Attribute::tryNormalizeType($copy['type']) ?? $copy['type']; + } + + return $copy; + } + + /** + * 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) { + $key = $attribute->getAttribute('key', $attribute->getId()); + if (! \is_string($key) || $key === '') { + continue; + } + + $columns[$key] = ! Attribute::isRelationship($attribute) || self::storesRelatedId(Relationship::fromDocument('', $attribute)); + } + + return $columns; + } + + private static function storesRelatedId(Relationship $relationship): bool + { + return match ($relationship->type) { + RelationType::OneToOne => $relationship->side === RelationSide::Parent || $relationship->twoWay, + RelationType::OneToMany => $relationship->side === RelationSide::Child, + RelationType::ManyToOne => $relationship->side === RelationSide::Parent, + RelationType::ManyToMany => false, + }; + } +} diff --git a/src/Database/Validator/Query/Limit.php b/src/Database/Validator/Query/Limit.php index facc266d75..960199268e 100644 --- a/src/Database/Validator/Query/Limit.php +++ b/src/Database/Validator/Query/Limit.php @@ -3,17 +3,19 @@ namespace Utopia\Database\Validator\Query; use Utopia\Database\Query; +use Utopia\Query\Method; use Utopia\Validator\Numeric; use Utopia\Validator\Range; +/** + * Validates limit query methods ensuring the value is a positive integer within the allowed range. + */ class Limit extends Base { protected int $maxLimit; /** * Query constructor - * - * @param int $maxLimit */ public function __construct(int $maxLimit = PHP_INT_MAX) { @@ -25,37 +27,44 @@ public function __construct(int $maxLimit = PHP_INT_MAX) * * Returns true if method is limit values are within range. * - * @param Query $value - * @return bool + * @param mixed $value */ public function isValid($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 + */ 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..f460d9dadd 100644 --- a/src/Database/Validator/Query/Offset.php +++ b/src/Database/Validator/Query/Offset.php @@ -3,15 +3,21 @@ namespace Utopia\Database\Validator\Query; use Utopia\Database\Query; +use Utopia\Query\Method; use Utopia\Validator\Numeric; use Utopia\Validator\Range; +/** + * Validates offset query methods ensuring the value is a non-negative integer within the allowed range. + */ class Offset extends Base { protected int $maxOffset; /** - * @param int $maxOffset + * Create a new offset query validator. + * + * @param int $maxOffset Maximum allowed offset value */ public function __construct(int $maxOffset = PHP_INT_MAX) { @@ -19,39 +25,49 @@ 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 { - 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; } + /** + * Get the method type this validator handles. + * + * @return string + */ 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..6242fb3356 100644 --- a/src/Database/Validator/Query/Order.php +++ b/src/Database/Validator/Query/Order.php @@ -4,50 +4,96 @@ use Utopia\Database\Document; use Utopia\Database\Query; +use Utopia\Query\Method; +use Utopia\Query\Query as BaseQuery; +/** + * Validates order query methods ensuring referenced attributes exist in the schema. + */ class Order extends Base { + use JoinedAttributes; + /** * @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; + /** @var string $attrKey */ + $attrKey = $attribute->getAttribute('key', $attribute->getAttribute(Document::ID)); + $this->schema[$attrKey] = true; } } - /** - * @param string $attribute - * @return bool - */ protected function isValidAttribute(string $attribute): bool { - if (\str_contains($attribute, '.')) { + $dot = \strpos($attribute, '.'); + if ($dot !== false) { // Check for special symbol `.` 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; } } + // Accept transient aggregation aliases registered by Queries::isValid + if (isset($this->aggregationAliases[$attribute])) { + return true; + } + // Search for attribute in schema - if ($this->supportForAttributes && !isset($this->schema[$attribute])) { - $this->message = 'Attribute not found in schema: ' . $attribute; + 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; } @@ -61,29 +107,108 @@ protected function isValidAttribute(string $attribute): bool * * Otherwise, returns false * - * @param Query $value - * @return bool + * @param mixed $value */ public function isValid($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; + } + + 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; } + } + + /** + * Clear any aggregation aliases added by a previous validation pass. + */ + public function resetAggregationAliases(): void + { + $this->aggregationAliases = []; + } - if ($method === Query::TYPE_ORDER_RANDOM) { - return true; // orderRandom doesn't need an attribute + /** + * 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; } + /** + * Get the method type this validator handles. + * + * @return string + */ 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..a2c01d1ac2 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -2,39 +2,84 @@ namespace Utopia\Database\Validator\Query; +use Utopia\Database\Attribute; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; +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 JoinedAttributes; + /** * @var array */ protected array $schema = []; /** - * List of internal attributes + * The relationship attributes of the collection. + * + * @var array + */ + private array $relationships = []; + + /** + * Whether the query set holds an aggregate. + */ + 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) { + /** @var string $attrKey */ + $attrKey = $attribute->getAttribute('key', $attribute->getAttribute(Document::ID)); + $this->schema[$attrKey] = true; + + if (Attribute::isRelationship($attribute)) { + $this->relationships[$attrKey] = true; + } + } + } + + /** + * 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. * - * @var array + * @param array $aggregations */ - protected const INTERNAL_ATTRIBUTES = [ - '$id', - '$sequence', - '$createdAt', - '$updatedAt', - '$permissions', - '$collection', - ]; + public function setAggregations(array $aggregations): void + { + $this->aggregates = $aggregations !== []; + } /** - * @param array $attributes - * @param bool $supportForAttributes + * @param array $attributes the groupBy attributes of the query set */ - public function __construct(array $attributes = [], protected bool $supportForAttributes = true) + public function setGroupBy(array $attributes): void { + $this->groupBy = []; + foreach ($attributes as $attribute) { - $this->schema[$attribute->getAttribute('key', $attribute->getAttribute('$id'))] = true; + if (\is_string($attribute) && $attribute !== '') { + $this->groupBy[] = $attribute; + } } } @@ -45,54 +90,81 @@ public function __construct(array $attributes = [], protected bool $supportForAt * * Otherwise, returns false * - * @param Query $value - * @return bool + * @param mixed $value */ public function isValid($value): bool { - if (!$value instanceof Query) { + 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) { + // special symbols with `dots` 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 @@ -100,14 +172,96 @@ public function isValid($value): bool 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]); + } + + 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 + { + $keys = []; + foreach (Database::internalAttributes() as $attribute) { + if ($this->sharedTables || $attribute->key !== Document::TENANT) { + $keys[] = $attribute->key; + } + } + + return $keys; + } + + /** + * Get the method type this validator handles. + * + * @return string + */ 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..f8f254e471 100644 --- a/src/Database/Validator/Roles.php +++ b/src/Database/Validator/Roles.php @@ -2,18 +2,28 @@ namespace Utopia\Database\Validator; +use Exception; use Utopia\Database\Helpers\Role; use Utopia\Validator; +/** + * Validates role strings ensuring they use valid role names, identifiers, and dimensions. + */ 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 = [ @@ -64,7 +74,7 @@ class Roles extends Validator 'dimension' => [ 'allowed' => true, 'required' => false, - 'options' => self::USER_DIMENSIONS + 'options' => self::USER_DIMENSIONS, ], ], self::ROLE_USER => [ @@ -75,7 +85,7 @@ class Roles extends Validator 'dimension' => [ 'allowed' => true, 'required' => false, - 'options' => self::USER_DIMENSIONS + 'options' => self::USER_DIMENSIONS, ], ], self::ROLE_TEAM => [ @@ -112,6 +122,7 @@ class Roles extends Validator // Dimensions public const DIMENSION_VERIFIED = 'verified'; + public const DIMENSION_UNVERIFIED = 'unverified'; public const USER_DIMENSIONS = [ @@ -122,8 +133,8 @@ class Roles extends Validator /** * 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) { @@ -135,8 +146,6 @@ public function __construct(int $length = 0, array $allowed = self::ROLES) * Get Description. * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -148,33 +157,36 @@ public function getDescription(): string * * Returns true if valid or false if not. * - * @param mixed $roles - * - * @return bool + * @param mixed $roles */ public function isValid($roles): bool { - if (!\is_array($roles)) { + if (! \is_array($roles)) { $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.'; + $this->message = 'You can only provide up to '.$this->length.' roles.'; + return false; } foreach ($roles as $role) { - if (!\is_string($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 +197,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,10 +215,11 @@ 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; } @@ -212,8 +227,6 @@ public function isValid($roles): bool * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -224,8 +237,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { @@ -250,7 +261,8 @@ 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; } @@ -259,51 +271,58 @@ protected function isValidRole( $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)) { + 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 (! \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..9a06a6540c 100644 --- a/src/Database/Validator/Sequence.php +++ b/src/Database/Validator/Sequence.php @@ -3,14 +3,24 @@ namespace Utopia\Database\Validator; use Utopia\Database\Database; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; use Utopia\Validator\Range; +/** + * Validates sequence/ID values based on the configured ID attribute type (UUID7 or integer). + */ class Sequence extends Validator { private string $idAttributeType; + private bool $primary; + /** + * Get the validator description. + * + * @return string + */ public function getDescription(): string { return 'Invalid sequence value'; @@ -25,16 +35,32 @@ public function __construct(string $idAttributeType, bool $primary) $this->idAttributeType = $idAttributeType; } + /** + * Is array. + * + * @return bool + */ public function isArray(): bool { return false; } + /** + * Get the validator type. + * + * @return string + */ public function getType(): string { return self::TYPE_STRING; } + /** + * Validate a sequence value against the configured ID attribute type. + * + * @param mixed $value The value to validate + * @return bool + */ public function isValid($value): bool { if ($this->primary && empty($value)) { @@ -45,23 +71,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..41533ea217 100644 --- a/src/Database/Validator/Spatial.php +++ b/src/Database/Validator/Spatial.php @@ -2,14 +2,23 @@ namespace Utopia\Database\Validator; -use Utopia\Database\Database; +use Utopia\Query\Schema\ColumnType; use Utopia\Validator; +/** + * Validates spatial data (point, linestring, polygon) as arrays or WKT strings with coordinate range checking. + */ class Spatial extends Validator { private string $spatialType; + protected string $message = ''; + /** + * Create a new spatial validator for the given type. + * + * @param string $spatialType The spatial type to validate (point, linestring, polygon) + */ public function __construct(string $spatialType) { $this->spatialType = $spatialType; @@ -18,50 +27,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 +85,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,29 +105,34 @@ 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; } } @@ -122,6 +140,7 @@ protected function validatePolygon(array $value): bool // 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,36 +149,63 @@ 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 { $value = trim($value); + return (bool) preg_match('/^(POINT|LINESTRING|POLYGON)\s*\(/i', $value); } + /** + * Get the validator description including the error message. + * + * @return string + */ public function getDescription(): string { - return 'Value must be a valid ' . $this->spatialType . ": {$this->message}"; + return 'Value must be a valid '.$this->spatialType.": {$this->message}"; } + /** + * Is array. + * + * @return bool + */ public function isArray(): bool { return false; } + /** + * Get the validator type. + * + * @return string + */ public function getType(): string { return self::TYPE_ARRAY; } + /** + * Get the spatial type this validator handles. + * + * @return string + */ public function getSpatialType(): string { return $this->spatialType; } /** - * 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 { @@ -172,23 +218,26 @@ public function isValid($value): bool } 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 +245,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 4e3f66ec4b..c85d3ebf67 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -3,13 +3,16 @@ namespace Utopia\Database\Validator; use Closure; +use DateTime; use Exception; +use Utopia\Database\Attribute; 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; @@ -17,6 +20,9 @@ use Utopia\Validator\Range; use Utopia\Validator\Text; +/** + * Validates document structure against collection schema including required attributes, types, and formats. + */ class Structure extends Validator { /** @@ -24,8 +30,8 @@ class Structure extends Validator */ protected array $attributes = [ [ - '$id' => '$id', - 'type' => Database::VAR_STRING, + Document::ID => Document::ID, + 'type' => 'string', 'size' => 255, 'required' => false, 'signed' => true, @@ -33,8 +39,8 @@ class Structure extends Validator 'filters' => [], ], [ - '$id' => '$sequence', - 'type' => Database::VAR_ID, + Document::ID => Document::SEQUENCE, + 'type' => 'id', 'size' => 0, 'required' => false, 'signed' => true, @@ -42,8 +48,8 @@ class Structure extends Validator 'filters' => [], ], [ - '$id' => '$collection', - 'type' => Database::VAR_STRING, + Document::ID => Document::COLLECTION, + 'type' => 'string', 'size' => 255, 'required' => true, 'signed' => true, @@ -51,8 +57,8 @@ class Structure extends Validator 'filters' => [], ], [ - '$id' => '$tenant', - 'type' => Database::VAR_ID, + Document::ID => Document::TENANT, + 'type' => 'id', 'size' => 0, 'required' => false, 'default' => null, @@ -61,17 +67,17 @@ class Structure extends Validator 'filters' => [], ], [ - '$id' => '$permissions', - 'type' => Database::VAR_STRING, - 'size' => 67000, // medium text + Document::ID => Document::PERMISSIONS, + 'type' => 'string', + 'size' => 67000, 'required' => false, 'signed' => true, 'array' => true, 'filters' => [], ], [ - '$id' => '$createdAt', - 'type' => Database::VAR_DATETIME, + Document::ID => Document::CREATED_AT, + 'type' => 'datetime', 'size' => 0, 'required' => true, 'signed' => false, @@ -79,14 +85,14 @@ class Structure extends Validator 'filters' => [], ], [ - '$id' => '$updatedAt', - 'type' => Database::VAR_DATETIME, + Document::ID => Document::UPDATED_AT, + 'type' => 'datetime', 'size' => 0, 'required' => true, 'signed' => false, 'array' => false, 'filters' => [], - ] + ], ]; /** @@ -94,24 +100,44 @@ class Structure extends Validator */ protected static array $formats = []; + protected string $message = 'General Error'; + /** - * @var string + * Lazily-built merged attribute list (internal + collection). + * + * Cached for the lifetime of this validator. `$collection` is `readonly` + * (the property cannot be reassigned), but its inner Document state is + * not deep-frozen — callers that mutate `$collection->setAttribute( + * 'attributes', ...)` between `isValid()` calls would see a stale memo. + * Construct a fresh validator if the underlying schema may change. + * + * @var array|Document>|null */ - protected string $message = 'General Error'; + private ?array $mergedAttributes = null; + + /** + * @var array + */ + private readonly array $storedAttributes; /** * Structure constructor. * + * @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 string $idAttributeType, - private readonly \DateTime $minAllowedDate = new \DateTime('0000-01-01'), - private readonly \DateTime $maxAllowedDate = new \DateTime('9999-12-31'), + 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 readonly ?Document $currentDocument = null, + array $storedAttributes = [], ) { + $this->storedAttributes = \array_fill_keys($storedAttributes, true); } /** @@ -128,28 +154,23 @@ public static function getFormats(): array * 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; } @@ -159,17 +180,16 @@ 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]; @@ -180,8 +200,6 @@ public static function getFormat(string $name, string $type): array /** * Remove a Validator - * - * @param string $name */ public static function removeFormat(string $name): void { @@ -192,8 +210,6 @@ public static function removeFormat(string $name): void * Get Description. * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -205,40 +221,48 @@ public function getDescription(): string * * Returns true if valid or false if not. * - * @param mixed $document - * - * @return bool + * @param mixed $document */ public function isValid($document): bool { - if (!$document instanceof Document) { + if (! $document instanceof Document) { $this->message = 'Value must be an instance of Document'; + return false; } if (empty($document->getCollection())) { - $this->message = 'Missing collection attribute $collection'; + $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', [])); + if ($this->mergedAttributes === null) { + /** @var array|Document> $collectionAttributes */ + $collectionAttributes = $this->collection->getAttribute('attributes', []); + /** @var array|Document> $merged */ + $merged = \array_merge($this->attributes, $collectionAttributes); + $this->mergedAttributes = $merged; + } + $attributes = $this->mergedAttributes; - if (!$this->checkForAllRequiredValues($structure, $attributes, $keys)) { + if (! $this->checkForAllRequiredValues($structure, $attributes, $keys)) { return false; } - if (!$this->checkForUnknownAttributes($structure, $keys)) { + if (! $this->checkForUnknownAttributes($structure, $keys)) { return false; } - if (!$this->checkForInvalidAttributeValues($document, $structure, $keys)) { + if (! $this->checkForInvalidAttributeValues($document, $structure, $keys)) { return false; } @@ -248,26 +272,26 @@ public function isValid($document): bool /** * Check for all required values * - * @param array $structure - * @param array $attributes - * @param array $keys - * - * @return bool + * @param array $structure + * @param array|Document> $attributes + * @param array|Document> $keys */ protected function checkForAllRequiredValues(array $structure, array $attributes, array &$keys): bool { - if (!$this->supportForAttributes) { + if (! $this->supportForAttributes) { return true; } foreach ($attributes as $attribute) { // Check all required attributes are set - $name = $attribute['$id'] ?? ''; + /** @var string $name */ + $name = $attribute[Document::ID] ?? ''; $required = $attribute['required'] ?? false; $keys[$name] = $attribute; // List of allowed attributes to help find unknown ones - if ($required && !isset($structure[$name])) { + if ($required && ! isset($structure[$name])) { $this->message = 'Missing required attribute "'.$name.'"'; + return false; } } @@ -278,19 +302,18 @@ protected function checkForAllRequiredValues(array $structure, array $attributes /** * Check for Unknown Attributes * - * @param array $structure - * @param array $keys - * - * @return bool + * @param array $structure + * @param array|Document> $keys */ protected function checkForUnknownAttributes(array $structure, array $keys): bool { - if (!$this->supportForAttributes) { + if (! $this->supportForAttributes) { return true; } foreach ($structure as $key => $value) { - if (!array_key_exists($key, $keys)) { // Check no unknown attributes are set + if (! array_key_exists($key, $keys)) { // Check no unknown attributes are set $this->message = 'Unknown attribute: "'.$key.'"'; + return false; } } @@ -301,46 +324,60 @@ 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|Document> $keys */ protected function checkForInvalidAttributeValues(Document $document, array $structure, array $keys): 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; + } + + if (isset($this->storedAttributes[$key])) { continue; } $attribute = $keys[$key] ?? []; + /** @var string $type */ $type = $attribute['type'] ?? ''; $array = $attribute['array'] ?? false; + /** @var string $format */ $format = $attribute['format'] ?? ''; $required = $attribute['required'] ?? false; + /** @var int $size */ $size = $attribute['size'] ?? 0; - $signed = $attribute['signed'] ?? true; + $signed = (bool) ($attribute['signed'] ?? true); if ($required === false && is_null($value)) { // Allow null value to optional params continue; } - if ($type === Database::VAR_RELATIONSHIP) { + $columnType = Attribute::tryNormalizeType($type); + + if ($columnType === 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)) { + if ($columnType === ColumnType::BigInteger && \is_string($value) && BigInt::fitsPhpInt($value, $signed)) { $normalized = (int)$value; $document->setAttribute($key, $normalized); $value = $normalized; @@ -348,83 +385,87 @@ protected function checkForInvalidAttributeValues(Document $document, array $str $validators = []; - switch ($type) { - case Database::VAR_ID: - $validators[] = new Sequence($this->idAttributeType, $attribute['$id'] === '$sequence'); + switch ($columnType) { + case ColumnType::Id: + $validators[] = new Sequence($this->idAttributeType, ($attribute[Document::ID] ?? '') === 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: + case ColumnType::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; // 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 ); break; - case Database::VAR_OBJECT: + case ColumnType::Object: $validators[] = new ObjectValidator(); break; - case Database::VAR_POINT: - case Database::VAR_LINESTRING: - case Database::VAR_POLYGON: + case ColumnType::Point: + case ColumnType::Linestring: + case ColumnType::Polygon: $validators[] = new Spatial($type); break; - case Database::VAR_VECTOR: - $validators[] = new Vector($attribute['size'] ?? 0); + case ColumnType::Vector: + /** @var int $vectorSize */ + $vectorSize = $attribute['size'] ?? 0; + $validators[] = new Vector($vectorSize); break; default: if ($this->supportForAttributes) { $this->message = 'Unknown attribute type "'.$type.'"'; + return false; } } @@ -434,36 +475,44 @@ protected function checkForInvalidAttributeValues(Document $document, array $str if ($format) { // Format encoded as json string containing format name and relevant format options - $format = self::getFormat($format, $type); - $validators[] = $format['callback']($attribute); + $formatDef = self::getFormat($format, Attribute::normalizeType($type)); + $formatAttribute = $attribute instanceof Document + ? $attribute->getArrayCopy() + : $attribute; + /** @var Validator $formatValidator */ + $formatValidator = $formatDef['callback']($formatAttribute); + $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 (! $required && ((is_array($value) && empty($value)) || is_null($value))) { // Allow both null and [] for optional arrays 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)) { // Allow null value to optional params 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; } } @@ -477,8 +526,6 @@ protected function checkForInvalidAttributeValues(Document $document, array $str * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -489,8 +536,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { diff --git a/src/Database/Validator/UID.php b/src/Database/Validator/UID.php index 743adbcde1..2014a90972 100644 --- a/src/Database/Validator/UID.php +++ b/src/Database/Validator/UID.php @@ -4,6 +4,9 @@ use Utopia\Database\Database; +/** + * Validates unique identifier strings with alphanumeric chars, underscores, hyphens, and periods. + */ class UID extends Key { /** @@ -18,11 +21,9 @@ public function __construct(int $maxLength = Database::MAX_UID_DEFAULT_LENGTH) * Get Description. * * Returns validator description - * - * @return string */ 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..b2b4007f5a 100644 --- a/src/Database/Validator/Vector.php +++ b/src/Database/Validator/Vector.php @@ -4,6 +4,9 @@ use Utopia\Validator; +/** + * Validates vector values ensuring they are numeric arrays of the expected dimension size. + */ class Vector extends Validator { protected int $size; @@ -11,7 +14,7 @@ class Vector extends Validator /** * 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) { @@ -22,8 +25,6 @@ public function __construct(int $size) * Get Description * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -34,25 +35,22 @@ public function getDescription(): string * Is valid * * Validation will pass when $value is a valid vector array or JSON string - * - * @param mixed $value - * @return bool */ 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; } @@ -62,7 +60,7 @@ public function isValid(mixed $value): bool // 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; } } @@ -74,8 +72,6 @@ public function isValid(mixed $value): bool * Is array * * Function will return true if object is array. - * - * @return bool */ public function isArray(): bool { @@ -86,8 +82,6 @@ public function isArray(): bool * Get Type * * Returns validator type. - * - * @return string */ public function getType(): string { 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..5a98ea486b 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -3,12 +3,16 @@ namespace Tests\E2E\Adapter; 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\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; @@ -17,69 +21,71 @@ use Tests\E2E\Adapter\Scopes\SpatialTests; use Tests\E2E\Adapter\Scopes\VectorTests; use Utopia\Database\Database; +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 DocumentTests; - use AttributeTests; + use GeneralTests; use IndexTests; + use JoinComboTests; + use JoinTests; + use MetadataCacheTests; + use ObjectAttributeTests; use OperatorTests; use PermissionTests; use RelationshipTests; - use SpatialTests; use SchemalessTests; - use ObjectAttributeTests; + use SpatialTests; use VectorTests; - use GeneralTests; protected static string $namespace; - /** - * @var Authorization - */ 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 + protected function setUp(): void { + $this->testDatabase = 'utopiaTests_'.static::getTestToken(); + if (is_null(self::$authorization)) { self::$authorization = new Authorization(); } self::$authorization->addRole('any'); + + $db = $this->getDatabase(); + if ($db->getRelationshipHook() === null) { + $db->addHook(new Relationships($db)); + } + if (! $db->getAdapter()->hasPermissionHook()) { + $db->addHook(new Permissions()); + } } - public function tearDown(): void + protected function tearDown(): void { - self::$authorization->setDefaultStatus(true); + self::$authorization?->setDefaultStatus(true); } protected string $testDatabase = 'utopiaTests'; + protected static function getTestToken(): string + { + return getenv('TEST_TOKEN') ?: getenv('UNIQUE_TEST_TOKEN') ?: (string) getmypid(); + } } diff --git a/tests/e2e/Adapter/MariaDBTest.php b/tests/e2e/Adapter/MariaDBTest.php index 923de242ed..ebfe0773e9 100644 --- a/tests/e2e/Adapter/MariaDBTest.php +++ b/tests/e2e/Adapter/MariaDBTest.php @@ -2,25 +2,38 @@ 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\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Permissions; use Utopia\Database\PDO; +use function Swoole\Coroutine\run; + class MariaDBTest extends Base { protected static ?Database $database = null; + protected static ?PDO $pdo = null; + protected static string $namespace; - /** - * @return Database - */ public function getDatabase(bool $fresh = false): Database { - if (!is_null(self::$database) && !$fresh) { + if (! is_null(self::$database) && ! $fresh) { return self::$database; } @@ -33,14 +46,15 @@ public function getDatabase(bool $fresh = false): Database $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,14 +63,122 @@ 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(new Collection( + 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] + MariaDB::getPDOAttributes(), + ); + + $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()); + + if (! $database->getAdapter()->hasPermissionHook()) { + $database->addHook(new Permissions()); + } + + return $database; + } + 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; @@ -64,9 +186,10 @@ protected function deleteColumn(string $collection, string $column): bool 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 fcb36e1f60..df0674d106 100644 --- a/tests/e2e/Adapter/MemoryTest.php +++ b/tests/e2e/Adapter/MemoryTest.php @@ -6,26 +6,30 @@ 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\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\Query; /** * 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 { @@ -49,9 +53,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()) { @@ -87,17 +92,18 @@ 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. */ public function testUpsertWithJSONFilters(): void { @@ -106,7 +112,7 @@ 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. */ public function testBulkUpsertWithOperatorsCallbackReceivesFreshData(): void @@ -129,32 +135,6 @@ 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, @@ -191,14 +171,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', []); } /** @@ -209,20 +187,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(new Collection(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(); @@ -255,20 +225,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(new Collection(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', @@ -300,7 +262,7 @@ public function testCreateUniqueIndexRejectsExistingDuplicates(): void ); $this->expectException(DuplicateException::class); - $adapter->createIndex('emails', 'unique_addr', Database::INDEX_UNIQUE, ['addr'], [], []); + $adapter->createIndex('emails', Index::unique(key: 'unique_addr', attributes: ['addr'])); } /** @@ -311,26 +273,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(new Collection(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', @@ -347,65 +297,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', Attribute::string(key: 'old', size: 256), 'fresh'); - $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')); - $this->assertEquals(['b'], $store[$key]['indexes']['idx_ab']['attributes']); + $adapter->createDocument($collection, new Document([ + '$id' => 'first', + '$permissions' => [], + 'b' => 'shared', + ])); + + $this->expectException(DuplicateException::class); + $adapter->createDocument($collection, new Document([ + '$id' => 'second', + '$permissions' => [], + 'b' => 'shared', + ])); } /** @@ -416,27 +426,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(new Collection(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', @@ -473,27 +471,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(new Collection(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', @@ -521,27 +507,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(new Collection(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([ @@ -551,13 +531,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')); } /** @@ -607,8 +593,8 @@ 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(new Collection(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'string', size: 128)); $database->setPreserveDates(true); $created = $database->createDocument($collection, new Document([ @@ -643,8 +629,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']); @@ -804,26 +790,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(new Collection(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', @@ -850,8 +824,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']); @@ -879,35 +853,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(new Collection(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', @@ -926,7 +881,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..967bc1518b 100644 --- a/tests/e2e/Adapter/MirrorTest.php +++ b/tests/e2e/Adapter/MirrorTest.php @@ -6,6 +6,8 @@ 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; @@ -22,9 +24,13 @@ 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; protected static string $namespace; @@ -35,7 +41,7 @@ class MirrorTest extends Base */ protected function getDatabase(bool $fresh = false): Mirror { - if (!is_null(self::$database) && !$fresh) { + if (! is_null(self::$database) && ! $fresh) { return self::$database; } @@ -48,8 +54,8 @@ protected function getDatabase(bool $fresh = false): Mirror $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); @@ -63,43 +69,51 @@ protected function getDatabase(bool $fresh = false): Mirror $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->exists($this->testDatabase, Database::METADATA)) { + throw new Exception('Mirror destination is missing _metadata after create'); + } + return self::$database = $database; } @@ -107,11 +121,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 +132,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 +146,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(new Collection(id: 'testCreateMirroredCollection')); // Assert collection exists in both databases $this->assertFalse($database->getSource()->getCollection('testCreateMirroredCollection')->isEmpty()); - $this->assertFalse($database->getDestination()->getCollection('testCreateMirroredCollection')->isEmpty()); + $destination = $database->getDestination(); + $this->assertNotNull($destination); + $this->assertFalse($destination->getCollection('testCreateMirroredCollection')->isEmpty()); } /** @@ -151,13 +166,13 @@ 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(new Collection(id: 'testUpdateMirroredCollection', permissions: [ Permission::read(Role::any()), - ]); + ])); $collection = $database->getCollection('testUpdateMirroredCollection'); @@ -166,7 +181,7 @@ public function testUpdateMirroredCollection(): void [ Permission::read(Role::users()), ], - $collection->getAttribute('documentSecurity') + (bool) $collection->getAttribute('documentSecurity') ); // Asset both databases have updated the collection @@ -175,23 +190,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(new Collection(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()); + $destination = $database->getDestination(); + $this->assertNotNull($destination); + $this->assertTrue($destination->getCollection('testDeleteMirroredCollection')->isEmpty()); } /** @@ -202,25 +221,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(new Collection(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 +243,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 +260,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(new Collection(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 +289,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(new Collection(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(new Collection(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,17 +376,12 @@ 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(new Collection(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 — @@ -343,12 +396,15 @@ 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, [ @@ -385,25 +441,27 @@ 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') ); } 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; @@ -411,14 +469,16 @@ protected function deleteColumn(string $collection, string $column): bool 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..43ab22a496 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -4,43 +4,51 @@ 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\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; +use Utopia\Database\Storage; use Utopia\Mongo\Client; class MongoDBTest extends Base { + use MongoReadFilterTests; + public static ?Database $database = null; + protected static string $namespace; /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mongodb"; + return 'mongodb'; } /** - * @return Database * @throws Exception */ 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', @@ -52,10 +60,11 @@ public function getDatabase(): Database $database = new Database(new Mongo($client), $cache); $database->getAdapter()->setSupportForAttributes(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(); @@ -72,30 +81,150 @@ public function getDatabase(): Database 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)); + $this->assertTrue($this->getDatabase()->create()); + $this->assertTrue($this->getDatabase()->delete($this->testDatabase)); + $this->assertTrue($this->getDatabase()->create()); + $this->assertSame($this->getDatabase(), $this->getDatabase()->setDatabase($this->testDatabase)); } - public function testRenameAttribute(): void + public function testCollectionGrantsAuthorizeWritesWithoutReadPermission(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $collection = 'collectionGrantedWrites'; + + $database->createCollection(new Collection( + 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 testRenameAttributeExisting(): void + public function testDocumentGrantsAuthorizeWritesWithoutReadPermission(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $collection = 'documentGrantedWrites'; + + $database->createCollection(new Collection( + 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 testUpdateAttributeStructure(): void + public function testDeniedReaderDoesNotHideADocumentFromItsReader(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $collection = 'deniedReaderProfiles'; + + $database->createCollection(new Collection( + 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 testKeywords(): void + public function testListCollectionsReturnsOnlyReadableDefinitions(): void { - $this->assertTrue(true); + $database = $this->getDatabase(); + $definitions = ['adminDefinition', 'listedDefinition', 'unlistedDefinition']; + + $database->createCollection(new Collection(id: 'listedDefinition', permissions: [Permission::read(Role::any())])); + $database->createCollection(new Collection(id: 'adminDefinition', permissions: [Permission::read(Role::user('admin'))])); + $database->createCollection(new Collection(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)); } protected function deleteColumn(string $collection, string $column): bool @@ -107,4 +236,69 @@ 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(new Collection( + 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..e5563b9726 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,22 @@ class MySQLTest extends Base { + use MySQLJoinPlanTests; + public static ?Database $database = null; + protected static ?PDO $pdo = null; + protected static string $namespace; /** - * @return Database * @throws Duplicate * @throws Exception * @throws Limit */ public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -39,14 +43,15 @@ public function getDatabase(): Database $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,14 +60,16 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } 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; @@ -70,9 +77,10 @@ protected function deleteColumn(string $collection, string $column): bool 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..ac50e27a55 100644 --- a/tests/e2e/Adapter/PoolTest.php +++ b/tests/e2e/Adapter/PoolTest.php @@ -3,12 +3,13 @@ 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; @@ -28,24 +29,24 @@ class PoolTest extends Base * @var UtopiaPool */ protected static UtopiaPool $pool; + protected static string $namespace; /** - * @return Database * @throws Exception * @throws Duplicate * @throws Limit */ 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'; @@ -62,11 +63,11 @@ public function getDatabase(): Database }, 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(); @@ -81,15 +82,12 @@ public function getDatabase(): Database 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); }); @@ -98,15 +96,12 @@ protected function deleteColumn(string $collection, string $column): bool 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,18 +109,15 @@ 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); + $pdo = $adapter->getDriver(); + assert($pdo instanceof PDO); $stmt = $pdo->prepare($sql); foreach ($binds as $key => $value) { $stmt->bindValue($key, $value); @@ -139,13 +131,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(new Collection(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..2be71289ad 100644 --- a/tests/e2e/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -6,17 +6,22 @@ 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\Query; class PostgresTest extends Base { public static ?Database $database = null; + protected static ?PDO $pdo = null; + protected static string $namespace; /** @@ -24,7 +29,7 @@ class PostgresTest extends Base */ public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -36,14 +41,15 @@ public function getDatabase(): Database $pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};", $dbUser, $dbPass, Postgres::getPDOAttributes()); $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,14 +58,16 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } 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; @@ -67,15 +75,49 @@ protected function deleteColumn(string $collection, string $column): bool 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(new Collection(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->exists($database->getDatabase(), $collection)); + $this->assertTrue($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 +127,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(new Collection(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 +143,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 +183,17 @@ public function testReadDoesNotTouchThePermissionsTable(): void public function testVectorSearchUsesTheIndex(): void { $database = $this->getDatabase(); - - $database->createCollection('vectorPlan', permissions: [ + $pdo = self::$pdo; + $this->assertNotNull($pdo); + + $database->createCollection(new Collection(id: 'vectorPlan', attributes: [ + Attribute::vector(key: 'embedding', size: 3, required: true), + ], indexes: [ + Index::hnswCosine(key: 'idx_cosine', attributes: ['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 +202,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 +224,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..25b3dea429 100644 --- a/tests/e2e/Adapter/RedisTest.php +++ b/tests/e2e/Adapter/RedisTest.php @@ -6,15 +6,27 @@ 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\Helpers\Permission; +use Utopia\Database\Helpers\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 { @@ -42,7 +54,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 +73,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,27 +87,6 @@ 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)); - } - protected function deleteColumn(string $collection, string $column): bool { // Redis keeps no out-of-band schema; raw column drops do not apply. @@ -137,36 +121,57 @@ 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'; + $permissions = [Permission::read(Role::any())]; + + $database->createCollection(new Collection( + 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', + ])); + + $adapter = $database->getAdapter(); + $client = self::$redisClient; + $this->assertInstanceOf(RedisAdapter::class, $adapter); + $this->assertNotNull($client); + $segments = [RedisAdapter::KEY_PREFIX, $adapter->getNamespace(), $adapter->getDatabase(), 'doc']; + if ($adapter->getSharedTables()) { + $tenant = $adapter->getTenant(); + \array_push($segments, 't', $tenant === null ? '_' : (string) $tenant); } - } + \array_push($segments, $collection, 'note'); + $key = \implode(RedisAdapter::SEP, $segments); + $payload = $client->get($key); + $this->assertIsString($payload); + $stored = \json_decode($payload, true, flags: JSON_THROW_ON_ERROR); + $this->assertIsArray($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, 'note')->getPermissions()); + $this->assertSame( + [$permissions], + \array_map(fn (Document $document): array => $document->getPermissions(), $database->find($collection)), + ); - /** - * @param array $patterns - */ - private static function scrubKeys(Redis $client, array $patterns): void - { - foreach ($patterns as $pattern) { - $iterator = null; - while (($keys = $client->scan($iterator, $pattern, 500)) !== false) { - if (\is_array($keys) && \count($keys) > 0) { - $client->del($keys); - } - if ($iterator === 0) { - break; - } - } - } + $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/SQLiteTest.php b/tests/e2e/Adapter/SQLiteTest.php index 6a6945e502..75352b110a 100644 --- a/tests/e2e/Adapter/SQLiteTest.php +++ b/tests/e2e/Adapter/SQLiteTest.php @@ -6,47 +6,53 @@ 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\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\PDO; +use Utopia\Database\Query; class SQLiteTest extends Base { public static ?Database $database = null; + protected static ?PDO $pdo = null; + protected static string $namespace; - /** - * @return Database - */ 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, SQLite::getPDOAttributes()); $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); $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,14 +61,55 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } + public function testPatternQueriesMatchWildcardCharactersLiterally(): void + { + $database = $this->getDatabase(); + $collection = 'likeEscape'; + + $database->createCollection(new Collection(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()); + } + } + 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; @@ -70,9 +117,10 @@ protected function deleteColumn(string $collection, string $column): bool 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..5f49917f9d 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,35 @@ class MongoDBTest extends Base { + use MongoReadFilterTests; + public static ?Database $database = null; + protected static string $namespace; /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "mongodb"; + return 'mongodb'; } /** - * @return Database * @throws Exception */ 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', @@ -53,16 +54,16 @@ public function getDatabase(): Database $database = new Database(new Mongo($client), $cache); $database->getAdapter()->setSupportForAttributes(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(); } - $database->create(); return self::$database = $database; @@ -74,30 +75,10 @@ public function getDatabase(): Database 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); + $this->assertTrue($this->getDatabase()->create()); + $this->assertTrue($this->getDatabase()->delete($this->testDatabase)); + $this->assertTrue($this->getDatabase()->create()); + $this->assertSame($this->getDatabase(), $this->getDatabase()->setDatabase($this->testDatabase)); } protected function deleteColumn(string $collection, string $column): bool diff --git a/tests/e2e/Adapter/Scopes/AggregationTests.php b/tests/e2e/Adapter/Scopes/AggregationTests.php new file mode 100644 index 0000000000..2b1861eb25 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/AggregationTests.php @@ -0,0 +1,2652 @@ + */ + private static array $createdProductCollections = []; + private static string $aggWorkerSuffix = ''; + + private function getAggSuffix(): string + { + if (self::$aggWorkerSuffix === '') { + self::$aggWorkerSuffix = '_' . substr(uniqid(), -6); + } + + return self::$aggWorkerSuffix; + } + + private function numericAttribute(Document $document, string $key): float + { + $value = $document->getAttribute($key); + $this->assertIsNumeric($value); + + return (float) $value; + } + + private function intAttribute(Document $document, string $key): int + { + $value = $document->getAttribute($key); + $this->assertIsNumeric($value); + + return (int) $value; + } + + private function createProducts(Database $database, string $collection = 'agg_products'): void + { + if (isset(self::$createdProductCollections[$collection])) { + return; + } + + if ($database->exists($database->getDatabase(), $collection)) { + self::$createdProductCollections[$collection] = true; + return; + } + + $database->createCollection(new Collection(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->exists($database->getDatabase(), $collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(new Collection(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->exists($database->getDatabase(), $collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(new Collection(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->exists($database->getDatabase(), $collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(new Collection(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->exists($database->getDatabase(), $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->find('cnt_all', [Query::count('*', 'total')]); + $this->assertCount(1, $results); + $this->assertEquals(9, $results[0]->getAttribute('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->find('cnt_alias', [Query::count('*', 'num_products')]); + $this->assertCount(1, $results); + $this->assertEquals(9, $results[0]->getAttribute('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->find('cnt_filter', [ + Query::equal('category', ['electronics']), + Query::count('*', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]->getAttribute('total')); + + $results = $database->find('cnt_filter', [ + Query::equal('category', ['clothing']), + Query::count('*', 'total'), + ]); + $this->assertEquals(3, $results[0]->getAttribute('total')); + + $results = $database->find('cnt_filter', [ + Query::greaterThan('price', 100), + Query::count('*', 'total'), + ]); + $this->assertEquals(4, $results[0]->getAttribute('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->exists($database->getDatabase(), $col)) { + $database->deleteCollection($col); + } + $database->createCollection(new Collection(id: $col, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($col, Attribute::integer(key: 'value', required: true)); + + $results = $database->find($col, [Query::count('*', 'total')]); + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]->getAttribute('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->find('cnt_multi', [ + Query::equal('category', ['electronics']), + Query::greaterThan('price', 600), + Query::count('*', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]->getAttribute('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->find('cnt_distinct', [Query::countDistinct('category', 'unique_cats')]); + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]->getAttribute('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->find('cnt_dist_f', [ + Query::greaterThan('price', 50), + Query::countDistinct('category', 'unique_cats'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]->getAttribute('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->find('sum_all', [Query::sum('price', 'total_price')]); + $this->assertCount(1, $results); + $this->assertEquals(2785, $results[0]->getAttribute('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->find('sum_filt', [ + Query::equal('category', ['electronics']), + Query::sum('price', 'total'), + ]); + $this->assertEquals(2500, $results[0]->getAttribute('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->find('sum_empty', [ + Query::equal('category', ['nonexistent']), + Query::sum('price', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertNull($results[0]->getAttribute('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->find('sum_stock', [Query::sum('stock', 'total_stock')]); + $this->assertEquals(1495, $results[0]->getAttribute('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->find('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->find('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->find('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->find('min_all', [Query::min('price', 'min_price')]); + $this->assertEquals(10, $results[0]->getAttribute('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->find('min_filt', [ + Query::equal('category', ['electronics']), + Query::min('price', 'cheapest'), + ]); + $this->assertEquals(500, $results[0]->getAttribute('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->find('max_all', [Query::max('price', 'max_price')]); + $this->assertEquals(1200, $results[0]->getAttribute('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->find('max_filt', [ + Query::equal('category', ['books']), + Query::max('price', 'expensive'), + ]); + $this->assertEquals(60, $results[0]->getAttribute('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->find('minmax', [ + Query::min('price', 'cheapest'), + Query::max('price', 'priciest'), + ]); + $this->assertCount(1, $results); + $this->assertEquals(10, $results[0]->getAttribute('cheapest')); + $this->assertEquals(1200, $results[0]->getAttribute('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->find('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]->getAttribute('total_count')); + $this->assertEquals(2785, $results[0]->getAttribute('total_price')); + $this->assertEqualsWithDelta(309.44, $this->numericAttribute($results[0], 'avg_price'), 1.0); + $this->assertEquals(10, $results[0]->getAttribute('min_price')); + $this->assertEquals(1200, $results[0]->getAttribute('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->find('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]->getAttribute('cnt')); + $this->assertEquals(200, $results[0]->getAttribute('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->find('grp_single', [ + Query::count('*', 'cnt'), + Query::groupBy(['category']), + ]); + + $this->assertCount(3, $results); + $mapped = []; + foreach ($results as $doc) { + $category = $doc->getAttribute('category'); + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(3, $mapped['electronics']->getAttribute('cnt')); + $this->assertEquals(3, $mapped['clothing']->getAttribute('cnt')); + $this->assertEquals(3, $mapped['books']->getAttribute('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->find('grp_sum', [ + Query::sum('price', 'total_price'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc->getAttribute('category'); + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(2500, $mapped['electronics']->getAttribute('total_price')); + $this->assertEquals(200, $mapped['clothing']->getAttribute('total_price')); + $this->assertEquals(85, $mapped['books']->getAttribute('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->find('grp_avg', [ + Query::avg('price', 'avg_price'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc->getAttribute('category'); + $this->assertIsString($category); + $value = $doc->getAttribute('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->find('grp_minmax', [ + Query::min('price', 'cheapest'), + Query::max('price', 'priciest'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc->getAttribute('category'); + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(500, $mapped['electronics']->getAttribute('cheapest')); + $this->assertEquals(1200, $mapped['electronics']->getAttribute('priciest')); + $this->assertEquals(30, $mapped['clothing']->getAttribute('cheapest')); + $this->assertEquals(120, $mapped['clothing']->getAttribute('priciest')); + $this->assertEquals(10, $mapped['books']->getAttribute('cheapest')); + $this->assertEquals(60, $mapped['books']->getAttribute('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->find('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->getAttribute('category'); + $this->assertIsString($category); + $mapped[$category] = $doc; + } + + $this->assertEquals(3, $mapped['electronics']->getAttribute('cnt')); + $this->assertEquals(2500, $mapped['electronics']->getAttribute('total')); + $this->assertEquals(50, $mapped['electronics']->getAttribute('min_stock')); + $this->assertEquals(100, $mapped['electronics']->getAttribute('max_stock')); + + $this->assertEquals(3, $mapped['books']->getAttribute('cnt')); + $this->assertEquals(85, $mapped['books']->getAttribute('total')); + $this->assertEquals(40, $mapped['books']->getAttribute('min_stock')); + $this->assertEquals(500, $mapped['books']->getAttribute('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->find('grp_filt', [ + Query::greaterThan('price', 50), + Query::count('*', 'cnt'), + Query::groupBy(['category']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $category = $doc->getAttribute('category'); + $this->assertIsString($category); + $mapped[$category] = $doc; + } + $this->assertEquals(3, $mapped['electronics']->getAttribute('cnt')); + $this->assertEquals(1, $mapped['clothing']->getAttribute('cnt')); + $this->assertEquals(1, $mapped['books']->getAttribute('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->find('grp_status', [ + Query::count('*', 'cnt'), + Query::sum('total', 'revenue'), + Query::groupBy(['status']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $status = $doc->getAttribute('status'); + $this->assertIsString($status); + $mapped[$status] = $doc; + } + $this->assertEquals(7, $mapped['completed']->getAttribute('cnt')); + $this->assertEquals(2, $mapped['pending']->getAttribute('cnt')); + $this->assertEquals(1, $mapped['cancelled']->getAttribute('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->find('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->getAttribute('customer_uid'); + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(3, $mapped['alice']->getAttribute('order_count')); + $this->assertEquals(2890, $mapped['alice']->getAttribute('total_spent')); + $this->assertEquals(2, $mapped['bob']->getAttribute('order_count')); + $this->assertEquals(1275, $mapped['bob']->getAttribute('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->find('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->getAttribute('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->find('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->getAttribute('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->find('having_cnt', [ + Query::count('*', 'review_count'), + Query::groupBy(['product_uid']), + Query::having([Query::greaterThanEqual('review_count', 3)]), + ]); + + $productIds = array_map(fn ($d) => $d->getAttribute('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->find('ij_orders', [ + Query::join('ij_customers', 'customer_uid', '$id'), + Query::count('*', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(10, $results[0]->getAttribute('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->find('ij_grp_o', [ + Query::join('ij_grp_c', '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->getAttribute('customer_uid'); + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(2890, $mapped['alice']->getAttribute('total_spent')); + $this->assertEquals(3, $mapped['alice']->getAttribute('order_count')); + $this->assertEquals(1275, $mapped['bob']->getAttribute('total_spent')); + $this->assertEquals(2, $mapped['bob']->getAttribute('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->find('ij_filt_o', [ + Query::join('ij_filt_c', 'customer_uid', '$id'), + Query::equal('status', ['completed']), + Query::sum('total', 'revenue'), + Query::groupBy(['customer_uid']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $customer_uid = $doc->getAttribute('customer_uid'); + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(2800, $mapped['alice']->getAttribute('revenue')); + $this->assertEquals(1275, $mapped['bob']->getAttribute('revenue')); + $this->assertEquals(240, $mapped['charlie']->getAttribute('revenue')); + $this->assertEquals(300, $mapped['diana']->getAttribute('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->find('ij_hav_o', [ + Query::join('ij_hav_c', '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->getAttribute('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->find('ij_prs_p', [ + Query::join('ij_prs_r', '$id', 'product_uid'), + Query::count('*', 'review_count'), + Query::avg('score', 'avg_score'), + Query::groupBy(['name']), + ]); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc; + } + + $this->assertEquals(3, $mapped['Laptop']->getAttribute('review_count')); + $this->assertEqualsWithDelta(4.0, $this->numericAttribute($mapped['Laptop'], 'avg_score'), 0.1); + $this->assertEquals(3, $mapped['Novel']->getAttribute('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->find('lj_basic_p', [ + Query::leftJoin('lj_basic_r', '$id', 'product_uid'), + Query::count('*', 'review_count'), + Query::groupBy(['name']), + ]); + + $this->assertCount(9, $results); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc; + } + + $this->assertEquals(3, $mapped['Laptop']->getAttribute('review_count')); + $this->assertEquals(2, $mapped['Phone']->getAttribute('review_count')); + $this->assertEquals(1, $mapped['Tablet']->getAttribute('review_count')); + $this->assertEquals(1, $mapped['Comic']->getAttribute('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->find('lj_filt_p', [ + Query::leftJoin('lj_filt_o', '$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->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc; + } + $this->assertEquals(2, $mapped['Laptop']->getAttribute('order_count')); + $this->assertEquals(2, $mapped['Phone']->getAttribute('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->find('lj_cos_c', [ + Query::leftJoin('lj_cos_o', '$id', 'customer_uid'), + Query::count('*', 'order_count'), + Query::groupBy(['name']), + ]); + + $this->assertCount(5, $results); + + $mapped = []; + foreach ($results as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc; + } + + $this->assertEquals(3, $mapped['Alice']->getAttribute('order_count')); + $this->assertEquals(2, $mapped['Bob']->getAttribute('order_count')); + $this->assertEquals(2, $mapped['Charlie']->getAttribute('order_count')); + $this->assertEquals(3, $mapped['Diana']->getAttribute('order_count')); + $this->assertEquals(1, $mapped['Eve']->getAttribute('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(new Collection(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(new Collection(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->find('jp_apg_o', [ + Query::join('jp_apg_c', '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->getAttribute('customer_uid'); + $this->assertIsString($customer_uid); + $mapped[$customer_uid] = $doc; + } + $this->assertEquals(300, $mapped['u1']->getAttribute('total')); + $this->assertEquals(2, $mapped['u1']->getAttribute('cnt')); + $this->assertEquals(50, $mapped['u2']->getAttribute('total')); + $this->assertEquals(1, $mapped['u2']->getAttribute('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(new Collection(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(new Collection(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->find('jp_ljpf_p', [ + Query::leftJoin('jp_ljpf_r', '$id', 'product_uid'), + Query::count('*', 'review_count'), + Query::groupBy(['name']), + ]); + + $this->assertCount(1, $results); + $this->assertEquals('Visible Product', $results[0]->getAttribute('name')); + $this->assertEquals(2, $results[0]->getAttribute('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->find($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]->getAttribute($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->find($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->find($col, [ + Query::equal('status', [$status]), + Query::sum('total', 'revenue'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals($expectedRevenue, $results[0]->getAttribute('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->find($col, [ + Query::equal('category', [$category]), + $aggQuery, + ]); + $this->assertEquals($expected, $results[0]->getAttribute('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->find($col, [ + Query::equal('product_uid', [$productId]), + Query::count('*', 'cnt'), + ]); + $this->assertEquals($expectedCount, $results[0]->getAttribute('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->find($col, [ + Query::between('price', $min, $max), + Query::count('*', 'cnt'), + ]); + $this->assertEquals($expected, $results[0]->getAttribute('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->find('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->find('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->find('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->find('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->find('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->find('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->find('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->find('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->find('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->exists($database->getDatabase(), $collection)) { + $database->deleteCollection($collection); + } + $database->createCollection(new Collection(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->find($collection, $queries), $message); + } + + $results = $database->find($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]->getAttribute('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->find($collection, [$query]), + 'Invalid query: Aggregate '.$method.' requires a numeric attribute that is not an array: '.$query->getAttribute(), + ); + } + + $this->assertRejectedAsQueryShape( + fn () => $database->find($collection, [Query::bitXor('rating', 'result')]), + 'Invalid query: Aggregate bitXor requires an integer attribute that is not an array: rating', + ); + + $results = $database->find($collection, [Query::min('category', 'first'), Query::max('name', 'last'), Query::countDistinct('category', 'categories')]); + $this->assertSame('books', $results[0]->getAttribute('first')); + $this->assertSame('Textbook', $results[0]->getAttribute('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->find($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->assertTrue($results[0]->offsetExists($alias), $alias.' must be returned'); + $this->assertNull($results[0]->getAttribute($alias), $alias.' over no rows must be null, got '.\var_export($results[0]->getAttribute($alias), true)); + } + + $expected = ['rows', 'categories', ...\array_keys($others)]; + $returned = \array_keys($results[0]->getArrayCopy()); + \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->exists($database->getDatabase(), $collection)) { + $database->deleteCollection($collection); + } + $database->createCollection(new Collection(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->find($collection, [Query::equal('kind', ['empty']), ...$bitwise]); + $this->assertCount(1, $ungrouped); + foreach (['all_bits', 'any_bits', 'odd_bits'] as $alias) { + $this->assertNull($ungrouped[0]->getAttribute($alias), $alias.' of only null values must be null, got '.\var_export($ungrouped[0]->getAttribute($alias), true)); + } + + $grouped = $database->find($collection, [...$bitwise, Query::groupBy(['kind']), Query::orderAsc('kind')]); + $this->assertCount(2, $grouped); + $this->assertSame('empty', $grouped[0]->getAttribute('kind')); + foreach (['all_bits', 'any_bits', 'odd_bits'] as $alias) { + $this->assertNull($grouped[0]->getAttribute($alias), $alias.' of a group of null values must be null'); + } + $this->assertSame('set', $grouped[1]->getAttribute('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) || $adapter->hasFeature(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 (! $database->getAdapter()->hasFeature(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 (! $database->getAdapter()->hasFeature(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->find($collection, [Query::sum('price', $tooLong)]), + 'Invalid query: Aggregate alias is too long: at most 63 characters are allowed', + ); + $this->assertRejectedAsQueryShape( + fn () => $database->find($collection, [Query::count('*', $tooLong), Query::groupBy(['category'])]), + 'Invalid query: Aggregate alias is too long: at most 63 characters are allowed', + ); + + $total = $database->find($collection, [Query::sum('price', $longest)]); + $this->assertCount(1, $total); + $this->assertSame([$longest], \array_keys($total[0]->getArrayCopy())); + $this->assertSame(2785, $this->intAttribute($total[0], $longest)); + + if ($database->getAdapter()->supports(Capability::BitwiseAggregates)) { + $grouped = $database->find($collection, [Query::bitOr('price', $longest), Query::groupBy(['category']), Query::orderAsc('category')]); + $this->assertSame( + [['books', 63], ['clothing', 126], ['electronics', 2036]], + \array_map(fn (Document $row): array => [$row->getAttribute('category'), $this->intAttribute($row, $longest)], $grouped), + ); + + $none = $database->find($collection, [Query::equal('category', ['nonexistent']), Query::bitAnd('price', $longest)]); + $this->assertCount(1, $none); + $this->assertSame([$longest], \array_keys($none[0]->getArrayCopy())); + $this->assertNull($none[0]->getAttribute($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 ($adapter->hasFeature(Postgres::class) || $adapter->hasFeature(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->exists($database->getDatabase(), $collection)) { + $database->deleteCollection($collection); + } + + $database->createCollection(new Collection(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(new Collection( + 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, 'link', 'link', '=', 'other'), 10, 1], + 'left join' => [Query::leftJoin($joined, 'link', 'link', '=', 'other'), 30, 2], + 'right join' => [Query::rightJoin($joined, 'link', 'link', '=', 'other'), 10, 2], + 'full outer join' => [Query::fullOuterJoin($joined, 'link', 'link', '=', 'other'), 30, 3], + ] as $type => [$join, $total, $rows]) { + $own = $database->find($main, [$join, Query::sum('score', 'score')]); + $this->assertCount(1, $own, $type); + $this->assertSame($total, $this->intAttribute($own[0], 'score'), $type); + + $other = $database->find($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->find($collection, $queries), $message); + } + + $results = $database->find($collection, [Query::count('*', 'products'), Query::sum('price', 'total'), Query::groupBy(['category']), Query::orderAsc('category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (Document $row): mixed => $row->getAttribute('category'), $results)); + $this->assertSame([3, 3, 3], \array_map(fn (Document $row): int => $this->intAttribute($row, 'products'), $results)); + $this->assertSame([85, 200, 2500], \array_map(fn (Document $row): int => $this->intAttribute($row, 'total'), $results)); + + $database->deleteCollection($collection); + } + + public function testBitwiseAggregateUnderALongAliasLeavesAnotherAggregateItsValue(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::BitwiseAggregates)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'long_bitwise_alias'; + $this->createProducts($database, $collection); + $alias = \str_repeat('b', 60); + $prefix = \substr($alias, 0, 55); + + $results = $database->find($collection, [ + Query::equal('category', ['nonexistent']), + Query::bitAnd('price', $alias), + Query::count('*', $prefix), + ]); + + $this->assertCount(1, $results); + $this->assertNull($results[0]->getAttribute($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->find($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_uid', '$id', '=', 'product'); + + 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->find($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->find($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->find($collection, [Query::count('*', 'total'), Query::groupBy(['category']), Query::select(['*']), Query::orderAsc('category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (Document $group): mixed => $group->getAttribute('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(new Collection(id: $regions, attributes: [Attribute::string(key: 'code', size: 16, required: true)], permissions: $permissions)); + $database->createCollection(new Collection(id: $accounts, attributes: [Attribute::string(key: 'plan', size: 16, required: true)], permissions: $permissions)); + $database->createCollection(new Collection(id: $customers, attributes: [Attribute::string(key: 'status', size: 16, required: true)], permissions: $permissions)); + $database->createRelationship(Relationship::oneToOne(collection: $customers, relatedCollection: $accounts, key: 'account', twoWayKey: 'customer')); + $database->createRelationship(Relationship::manyToOne(collection: $accounts, 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->find($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->find($customers, [Query::count('*', 'total'), Query::groupBy(['status']), Query::select($selects), Query::orderAsc('status')]); + $this->assertSame(['active', 'closed'], \array_map(fn (Document $group): mixed => $group->getAttribute('status'), $groups)); + $this->assertSame([2, 1], \array_map(fn (Document $group): int => $this->intAttribute($group, 'total'), $groups)); + $this->assertSame(['status', 'total'], $this->sortedAttributeNames($groups[0])); + + if ($adapter->supports(Capability::Joins)) { + $joined = $database->find($customers, [Query::fullOuterJoin($accounts, 'account', '$id', '=', 'owned'), 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->find($collection, [Query::count('*', 'total'), Query::groupBy(['category']), Query::select($selects), Query::orderAsc('category')]); + $this->assertSame(['books', 'clothing', 'electronics'], \array_map(fn (Document $group): mixed => $group->getAttribute('category'), $groups)); + foreach ($groups as $group) { + $this->assertSame(['category', 'total'], $this->sortedAttributeNames($group)); + $this->assertSame(3, $this->intAttribute($group, 'total')); + } + } + + $categories = $database->find($collection, [Query::groupBy(['category']), Query::select(['category']), Query::orderAsc('category')]); + $this->assertSame( + [['category' => 'books'], ['category' => 'clothing'], ['category' => 'electronics']], + \array_map(fn (Document $group): array => $group->getArrayCopy(), $categories), + ); + + if ($database->getAdapter()->supports(Capability::Joins)) { + $orders = 'agg_select_grouped_orders'; + $this->createOrders($database, $orders); + + foreach ([ + 'inner join' => [Query::join($collection, 'product_uid', '$id', '=', 'product'), [2, 3, 5]], + 'full outer join' => [Query::fullOuterJoin($collection, 'product_uid', '$id', '=', 'product'), [3, 3, 5]], + ] as $type => [$product, $totals]) { + $groups = $database->find($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 (Document $group): mixed => $group->getAttribute('category'), $groups), $type); + $this->assertSame($totals, \array_map(fn (Document $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'; + } + + /** + * @return list + */ + private function sortedAttributeNames(Document $row): array + { + $names = \array_map(strval(...), \array_keys($row->getArrayCopy())); + \sort($names); + + return $names; + } + + public function testAggregateNextToASearchWithoutAnOrderCountsTheMatches(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->supports(Capability::Fulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_search_relevance'; + $this->createProducts($database, $collection); + $database->createDocument($collection, new Document([ + '$id' => 'sleeve', + 'name' => 'Laptop Sleeve', + 'category' => 'clothing', + 'price' => 25, + 'stock' => 60, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createIndex($collection, Index::fullText(key: 'name_search', attributes: ['name'])); + + $totals = $database->find($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->find($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->find($collection, [Query::groupBy(['category']), Query::search('name', 'Laptop')]); + $this->assertSame([['category'], ['category']], \array_map($this->sortedAttributeNames(...), $categories)); + $names = \array_map(fn (Document $group): mixed => $group->getAttribute('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::Fulltext) || ! $adapter->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'agg_vector_distance'; + $database->createCollection(new Collection( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 100, required: true), + Attribute::string(key: 'category', size: 50, required: true), + Attribute::vector(key: 'embedding', size: 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->find($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->find($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->find($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 array $groups + * @return array + */ + private function totalsByCategory(array $groups): array + { + $totals = []; + foreach ($groups as $group) { + $category = $group->getAttribute('category'); + $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::Fulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'distinct_search_relevance'; + $this->createProducts($database, $collection); + $database->createDocument($collection, new Document([ + '$id' => 'dock', + 'name' => 'Laptop Laptop Dock', + 'category' => 'electronics', + 'price' => 90, + 'stock' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + $database->createIndex($collection, Index::fullText(key: 'name_search', attributes: ['name'])); + + $categories = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop')]); + $this->assertSame(['electronics'], $this->categoriesOf($categories)); + $this->assertArrayNotHasKey('_relevance', $categories[0]->getArrayCopy()); + + $ordered = $database->find($collection, [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop'), Query::orderAsc('category')]); + $this->assertSame(['electronics'], $this->categoriesOf($ordered)); + $this->assertArrayNotHasKey('_relevance', $ordered[0]->getArrayCopy()); + + $database->deleteCollection($collection); + } + + public function testDistinctNextToAVectorQueryReturnsEachSelectionOnce(): void + { + $database = static::getDatabase(); + $adapter = $database->getAdapter(); + if (! $adapter->supports(Capability::Aggregations) || ! $adapter->supports(Capability::Fulltext) || ! $adapter->supports(Capability::Vectors)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'distinct_vector_distance'; + $database->createCollection(new Collection( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 100, required: true), + Attribute::string(key: 'category', size: 50, required: true), + Attribute::vector(key: 'embedding', size: 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())], + ])); + } + + $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'); + $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($sleeve)])), + ); + + $database->deleteCollection($collection); + } + + /** + * @param array $rows + * @return list + */ + private function categoriesOf(array $rows): array + { + return \array_values(\array_map(fn (Document $row): mixed => $row->getAttribute('category'), $rows)); + } + + public function testSearchPagedWithACursorListsEachMatchOnce(): void + { + $database = static::getDatabase(); + if (! $database->getAdapter()->supports(Capability::Fulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'search_cursor_pages'; + $this->createProducts($database, $collection); + foreach (['dock' => 'Laptop Laptop Dock', 'sleeve' => 'Laptop Sleeve', 'bag' => 'Laptop Bag'] as $id => $name) { + $database->createDocument($collection, new Document([ + '$id' => $id, + 'name' => $name, + 'category' => 'electronics', + 'price' => 90, + 'stock' => 5, + '$permissions' => [Permission::read(Role::any())], + ])); + } + $database->createIndex($collection, Index::fullText(key: 'name_search', attributes: ['name'])); + + $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' => \array_map(fn (Document $document): string => $document->getId(), $after), + 'before' => \array_map(fn (Document $document): string => $document->getId(), $before), + ], + ); + foreach ([...$after, ...$before] as $document) { + $this->assertArrayNotHasKey('_relevance', $document->getArrayCopy()); + } + + $unpaged = $database->find($collection, [$search]); + $this->assertSame($matches, \array_map(fn (Document $document): string => $document->getId(), $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..2c4673d2b2 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -3,7 +3,15 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; use Throwable; +use Utopia\Database\Adapter\Feature; +use Utopia\Database\Adapter\Postgres; +use Utopia\Database\Adapter\SQL; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; @@ -13,57 +21,111 @@ 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\Index; use Utopia\Database\Query; +use Utopia\Database\Relationship; use Utopia\Database\Validator\Datetime as DatetimeValidator; use Utopia\Database\Validator\Structure; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\Order; 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 +134,169 @@ public function testCreateDeleteAttribute(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('attributes'); + $database->createCollection(new Collection(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)); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string1', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string2', size: 16382 + 1, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string3', size: 65535 + 1, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string4', size: 16777215 + 1, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::integer(key: 'integer', required: true))); + $this->assertTrue($database->createAttribute($this->getAttributesCollection(), Attribute::bigInteger(key: 'bigint', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::double(key: 'float', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'boolean', required: true))); + $this->assertEquals(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')); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar1', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar2', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::text(key: 'text1', size: 65535, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::mediumText(key: 'mediumtext1', size: 16777215, required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::longText(key: 'longtext1', size: 4294967295, required: true))); + + $this->assertEquals(true, $database->createIndex($this->getAttributesCollection(), Index::key(key: 'id_index', attributes: ['id']))); + $this->assertEquals(true, $database->createIndex($this->getAttributesCollection(), Index::key(key: 'string1_index', attributes: ['string1']))); + $this->assertEquals(true, $database->createIndex($this->getAttributesCollection(), Index::key(key: 'string2_index', attributes: ['string2'], lengths: [255]))); + $this->assertEquals(true, $database->createIndex($this->getAttributesCollection(), Index::key(key: 'multi_index', attributes: ['string1', 'string2', 'string3'], lengths: [128, 128, 128]))); + $this->assertEquals(true, $database->createIndex($this->getAttributesCollection(), Index::key(key: 'varchar1_index', attributes: ['varchar1']))); + $this->assertEquals(true, $database->createIndex($this->getAttributesCollection(), Index::key(key: 'varchar2_index', attributes: ['varchar2']))); + $this->assertEquals(true, $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')); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string_list', size: 128, required: true, array: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::integer(key: 'integer_list', required: true, array: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::double(key: 'float_list', required: true, array: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'boolean_list', required: true, array: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar_list', size: 128, required: true, array: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::text(key: 'text_list', size: 65535, required: true, array: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::mediumText(key: 'mediumtext_list', size: 16777215, required: true, array: true))); + $this->assertEquals(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')); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::string(key: 'string_default', size: 256, default: 'test'))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::integer(key: 'integer_default', default: 1))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::double(key: 'float_default', default: 1.5))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'boolean_default', default: false))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::datetime(key: 'datetime_default', default: '2000-06-12T14:12:55.000+00:00', filters: ['datetime']))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::varchar(key: 'varchar_default', default: 'varchar default'))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::text(key: 'text_default', size: 65535, default: 'text default'))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::mediumText(key: 'mediumtext_default', size: 16777215, default: 'mediumtext default'))); + $this->assertEquals(true, $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')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'string1')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'string2')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'string3')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'string4')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'integer')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'bigint')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'float')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'boolean')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'id')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'varchar1')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'varchar2')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'text1')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'mediumtext1')); + $this->assertEquals(true, $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')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'string_list')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'integer_list')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'float_list')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'boolean_list')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'varchar_list')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'text_list')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'mediumtext_list')); + $this->assertEquals(true, $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')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'string_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'integer_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'float_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'boolean_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'datetime_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'varchar_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'text_default')); + $this->assertEquals(true, $database->deleteAttribute($this->getAttributesCollection(), 'mediumtext_default')); + $this->assertEquals(true, $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)); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as_5dasdasdas', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as5dasdasdas_', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: '.as5dasdasdas', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: '-as5dasdasdas', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as-5dasdasdas', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'as5dasdasdas-', required: true))); + $this->assertEquals(true, $database->createAttribute($this->getAttributesCollection(), Attribute::boolean(key: 'socialAccountForYoutubeSubscribersss', required: true))); + $this->assertEquals(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)); + $this->assertEquals(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->assertInstanceOf(Attribute::class, $attribute); $this->assertEquals('string1', $attribute->getId()); - $this->assertEquals(true, $database->deleteAttribute('attributes', 'string1')); + $this->assertEquals(true, $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->assertInstanceOf(Attribute::class, $attribute); $this->assertNotEquals('string1', $attribute->getId()); - $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(new Collection(id: $this->getAttributesCollection())); + + self::$attributesCollectionFixtureInit = true; } public function testAttributeKeyWithSymbols(): void @@ -246,15 +304,15 @@ public function testAttributeKeyWithSymbols(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('attributesWithKeys'); + $database->createCollection(new Collection(id: 'attributesWithKeys')); - $this->assertEquals(true, $database->createAttribute('attributesWithKeys', 'key_with.sym$bols', Database::VAR_STRING, 128, true)); + $this->assertEquals(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,39 +324,28 @@ 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(new Collection(id: 'dots.parent')); - $this->assertTrue($database->createAttribute( - collection: 'dots.parent', - id: 'dots.name', - type: Database::VAR_STRING, - size: 255, - required: false - )); + $this->assertTrue($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(new Collection(id: 'dots')); - $this->assertTrue($database->createAttribute( - collection: 'dots', - id: 'name', - type: Database::VAR_STRING, - size: 255, - required: false - )); + $this->assertTrue($database->createAttribute('dots', Attribute::string(key: 'name'))); - $database->createRelationship( - collection: 'dots.parent', - relatedCollection: 'dots', - type: Database::RELATION_ONE_TO_ONE - ); + $database->createRelationship(Relationship::oneToOne(collection: 'dots.parent', relatedCollection: 'dots')); $database->createDocument('dots.parent', new Document([ '$id' => ID::custom('father'), @@ -317,7 +364,7 @@ public function testAttributeNamesWithDots(): void Permission::update(Role::any()), Permission::delete(Role::any()), ], - ] + ], ])); $documents = $database->find('dots.parent', [ @@ -327,18 +374,18 @@ public function testAttributeNamesWithDots(): void $this->assertEquals('Bill clinton', $documents[0]['dots.name']); } - 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(new Collection(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 +395,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->updateAttributeDefault($this->getFlowersCollection(), 'inStock', 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->updateAttributeDefault($this->getFlowersCollection(), 'inStock', 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(new Collection(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: [Order::Asc])); - $database->createDocument('colors', new Document([ + $database->createDocument($this->getColorsCollection(), new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -401,70 +449,201 @@ public function testRenameAttribute(): void Permission::delete(Role::any()), ], 'name' => 'black', - 'hex' => '#000000' + 'hex' => '#000000', ])); - $attribute = $database->renameAttribute('colors', 'name', 'verbose'); + $attribute = $database->renameAttribute($this->getColorsCollection(), 'name', 'verbose'); $this->assertTrue($attribute); - $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]['$id']); + $this->assertEquals('verbose', $colors->attributes[0]['$id']); + $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(new Collection(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->updateAttributeRequired($this->getFlowersCollection(), 'inStock', 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(new Collection(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, new Attribute(key: 'value', type: $type)); + $this->fail('Expected createAttribute() to reject '.$type->value); + } catch (DatabaseException $error) { + $this->assertStringContainsString($message, $error->getMessage()); + } + + try { + $database->createCollection(new Collection(id: $inline, attributes: [new Attribute(key: 'value', type: $type)])); + $this->fail('Expected createCollection() to reject '.$type->value); + } catch (DatabaseException $error) { + $this->assertStringContainsString($message, $error->getMessage()); + } + + $this->assertTrue($database->getCollection($inline)->isEmpty()); + } + + $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(new Collection(id: $collection)); + $this->assertTrue($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', newKey: 'target'); + + $this->assertSame('target', $updated->getAttribute('key')); + $type = $updated->getAttribute('type'); + $this->assertTrue($type instanceof ColumnType || \is_string($type)); + $this->assertSame(ColumnType::Id, Attribute::normalizeType($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(new Collection(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', required: false); + $this->assertFalse($updated->getAttribute('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,37 +652,44 @@ 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->updateAttributeFilters($this->getFlowersCollection(), 'cartModel', ['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()), @@ -514,25 +700,20 @@ public function testUpdateAttributeFormat(): void '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->updateAttributeFormat($this->getFlowersCollection(), 'price', 'priceRange'); + $database->updateAttributeFormatOptions($this->getFlowersCollection(), 'price', ['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,30 +723,81 @@ 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->updateAttributeFormat($this->getFlowersCollection(), 'price', 'priceRange'); + $database->updateAttributeFormatOptions($this->getFlowersCollection(), 'price', ['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]; + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); $this->assertEquals(null, $attribute['default']); @@ -574,9 +806,9 @@ public function testUpdateAttributeStructure(): void $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]; + $database->updateAttribute($this->getFlowersCollection(), 'price', default: 100); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -586,9 +818,9 @@ public function testUpdateAttributeStructure(): void $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]; + $database->updateAttribute($this->getFlowersCollection(), 'price', format: 'priceRangeNew'); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -598,9 +830,9 @@ public function testUpdateAttributeStructure(): void $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]; + $database->updateAttribute($this->getFlowersCollection(), 'price', format: ''); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -610,9 +842,9 @@ public function testUpdateAttributeStructure(): void $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]; + $database->updateAttribute($this->getFlowersCollection(), 'price', formatOptions: ['min' => 1, 'max' => 999]); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -622,9 +854,9 @@ public function testUpdateAttributeStructure(): void $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]; + $database->updateAttribute($this->getFlowersCollection(), 'price', formatOptions: []); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -634,9 +866,9 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('', $attribute['format']); $this->assertEquals([], $attribute['formatOptions']); - $database->updateAttribute('flowers', 'price', signed: false); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; + $database->updateAttribute($this->getFlowersCollection(), 'price', signed: false); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(false, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -646,9 +878,9 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('', $attribute['format']); $this->assertEquals([], $attribute['formatOptions']); - $database->updateAttribute('flowers', 'price', required: true); - $collection = $database->getCollection('flowers'); - $attribute = $collection->getAttribute('attributes')[4]; + $database->updateAttribute($this->getFlowersCollection(), 'price', required: true); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('integer', $attribute['type']); $this->assertEquals(false, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -658,9 +890,9 @@ public function testUpdateAttributeStructure(): void $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]; + $database->updateAttribute($this->getFlowersCollection(), 'price', type: ColumnType::String, size: Database::LENGTH_KEY, format: ''); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[4]; $this->assertEquals('string', $attribute['type']); $this->assertEquals(false, $attribute['signed']); $this->assertEquals(255, $attribute['size']); @@ -668,17 +900,17 @@ public function testUpdateAttributeStructure(): void $this->assertEquals(false, $attribute['array']); $this->assertEquals(true, $attribute['required']); $this->assertEquals('', $attribute['format']); - $this->assertEquals([], $collection->getAttribute('attributes')[4]['formatOptions']); + $this->assertEquals([], $collection->attributes[4]['formatOptions']); // Date attribute - $attribute = $collection->getAttribute('attributes')[2]; + $attribute = $collection->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]; + $database->updateAttribute($this->getFlowersCollection(), 'date', type: ColumnType::Datetime, size: 0, filters: ['datetime']); + $collection = $database->getCollection($this->getFlowersCollection()); + $attribute = $collection->attributes[2]; $this->assertEquals('datetime', $attribute['type']); $this->assertEquals(0, $attribute['size']); $this->assertEquals(null, $attribute['default']); @@ -688,11 +920,11 @@ public function testUpdateAttributeStructure(): void $this->assertEquals('', $attribute['format']); $this->assertEquals([], $attribute['formatOptions']); - $doc = $database->getDocument('flowers', 'LiliPriced'); + $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 +933,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(new Collection(id: 'rename_test')); - $this->assertEquals(true, $database->createAttribute('rename_test', 'rename_me', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('rename_test', Attribute::string(key: 'rename_me', size: 128, required: true))); $doc = $database->createDocument('rename_test', new Document([ '$permissions' => [ @@ -717,13 +950,13 @@ 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: [Order::Desc, Order::Desc])); $database->updateAttribute( collection: 'rename_test', @@ -746,37 +979,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]['$id']); + $this->assertEquals('renamed', $collection->indexes[0]->attributes[0]); - $supportsIdenticalIndexes = $database->getAdapter()->getSupportForIdenticalIndexes(); + $supportsIdenticalIndexes = $database->getAdapter()->supports(Capability::IdenticalIndexes); try { // Check empty newKey doesn't cause issues $database->updateAttribute( collection: 'rename_test', id: 'renamed', - type: Database::VAR_STRING, + 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]['$id']); + $this->assertEquals('renamed', $collection->indexes[0]->attributes[0]); $doc = $database->getDocument('rename_test', $doc->getId()); @@ -784,14 +1018,14 @@ public function testUpdateAttributeRename(): void $this->assertArrayNotHasKey('rename_me', $doc->getAttributes()); // Check the metadata was correctly updated - $attribute = $collection->getAttribute('attributes')[0]; + $attribute = $collection->attributes[0]; $this->assertEquals('renamed', $attribute['key']); $this->assertEquals('renamed', $attribute['$id']); // 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 +1035,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 +1049,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) { @@ -835,294 +1069,148 @@ 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(new Collection(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: [Order::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 { + $this->initColorsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); $this->expectExceptionMessage('Attribute name already used'); - $database->renameAttribute('colors', 'verbose', 'hex'); + $database->renameAttribute($this->getColorsCollection(), 'verbose', 'hex'); } - public function testWidthLimit(): void + public function testExceptionWidthLimit(): void { /** @var Database $database */ $database = $this->getDatabase(); if ($database->getAdapter()->getDocumentSizeLimit() === 0) { $this->expectNotToPerformAssertions(); - return; - } - - $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' => [], - ]); - } + $attributes[] = Attribute::string(key: 'varchar_16000', size: 16000, required: true); + + $attributes[] = Attribute::string(key: 'varchar_200', size: 200, required: true); try { - $database->createCollection('attributes_limit', $attributes); + $database->createCollection(new Collection(id: 'attributes_row_size', attributes: $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()); + $this->assertEquals('Document size limit of 65535 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' => [], - ]); + $collection = $database->createCollection(new Collection(id: 'attributes_row_size', attributes: $attributes)); + + $attribute = Attribute::string(key: 'breaking', size: 200, required: true); try { $database->checkAttribute($collection, $attribute); $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { + } catch (\Exception $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->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); + $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); } try { - $database->createAttribute($collection->getId(), 'breaking', Database::VAR_STRING, 100, 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); - $this->assertStringContainsString('Column limit reached. Cannot create new attribute.', $e->getMessage()); - $this->assertStringContainsString('Remove some attributes to free up space.', $e->getMessage()); + $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); + $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); + $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); } } - public function testExceptionWidthLimit(): void + public function testUpdateAttributeSize(): void { /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getDocumentSizeLimit() === 0) { + if (! $database->getAdapter()->supports(Capability::AttributeResizing)) { $this->expectNotToPerformAssertions(); + return; } - $attributes = []; + $database->createCollection(new Collection(id: 'resize_test')); - $attributes[] = new Document([ - '$id' => ID::custom('varchar_16000'), - 'type' => Database::VAR_STRING, - 'size' => 16000, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); + $this->assertEquals(true, $database->createAttribute('resize_test', Attribute::string(key: 'resize_me', size: 128, required: true))); + $document = $database->createDocument('resize_test', new Document([ + '$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), + ])); - $attributes[] = new Document([ - '$id' => ID::custom('varchar_200'), - 'type' => Database::VAR_STRING, - 'size' => 200, - 'required' => true, - 'default' => null, - 'signed' => true, - 'array' => false, - 'filters' => [], - ]); - - try { - $database->createCollection("attributes_row_size", $attributes); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertInstanceOf(LimitException::class, $e); - $this->assertEquals('Document size limit of 65535 exceeded. Cannot create collection.', $e->getMessage()); - } - - /** - * 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' => [], - ]); - - try { - $database->checkAttribute($collection, $attribute); - $this->fail('Failed to throw exception'); - } catch (\Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); - $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); - $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); - $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); - } - - try { - $database->createAttribute($collection->getId(), 'breaking', Database::VAR_STRING, 200, true); - $this->fail('Failed to throw exception'); - } catch (\Throwable $e) { - $this->assertInstanceOf(LimitException::class, $e); - $this->assertStringContainsString('Row width limit reached. Cannot create new attribute.', $e->getMessage()); - $this->assertStringContainsString('bytes but the maximum is 65535 bytes', $e->getMessage()); - $this->assertStringContainsString('Reduce the size of existing attributes or remove some attributes to free up space.', $e->getMessage()); - } - } - - public function testUpdateAttributeSize(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForAttributeResizing()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection('resize_test'); - - $this->assertEquals(true, $database->createAttribute('resize_test', 'resize_me', Database::VAR_STRING, 128, true)); - $document = $database->createDocument('resize_test', new Document([ - '$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) - ])); - - // Go up in size + // Go up in size // 0-16381 to 16382-65535 $document = $this->updateStringAttributeSize(16382, $document); @@ -1135,21 +1223,21 @@ 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', ColumnType::String->value, 128, 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', ColumnType::String->value, 128, true); // VARCHAR -> VARCHAR Truncation Test - $database->updateAttribute('resize_test', 'resize_me', Database::VAR_STRING, 1000, true); + $database->updateAttribute('resize_test', 'resize_me', ColumnType::String->value, 1000, 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', ColumnType::String->value, 128, true); $this->fail('Succeeded updating attribute size to smaller size with data that is too big'); } catch (TruncateException $e) { } @@ -1157,16 +1245,16 @@ public function testUpdateAttributeSize(): void if ($database->getAdapter()->getMaxIndexLength() > 0) { $length = intval($database->getAdapter()->getMaxIndexLength() / 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)); + $this->assertEquals(true, $database->createAttribute('resize_test', Attribute::string(key: 'attr1', size: $length, required: true))); + $this->assertEquals(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', ColumnType::String->value, 5000); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); @@ -1178,15 +1266,15 @@ 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', ColumnType::String->value, 5000); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertEquals('Index length is longer than the maximum: '.$database->getAdapter()->getMaxIndexLength(), $e->getMessage()); @@ -1198,14 +1286,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', ColumnType::String->value, 5000); } } @@ -1217,27 +1305,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(new Collection(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 +1387,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(new Collection(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 +1442,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', ColumnType::String->value, $size, true); $document = $document->setAttribute('resize_me', $this->createRandomString($size)); @@ -1345,42 +1450,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,89 +1474,31 @@ public function testArrayAttribute(): void $collection = 'json'; $permissions = [Permission::read(Role::any())]; - $database->createCollection($collection, permissions: [ + $database->createCollection(new Collection(id: $collection, permissions: [ Permission::create(Role::any()), - ]); + ])); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'booleans', - Database::VAR_BOOLEAN, - size: 0, - required: true, - array: true - )); + $this->assertEquals(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 - )); + $this->assertEquals(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 - )); + $this->assertEquals(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 - )); + $this->assertEquals(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 - )); + $this->assertEquals(true, $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, - )); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'tv_show', size: $database->getAdapter()->getMaxIndexLength() - 68, signed: false))); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'short', - Database::VAR_STRING, - size: 5, - required: false, - signed: false, - array: true - )); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'short', size: 5, signed: false, array: true))); - $this->assertEquals(true, $database->createAttribute( - $collection, - 'pref', - Database::VAR_STRING, - size: 16384, - required: false, - signed: false, - filters: ['json'], - )); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'pref', size: 16384, signed: false, filters: ['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()); } } @@ -1488,7 +1506,7 @@ public function testArrayAttribute(): void $database->updateAttribute($collection, 'booleans', required: false); $doc = $database->getCollection($collection); - $attribute = $doc->getAttribute('attributes')[0]; + $attribute = $doc->attributes[0]; $this->assertEquals('boolean', $attribute['type']); $this->assertEquals(true, $attribute['signed']); $this->assertEquals(0, $attribute['size']); @@ -1502,7 +1520,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 +1531,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 +1542,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 +1553,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 +1576,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::CastIndexArray)) { /** * Delete attribute */ @@ -1596,7 +1614,7 @@ public function testArrayAttribute(): void * Update attribute */ try { - $database->updateAttribute($collection, id:'cards', newKey: 'cards_new'); + $database->updateAttribute($collection, id: 'cards', newKey: 'cards_new'); $this->fail('Failed to throw exception'); } catch (Throwable $e) { $this->assertInstanceOf(DependencyException::class, $e); @@ -1608,14 +1626,14 @@ public function testArrayAttribute(): void $this->assertTrue($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::Fulltext)) { $this->assertEquals('"Fulltext" index is forbidden on array attributes', $e->getMessage()); } else { $this->assertEquals('Fulltext index is not supported', $e->getMessage()); @@ -1623,12 +1641,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,44 +1654,37 @@ public function testArrayAttribute(): void } } - $this->assertEquals(true, $database->createAttribute( - $collection, - 'long_size', - Database::VAR_STRING, - size: 2000, - required: false, - array: true - )); + $this->assertEquals(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()->getMaxIndexLength() > 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()->getMaxIndexLength(), $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], [])); + $this->assertTrue($database->createIndex($collection, Index::key(key: 'indx6', attributes: ['age', 'names'], lengths: [null, 999]))); + $this->assertTrue($database->createIndex($collection, Index::key(key: 'indx7', attributes: ['age', 'booleans'], lengths: [0, 999]))); } - if ($this->getDatabase()->getAdapter()->getSupportForQueryContains()) { + if ($this->getDatabase()->getAdapter()->supports(Capability::QueryContains)) { try { $database->find($collection, [ Query::equal('names', ['Joe']), @@ -1685,7 +1696,7 @@ public function testArrayAttribute(): void try { $database->find($collection, [ - Query::contains('age', [10]) + new Query(Method::Contains, 'age', [10]), ]); $this->fail('Failed to throw exception'); } catch (Throwable $e) { @@ -1693,66 +1704,66 @@ public function testArrayAttribute(): void } $documents = $database->find($collection, [ - Query::isNull('long_size') + Query::isNull('long_size'), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::contains('tv_show', ['love']) + Query::containsString('tv_show', ['love']), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::contains('names', ['Jake', 'Joe']) + new Query(Method::Contains, 'names', ['Jake', 'Joe']), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::contains('numbers', [-1, 0, 999]) + new Query(Method::Contains, 'numbers', [-1, 0, 999]), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::contains('booleans', [false, true]) + new Query(Method::Contains, 'booleans', [false, true]), ]); $this->assertCount(1, $documents); // Regular like query on primitive json string data $documents = $database->find($collection, [ - Query::contains('pref', ['Joe']) + Query::containsString('pref', ['Joe']), ]); $this->assertCount(1, $documents); // containsAny tests — should behave identically to contains $documents = $database->find($collection, [ - Query::containsAny('tv_show', ['love']) + Query::containsAny('tv_show', ['love']), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::containsAny('names', ['Jake', 'Joe']) + Query::containsAny('names', ['Jake', 'Joe']), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::containsAny('numbers', [-1, 0, 999]) + Query::containsAny('numbers', [-1, 0, 999]), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::containsAny('booleans', [false, true]) + Query::containsAny('booleans', [false, true]), ]); $this->assertCount(1, $documents); $documents = $database->find($collection, [ - Query::containsAny('pref', ['Joe']) + Query::containsAny('pref', ['Joe']), ]); $this->assertCount(1, $documents); // containsAny with no matching values $documents = $database->find($collection, [ - Query::containsAny('names', ['Jake', 'Unknown']) + Query::containsAny('names', ['Jake', 'Unknown']), ]); $this->assertCount(0, $documents); @@ -1760,37 +1771,37 @@ public function testArrayAttribute(): void // All values present in names array $documents = $database->find($collection, [ - Query::containsAll('names', ['Joe', 'Antony']) + Query::containsAll('names', ['Joe', 'Antony']), ]); $this->assertCount(1, $documents); // One value missing from names array $documents = $database->find($collection, [ - Query::containsAll('names', ['Joe', 'Jake']) + Query::containsAll('names', ['Joe', 'Jake']), ]); $this->assertCount(0, $documents); // All values present in numbers array $documents = $database->find($collection, [ - Query::containsAll('numbers', [0, 100, -1]) + Query::containsAll('numbers', [0, 100, -1]), ]); $this->assertCount(1, $documents); // One value missing from numbers array $documents = $database->find($collection, [ - Query::containsAll('numbers', [0, 999]) + Query::containsAll('numbers', [0, 999]), ]); $this->assertCount(0, $documents); // Single value containsAll — should match $documents = $database->find($collection, [ - Query::containsAll('booleans', [false]) + Query::containsAll('booleans', [false]), ]); $this->assertCount(1, $documents); // Boolean value not present $documents = $database->find($collection, [ - Query::containsAll('booleans', [true]) + Query::containsAll('booleans', [true]), ]); $this->assertCount(0, $documents); } @@ -1801,21 +1812,21 @@ 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(new Collection(id: 'datetime')); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertEquals(true, $database->createAttribute('datetime', Attribute::datetime(key: 'date', required: true, filters: ['datetime']))); + $this->assertEquals(true, $database->createAttribute('datetime', Attribute::datetime(key: 'date2', filters: ['datetime']))); } 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); } } @@ -1831,10 +1842,14 @@ 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()); @@ -1861,26 +1876,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 +1903,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 +1919,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 +1931,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 +1952,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,520 +1964,280 @@ 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(new Collection( + 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') + ); + $database->createAttribute($collection, Attribute::datetime(key: 'added')); - // Bulk attribute creation tests - public function testCreateAttributesEmpty(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $database->createDocument($collection, new Document([ + Document::ID => 'both', + 'inline' => $offset, + 'added' => $offset, + ])); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $both = $database->getDocument($collection, 'both'); + $this->assertSame($both->getAttribute('inline'), $both->getAttribute('added')); - $database->createCollection(__FUNCTION__); + $attributes = $database->getCollection($collection)->attributes; + $this->assertCount(2, $attributes); - try { - $database->createAttributes(__FUNCTION__, []); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); + 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; + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->markTestSkipped('Adapter does not support batch attribute creation'); } - $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(new Collection( + 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()) { + if (!$database->getAdapter()->supports(Capability::BatchCreateAttributes)) { $this->expectNotToPerformAssertions(); return; } - $database->createCollection(__FUNCTION__); + $collectionName = 'bigint_ignores_size_limit'; + $database->createCollection(new Collection(id: $collectionName)); - $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); - } + $attributes = [Attribute::bigInteger(key: 'foo', size: 9999)]; + + $result = $database->createAttributes($collectionName, $attributes); + $this->assertTrue($result); + + $collection = $database->getCollection($collectionName); + $attrs = $collection->attributes; + $this->assertCount(1, $attrs); + $attribute = $attrs[0] ?? null; + $this->assertInstanceOf(Document::class, $attribute); + $this->assertSame('foo', $attribute->getId()); + $this->assertSame(0, $attribute->getAttribute('size')); + + $database->updateAttribute($collectionName, 'foo', type: ColumnType::BigInteger, size: 1); + $collection = $database->getCollection($collectionName); + $attrs = $collection->attributes; + $attribute = $attrs[0] ?? null; + $this->assertInstanceOf(Document::class, $attribute); + $this->assertSame(0, $attribute->getAttribute('size')); } - public function testCreateAttributesMissingRequired(): void + public function testCreateAttributesBigIntValidationSignedUnsignedAndMetadata(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; + $collectionName = 'bigint_attr_validation'; + $database->createCollection(new Collection(id: $collectionName)); + + $this->assertTrue($database->createAttribute( + $collectionName, + Attribute::bigInteger(key: 'signed_bigint'), + )); + $this->assertTrue($database->createAttribute( + $collectionName, + Attribute::bigInteger(key: 'unsigned_bigint', signed: false), + )); + + $collection = $database->getCollection($collectionName); + $attributes = $collection->attributes; + + $signedAttribute = null; + $unsignedAttribute = null; + foreach ($attributes as $attribute) { + if ($attribute->getId() === 'signed_bigint') { + $signedAttribute = $attribute; + } + if ($attribute->getId() === 'unsigned_bigint') { + $unsignedAttribute = $attribute; + } } - $database->createCollection(__FUNCTION__); + $this->assertInstanceOf(Document::class, $signedAttribute); + $this->assertInstanceOf(Document::class, $unsignedAttribute); + $this->assertTrue($signedAttribute->getAttribute('signed')); + $this->assertFalse($unsignedAttribute->getAttribute('signed')); + $this->assertSame(0, $signedAttribute->getAttribute('size')); + $this->assertSame(0, $unsignedAttribute->getAttribute('size')); - $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); + $largeUnsignedAttribute = [Attribute::bigInteger(key: 'unsigned_bigint_large', default: '18446744073709551615', signed: false)]; + if ($database->getAdapter()->supports(Capability::UnsignedBigInt)) { + $this->assertTrue($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 testCreateAttributesDuplicateMetadata(): void + public function testBigIntegerAttributesPersistTheBigintSpelling(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; + $collectionName = 'bigint_persisted_spelling'; + $database->createCollection(new Collection( + id: $collectionName, + attributes: [Attribute::bigInteger(key: 'inline')], + )); + $this->assertTrue($database->createAttribute($collectionName, Attribute::bigInteger(key: 'single'))); + $expected = ['inline' => 'bigint', 'single' => 'bigint']; + + if ($database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->assertTrue($database->createAttributes($collectionName, [Attribute::bigInteger(key: 'batch')])); + $expected['batch'] = 'bigint'; } - $database->createCollection(__FUNCTION__); - $database->createAttribute(__FUNCTION__, 'dup', Database::VAR_STRING, 10, false); + $database->updateAttributeRequired($collectionName, 'single', true); - $attributes = [[ - '$id' => 'dup', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ]]; + $stored = $database->skipFilters(fn (): Document => $database->getAuthorization()->skip( + fn (): Document => $database->getDocument(Database::METADATA, $collectionName), + )); + $storedAttributes = $stored->getAttribute('attributes'); + $this->assertIsString($storedAttributes); - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DuplicateException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); + /** @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); + + foreach ($database->getCollection($collectionName)->attributes as $attribute) { + $this->assertSame(ColumnType::BigInteger, $attribute->type, $attribute->key); } } - public function testCreateAttributesInvalidFilter(): void + public function testCreateAttributesSuccessMultiple(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { $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); - } - } + $database->createCollection(new Collection(id: __FUNCTION__)); - public function testCreateAttributesInvalidFormat(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $attributes = [Attribute::string(key: 'a', size: 10), Attribute::integer(key: 'b')]; - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $result = $database->createAttributes(__FUNCTION__, $attributes); + $this->assertTrue($result); - $database->createCollection(__FUNCTION__); + $collection = $database->getCollection(__FUNCTION__); + $attrs = $collection->attributes; + $this->assertCount(2, $attrs); + $this->assertEquals('a', $attrs[0]['$id']); + $this->assertEquals('b', $attrs[1]['$id']); - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false, - 'format' => 'nonexistent' - ]]; + $doc = $database->createDocument(__FUNCTION__, new Document([ + 'a' => 'foo', + 'b' => 123, + ])); - try { - $database->createAttributes(__FUNCTION__, $attributes); - $this->fail('Expected DatabaseException not thrown'); - } catch (\Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - } + $this->assertEquals('foo', $doc->getAttribute('a')); + $this->assertEquals(123, $doc->getAttribute('b')); } - public function testCreateAttributesDefaultOnRequired(): void + public function testCreateAttributesDelete(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection(__FUNCTION__); + $database->createCollection(new Collection(id: __FUNCTION__)); - $attributes = [[ - '$id' => 'foo', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => true, - 'default' => 'bar' - ]]; + $attributes = [Attribute::string(key: 'a', size: 10), Attribute::integer(key: 'b')]; - 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); - - $collection = $database->getCollection($collectionName); - $attrs = $collection->getAttribute('attributes'); - $this->assertCount(1, $attrs); - $this->assertEquals('foo', $attrs[0]['$id']); - $this->assertEquals(0, $attrs[0]['size']); - - $database->updateAttribute($collectionName, 'foo', size: 1); - $collection = $database->getCollection($collectionName); - $attrs = $collection->getAttribute('attributes'); - $this->assertEquals(0, $attrs[0]['size']); - } - - public function testCreateAttributesBigIntValidationSignedUnsignedAndMetadata(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $collectionName = 'bigint_attr_validation'; - $database->createCollection($collectionName); - - $this->assertTrue($database->createAttribute( - $collectionName, - 'signed_bigint', - Database::VAR_BIGINT, - 0, - false, - signed: true - )); - $this->assertTrue($database->createAttribute( - $collectionName, - 'unsigned_bigint', - Database::VAR_BIGINT, - 0, - false, - signed: false - )); - - $collection = $database->getCollection($collectionName); - $attributes = $collection->getAttribute('attributes', []); - - $signedAttribute = null; - $unsignedAttribute = null; - foreach ($attributes as $attribute) { - if (($attribute['$id'] ?? '') === 'signed_bigint') { - $signedAttribute = $attribute; - } - if (($attribute['$id'] ?? '') === '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']); - - $largeUnsignedAttribute = [[ - '$id' => 'unsigned_bigint_large', - 'type' => Database::VAR_BIGINT, - 'size' => 0, - 'required' => false, - 'signed' => false, - 'default' => '18446744073709551615' - ]]; - $this->assertTrue($database->createAttributes($collectionName, $largeUnsignedAttribute)); - } - - public function testCreateAttributesSuccessMultiple(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [ - [ - '$id' => 'a', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ], - [ - '$id' => 'b', - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false - ], - ]; - - $result = $database->createAttributes(__FUNCTION__, $attributes); - $this->assertTrue($result); - - $collection = $database->getCollection(__FUNCTION__); - $attrs = $collection->getAttribute('attributes'); - $this->assertCount(2, $attrs); - $this->assertEquals('a', $attrs[0]['$id']); - $this->assertEquals('b', $attrs[1]['$id']); - - $doc = $database->createDocument(__FUNCTION__, new Document([ - 'a' => 'foo', - 'b' => 123, - ])); - - $this->assertEquals('foo', $doc->getAttribute('a')); - $this->assertEquals(123, $doc->getAttribute('b')); - } - - public function testCreateAttributesDelete(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchCreateAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->createCollection(__FUNCTION__); - - $attributes = [ - [ - '$id' => 'a', - 'type' => Database::VAR_STRING, - 'size' => 10, - 'required' => false - ], - [ - '$id' => 'b', - 'type' => Database::VAR_INTEGER, - 'size' => 0, - 'required' => false - ], - ]; - - $result = $database->createAttributes(__FUNCTION__, $attributes); - $this->assertTrue($result); + $result = $database->createAttributes(__FUNCTION__, $attributes); + $this->assertTrue($result); $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']); @@ -2470,33 +2245,30 @@ public function testCreateAttributesDelete(): void $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']); } - /** - * @depends testCreateDeleteAttribute - */ public function testStringTypeAttributes(): void { /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('stringTypes'); + $database->createCollection(new Collection(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)); + $this->assertEquals(true, $database->createAttribute('stringTypes', Attribute::varchar(key: 'varchar_field', default: 'default varchar'))); + $this->assertEquals(true, $database->createAttribute('stringTypes', Attribute::text(key: 'text_field', size: 65535))); + $this->assertEquals(true, $database->createAttribute('stringTypes', Attribute::mediumText(key: 'mediumtext_field', size: 16777215))); + $this->assertEquals(true, $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)); + $this->assertEquals(true, $database->createAttribute('stringTypes', Attribute::varchar(key: 'varchar_array', size: 128, array: true))); + $this->assertEquals(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([ @@ -2549,7 +2321,7 @@ 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'), @@ -2585,10 +2357,10 @@ public function testStringTypeAttributes(): void } // Test querying by VARCHAR field - $this->assertEquals(true, $database->createIndex('stringTypes', 'varchar_index', Database::INDEX_KEY, ['varchar_field'])); + $this->assertEquals(true, $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 +2380,626 @@ 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(new Collection(id: $collection)); + + try { + $database->createAttribute($collection, new Attribute(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(new Collection(id: $collection)); + + $this->assertTrue($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); + } + + $this->assertTrue($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(new Collection(id: $collection)); + + try { + $database->createAttribute($collection, Attribute::string(key: 'bad_format', size: 256, required: true, 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->getLimitForAttributes() === 0) { + $this->expectNotToPerformAssertions(); + + return; + } + + $limit = $adapter->getLimitForAttributes() - $adapter->getCountOfDefaultAttributes(); + + $attributes = []; + for ($i = 0; $i <= $limit; $i++) { + $attributes[] = Attribute::integer(key: "attr_{$i}"); + } + + try { + $database->createCollection(new Collection(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->getLimitForAttributes().' exceeded. Cannot create collection.', $e->getMessage()); + } + + array_pop($attributes); + + $collection = $database->createCollection(new Collection(id: 'attributes_limit', attributes: $attributes)); + + $attribute = Attribute::string(key: 'breaking', size: 100, required: true); + + 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(), $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->getDocumentSizeLimit() === 0) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $database->createCollection(new Collection(id: 'width_limit')); + + $init = $adapter->getAttributeWidth($collection); + $this->assertSame(1067, $init); + + $width = fn (Attribute $attribute): int => $adapter->getAttributeWidth($collection->setAttribute('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', size: 8))); + $this->assertSame(7, $width(Attribute::datetime(key: 'date', size: 8))); + + $database->deleteCollection('width_limit'); + } + + public function testCreateAttributesEmpty(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(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(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + + try { + $database->createAttributes(__FUNCTION__, [new Attribute(type: ColumnType::String, 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(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(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(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + + try { + $database->createAttributes(__FUNCTION__, [Attribute::string(key: 'foo', size: 10, format: 'nonexistent')]); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesDefaultOnRequired(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(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(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + + $max = $database->getAdapter()->getLimitForString(); + + 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 testCreateAttributesIntegerSizeLimit(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + + $limit = (int) ($database->getAdapter()->getLimitForInt() / 2); + + try { + $database->createAttributes(__FUNCTION__, [Attribute::integer(key: 'foo', size: $limit + 1)]); + $this->fail('Expected DatabaseException not thrown'); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesSkipsAColumnThatExistsOnlyInTheSchema(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'schemaOnlyColumn'; + $database->createCollection(new Collection(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + $database->getAdapter()->createAttribute($collection, Attribute::string(key: 'b', size: 64)); + + $this->assertTrue($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, + \array_values($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->getSharedTables()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getAdapter()->getIdAttributeType() === ColumnType::Integer->value; + $first = $integerTenants ? 301 : 'tenant_301'; + $second = $integerTenants ? 302 : 'tenant_302'; + $collection = 'sharedColumn_'.\uniqid(); + $definition = new Collection(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->hasFeature(Feature\SchemaAttributes::class) || $adapter instanceof Postgres) { + 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()); + } + + $this->assertTrue($database->createAttribute($collection, Attribute::integer(key: 'age'))); + $this->assertSame(['age'], \array_map( + static fn (Attribute $attribute): string => $attribute->key, + \array_values($database->getCollection($collection)->attributes), + )); + } else { + $this->assertTrue($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->hasFeature(Feature\SchemaAttributes::class); + + if (! $schemaAttributes && ! $adapter instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'orphanedRename'; + $database->createCollection(new Collection(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'); + + $this->assertTrue($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, + \array_values($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); + $this->assertTrue($database->renameAttribute($collection, 'age', 'years')); + $this->assertSame('fullName', $database->updateAttribute($collection, 'name', size: 128, newKey: 'fullName')->getId()); + } + + 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); + $this->assertTrue($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); + $this->assertTrue($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) { + $this->assertTrue($database->renameIndex($collection, 'byAge', 'ageIndex'), 'SQLite builds the tenant its own index under the new name'); + $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)); + }); + } + + 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', newKey: '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->getSharedTables() || ! $database->getAdapter() instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getAdapter()->getIdAttributeType() === ColumnType::Integer->value; + $tenants = $integerTenants ? [501, 502] : ['tenant_501', 'tenant_502']; + $collection = 'sharedRename_'.\uniqid(); + $definition = new Collection(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..bd82edbe0b 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -3,32 +3,64 @@ 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\SQL; +use Utopia\Database\Adapter\SQLite; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Collection; 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\Exception\Type as TypeException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Transform; +use Utopia\Database\Index; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\Storage; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKeyAction; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; 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,15 +70,21 @@ 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(new Collection(id: 'actors', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), ])); @@ -54,7 +92,7 @@ public function testCreateListExistsDeleteCollection(): void $this->assertEquals(true, $database->exists($this->testDatabase, 'actors')); // Collection names should not be unique - $this->assertInstanceOf('Utopia\Database\Document', $database->createCollection('actors2', permissions: [ + $database->createCollection(new Collection(id: 'actors2', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), ])); @@ -62,11 +100,12 @@ public function testCreateListExistsDeleteCollection(): void $this->assertEquals(true, $database->exists($this->testDatabase, 'actors2')); $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->assertSame('actors', $updated->getAttribute('name')); $this->assertEquals(true, $database->deleteCollection('actors2')); // Delete collection when finished $this->assertCount(1, $database->listCollections()); @@ -81,8 +120,7 @@ public function testDatabaseHostname(): void /** @var Database $database */ $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForHostname()) { + if (!$database->getAdapter()->supports(Capability::Hostname)) { $this->expectNotToPerformAssertions(); return; } @@ -97,290 +135,76 @@ 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', signed: false), ]; $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: [Order::Asc]), + Index::key(key: 'index2', attributes: ['attribute2'], orders: [Order::Desc]), + Index::key(key: 'index3', attributes: ['attribute3', 'attribute2'], orders: [Order::Desc, Order::Asc]), + Index::key(key: 'index4', attributes: ['attribute4'], orders: [Order::Desc]), ]; - $collection = $database->createCollection('withSchema', $attributes, $indexes); + $collection = $database->createCollection(new Collection(id: 'withSchema', attributes: $attributes, indexes: $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']); - + $this->assertCount(4, $collection->attributes); + $this->assertEquals('attribute1', $collection->attributes[0]['$id']); + $this->assertSame('attribute1', $collection->attributes[0]->key); + $this->assertEquals(ColumnType::String->value, $collection->attributes[0]['type']); + $this->assertEquals('attribute2', $collection->attributes[1]['$id']); + $this->assertEquals(ColumnType::Integer->value, $collection->attributes[1]['type']); + $this->assertEquals('attribute3', $collection->attributes[2]['$id']); + $this->assertEquals(ColumnType::Boolean->value, $collection->attributes[2]['type']); + $this->assertEquals('attribute4', $collection->attributes[3]['$id']); + $this->assertEquals(ColumnType::Id->value, $collection->attributes[3]['type']); + + $this->assertCount(4, $collection->indexes); + $this->assertEquals('index1', $collection->indexes[0]['$id']); + $this->assertSame('index1', $collection->indexes[0]->key); + $this->assertEquals(IndexType::Key->value, $collection->indexes[0]['type']); + $this->assertEquals('index2', $collection->indexes[1]['$id']); + $this->assertEquals(IndexType::Key->value, $collection->indexes[1]['type']); + $this->assertEquals('index3', $collection->indexes[2]['$id']); + $this->assertEquals(IndexType::Key->value, $collection->indexes[2]['type']); + $this->assertEquals('index4', $collection->indexes[3]['$id']); + $this->assertEquals(IndexType::Key->value, $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(new Collection(id: 'with-dash', attributes: [ + Attribute::string(key: 'attribute-one', size: 256), + ], indexes: [ + Index::key(key: 'index-one', attributes: ['attribute-one'], lengths: [256], orders: [Order::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->assertCount(1, $collection2->attributes); + $this->assertEquals('attribute-one', $collection2->attributes[0]['$id']); + $this->assertEquals(ColumnType::String->value, $collection2->attributes[0]['type']); + $this->assertCount(1, $collection2->indexes); + $this->assertEquals('index-one', $collection2->indexes[0]['$id']); + $this->assertEquals(IndexType::Key->value, $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(new Collection(id: 'sizeTest1')); + $database->createCollection(new Collection(id: 'sizeTest2')); $size1 = $database->getSizeOfCollection('sizeTest1'); $size2 = $database->getSizeOfCollection('sizeTest2'); @@ -389,24 +213,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 +245,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 +255,19 @@ public function testSizeCollection(): void $size3 = $this->getDatabase()->getSizeOfCollection('sizeTest2'); + if ($database->getAdapter()->hasFeature(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(new Collection(id: 'sizeTestDisk1')); + $this->getDatabase()->createCollection(new Collection(id: 'sizeTestDisk2')); $size1 = $this->getDatabase()->getSizeOfCollectionOnDisk('sizeTestDisk1'); $size2 = $this->getDatabase()->getSizeOfCollectionOnDisk('sizeTestDisk2'); @@ -446,18 +277,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 +303,28 @@ public function testSizeFullText(): void $database = $this->getDatabase(); // SQLite does not support fulltext indexes - if (!$database->getAdapter()->getSupportForFulltextIndex()) { + if (! $database->getAdapter()->supports(Capability::Fulltext)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('fullTextSizeTest'); + $database->createCollection(new Collection(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,54 +332,18 @@ 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 - { - /** @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)); - - $document = $database->getDocument('redis', 'doc1'); - $this->assertEquals('Richard', $document->getAttribute('name')); - $this->assertArrayHasKey('age', $document); - } - public function testSchemaAttributes(): void { - if (!$this->getDatabase()->getAdapter()->getSupportForSchemaAttributes()) { + if (! ($this->getDatabase()->getAdapter()->hasFeature(Feature\SchemaAttributes::class))) { $this->expectNotToPerformAssertions(); + return; } @@ -556,19 +352,18 @@ public function testSchemaAttributes(): void $this->assertEmpty($db->getSchemaAttributes('no_such_collection')); - $db->createCollection($collection); + $db->createCollection(new Collection(id: $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']); + $db->createAttribute($collection, Attribute::string(key: 'username', size: 128, required: true)); + $db->createAttribute($collection, Attribute::string(key: 'story', size: 20000, required: true)); + $db->createAttribute($collection, Attribute::string(key: 'string_list', size: 128, required: true, array: true)); + $db->createAttribute($collection, Attribute::datetime(key: 'dob', default: '2000-06-12T14:12:55.000+00:00', filters: ['datetime'])); $attributes = []; foreach ($db->getSchemaAttributes($collection) as $attribute) { /** * @var Document $attribute */ - $attributes[$attribute->getId()] = $attribute; } @@ -608,188 +403,40 @@ public function testSchemaAttributes(): void } } - public function testRowSizeToLarge(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if ($database->getAdapter()->getDocumentSizeLimit() === 0) { - $this->expectNotToPerformAssertions(); - return; - } - /** - * 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); - } - - /** - * Relation takes length of Database::LENGTH_KEY so exceeding getDocumentSizeLimit - */ - - try { - $database->createRelationship( - collection: $collection_2->getId(), - relatedCollection: $collection_1->getId(), - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); - - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); - } - - 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); - } - } - public function testCreateCollectionWithSchemaIndexes(): void { /** @var Database $database */ $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: [Order::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], - ]); - } - - $collection = $database->createCollection( - 'collection98', - $attributes, - $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->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); - - 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)) { + $indexes[] = Index::key(key: 'idx_cards', attributes: ['cards'], lengths: [500], orders: [Order::Desc]); } - } - - public function testCollectionUpdate(): Document - { - /** @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; - } + $collection = $database->createCollection(new Collection(id: 'collection98', attributes: $attributes, indexes: $indexes, permissions: [ + Permission::create(Role::any()), + ])); - public function testUpdateDeleteCollectionNotFound(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals($collection->indexes[0]->attributes[0], 'username'); + $this->assertEquals($collection->indexes[0]->lengths[0], null); - try { - $database->deleteCollection('not_found'); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Collection not found', $e->getMessage()); - } + $this->assertEquals($collection->indexes[1]->attributes[0], 'username'); + $this->assertEquals($collection->indexes[1]->lengths[0], 99); + $this->assertEquals($collection->indexes[1]->orders[0], Order::Desc); - try { - $database->updateCollection('not_found', [], true); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals('Collection not found', $e->getMessage()); + 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); } } @@ -798,12 +445,13 @@ public function testGetCollectionId(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForGetConnectionId()) { + if (! ($database->getAdapter()->hasFeature(Feature\ConnectionId::class))) { $this->expectNotToPerformAssertions(); + return; } - $this->assertIsString($database->getConnectionId()); + $this->assertNotSame('', $database->getConnectionId()); } public function testKeywords(): void @@ -811,31 +459,23 @@ public function testKeywords(): void $database = $this->getDatabase(); $keywords = $database->getKeywords(); + if ($keywords === []) { + $this->expectNotToPerformAssertions(); + + return; + } + // 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: [Order::Asc]), ]; foreach ($keywords as $keyword) { - $collection = $database->createCollection($keyword, $attributes, $indexes); + $collection = $database->createCollection(new Collection(id: $keyword, attributes: $attributes, indexes: $indexes)); $this->assertEquals($keyword, $collection->getId()); $document = $database->createDocument($keyword, new Document([ @@ -865,12 +505,12 @@ public function testKeywords(): void // 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(new Collection(id: $collectionName)); $this->assertEquals($collectionName, $collection->getId()); - $attribute = $database->createAttribute($collectionName, $keyword, Database::VAR_STRING, 128, true); + $attribute = $database->createAttribute($collectionName, Attribute::string(key: $keyword, size: 128, required: true)); $this->assertEquals(true, $attribute); $document = new Document([ @@ -880,29 +520,29 @@ 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()); @@ -916,11 +556,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(new Collection(id: 'labels_test')); + $this->assertTrue($database->createAttribute('labels_test', Attribute::string(key: 'attr1', size: 10))); $database->createDocument('labels_test', new Document([ '$id' => 'doc1', @@ -930,31 +570,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,27 +597,27 @@ 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->getCollection('testers')->isEmpty()) { + $database->createCollection(new Collection(id: 'testers')); + } + + $database->createCollection(new Collection(id: 'devices')); - $database->createRelationship( - collection: 'testers', - relatedCollection: 'devices', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - twoWayKey: 'tester' - ); + $database->createRelationship(Relationship::oneToMany(collection: 'testers', 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'); @@ -990,60 +625,48 @@ public function testDeleteCollectionDeletesRelationships(): void $devices = $database->getCollection('devices'); $this->assertEquals(true, $testers->isEmpty()); - $this->assertEquals(0, \count($devices->getAttribute('attributes'))); - $this->assertEquals(0, \count($devices->getAttribute('indexes'))); + $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(new Collection(id: 'cascadeMultiDelete1')); + $database->createCollection(new Collection(id: 'cascadeMultiDelete2')); + $database->createCollection(new Collection(id: 'cascadeMultiDelete3')); - $database->createRelationship( - collection: 'cascadeMultiDelete1', - relatedCollection: 'cascadeMultiDelete2', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - onDelete: Database::RELATION_MUTATE_CASCADE - ); + $database->createRelationship(Relationship::oneToMany(collection: 'cascadeMultiDelete1', relatedCollection: 'cascadeMultiDelete2', twoWay: true, onDelete: ForeignKeyAction::Cascade)); - $database->createRelationship( - collection: 'cascadeMultiDelete2', - relatedCollection: 'cascadeMultiDelete3', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - onDelete: Database::RELATION_MUTATE_CASCADE - ); + $database->createRelationship(Relationship::oneToMany(collection: 'cascadeMultiDelete2', relatedCollection: 'cascadeMultiDelete3', twoWay: true, onDelete: ForeignKeyAction::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 +674,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())); @@ -1082,38 +706,45 @@ public function testSharedTables(): void $sharedTables = $database->getSharedTables(); $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,49 +753,30 @@ 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(new Collection(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::Fulltext)) { + $database->createIndex('people', Index::fullText(key: 'idx_lifeStory', attributes: ['lifeStory'])); } $docId = ID::unique(); @@ -1175,7 +787,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 +798,7 @@ public function testSharedTables(): void * Remove Permissions */ $doc->setAttribute('$permissions', [ - Permission::read(Role::any()) + Permission::read(Role::any()), ]); $database->updateDocument('people', $docId, $doc); @@ -1251,9 +863,11 @@ public function testSharedTables(): void // Reset state $database ->setSharedTables($sharedTables) + ->setTenant($tenant) ->setNamespace($namespace) ->setDatabase($schema); } + /** * @throws LimitException * @throws DuplicateException @@ -1264,12 +878,12 @@ public function testCreateDuplicates(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('duplicates', permissions: [ - Permission::read(Role::any()) - ]); + $database->createCollection(new Collection(id: 'duplicates', permissions: [ + Permission::read(Role::any()), + ])); try { - $database->createCollection('duplicates'); + $database->createCollection(new Collection(id: 'duplicates')); $this->fail('Failed to throw exception'); } catch (Exception $e) { $this->assertInstanceOf(DuplicateException::class, $e); @@ -1279,6 +893,7 @@ public function testCreateDuplicates(): void $database->deleteCollection('duplicates'); } + public function testSharedTablesDuplicates(): void { /** @var Database $database */ @@ -1286,60 +901,66 @@ public function testSharedTablesDuplicates(): void $sharedTables = $database->getSharedTables(); $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(new Collection(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(new Collection(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); } @@ -1356,7 +977,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,43 +991,34 @@ 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->getAdapter()->getIdAttributeType() === ColumnType::Integer->value ? 10 : 'tenant_10'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === ColumnType::Integer->value ? 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(new Collection(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'))); + $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(new Collection(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'))); + $this->assertEquals(1, \count($col2->attributes)); $database->setTenant($tenant1); $col1Again = $database->getCollection($colName); @@ -1417,8 +1029,11 @@ public function testSharedTablesMultiTenantCreateCollection(): void } else { $database->setTenant($tenant1); $database->deleteCollection($colName); - $database->setTenant($tenant2); - $database->deleteCollection($colName); + try { + $database->setTenant($tenant2); + $database->deleteCollection($colName); + } catch (\Throwable) { + } } } finally { $database @@ -1439,8 +1054,8 @@ public function testSharedTablesMultiTenantCreate(): void $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->getAdapter()->getIdAttributeType() === ColumnType::Integer->value ? 100 : 'tenant_100'; + $tenant2 = $database->getAdapter()->getIdAttributeType() === ColumnType::Integer->value ? 200 : 'tenant_200'; if ($sharedTables) { // Already in shared-tables mode; create() should be idempotent. @@ -1450,8 +1065,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 +1084,7 @@ public function testSharedTablesMultiTenantCreate(): void $database->delete(); } else { $this->expectNotToPerformAssertions(); + return; } } finally { @@ -1485,61 +1101,55 @@ 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::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(); @@ -1547,13 +1157,13 @@ public function testEvents(): void $database->setDatabase($this->testDatabase); $collectionId = ID::unique(); - $database->createCollection($collectionId); + $database->createCollection(new Collection(id: $collectionId)); $database->listCollections(); $database->getCollection($collectionId); - $database->createAttribute($collectionId, 'attr1', Database::VAR_INTEGER, 2, false); + $database->createAttribute($collectionId, Attribute::integer(key: 'attr1', size: 2)); $database->updateAttributeRequired($collectionId, 'attr1', true); - $indexId1 = 'index2_' . uniqid(); - $database->createIndex($collectionId, $indexId1, Database::INDEX_KEY, ['attr1']); + $indexId1 = 'index2_'.uniqid(); + $database->createIndex($collectionId, Index::key(key: $indexId1, attributes: ['attr1'])); $document = $database->createDocument($collectionId, new Document([ '$id' => 'doc1', @@ -1565,10 +1175,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 +1189,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 +1212,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(new Collection(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,9 +1243,10 @@ 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([ + $created = $database->createCollection(new Collection(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' => [ @@ -1632,66 +1261,120 @@ 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(new Collection(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(new Collection(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()), + ], + ])); + + return $collection; + } + + 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(new Collection(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'); - $capturedSql = ''; - $database->before(Database::EVENT_DOCUMENT_READ, 'test', function (string $sql) use (&$capturedSql) { - $sql .= ' AND 1=0'; - $capturedSql = $sql; - return $sql; - }); + $hook = new class ($database->getNamespace().'_docs') implements Transform { + public string $query = ''; + + public function __construct(private readonly string $table) + { + } + + 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 @@ -1715,17 +1398,20 @@ public function testSetGlobalCollection(): void $this->assertNotEmpty($hashKey); if ($db->getSharedTables()) { - $this->assertStringNotContainsString((string)$db->getAdapter()->getTenant(), $collectionKey); + $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); + $this->assertSame( + (string) $db->getAdapter()->getTenant(), + $this->cacheKeyTenantSegment($collectionKeyRegular) + ); } // Non metadata collection should contain tenant in the cache key @@ -1740,81 +1426,46 @@ public function testSetGlobalCollection(): void $this->assertNotEmpty($hashKey); if ($db->getSharedTables()) { - $this->assertStringContainsString((string)$db->getAdapter()->getTenant(), $collectionKey); + $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: [Order::Asc]), + Index::key(key: 'idx_name_age', attributes: ['name', 'age'], lengths: [128, null], orders: [Order::Asc, Order::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(new Collection(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', @@ -1860,22 +1511,18 @@ 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()); - $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(new Collection(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); $peer->createDocument($collection, new Document([ '$id' => ID::custom('written'), @@ -1884,17 +1531,10 @@ public function testCreateCollectionConcurrentlyKeepsPeerData(): void ])); try { - $database->createCollection($collection, [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 128, - 'required' => false, - ]), - ], permissions: [ + $database->createCollection(new Collection(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) { } @@ -1932,12 +1572,7 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void } $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 +1580,7 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void '$id' => $collection, '$collection' => Database::METADATA, 'name' => $collection, - 'attributes' => [$name], + 'attributes' => [$name->toDocument()], 'indexes' => [], 'documentSecurity' => true, '$permissions' => [ @@ -1963,10 +1598,10 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void ])); try { - $database->createCollection($collection, [$name], permissions: [ + $database->createCollection(new Collection(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); } catch (DuplicateException) { // SQL adapters report the existing table as Duplicate. Mongo's // createCollection is idempotent, so this process continues and @@ -1985,4 +1620,663 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void $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', [], 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(new Collection(id: 'collectionUpdate', permissions: [ + Permission::create(Role::users()), + Permission::read(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], documentSecurity: false)); + + $this->assertFalse($collection->isEmpty()); + + $collection = $database->getCollection('collectionUpdate'); + + $this->assertFalse($collection->getAttribute('documentSecurity')); + $this->assertCount(4, $collection->getPermissions()); + + $collection = $database->updateCollection('collectionUpdate', [], 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: [Order::Asc]), + Index::key(key: 'index-2', attributes: ['attribute-2'], orders: [Order::Asc]), + Index::key(key: 'index_3', attributes: ['attribute_3'], orders: [Order::Asc]), + Index::key(key: 'index.4', attributes: ['attribute.4'], orders: [Order::Asc]), + Index::key(key: 'index_2_attributes', attributes: ['attribute1', 'attribute5'], lengths: [200, 300], orders: [Order::Desc]), + ]; + + foreach ($collections as $id) { + $collection = $database->createCollection(new Collection(id: $id, attributes: $attributes, indexes: $indexes)); + + $this->assertFalse($collection->isEmpty()); + $this->assertSame($id, $collection->getId()); + + $this->assertCount(5, $collection->attributes); + $this->assertSame('attribute1', $collection->attributes[0]->getId()); + $this->assertSame(ColumnType::String, $collection->attributes[0]->type); + $this->assertSame('attribute-2', $collection->attributes[1]->getId()); + $this->assertSame(ColumnType::Integer, $collection->attributes[1]->type); + $this->assertSame('attribute_3', $collection->attributes[2]->getId()); + $this->assertSame(ColumnType::Boolean, $collection->attributes[2]->type); + $this->assertSame('attribute.4', $collection->attributes[3]->getId()); + $this->assertSame(ColumnType::Boolean, $collection->attributes[3]->type); + + $this->assertCount(5, $collection->indexes); + $this->assertSame('index1', $collection->indexes[0]->getId()); + $this->assertSame(IndexType::Key, $collection->indexes[0]->type); + $this->assertSame('index-2', $collection->indexes[1]->getId()); + $this->assertSame(IndexType::Key, $collection->indexes[1]->type); + $this->assertSame('index_3', $collection->indexes[2]->getId()); + $this->assertSame(IndexType::Key, $collection->indexes[2]->type); + $this->assertSame('index.4', $collection->indexes[3]->getId()); + $this->assertSame(IndexType::Key, $collection->indexes[3]->type); + + $database->deleteCollection($id); + } + } + + public function testMetadata(): void + { + $database = $this->getDatabase(); + + $database->setMetadata('key', 'value'); + + $database->createCollection(new Collection(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(new Collection(id: 'purgeCache')); + + $this->assertTrue($database->createAttribute('purgeCache', Attribute::string(key: 'name', size: 128, required: true))); + $this->assertTrue($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')); + + $this->assertTrue($database->deleteAttribute('purgeCache', 'age')); + + $document = $database->getDocument('purgeCache', 'doc1'); + $this->assertSame('Richard', $document->getAttribute('name')); + $this->assertArrayNotHasKey('age', $document); + + $this->assertTrue($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()->getDocumentSizeLimit() === 0) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection1 = $database->createCollection(new Collection(id: 'row_size_1')); + $collection2 = $database->createCollection(new Collection(id: 'row_size_2')); + + $this->assertTrue($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(Relationship::oneToOne( + collection: $collection2->getId(), + 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(Relationship::oneToOne( + collection: $collection1->getId(), + 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(new Collection(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()); + } + + $this->assertTrue($database->deleteCollection($collection)); + $this->assertTrue($database->getCollection($collection)->isEmpty()); + + if ($adapter instanceof Postgres || $adapter instanceof SQLite) { + $database->createCollection(new Collection(id: $collection, permissions: [Permission::read(Role::any())])); + $this->assertTrue($database->deleteCollection($collection), 'The permissions table of the collection whose table was gone was left behind'); + } + } + + public function testIndexOnAColumnTheTableLacksIsAttributeNotFound(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $adapter = $database->getAdapter(); + + if (! $adapter->hasFeature(Feature\RawQuery::class) || $adapter->hasFeature(SQLite::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'indexDrifted'; + $database->createCollection(new Collection(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 (! $database->getAdapter()->hasFeature(Postgres::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'maxOverBoolean'; + $database->createCollection(new Collection(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->find($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 (! $adapter->hasFeature(Mongo::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'incrementText'; + $database->createCollection(new Collection(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($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 (! $adapter->hasFeature(SQL::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'analyzed'; + $database->createCollection(new Collection( + 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(new Collection( + 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', newKey: 'happenedAt'); + $this->assertSame('2024-05-06T07:08:09.123+00:00', $database->getDocument($collection, 'moment')->getAttribute('happenedAt')); + + $database->updateAttribute($collection, 'happenedAt', 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(new Collection( + id: $collection, + attributes: [Attribute::object(key: 'data'), Attribute::string(key: 'status', size: 32)], + indexes: [Index::key(key: 'countryfirst', attributes: ['data.country', 'status'], orders: [Order::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); + } + } + + public function testDeletingACollectionWhoseTableIsGoneDropsItsPermissionsTable(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(MariaDB::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'mainTableGone'; + $database->createCollection(new Collection(id: $collection, permissions: [Permission::read(Role::any())])); + $this->assertTrue($database->exists(collection: Storage::permissionsTable($collection))); + + $table = $database->getDatabase().'.'.$database->getNamespace().'_'.$collection; + $database->getAuthorization()->skip(fn () => $database->schema()->table($table)->drop()->execute()); + + $this->assertTrue($database->deleteCollection($collection)); + $this->assertTrue($database->getCollection($collection)->isEmpty()); + $this->assertFalse($database->exists(collection: 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->getSharedTables() || ! $database->getAdapter()->hasFeature(Postgres::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getAdapter()->getIdAttributeType() === ColumnType::Integer->value; + $first = $integerTenants ? 401 : 'tenant_401'; + $second = $integerTenants ? 402 : 'tenant_402'; + $collection = 'sharedColumnType'; + $definition = new Collection(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()); + } + + $this->assertSame([], $database->getCollection($collection)->getAttribute('attributes', [])); + + $this->assertTrue($database->createAttribute($collection, Attribute::integer(key: 'age'))); + /** @var array $attributes */ + $attributes = $database->getCollection($collection)->getAttribute('attributes', []); + $this->assertSame(['age'], \array_map(static fn (Attribute $attribute): string => $attribute->key, \array_values($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->getSharedTables() || ! $database->getAdapter()->hasFeature(Postgres::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $originalTenant = $database->getTenant(); + $integerTenants = $database->getAdapter()->getIdAttributeType() === ColumnType::Integer->value; + $tenants = $integerTenants ? [411, 412] : ['tenant_411', 'tenant_412']; + $collection = 'sharedColumnSameType'; + $definition = new Collection(id: $collection, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ]); + + try { + foreach ($tenants as $tenant) { + $database->setTenant($tenant); + $database->createCollection($definition); + $this->assertTrue($database->createAttribute($collection, Attribute::integer(key: 'age'))); + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'name', size: 64))); + $this->assertTrue($database->createAttributes($collection, [ + Attribute::datetime(key: 'seen'), + Attribute::string(key: 'bio', size: 20000), + ])); + + /** @var array $attributes */ + $attributes = $database->getCollection($collection)->getAttribute('attributes', []); + $this->assertSame(['age', 'name', 'seen', 'bio'], \array_map(static fn (Attribute $attribute): string => $attribute->key, \array_values($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..5ae0463627 100644 --- a/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php +++ b/tests/e2e/Adapter/Scopes/CustomDocumentTypeTests.php @@ -2,7 +2,10 @@ 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; @@ -10,95 +13,63 @@ use Utopia\Database\Helpers\Role; use Utopia\Database\Query; -// 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', ''); - } + $database->clearDocumentType('users'); - public function getContent(): string - { - return $this->getAttribute('content', ''); - } -} + $database->setDocumentType('users', User::class); + $database->setDocumentType('posts', Post::class); -trait CustomDocumentTypeTests -{ - public function testSetDocumentType(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - $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->clearAllDocumentTypes(); - // 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,14 +77,13 @@ 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(); @@ -123,44 +93,39 @@ 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(); } public function testCustomDocumentTypeWithGetDocument(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - // Create collection - $database->createCollection('customUsers', permissions: [ + $database->createCollection(new Collection(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(), 'email' => 'test@example.com', @@ -169,92 +134,76 @@ public function testCustomDocumentTypeWithGetDocument(): void '$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(new Collection(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([ + $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([ + $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(new Collection(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(), 'email' => 'original@example.com', @@ -263,8 +212,6 @@ public function testCustomDocumentTypeWithUpdateDocument(): void '$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(new Collection(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(), '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/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 2b6d378222..6a54b27c8d 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3,10 +3,19 @@ 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\Capability; +use Utopia\Database\Collection; 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,6 +23,7 @@ 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; @@ -21,303 +31,358 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; +use Utopia\Database\PDO as DatabasePDO; use Utopia\Database\Query; +use Utopia\Database\Relationship; +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; +use Utopia\Query\Schema\Order; 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(new Collection(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', size: 4, required: true, signed: false)); + $database->createAttribute($collection, Attribute::integer(key: 'bigint_signed', size: 8, required: true)); + $database->createAttribute($collection, Attribute::integer(key: 'bigint_unsigned', size: 9, 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->getAdapter()->getIdAttributeType() == ColumnType::Uuid7->value) { + $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(new Collection(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(new Collection(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', size: 8, 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 testBigintSequence(): 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)); + $database->createCollection(new Collection(id: __FUNCTION__)); - $sequence = '1000000'; - if ($database->getAdapter()->getIdAttributeType() == Database::VAR_UUID7) { - $sequence = '01890dd5-7331-7f3a-9c1b-123456789abc' ; + $sequence = 5_000_000_000_000_000; + if ($database->getAdapter()->getIdAttributeType() == ColumnType::Uuid7->value) { + $sequence = '01995753-881b-78cf-9506-2cffecf8f227'; } - $document = $database->createDocument('documents', new Document([ + $document = $database->createDocument(__FUNCTION__, new Document([ + '$sequence' => (string) $sequence, '$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()); + + $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->getAdapter()->getIdAttributeType() == ColumnType::Integer->value) { + $this->assertTrue($sequence === 5_000_000_000_000_000); + $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->getAdapter()->getIdAttributeType() == ColumnType::Uuid7->value) { + $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 +392,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,14 +407,13 @@ 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->getAdapter()->getIdAttributeType() == ColumnType::Uuid7->value) { + $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' => [ @@ -403,7 +467,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 +493,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 +507,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 +528,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 +552,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 +561,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 +579,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->getAdapter()->getIdAttributeType() == ColumnType::Uuid7->value) { $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 +617,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 +638,11 @@ public function testCreateDocuments(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection($collection); + $database->createCollection(new Collection(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->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'integer', required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'bigint', size: 8, required: true))); // Create an array of documents with random attributes. Don't use the createDocument function $documents = []; @@ -657,7 +688,7 @@ public function testCreateDocuments(): void } $documents = $database->find($collection, [ - Query::orderAsc() + Query::orderAsc(), ]); $this->assertEquals($count, \count($documents)); @@ -677,50 +708,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(new Collection(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)); + ], documentSecurity: false)); + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'name', size: 128))); - // 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()); + $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'); - [, $documentKey, $hashKey] = $database->getCacheKeys($collection, 'ghost'); - $cached = $cache->load($documentKey, Database::TTL, $hashKey); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); // Database::CACHE_EMPTY_MARKER - - // 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 +749,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(new Collection(id: $collection, permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ], documentSecurity: false); + ], 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' - ); + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'name', size: 128))); - // 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 = fn () => $this->assertTrue($database->getDocument($collection, 'ghost', [Query::select(['name'])])->isEmpty()); + $plain = fn () => $this->assertTrue($database->getDocument($collection, 'ghost')->isEmpty()); + + $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()); + $missing = fn () => $this->assertTrue($database->getCollection($collectionId)->isEmpty()); + $missing(); + $this->assertReadServedFromCache($database, $missing, 'A missing collection must be cached as absent like any other document'); - [, $metaKey, $metaHash] = $database->getCacheKeys(Database::METADATA, $collectionId); - $cached = $cache->load($metaKey, Database::TTL, $metaHash); - $this->assertIsArray($cached); - $this->assertArrayHasKey('$empty', $cached); - - // 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(new Collection(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()); + $this->assertFalse($fetched->isEmpty(), '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(new Collection(id: $collectionId)); $this->fail('Expected DuplicateException when recreating an existing collection'); } catch (DuplicateException) { // expected @@ -856,22 +838,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(new Collection(id: $collection)); + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'name', size: 128))); $database->createDocument($collection, new Document([ '$id' => 'secret', '$permissions' => [ @@ -882,23 +860,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()); - - [, $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' - ); + $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'); - // 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 +874,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 +888,19 @@ public function testCreateDocumentsWithAutoIncrement(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); + $database->createCollection(new Collection(id: __FUNCTION__)); - $this->assertEquals(true, $database->createAttribute(__FUNCTION__, 'string', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128, required: true))); /** @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->getAdapter()->getIdAttributeType() == ColumnType::Uuid7->value) { // 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 +921,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 +938,12 @@ public function testCreateDocumentsWithDifferentAttributes(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection($collection); + $database->createCollection(new Collection(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->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'integer'))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'bigint', size: 8))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string_default', size: 128, default: 'default'))); $documents = [ new Document([ @@ -1039,72 +1003,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(new Collection(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 +1078,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(new Collection(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', size: 8, required: true)); $documents = [ new Document([ @@ -1233,16 +1200,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(new Collection(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 +1244,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(new Collection(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 +1279,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(new Collection(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::text(key: 'text', size: Database::MAX_TEXT_BYTES)); $document = new Document([ '$id' => 'first', @@ -1350,14 +1317,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(new Collection(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128)); + $database->createAttribute(__FUNCTION__, Attribute::integer(key: 'integer')); $documents = [ new Document([ @@ -1422,13 +1390,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(new Collection(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'string', size: 128, required: true)); $document = new Document([ '$id' => 'first', @@ -1506,4609 +1475,6119 @@ 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(new Collection(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()) { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::QueryContains)) { $this->expectNotToPerformAssertions(); + return; } - $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::Fulltext)) { + $success = $database->createIndex($this->getMoviesCollection(), Index::fullText(key: 'name', attributes: ['name'])); + $this->assertEquals(true, $success); - $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::FulltextWildcard)) { + $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::Fulltext)) { $this->expectNotToPerformAssertions(); + return; } - $collectionName = 'preserve_sequence_upsert'; - - $database->createCollection($collectionName); + $collection = 'full_text'; + $database->createCollection(new Collection(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->assertTrue($database->createAttribute($collection, Attribute::string(key: 'ft', size: 128, required: true))); + $this->assertTrue($database->createIndex($collection, Index::fullText(key: 'ft-index', attributes: ['ft']))); - // 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::FulltextWildcard)) { + $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', - ]), - ]); - - $doc1Updated = $database->getDocument($collectionName, 'doc1'); - $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); - $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged - - // Test: With preserveSequence=true, $sequence from document should be used - $database->setPreserveSequence(true); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'ft' => 'donald trump', + ])); - $database->upsertDocuments($collectionName, [ - new Document([ - '$id' => 'doc2', - '$sequence' => $originalSeq2, // Keep original sequence - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - 'name' => 'Bob Updated', - ]), + $documents = $database->find($collection, [ + Query::search('ft', 'donald trump'), + Query::orderAsc('ft'), ]); + $this->assertEquals(2, count($documents)); - $doc2Updated = $database->getDocument($collectionName, 'doc2'); - $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); - $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved - - // Test: withPreserveSequence helper - $database->setPreserveSequence(false); - - $doc1 = $database->getDocument($collectionName, 'doc1'); - $currentSeq1 = $doc1->getSequence(); - - $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', - ]), - ]); - }); - - $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->getPreserveSequence()); - - // Test: With preserveSequence=true, invalid $sequence should throw error (SQL adapters only) - $database->setPreserveSequence(true); - - 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('ft', '"donald trump"'), // Exact match + ]); - $database->setPreserveSequence(false); - $database->deleteCollection($collectionName); + $this->assertEquals(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. + * 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 testUpsertSequencesOnMixedBatch(): void + public function testFindFulltextAccentedAndSpecialChars(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! $database->getAdapter()->supports(Capability::Fulltext)) { $this->expectNotToPerformAssertions(); return; } - $collectionName = 'upsert_mixed_sequences'; - - $database->createCollection($collectionName, permissions: [ + $collection = 'full_text_unicode'; + $database->createCollection(new Collection(id: $collection, permissions: [ Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - ]); + Permission::update(Role::users()), + ])); - if ($database->getAdapter()->getSupportForAttributes()) { - $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 128, true); - } + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'nombre', size: 128, required: true))); + $this->assertTrue($database->createIndex($collection, Index::fullText(key: 'nombre-ft', attributes: ['nombre']))); - $permissions = [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ]; + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'nombre' => 'Luis García' + ])); - $existingSequences = []; - foreach (['existing1', 'existing2'] as $id) { - $created = $database->createDocument($collectionName, new Document([ - '$id' => $id, - '$permissions' => $permissions, - 'name' => $id, - ])); + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'nombre' => 'Álvaro Yair Cuéllar' + ])); - $this->assertNotEmpty($created->getSequence()); - $existingSequences[$id] = $created->getSequence(); - } + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any())], + 'nombre' => 'Fernando naïve über' + ])); - // 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(); - } - ); + /** + * Accented characters must not cause FTS parser errors + */ + $documents = $database->find($collection, [ + Query::search('nombre', 'García'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); - $this->assertCount(4, $upserted); + $documents = $database->find($collection, [ + Query::search('nombre', 'Álvaro'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); - 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}" - ); - } + $documents = $database->find($collection, [ + Query::search('nombre', 'Cuéllar'), + ]); + $this->assertGreaterThanOrEqual(1, count($documents)); - // An upsert must not move a row that was already there. - $this->assertEquals($existingSequences['existing1'], $upserted['existing1']); - $this->assertEquals($existingSequences['existing2'], $upserted['existing2']); + /** + * 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)); - $database->deleteCollection($collectionName); + $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)); } - public function testRespectNulls(): Document + public function testFindByID(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('documents_nulls'); + /** + * $id condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('$id', ['frozen']), + ]); - $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)); + $this->assertEquals(1, count($documents)); + $this->assertEquals('Frozen', $documents[0]['name']); + } - $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')), - ], - ])); + public function testFindByInternalID(): void + { + $sequence = $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $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; + /** + * Test that internal ID queries are handled correctly + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('$sequence', [$sequence]), + ]); + + $this->assertEquals(1, count($documents)); } - public function testCreateDocumentDefaults(): void + public function testOrSingleQuery(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('defaults'); + try { + $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()); + } + } - $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'])); + public function testOrMultipleQueries(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $document = $database->createDocument('defaults', new Document([ - '$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]); + $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->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')); + $queries = [ + Query::equal('active', [true]), + Query::or([ + Query::equal('name', ['Frozen']), + Query::equal('name', ['Frozen II']), + Query::equal('director', ['Joe Johnston']), + ]), + ]; - // cleanup collection - $database->deleteCollection('defaults'); + $this->assertCount(3, $database->find($this->getMoviesCollection(), $queries)); + $this->assertEquals(3, $database->count($this->getMoviesCollection(), $queries)); } - /** - * 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 testOrNested(): 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()); + $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]), + ]), + ]), + ]; - // 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()); + $documents = $database->find($this->getMoviesCollection(), $queries); + $this->assertCount(1, $documents); + $this->assertArrayNotHasKey('name', $documents[0]); - // alice's permission rows were removed: as alice nothing is returned. - $this->assertCount(0, $database->find($collection)); + $count = $database->count($this->getMoviesCollection(), $queries); + $this->assertEquals(1, $count); + } - // 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()); + public function testAndSingleQuery(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $auth->skip(fn () => $database->deleteCollection($collection)); + 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()); } } - public function testIncreaseDecrease(): Document + public function testAndMultipleQueries(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'increase_decrease'; - $database->createCollection($collection); + $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)); + } - $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)); + public function testAndNested(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $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()), - ] - ])); + $queries = [ + Query::or([ + Query::equal('active', [false]), + Query::and([ + Query::equal('active', [true]), + Query::equal('name', ['Frozen']), + ]), + ]), + ]; - $updatedAt = $document->getUpdatedAt(); + $documents = $database->find($this->getMoviesCollection(), $queries); + $this->assertCount(3, $documents); - \usleep(2000); // Ensure $updatedAt differs when adapter timestamp precision is milliseconds + $count = $database->count($this->getMoviesCollection(), $queries); + $this->assertEquals(3, $count); + } - $doc = $database->increaseDocumentAttribute($collection, $document->getId(), 'increase', 1, 101); - $this->assertEquals(101, $doc->getAttribute('increase')); + public function testFindNull(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $document = $database->getDocument($collection, $document->getId()); - $this->assertEquals(101, $document->getAttribute('increase')); - $this->assertNotEquals($updatedAt, $document->getUpdatedAt()); + $documents = $database->find($this->getMoviesCollection(), [ + Query::isNull('nullable'), + ]); - $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')); + $this->assertEquals(5, count($documents)); + } - $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')); + public function testFindNotNull(): void + { + $this->initMoviesFixture(); + /** @var Database $database */ + $database = $this->getDatabase(); - $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')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::isNotNull('nullable'), + ]); - return $document; + $this->assertEquals(1, count($documents)); } - public function testCreateUpdateBigIntAndIncrementDecrement(): void + public function testFindStartsWith(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $collection = 'bigint_update_increase_decrease'; - $database->createCollection($collection); + $documents = $database->find($this->getMoviesCollection(), [ + Query::startsWith('name', 'Work'), + ]); - $this->assertEquals(true, $database->createAttribute($collection, 'inc', Database::VAR_BIGINT, 8, true)); - $this->assertEquals(true, $database->createAttribute($collection, 'dec', Database::VAR_BIGINT, 8, true)); + $this->assertEquals(2, count($documents)); - $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()), - ] - ])); + 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->assertIsInt($document->getAttribute('inc')); - $this->assertEquals(10, $document->getAttribute('inc')); + $this->assertEquals(0, count($documents)); + } - // 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 testFindStartsWithWords(): 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::startsWith('name', 'Work in Progress'), + ]); - $afterDec = $database->decreaseDocumentAttribute($collection, $document->getId(), 'dec', 3, 7); - $this->assertEquals(7, $afterDec->getAttribute('dec')); + $this->assertEquals(2, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseLimitMax(Document $document): void + public function testFindEndsWith(): 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::endsWith('name', 'Marvel'), + ]); + + $this->assertEquals(1, count($documents)); } - /** - * @depends testIncreaseDecrease - */ - public function testDecreaseLimitMin(Document $document): void + public function testFindNotContains(): 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); + if (! $database->getAdapter()->supports(Capability::QueryContains)) { + $this->expectNotToPerformAssertions(); + + return; } + // Test notContains with array attributes - should return documents that don't contain specified genres + $documents = $database->find($this->getMoviesCollection(), [ + Query::notContains('genres', ['comics']), + ]); + + $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']), + ]); + + $this->assertEquals(2, count($documents)); // Only 'Work in Progress' and 'Work in Progress 2' have neither 'comics' nor 'kids' + + // 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)); + + // 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' + + // 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->decreaseDocumentAttribute( - 'increase_decrease', - $document->getId(), - 'decrease', - 1000, - 0 - ); + $database->find($this->getMoviesCollection(), [ + Query::notContains('price', [10.5]), + ]); $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(LimitException::class, $e); + } 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 testIncreaseDecrease - */ - public function testIncreaseTextAttribute(Document $document): void + public function testFindNotSearch(): 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()); - } - } + // Only test if fulltext search is supported + if ($this->getDatabase()->getAdapter()->supports(Capability::Fulltext)) { + // 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; + } + } - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseArrayAttribute(Document $document): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test notSearch - should return documents that don't match the search term + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'captain'), + ]); - 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); - } - } + $this->assertEquals(4, count($documents)); // 6 readable movies (user:x role added earlier) minus 2 with 'captain' in name - /** - * @depends testIncreaseDecrease - */ - public function testIncreaseDecreasePreserveDates(Document $document): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test notSearch with term that doesn't exist - should return all readable documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'nonexistent'), + ]); - $database->setPreserveDates(true); + $this->assertEquals(6, count($documents)); - try { - $before = $database->getDocument('increase_decrease', $document->getId()); - $updatedAt = $before->getUpdatedAt(); - $increase = $before->getAttribute('increase'); - $decrease = $before->getAttribute('decrease'); + // Test notSearch with partial term + if ($this->getDatabase()->getAdapter()->supports(Capability::FulltextWildcard)) { + $documents = $database->find($this->getMoviesCollection(), [ + Query::notSearch('name', 'cap'), + ]); - $database->increaseDocumentAttribute('increase_decrease', $document->getId(), 'increase', 1); + $this->assertEquals(4, count($documents)); // 6 readable movies minus 2 matching 'cap*' + } - $after = $database->getDocument('increase_decrease', $document->getId()); - $this->assertSame($increase + 1, $after->getAttribute('increase')); - $this->assertSame($updatedAt, $after->getUpdatedAt()); + // 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 - $database->decreaseDocumentAttribute('increase_decrease', $document->getId(), 'decrease', 1); + // 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 - $after = $database->getDocument('increase_decrease', $document->getId()); - $this->assertSame($decrease - 1, $after->getAttribute('decrease')); - $this->assertSame($updatedAt, $after->getUpdatedAt()); - } finally { - $database->setPreserveDates(false); + // 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 testGetDocument(Document $document): Document + public function testFindNotStartsWith(): void { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->getDocument('documents', $document->getId()); + // Test notStartsWith - should return documents that don't start with 'Work' + $documents = $database->find($this->getMoviesCollection(), [ + Query::notStartsWith('name', 'Work'), + ]); - $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')); + $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' - return $document; + // Test notStartsWith with non-existent prefix - should return all documents + $documents = $database->find($this->getMoviesCollection(), [ + 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($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 } - /** - * @depends testCreateDocument - */ - public function testGetDocumentSelect(Document $document): Document + public function testFindNotEndsWith(): void { - $documentId = $document->getId(); - + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $document = $database->getDocument('documents', $documentId, [ - Query::select(['string', 'integer_signed']), + // Test notEndsWith - should return documents that don't end with 'Marvel' + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'Marvel'), ]); - $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(5, count($documents)); // All movies except the 1 ending with 'Marvel' - $document = $database->getDocument('documents', $documentId, [ - Query::select(['string', 'integer_signed', '$id']), + // Test notEndsWith with non-existent suffix - should return all documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('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 notEndsWith with partial suffix + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'vel'), + ]); + + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') + + // 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 + + // Test notEndsWith with single character + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEndsWith('name', 'l'), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' + + // 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 + + // 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 } - /** - * @return array - */ - public function testFind(): array - { - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + public function testFindOrderRandom(): void + { + $this->initMoviesFixture(); /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('movies', permissions: [ - Permission::create(Role::any()), - Permission::update(Role::users()) - ]); - - $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)); + if (! $database->getAdapter()->supports(Capability::OrderRandom)) { + $this->expectNotToPerformAssertions(); - 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); + return; } - $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' - ])); - - $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' - ])); - - $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' - ])); - - $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' - ])); - - $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' - ])); - - $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' - ])); - - return [ - '$sequence' => $document->getSequence() - ]; - } - - /** - * @depends testFind - */ - public function testFindOne(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $document = $database->findOne('movies', [ - Query::offset(2), - Query::orderAsc('name') + // Test orderRandom with default limit + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(1), ]); + $this->assertEquals(1, count($documents)); + $this->assertNotEmpty($documents[0]['name']); // Ensure we got a valid document - $this->assertFalse($document->isEmpty()); - $this->assertEquals('Frozen', $document->getAttribute('name')); - - $document = $database->findOne('movies', [ - Query::offset(10) + // Test orderRandom with multiple documents + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderRandom(), + Query::limit(3), ]); - $this->assertTrue($document->isEmpty()); - } - - public function testFindBasicChecks(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $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->getRead()); + $this->assertContains('guests', $new->getWrite()); + $this->assertContains('guests', $new->getCreate()); + $this->assertContains('guests', $new->getUpdate()); + $this->assertContains('guests', $new->getDelete()); - /** - * 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->getRead()); + $this->assertNotContains('guests', $new->getWrite()); + $this->assertNotContains('guests', $new->getCreate()); + $this->assertNotContains('guests', $new->getUpdate()); + $this->assertNotContains('guests', $new->getDelete()); - /** - * 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(new Collection( + 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', + ])); - public function testFindNotEqual(): void + $reloadedBystander = $database->getDocument($collection, 'bystander'); + $this->assertSame('untouched', $reloadedBystander->getAttribute('name')); + $this->assertSame($bystander->getSequence(), $reloadedBystander->getSequence()); + + $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 (! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $collection, attributes: [ + Attribute::string(key: 'string', size: 100, format: ''), + Attribute::integer(key: 'integer', size: 10000, format: ''), + 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, 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'), - ]); - - $this->assertEquals(2, count($documents)); - - /** - * 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)); - } + 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(true, true); // Test must do an assertion - } - public function testFindFulltextSpecialChars(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForFulltextIndex()) { - $this->expectNotToPerformAssertions(); - return; - } + // Check document level permissions + $database->updateCollection($collection, permissions: [], documentSecurity: true); - $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 (! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $collection = 'full_text_unicode'; - $database->createCollection($collection, permissions: [ + $collection = 'update_callback'; + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + + $database->createCollection(new Collection(id: $collection, attributes: [ + Attribute::string(key: 'string', size: 100, format: ''), + Attribute::integer(key: 'integer', size: 10000, format: ''), + ], permissions: [ + Permission::read(Role::any()), Permission::create(Role::any()), - Permission::update(Role::users()) - ]); + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: false)); - $this->assertTrue($database->createAttribute($collection, 'nombre', Database::VAR_STRING, 128, true)); - $this->assertTrue($database->createIndex($collection, 'nombre-ft', Database::INDEX_FULLTEXT, ['nombre'])); + 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()); + } - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'nombre' => 'Luis García' - ])); + // 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->assertEquals("Error thrown to test that update doesn't stop and error is caught", $e->getMessage()); + }); - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'nombre' => 'Álvaro Yair Cuéllar' - ])); + $this->assertEquals(5, $count); - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any())], - 'nombre' => 'Fernando naïve über' - ])); + foreach ($results as $document) { + $this->assertEquals('text📝 updated', $document->getAttribute('string')); + } - /** - * Accented characters must not cause FTS parser errors - */ - $documents = $database->find($collection, [ - Query::search('nombre', 'García'), + $updatedDocuments = $database->find($collection, [ + Query::greaterThanEqual('integer', 5), ]); - $this->assertGreaterThanOrEqual(1, count($documents)); - $documents = $database->find($collection, [ - Query::search('nombre', 'Álvaro'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + $this->assertCount(5, $updatedDocuments); + } - $documents = $database->find($collection, [ - Query::search('nombre', 'Cuéllar'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + public function testReadPermissionsSuccess(): void + { + $this->initDocumentsFixture(); + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - /** - * 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)); + /** @var Database $database */ + $database = $this->getDatabase(); - $documents = $database->find($collection, [ - Query::search('nombre', '$$$%%%^^^'), - ]); - $this->assertEquals(0, count($documents)); + $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'], + ])); - /** - * FTS operator-only input also must not error - */ - $documents = $database->find($collection, [ - Query::search('nombre', '+-*@<>~'), - ]); - $this->assertEquals(0, count($documents)); + $this->assertEquals(false, $document->isEmpty()); - /** - * Mixed special chars + accented word should still find results - */ - $documents = $database->find($collection, [ - Query::search('nombre', '@García!'), - ]); - $this->assertGreaterThanOrEqual(1, count($documents)); + $this->getDatabase()->getAuthorization()->cleanRoles(); + + $document = $database->getDocument($document->getCollection(), $document->getId()); + $this->assertEquals(true, $document->isEmpty()); + + $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); } - public function testFindMultipleConditions(): void + public function testWritePermissionsSuccess(): void { + $this->initDocumentsFixture(); + $this->getDatabase()->getAuthorization()->cleanRoles(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * Multiple conditions - */ - $documents = $database->find('movies', [ - Query::equal('director', ['TBD']), - Query::equal('year', [2026]), - ]); - - $this->assertEquals(1, count($documents)); - - /** - * Multiple conditions and OR values - */ - $documents = $database->find('movies', [ - 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']); + $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'], + ])); } - public function testFindByID(): 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(); - /** - * $id condition - */ - $documents = $database->find('movies', [ - Query::equal('$id', ['frozen']), - ]); + $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)); - $this->assertEquals('Frozen', $documents[0]['name']); } - /** - * @depends testFind - * @param array $data - * @return void - * @throws \Utopia\Database\Exception - */ - public function testFindByInternalID(array $data): void + + public function testUniqueIndexDuplicate(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - /** - * Test that internal ID queries are handled correctly - */ - $documents = $database->find('movies', [ - Query::equal('$sequence', [$data['$sequence']]), - ]); + $this->assertEquals(true, $database->createIndex($this->getMoviesCollection(), Index::unique(key: 'uniqueIndex', attributes: ['name'], lengths: [128], orders: [Order::Asc]))); - $this->assertEquals(1, count($documents)); + 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 testFindOrderBy(): void + public function testUniqueIndexDuplicateUpdate(): 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') - ]); + // Ensure the unique index exists (created in testUniqueIndexDuplicate) + try { + $database->createIndex($this->getMoviesCollection(), Index::unique(key: 'uniqueIndex', attributes: ['name'], lengths: [128], orders: [Order::Asc])); + } catch (\Throwable) { + // Index may already exist + } - $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']); + $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', + ])); + + 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 testFindOrderByNatural(): void + + public function propagateBulkDocuments(string $collection, int $amount = 10, bool $documentSecurity = false): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY natural - */ - $base = array_reverse($database->find('movies', [ - Query::limit(25), - Query::offset(0), - ])); - $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']); + 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 testFindOrderByMultipleAttributes(): void + + public function testFulltextIndexWithInteger(): void { + $this->initDocumentsFixture(); + /** @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') - ]); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectException(Exception::class); + if (! $this->getDatabase()->getAdapter()->supports(Capability::Fulltext)) { + $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'); + } - $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']); + $database->createIndex($this->getDocumentsCollection(), Index::fullText(key: 'fulltext_integer', attributes: ['string', 'integer_signed'])); + } else { + $this->expectNotToPerformAssertions(); + + return; + } } - public function testFindOrderByCursorAfter(): void + public function testEnableDisableValidation(): void { - /** @var Database $database */ $database = $this->getDatabase(); - /** - * ORDER BY - After - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - ]); + $database->createCollection(new Collection(id: 'validation', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - $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->createAttribute('validation', Attribute::string(key: 'name', size: 10)); - $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']); + $database->createDocument('validation', new Document([ + '$id' => 'docwithmorethan36charsasitsidentifier', + 'name' => 'value1', + ])); - $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']); + try { + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertNotSame('', $e->getMessage()); + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); + $database->disableValidation(); - /** - * Multiple order by, Test tie-break on year 2019 - */ - $movies = $database->find('movies', [ - Query::orderAsc('year'), - Query::orderAsc('price'), + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), ]); - $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); + $database->enableValidation(); - $this->assertEquals($movies[1]['name'], 'Frozen'); - $this->assertEquals($movies[1]['year'], 2013); - $this->assertEquals($movies[1]['price'], 39.5); + 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[2]['name'], 'Captain Marvel'); - $this->assertEquals($movies[2]['year'], 2019); - $this->assertEquals($movies[2]['price'], 25.99); + $database->skipValidation(function () use ($database) { + $database->find('validation', queries: [ + Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), + ]); + }); - $this->assertEquals($movies[3]['name'], 'Frozen II'); - $this->assertEquals($movies[3]['year'], 2019); - $this->assertEquals($movies[3]['price'], 39.5); + $database->enableValidation(); + } - $this->assertEquals($movies[4]['name'], 'Work in Progress'); - $this->assertEquals($movies[4]['year'], 2025); - $this->assertEquals($movies[4]['price'], 0); + public function testExceptionDuplicate(): void + { + $document = $this->initDocumentsFixture(); - $this->assertEquals($movies[5]['name'], 'Work in Progress 2'); - $this->assertEquals($movies[5]['year'], 2026); - $this->assertEquals($movies[5]['price'], 0); + /** @var Database $database */ + $database = $this->getDatabase(); - $pos = 2; - $documents = $database->find('movies', [ - Query::orderAsc('year'), - Query::orderAsc('price'), - Query::cursorAfter($movies[$pos]) - ]); + $document->setAttribute('$id', 'duplicated'); + $document->removeAttribute('$sequence'); - $this->assertEquals(3, count($documents)); + $database->createDocument($document->getCollection(), $document); + $document->removeAttribute('$sequence'); - 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->createDocument($document->getCollection(), $document); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); } } - - public function testFindOrderByCursorBefore(): void + public function testExceptionCaseInsensitiveDuplicate(): 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']); + $document->setAttribute('$id', 'caseSensitive'); + $document->removeAttribute('$sequence'); - $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']); + $database->createDocument($document->getCollection(), $document); - $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']); + $document->setAttribute('$id', 'CaseSensitive'); + $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 testEmptyTenant(): void { + $this->initDocumentsFixture(); + /** @var Database $database */ $database = $this->getDatabase(); + if ($database->getAdapter()->getSharedTables()) { + $documents = $database->find( + $this->getDocumentsCollection(), + [Query::select(['*'])] // Mongo bug with Integer UID + ); + + $document = $documents[0]; + $doc = $database->getDocument($document->getCollection(), $document->getId()); + $this->assertEquals($document->getTenant(), $doc->getTenant()); + + return; + } + + $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', + ])); + + $this->assertArrayHasKey('$id', $doc); + $this->assertArrayNotHasKey('$tenant', $doc); + + $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 testDateTimeDocument(): void + { /** - * ORDER BY - After by natural order + * @var Database $database */ - $movies = array_reverse($database->find('movies', [ - Query::limit(25), - Query::offset(0), + $database = $this->getDatabase(); + $collection = 'create_modify_dates'; + $database->createCollection(new Collection(id: $collection)); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::datetime(key: 'datetime', filters: ['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')); + + $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, ])); - $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']); + $this->assertEquals($doc->getAttribute('$createdAt'), $date); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); - $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']); + $doc = $database->getDocument($collection, 'doc2'); - $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']); + $this->assertEquals($doc->getAttribute('$createdAt'), $date); + $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); + $database->setPreserveDates(false); + $database->deleteCollection($collection); } - public function testFindOrderByBeforeNaturalOrder(): void + + public function testUpsertDateOperations(): void { /** @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()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); - $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']); + return; + } - $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']); + $collection = 'upsert_date_operations'; + $database->createCollection(new Collection(id: $collection)); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))); - $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']); + $database->setPreserveDates(true); - $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']); + $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())]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc(''), - Query::cursorBefore($movies[0]) - ]); - $this->assertEmpty(count($documents)); - } + // 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]; - public function testFindOrderBySingleAttributeAfter(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals($createDate, $upsertDoc1->getAttribute('$createdAt')); + $this->assertNotEquals($createDate, $upsertDoc1->getAttribute('$updatedAt')); - /** - * ORDER BY - Single Attribute After - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('year') - ]); + // 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('year'), - Query::cursorAfter($movies[1]) - ]); + $this->assertEquals($createDate, $updatedUpsertDoc1->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $updatedUpsertDoc1->getAttribute('$updatedAt')); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['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('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, $upsertDoc2->getAttribute('$createdAt')); + $this->assertEquals($updateDate, $upsertDoc2->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']); + // 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]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorAfter($movies[5]) - ]); - $this->assertEmpty(count($documents)); - } + $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$createdAt')); + $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$updatedAt')); + // Test 5: Upsert with preserve dates disabled + $database->setPreserveDates(false); - public function testFindOrderBySingleAttributeBefore(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $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]; - /** - * ORDER BY - Single Attribute Before - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('year') - ]); + $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$updatedAt')); - $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']); + // 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('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']); + $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$updatedAt')); - $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']); + // Test 6: Bulk upsert operations with custom dates + $database->setPreserveDates(true); - $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']); + // 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', + ]), + ]; - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('year'), - Query::cursorBefore($movies[0]) - ]); - $this->assertEmpty(count($documents)); - } + $bulkUpsertResults = []; + $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { + $bulkUpsertResults[] = $doc; + }); - public function testFindOrderByMultipleAttributeAfter(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // 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"); + } - /** - * ORDER BY - Multiple Attribute After - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year') - ]); - - $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']); - - $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']); + foreach (['bulk_upsert2', 'bulk_upsert3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + } - $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']); + 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"); + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorAfter($movies[5]) + // 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, ]); - $this->assertEmpty(count($documents)); - } - - public function testFindOrderByMultipleAttributeBefore(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - /** - * ORDER BY - Multiple Attribute Before - */ - $movies = $database->find('movies', [ - Query::limit(25), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year') - ]); + $upsertIds = []; + foreach ($upsertDocuments as $doc) { + $upsertIds[] = $doc->getId(); + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[5]) + $database->updateDocuments($collection, $updateUpsertDoc, [ + Query::equal('$id', $upsertIds), ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[3]['name'], $documents[0]['name']); - $this->assertEquals($movies[4]['name'], $documents[1]['name']); + 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"); + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[4]) + // Test 10: checking by passing null to each + $updateUpsertDoc = new Document([ + 'string' => 'bulk_upsert_updated', + '$createdAt' => null, + '$updatedAt' => null, ]); - $this->assertEquals(2, count($documents)); - $this->assertEquals($movies[2]['name'], $documents[0]['name']); - $this->assertEquals($movies[3]['name'], $documents[1]['name']); - $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']); + $upsertIds = []; + foreach ($upsertDocuments as $doc) { + $upsertIds[] = $doc->getId(); + } - $documents = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('price'), - Query::orderAsc('year'), - Query::cursorBefore($movies[1]) + $database->updateDocuments($collection, $updateUpsertDoc, [ + Query::equal('$id', $upsertIds), ]); - $this->assertEquals(1, count($documents)); - $this->assertEquals($movies[0]['name'], $documents[0]['name']); - $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(); + 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"); + } - /** - * 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 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($documentsTest[1]['$id'], $documents[0]['$id']); - } - public function testFindOrderByIdAndCursor(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $upsertUpdateResults = []; + $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { + $upsertUpdateResults[] = $doc; + }); + $this->assertEquals(4, $countUpsertUpdate); - /** - * 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]) - ]); + 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'); + } - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); - } + // Test 12: Bulk upsert with preserve dates disabled + $database->setPreserveDates(false); - public function testFindOrderByCreateDateAndCursor(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $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; + } - /** - * ORDER BY CREATE DATE + CURSOR - */ - $documentsTest = $database->find('movies', [ - Query::limit(2), - Query::offset(0), - Query::orderDesc('$createdAt'), - ]); + $upsertDisabledResults = []; + $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { + $upsertDisabledResults[] = $doc; + }); + $this->assertEquals(4, $countUpsertDisabled); - $documents = $database->find('movies', [ - Query::limit(1), - Query::offset(0), - Query::orderDesc('$createdAt'), - Query::cursorAfter($documentsTest[0]) - ]); + 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'); + } - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + $database->setPreserveDates(false); + $database->deleteCollection($collection); } - public function testFindOrderByUpdateDateAndCursor(): void + public function testUpdateDocumentsCount(): void { /** @var Database $database */ $database = $this->getDatabase(); - /** - * 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]) - ]); + if (! ($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); - $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); - } + return; + } - /** - * 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(); + $collectionName = 'update_count'; + $database->createCollection(new Collection(id: $collectionName)); - $database->createCollection('tieBreak', permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - ], documentSecurity: false); + $database->createAttribute($collectionName, Attribute::string(key: 'key', size: 60)); + $database->createAttribute($collectionName, Attribute::string(key: 'value', size: 60)); - $this->assertEquals(true, $database->createAttribute('tieBreak', 'name', Database::VAR_STRING, 128, true)); + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any())]; - $documents = []; - foreach (['alpha', 'bravo', 'charlie'] as $name) { - $documents[] = new Document([ - 'name' => $name, - '$permissions' => [Permission::read(Role::any())], - ]); - } - - $this->assertEquals(3, $database->createDocuments('tieBreak', $documents)); - - $created = $database->find('tieBreak', [Query::orderAsc('$sequence')]); - $this->assertCount(3, $created); - - // The batch shares one timestamp, otherwise the tie break is never consulted. - $this->assertEquals($created[0]->getCreatedAt(), $created[2]->getCreatedAt()); + $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); - $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) - ); + $updates = new Document(['value' => 'test']); + $newDocs = []; + $count = $database->updateDocuments($collectionName, $updates, onNext: function ($doc) use (&$newDocs) { + $newDocs[] = $doc; + }); - $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) - ); + $this->assertCount(4, $newDocs); + $this->assertEquals(4, $count); - // 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) - ); + $database->deleteCollection($collectionName); } - public function testFindCreatedBefore(): void + public function testUpsertWithJSONFilters(): void { - /** @var Database $database */ - $database = $this->getDatabase(); + $database = static::getDatabase(); - /** - * Test Query::createdBefore 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::createdBefore($futureDate), - Query::limit(1) - ]); + return; + } - $this->assertGreaterThan(0, count($documents)); + // Create collection with JSON filter attribute + $collection = ID::unique(); + $database->createCollection(new Collection(id: $collection, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - $documents = $database->find('movies', [ - Query::createdBefore($pastDate), - 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: ['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 testFindCreatedAfter(): 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::createdAfter wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; + $document1 = $database->createDocument($collection, new Document([ + '$id' => $docId1, + 'name' => 'Initial Document', + 'metadata' => $initialMetadata, + '$permissions' => $permissions, + ])); - $documents = $database->find('movies', [ - Query::createdAfter($pastDate), - Query::limit(1) - ]); + $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, + ]; - $documents = $database->find('movies', [ - Query::createdAfter($futureDate), - Query::limit(1) - ]); + $document1->setAttribute('name', 'Updated Document'); + $document1->setAttribute('metadata', $updatedMetadata); - $this->assertEquals(0, count($documents)); - } + $updatedDoc = $database->updateDocument($collection, $docId1, $document1); - public function testFindUpdatedBefore(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $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']); - /** - * Test Query::updatedBefore wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; + // Test 3: Upsert - Create new document (upsertDocument) + $docId2 = 'json-doc-2'; + $newMetadata = [ + 'version' => '1.5.0', + 'tags' => ['javascript', 'node'], + 'config' => [ + 'debug' => false, + 'timeout' => 45, + ], + ]; - $documents = $database->find('movies', [ - Query::updatedBefore($futureDate), - Query::limit(1) + $document2 = new Document([ + '$id' => $docId2, + 'name' => 'New Upsert Document', + 'metadata' => $newMetadata, + '$permissions' => $permissions, ]); - $this->assertGreaterThan(0, count($documents)); + $upsertedDoc = $database->upsertDocument($collection, $document2); - $documents = $database->find('movies', [ - Query::updatedBefore($pastDate), - Query::limit(1) + $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']); + + // 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); - public function testFindUpdatedAfter(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $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']); - /** - * Test Query::updatedAfter wrapper - */ - $futureDate = '2050-01-01T00:00:00.000Z'; - $pastDate = '1900-01-01T00:00:00.000Z'; + // Test 5: Upsert - Bulk upsertDocuments (create and update) + $docId3 = 'json-doc-3'; + $docId4 = 'json-doc-4'; - $documents = $database->find('movies', [ - Query::updatedAfter($pastDate), - Query::limit(1) - ]); + $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->assertGreaterThan(0, count($documents)); + $count = $database->upsertDocuments($collection, $bulkDocuments); + $this->assertEquals(3, $count); - $documents = $database->find('movies', [ - Query::updatedAfter($futureDate), - Query::limit(1) - ]); + // 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']); - $this->assertEquals(0, count($documents)); - } + $bulkDoc2 = $database->getDocument($collection, $docId4); + $this->assertEquals('Bulk Upsert 2', $bulkDoc2->getAttribute('name')); + $this->assertEquals('3.1.0', $bulkDoc2->getArray('metadata')['version']); - public function testFindCreatedBetween(): void + $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']); + + // Cleanup + $database->deleteCollection($collection); + } + + public function testFindRegex(): void { /** @var Database $database */ - $database = $this->getDatabase(); + $database = static::getDatabase(); - /** - * 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'; + // Skip test if regex is not supported + if (! $database->getAdapter()->supports(Capability::Regex)) { + $this->expectNotToPerformAssertions(); - // All documents should be between past and future - $documents = $database->find('movies', [ - Query::createdBetween($pastDate, $futureDate), - Query::limit(25) - ]); + return; + } - $this->assertGreaterThan(0, count($documents)); + // Determine regex support type + $supportsPCRE = $database->getAdapter()->supports(Capability::PCRE); + $supportsPOSIX = $database->getAdapter()->supports(Capability::POSIX); - // No documents should exist in this range - $documents = $database->find('movies', [ - Query::createdBetween($pastDate, $pastDate), - Query::limit(25) - ]); + // 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 + } - $this->assertEquals(0, count($documents)); + $database->createCollection(new Collection(id: 'moviesRegex', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); - // Documents created between recent past and near future - $documents = $database->find('movies', [ - Query::createdBetween($recentPastDate, $nearFutureDate), - Query::limit(25) - ]); + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertEquals(true, $database->createAttribute('moviesRegex', Attribute::string(key: 'name', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute('moviesRegex', Attribute::string(key: 'director', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute('moviesRegex', Attribute::integer(key: 'year', required: true))); + } - $count = count($documents); + if ($database->getAdapter()->supports(Capability::TrigramIndex)) { + $database->createIndex('moviesRegex', Index::trigram(key: 'trigram_name', attributes: ['name'])); + $database->createIndex('moviesRegex', Index::trigram(key: 'trigram_director', attributes: ['director'])); + } - // Same count should be returned with expanded range - $documents = $database->find('movies', [ - Query::createdBetween($pastDate, $nearFutureDate), - Query::limit(25) + // 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, + ]), ]); - $this->assertGreaterThanOrEqual($count, count($documents)); - } - - public function testFindUpdatedBetween(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - + // Helper function to verify regex query completeness /** - * Test Query::updatedBetween wrapper + * @param list $queryResults */ - $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'; + $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).'/'; - // All documents should be between past and future - $documents = $database->find('movies', [ - Query::updatedBetween($pastDate, $futureDate), - Query::limit(25) - ]); + // Get all documents to manually verify + $allDocuments = $database->find('moviesRegex'); - $this->assertGreaterThan(0, count($documents)); + // 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(); + } + } - // No documents should exist in this range - $documents = $database->find('movies', [ - Query::updatedBetween($pastDate, $pastDate), - Query::limit(25) - ]); + $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}'" + ); + } - $this->assertEquals(0, count($documents)); + // 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 updated between recent past and near future - $documents = $database->find('movies', [ - Query::updatedBetween($recentPastDate, $nearFutureDate), - Query::limit(25) + // 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 = count($documents); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '^Captain', $documents); - // Same count should be returned with expanded range - $documents = $database->find('movies', [ - Query::updatedBetween($pastDate, $nearFutureDate), - Query::limit(25) - ]); + // 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)); - $this->assertGreaterThanOrEqual($count, count($documents)); - } + // Test regex pattern - match movies containing 'Frozen' + $pattern = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Frozen'), + ]); - public function testFindLimit(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Frozen', $documents); - /** - * Limit - */ - $documents = $database->find('movies', [ - Query::limit(4), - Query::offset(0), - Query::orderAsc('name') + // 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)); - $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 regex pattern - match movies ending with 'Marvel' + $pattern = '/Marvel$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Marvel$'), + ]); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Marvel$', $documents); - public function testFindLimitAndOffset(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals(1, count($documents)); // Only Captain Marvel + $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); - /** - * Limit + Offset - */ - $documents = $database->find('movies', [ - Query::limit(4), - Query::offset(2), - Query::orderAsc('name') + // Test regex pattern - match movies with 'Work' in the name + $pattern = '/.*Work.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*Work.*'), ]); - $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']); - } + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*Work.*', $documents); - public function testFindOrQueries(): 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('Work in Progress', $names)); + $this->assertTrue(in_array('Work in Progress 2', $names)); - /** - * Test that OR queries are handled correctly - */ - $documents = $database->find('movies', [ - Query::equal('director', ['TBD', 'Joe Johnston']), - Query::equal('year', [2025]), + // Test regex pattern - match movies with 'Buck' in director + $pattern = '/.*Buck.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('director', '.*Buck.*'), ]); - $this->assertEquals(1, count($documents)); - } - - /** - * @depends testUpdateDocument - */ - public function testFindEdgeCases(Document $document): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - $collection = 'edgeCases'; + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('director', '.*Buck.*', $documents); - $database->createCollection($collection); + // 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(true, $database->createAttribute($collection, 'value', Database::VAR_STRING, 256, true)); + // 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 + ]); - $values = [ - 'NormalString', - '{"type":"json","somekey":"someval"}', - '{NormalStringInBraces}', - '"NormalStringInDoubleQuotes"', - '{"NormalStringInDoubleQuotesAndBraces"}', - "'NormalStringInSingleQuotes'", - "{'NormalStringInSingleQuotesAndBraces'}", - "SingleQuote'InMiddle", - 'DoubleQuote"InMiddle', - 'Slash/InMiddle', - 'Backslash\InMiddle', - 'Colon:InMiddle', - '"quoted":"colon"' - ]; + // 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)" + ); + } - 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 - ])); + // 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(); + } } - /** - * Check Basic - */ - $documents = $database->find($collection); + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($actualMatches); - $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')); + // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) + // Check which one matches the actual results + sort($expectedMatchesCaseSensitive); + sort($expectedMatchesCaseInsensitive); - /** - * Check `equals` query - */ - foreach ($values as $value) { - $documents = $database->find($collection, [ - Query::limit(25), - Query::equal('value', [$value]) - ]); + // Verify that actual results match either case-sensitive or case-insensitive expectations + $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); + $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); - $this->assertEquals(1, count($documents)); - $this->assertEquals($value, $documents[0]->getAttribute('value')); - } - } + $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.' + ); - public function testOrSingleQuery(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // 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 + ]); - 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()); + // 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 testOrMultipleQueries(): 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'" + ); - $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)); + // Test regex combined with other queries + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + Query::greaterThan('year', 2010), + ]); - $queries = [ - Query::equal('active', [true]), - Query::or([ - Query::equal('name', ['Frozen']), - Query::equal('name', ['Frozen II']), - Query::equal('director', ['Joe Johnston']) - ]) - ]; + // 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->assertCount(3, $database->find('movies', $queries)); - $this->assertEquals(3, $database->count('movies', $queries)); - } + // 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" + ); - public function testOrNested(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test regex with limit + $pattern = '/.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*'), // Match all + Query::limit(3), + ]); - $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]), - ]) - ]) - ]; + $this->assertEquals(3, count($documents)); - $documents = $database->find('movies', $queries); - $this->assertCount(1, $documents); - $this->assertArrayNotHasKey('name', $documents[0]); + // 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}'" + ); + } - $count = $database->count('movies', $queries); - $this->assertEquals(1, $count); - } + // Note: With limit, we can't verify completeness, but we can verify all returned match - public function testAndSingleQuery(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Test regex with non-matching pattern + $pattern = '/^NonExistentPattern$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^NonExistentPattern$'), + ]); - 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()); + $this->assertEquals(0, count($documents)); + + // 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}'"); - public function testAndMultipleQueries(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness: no documents should be returned + $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $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 with special characters (should be escaped or handled properly) + $pattern = '/.*:.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*:.*'), // Match movies with colon + ]); - public function testAndNested(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*:.*', $documents); - $queries = [ + // 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'); + + // 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), + ]); + + // 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}'" + ); + } + + // 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 regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' + $pattern1 = '/Captain/'; + $pattern2 = '/Frozen/'; + $documents = $database->find('moviesRegex', [ Query::or([ - Query::equal('active', [false]), - Query::and([ - Query::equal('active', [true]), - Query::equal('name', ['Frozen']), - ]) - ]) - ]; + Query::regex('name', 'Captain'), + Query::regex('name', 'Frozen'), + ]), + ]); - $documents = $database->find('movies', $queries); - $this->assertCount(3, $documents); + // 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'" + ); + } - $count = $database->count('movies', $queries); - $this->assertEquals(3, $count); + // 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 testNestedIDQueries(): void + public function testRegexInjection(): void { /** @var Database $database */ - $database = $this->getDatabase(); - - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $database = static::getDatabase(); - $database->createCollection('movies_nested_id', permissions: [ - Permission::create(Role::any()), - Permission::update(Role::users()) - ]); + // Skip test if regex is not supported + if (! $database->getAdapter()->supports(Capability::Regex)) { + $this->expectNotToPerformAssertions(); - $this->assertEquals(true, $database->createAttribute('movies_nested_id', 'name', Database::VAR_STRING, 128, true)); + return; + } - $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', + $collectionName = 'injectionTest'; + $database->createCollection(new Collection(id: $collectionName, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), ])); - $database->createDocument('movies_nested_id', new Document([ - '$id' => ID::custom('2'), + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::string(key: 'text', size: 1000, required: 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()), ], - 'name' => '2', + 'text' => 'target', ])); - $database->createDocument('movies_nested_id', new Document([ - '$id' => ID::custom('3'), + $database->createDocument($collectionName, new Document([ '$permissions' => [ Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ], - 'name' => '3', + 'text' => 'other', ])); - $queries = [ - Query::or([ - Query::equal('$id', ["1"]), - Query::equal('$id', ["2"]) - ]) + // 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 ]; - $documents = $database->find('movies_nested_id', $queries); - $this->assertCount(2, $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 + ]; - // Make sure the query was not modified by reference - $this->assertEquals($queries[0]->getValues()[0]->getAttribute(), '$id'); + $allInjectionPatterns = array_merge($sqlInjectionPatterns, $mongoInjectionPatterns); - $count = $database->count('movies_nested_id', $queries); - $this->assertEquals(2, $count); - } + foreach ($allInjectionPatterns as $pattern) { + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); - public function testFindNull(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // 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; - $documents = $database->find('movies', [ - Query::isNull('nullable'), - ]); + // 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." + ); + } + } + } - $this->assertEquals(5, count($documents)); - } + // 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); - public function testFindNotNull(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // If pattern is invalid, skip validation + if ($matches === false) { + continue; + } - $documents = $database->find('movies', [ - Query::isNotNull('nullable'), - ]); + // 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." + ); + } + } - $this->assertEquals(1, count($documents)); + } catch (\Exception $e) { + $this->assertNotSame('', $e->getMessage()); + } + } + + // Test that legitimate regex patterns still work correctly + $legitimatePatterns = [ + 'target', // Should match "target" + '^target', // Should match "target" (anchored) + 'other', // Should match "other" + ]; + + foreach ($legitimatePatterns as $pattern) { + try { + $results = $database->find($collectionName, [ + Query::regex('text', $pattern), + ]); + + // 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()); + } + } + + // 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()->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 testNonUtfChars(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportNonUtfCharacters()) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title', size: 128, required: true))); + + $nonUtfString = "Hello\x00World\xC3\x28\xFF\xFE\xA0Test\x00End"; + + try { + $database->createDocument(__FUNCTION__, new Document([ + 'title' => $nonUtfString, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertTrue($e instanceof CharacterException); + } + + /** + * Convert to UTF-8 and replace invalid bytes with empty string + */ + $nonUtfString = mb_convert_encoding($nonUtfString, 'UTF-8', 'UTF-8'); + + /** + * Remove null bytes + */ + $nonUtfString = str_replace("\0", '', $nonUtfString); + + $document = $database->createDocument(__FUNCTION__, new Document([ + 'title' => $nonUtfString, + ])); + + $this->assertFalse($document->isEmpty()); + $this->assertEquals('HelloWorld?(???TestEnd', $document->getAttribute('title')); + } + + public function testCreateDocumentNumericalId(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(new Collection(id: 'numericalIds')); + + $this->assertEquals(true, $database->createAttribute('numericalIds', Attribute::string(key: 'name', size: 128, required: 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->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 testSkipPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::integer(key: 'number')); + + $data = []; + for ($i = 1; $i <= 10; $i++) { + $data[] = [ + '$id' => "$i", + 'number' => $i, + ]; + } + + $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, + ]; + + $documents = array_map(fn ($d) => new Document($d), $data); + + $this->getDatabase()->getAuthorization()->disable(); + + $results = []; + $count = $database->upsertDocuments( + __FUNCTION__, + $documents, + onNext: function ($doc) use (&$results) { + $results[] = $doc; + } + ); + + $this->getDatabase()->getAuthorization()->reset(); + + $this->assertEquals(2, \count($results)); + $this->assertEquals(2, $count); + + foreach ($results as $result) { + $this->assertArrayHasKey('$permissions', $result); + $this->assertEquals([], $result->getAttribute('$permissions')); + } + } + + public function testUpsertDocumentsAttributeMismatch(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $database->createCollection(new Collection(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)); + + $existingDocument = $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'first', + 'first' => 'first', + 'last' => 'last', + ])); + + $newDocument = new Document([ + '$id' => 'second', + 'first' => 'second', + ]); + + // Ensure missing optionals on new document is allowed + $docs = $database->upsertDocuments(__FUNCTION__, [ + $existingDocument->setAttribute('first', 'updated'), + $newDocument, + ]); + + $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')); + + try { + $database->upsertDocuments(__FUNCTION__, [ + $existingDocument->removeAttribute('first'), + $newDocument + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + 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') + ]); + + $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')); + + // 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(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')); + + $doc3 = new Document([ + '$id' => 'third', + 'last' => 'last', + 'first' => 'third', + ]); + + $doc4 = new Document([ + '$id' => 'fourth', + 'first' => 'fourth', + 'last' => 'last', + ]); + + // Ensure mismatch of attribute orders is allowed + $docs = $database->upsertDocuments(__FUNCTION__, [ + $doc3, + $doc4 + ]); + + $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(new Collection(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); + + // No changes, should return 0 + $count = $this->getDatabase()->upsertDocuments(__FUNCTION__, [$document]); + $this->assertEquals(0, $count); + } + + public function testUpsertDocumentsReadsStoredRowsInOneBatch(): void + { + $shared = $this->getDatabase(); + $adapter = $shared->getAdapter(); + + if (! $adapter->hasFeature(Feature\Upserts::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + 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::upsertDocumentsWithIncrease, which every adapter runs, + // so the SQL lanes cover the regression for all of them. + $this->markTestSkipped($adapter::class.' issues no countable statements.'); + } + + // 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()); + + $collection = 'upsert_batch_read'; + $database->createCollection(new Collection(id: $collection)); + $database->createAttribute($collection, Attribute::integer(key: 'value', required: true)); + + $documents = []; + for ($index = 0; $index < 20; $index++) { + $documents[] = new Document([ + '$id' => 'document'.$index, + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'value' => $index, + ]); + } + + $this->assertEquals(count($documents), $database->upsertDocuments($collection, $documents)); + + foreach ($documents as $index => $document) { + $document->setAttribute('value', $index + 100); + } + + $profiler = $database->enableProfiling()->getProfiler(); + $this->assertNotNull($profiler); + + try { + $profiler->reset(); + $updated = $database->upsertDocuments($collection, $documents); + } finally { + $database->disableProfiling(); + } + + $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(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, + ), + ); + + foreach ($documents as $index => $document) { + $this->assertEquals($index + 100, $database->getDocument($collection, $document->getId())->getAttribute('value')); + } + } + + public function testUpsertDuplicateIds(): void + { + $db = $this->getDatabase(); + if (!($db->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $db->createCollection(new Collection(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 testPreserveSequenceUpsert(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!($database->getAdapter()->hasFeature(Feature\Upserts::class))) { + $this->expectNotToPerformAssertions(); + return; + } + + $collectionName = 'preserve_sequence_upsert'; + + $database->createCollection(new Collection(id: $collectionName)); + + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $database->createAttribute($collectionName, Attribute::string(key: 'name', size: 128, required: true)); + } + + // Create initial documents + $doc1 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Alice', + ])); + + $doc2 = $database->createDocument($collectionName, new Document([ + '$id' => 'doc2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob', + ])); + + $originalSeq1 = $doc1->getSequence(); + $originalSeq2 = $doc2->getSequence(); + + $this->assertNotEmpty($originalSeq1); + $this->assertNotEmpty($originalSeq2); + + // Test: Without preserveSequence (default), $sequence should be ignored + $database->setPreserveSequence(false); + + $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', + ]), + ]); + + $doc1Updated = $database->getDocument($collectionName, 'doc1'); + $this->assertEquals('Alice Updated', $doc1Updated->getAttribute('name')); + $this->assertEquals($originalSeq1, $doc1Updated->getSequence()); // Sequence unchanged + + // Test: With preserveSequence=true, $sequence from document should be used + $database->setPreserveSequence(true); + + $database->upsertDocuments($collectionName, [ + new Document([ + '$id' => 'doc2', + '$sequence' => $originalSeq2, // Keep original sequence + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'name' => 'Bob Updated', + ]), + ]); + + $doc2Updated = $database->getDocument($collectionName, 'doc2'); + $this->assertEquals('Bob Updated', $doc2Updated->getAttribute('name')); + $this->assertEquals($originalSeq2, $doc2Updated->getSequence()); // Sequence preserved + + // Test: withPreserveSequence helper + $database->setPreserveSequence(false); + + $doc1 = $database->getDocument($collectionName, 'doc1'); + $currentSeq1 = $doc1->getSequence(); + + $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', + ]), + ]); + }); + + $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->getPreserveSequence()); + + // Test: With preserveSequence=true, invalid $sequence should throw error (SQL adapters only) + $database->setPreserveSequence(true); + + 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()->supports(Capability::DefinedAttributes)) { + $this->fail('Expected StructureException for invalid sequence'); + } + } catch (Throwable $e) { + if ($database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->assertInstanceOf(StructureException::class, $e); + $this->assertStringContainsString('sequence', $e->getMessage()); + } + } + + $database->setPreserveSequence(false); + $database->deleteCollection($collectionName); + } + + public function testRespectNulls(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(new Collection(id: 'documents_nulls')); + + $this->assertEquals(true, $database->createAttribute('documents_nulls', Attribute::string(key: 'string', size: 128))); + $this->assertEquals(true, $database->createAttribute('documents_nulls', Attribute::integer(key: 'integer'))); + $this->assertEquals(true, $database->createAttribute('documents_nulls', Attribute::integer(key: 'bigint', size: 8))); + $this->assertEquals(true, $database->createAttribute('documents_nulls', Attribute::double(key: 'float'))); + $this->assertEquals(true, $database->createAttribute('documents_nulls', Attribute::boolean(key: 'boolean'))); + + $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')), + ], + ])); + + $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')); + } + + public function testCreateDocumentDefaults(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(new Collection(id: 'defaults')); + + $this->assertEquals(true, $database->createAttribute('defaults', Attribute::string(key: 'string', size: 128, default: 'default'))); + $this->assertEquals(true, $database->createAttribute('defaults', Attribute::integer(key: 'integer', default: 1))); + $this->assertEquals(true, $database->createAttribute('defaults', Attribute::double(key: 'float', default: 1.5))); + $this->assertEquals(true, $database->createAttribute('defaults', Attribute::boolean(key: 'boolean', default: true))); + $this->assertEquals(true, $database->createAttribute('defaults', Attribute::string(key: 'colors', size: 32, default: ['red', 'green', 'blue'], array: true))); + $this->assertEquals(true, $database->createAttribute('defaults', Attribute::datetime(key: 'datetime', default: '2000-06-12T14:12:55.000+00:00', filters: ['datetime']))); + + $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 testIncreaseDecrease(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = $this->getIncDecCollection(); + $database->createCollection(new Collection(id: $collection)); + + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'increase', required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'decrease', required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'increase_text', required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::double(key: 'increase_float', required: true))); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::integer(key: 'sizes', size: 8, array: true))); + + $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()), + ] + ])); + + $updatedAt = $document->getUpdatedAt(); + + $doc = $database->increaseDocumentAttribute($collection, $document->getId(), 'increase', 1, 101); + $this->assertEquals(101, $doc->getAttribute('increase')); + + $document = $database->getDocument($collection, $document->getId()); + $this->assertEquals(101, $document->getAttribute('increase')); + $this->assertNotEquals($updatedAt, $document->getUpdatedAt()); + + $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')); + + $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')); + + $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')); + + self::$incDecFixtureInit = true; + self::$incDecFixtureDoc = $document; + } + + public function testIncreaseLimitMax(): void + { + $document = $this->initIncreaseDecreaseFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $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(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $database->decreaseDocumentAttribute( + $this->getIncDecCollection(), + $document->getId(), + 'decrease', + 10, + 99 + ); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(LimitException::class, $e); + } + + 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(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $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(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $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(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->setPreserveDates(true); + + 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->increaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'increase', 1); + + $after = $database->getDocument($this->getIncDecCollection(), $document->getId()); + $this->assertSame($increase + 1, $after->getAttribute('increase')); + $this->assertSame($updatedAt, $after->getUpdatedAt()); + + $database->decreaseDocumentAttribute($this->getIncDecCollection(), $document->getId(), 'decrease', 1); + + $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__); + + $documentId = $document->getId(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $document = $database->getDocument($document->getCollection(), $documentId, [ + Query::select(['string', 'integer_signed']), + ]); + + $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); + + $document = $database->getDocument($document->getCollection(), $documentId, [ + Query::select(['string', 'integer_signed', '$id']), + ]); + + $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 testDocumentsFixturesCanBeIsolated(): void + { + $first = $this->initDocumentsFixture('fixtureIsolationFirst'); + $second = $this->initDocumentsFixture('fixtureIsolationSecond'); + $database = $this->getDatabase(); + + $database->deleteDocument($first->getCollection(), $first->getId()); + + $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')); + } + + public function testFindOne(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $document = $database->findOne($this->getMoviesCollection(), [ + Query::offset(2), + Query::orderAsc('name') + ]); + + $this->assertFalse($document->isEmpty()); + $this->assertEquals('Frozen', $document->getAttribute('name')); + + $document = $database->findOne($this->getMoviesCollection(), [ + Query::offset(10) + ]); + $this->assertTrue($document->isEmpty()); + } + + public function testFindBasicChecks(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $this->getDatabase()->getAuthorization()->removeRole('user:x'); + + 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]->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 (Document $doc1, Document $doc2) { + return strcmp($doc1->getId(), $doc2->getId()); + }); + + $firstDocumentId = $sortedDocuments[0]->getId(); + $lastDocumentId = $sortedDocuments[\count($sortedDocuments) - 1]->getId(); + + /** + * 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()); + + /** + * 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()); + + } finally { + $this->getDatabase()->getAuthorization()->addRole('user:x'); + } + } + + public function testFindAttributeNamedAfterInternalKey(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(new Collection(id: __FUNCTION__)); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, Attribute::string(key: 'collection', size: 128))); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => ID::custom('clash'), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'collection' => 'value', + ])); + + $database->createDocument(__FUNCTION__, new Document([ + '$id' => ID::custom('clashNull'), + '$permissions' => [ + Permission::read(Role::any()), + ], + 'collection' => null, + ])); + + $documents = $database->find(__FUNCTION__, [Query::orderAsc('$id')]); + + $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')); + } + + public function testFindAttributeNamedAfterTenantKey(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getSharedTables()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, Attribute::string(key: 'tenant', size: 128))); + + $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->assertCount(1, $documents); + $this->assertEquals('value', $documents[0]->getAttribute('tenant')); + } + + public function testFindCheckPermissions(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * Check Permissions + */ + $this->getDatabase()->getAuthorization()->addRole('user:x'); + $documents = $database->find($this->getMoviesCollection()); + + $this->assertEquals(6, count($documents)); + } + + public function testFindStringQueryEqual(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * String condition + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['TBD']), + ]); + + $this->assertEquals(2, count($documents)); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['']), + ]); + + $this->assertEquals(0, count($documents)); + } + + public function testFindNotEqual(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * Not Equal query + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEqual('director', 'TBD'), + ]); + + $this->assertGreaterThan(0, count($documents)); + + foreach ($documents as $document) { + $this->assertTrue($document['director'] !== 'TBD'); + } + + $documents = $database->find($this->getMoviesCollection(), [ + Query::notEqual('director', ''), + ]); + + $total = $database->count($this->getMoviesCollection()); + + $this->assertEquals($total, count($documents)); + } + + public function testFindBetween(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('price', 25.94, 25.99), + ]); + $this->assertEquals(2, count($documents)); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('price', 30, 35), + ]); + $this->assertEquals(0, count($documents)); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::between('$createdAt', '1975-12-06', '2050-12-06'), + ]); + $this->assertEquals(6, count($documents)); + + $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)); + } + + public function testFindMultipleConditions(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * Multiple conditions + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::equal('director', ['TBD']), + Query::equal('year', [2026]), + ]); + + $this->assertEquals(1, count($documents)); + + /** + * 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 testFindOrderBy(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * ORDER BY + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('name') + ]); + + $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']); + } + + public function testFindOrderByNatural(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * 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(''), + ]); + + $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 testFindStartsWith(): void + public function testFindOrderByMultipleAttributes(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $documents = $database->find('movies', [ - Query::startsWith('name', 'Work'), + /** + * ORDER BY - Multiple attributes + */ + $documents = $database->find($this->getMoviesCollection(), [ + 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']); + } + + public function testFindOrderByCursorAfter(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * ORDER BY - After + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), ]); + $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']); - if ($this->getDatabase()->getAdapter() instanceof SQL) { - $documents = $database->find('movies', [ - Query::startsWith('name', '%ork'), - ]); - } else { - $documents = $database->find('movies', [ - Query::startsWith('name', '.*ork'), - ]); + $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']); + + $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']); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorAfter($movies[5]) + ]); + $this->assertEmpty(count($documents)); + + /** + * Multiple order by, Test tie-break on year 2019 + */ + $movies = $database->find($this->getMoviesCollection(), [ + 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); + + $this->assertEquals($movies[3]['name'], 'Frozen II'); + $this->assertEquals($movies[3]['year'], 2019); + $this->assertEquals($movies[3]['price'], 39.5); + + $this->assertEquals($movies[4]['name'], 'Work in Progress'); + $this->assertEquals($movies[4]['year'], 2025); + $this->assertEquals($movies[4]['price'], 0); + + $this->assertEquals($movies[5]['name'], 'Work in Progress 2'); + $this->assertEquals($movies[5]['year'], 2026); + $this->assertEquals($movies[5]['price'], 0); + + $pos = 2; + $documents = $database->find($this->getMoviesCollection(), [ + Query::orderAsc('year'), + Query::orderAsc('price'), + Query::cursorAfter($movies[$pos]) + ]); + + $this->assertEquals(3, count($documents)); + + 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']); } + } + + public function testFindOrderByCursorBefore(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * ORDER BY - Before + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + ]); + + $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']); + + $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']); + + $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']); + + $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']); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::cursorBefore($movies[0]) + ]); + $this->assertEmpty(count($documents)); + } + + public function testFindOrderByAfterNaturalOrder(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * ORDER BY - After by natural order + */ + $movies = array_reverse($database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + ])); + + $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']); + + $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']); + + $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']); + + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorAfter($movies[5]) + ]); + $this->assertEmpty(count($documents)); + } + + public function testFindOrderByBeforeNaturalOrder(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); + + /** + * ORDER BY - Before by natural order + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc(''), + ]); + + $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']); + + $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']); + + $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']); + + $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']); - $this->assertEquals(0, count($documents)); + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc(''), + Query::cursorBefore($movies[0]) + ]); + $this->assertEmpty(count($documents)); } - public function testFindStartsWithWords(): void + public function testFindOrderBySingleAttributeAfter(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $documents = $database->find('movies', [ - Query::startsWith('name', 'Work in Progress'), + /** + * ORDER BY - Single Attribute After + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('year') ]); - $this->assertEquals(2, count($documents)); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorAfter($movies[1]) + ]); - public function testFindEndsWith(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - $documents = $database->find('movies', [ - Query::endsWith('name', 'Marvel'), + $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']); + $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 testFindNotContains(): void + public function testFindOrderBySingleAttributeBefore(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForQueryContains()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Test notContains with array attributes - should return documents that don't contain specified genres - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics']) + /** + * ORDER BY - Single Attribute Before + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('year') ]); - $this->assertEquals(4, count($documents)); // All movies except the 2 with 'comics' genre - - // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics', 'kids']), + $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']); - $this->assertEquals(2, count($documents)); // Movies that have neither 'comics' nor 'kids' - - // Test notContains with non-existent genre - should return all documents - $documents = $database->find('movies', [ - Query::notContains('genres', ['non-existent']), + $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']); - $this->assertEquals(6, count($documents)); - - // Test notContains with string attribute (substring search) - $documents = $database->find('movies', [ - Query::notContains('name', ['Captain']) + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[2]) ]); - $this->assertEquals(4, count($documents)); // All movies except those containing 'Captain' + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertEquals($movies[1]['name'], $documents[1]['name']); - // Test notContains combined with other queries (AND logic) - $documents = $database->find('movies', [ - Query::notContains('genres', ['comics']), - Query::greaterThan('year', 2000) + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[1]) ]); - $this->assertLessThanOrEqual(4, count($documents)); // Subset of movies without 'comics' and after 2000 + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); - // Test notContains with case sensitivity - $documents = $database->find('movies', [ - Query::notContains('genres', ['COMICS']) // Different case + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('year'), + Query::cursorBefore($movies[0]) ]); - $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); - } + $this->assertEmpty(count($documents)); } - public function testFindNotSearch(): void + public function testFindOrderByMultipleAttributeAfter(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->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; - } - } - - // 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' - } + /** + * ORDER BY - Multiple Attribute After + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year') + ]); - // 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 + $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']); - // 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 + $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']); - // 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 - } + $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']); - $this->assertEquals(true, true); // Test must do an assertion + $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 testFindNotStartsWith(): void + public function testFindOrderByMultipleAttributeBefore(): void { + $this->initMoviesFixture(); + /** @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'), + /** + * ORDER BY - Multiple Attribute Before + */ + $movies = $database->find($this->getMoviesCollection(), [ + Query::limit(25), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year') ]); - $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'), + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[5]) ]); - $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'), - ]); - } - - $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[3]['name'], $documents[0]['name']); + $this->assertEquals($movies[4]['name'], $documents[1]['name']); - // Test notStartsWith with empty string - should return no documents (all strings start with empty) - $documents = $database->find('movies', [ - Query::notStartsWith('name', ''), + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[4]) ]); - $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[2]['name'], $documents[0]['name']); + $this->assertEquals($movies[3]['name'], $documents[1]['name']); - // Test notStartsWith with single character - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'C'), + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[2]) ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' + $this->assertEquals(2, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); + $this->assertEquals($movies[1]['name'], $documents[1]['name']); - // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'work'), // lowercase vs 'Work' + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[1]) ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively + $this->assertEquals(1, count($documents)); + $this->assertEquals($movies[0]['name'], $documents[0]['name']); - // Test notStartsWith combined with other queries - $documents = $database->find('movies', [ - Query::notStartsWith('name', 'Work'), - Query::equal('year', [2006]) + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('price'), + Query::orderAsc('year'), + Query::cursorBefore($movies[0]) ]); - $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 + $this->assertEmpty(count($documents)); } - public function testFindNotEndsWith(): void + public function testFindOrderByAndCursor(): void { + $this->initMoviesFixture(); + /** @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'), + /** + * 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]) ]); - $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + } - // Test notEndsWith with non-existent suffix - should return all documents - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'NonExistent'), - ]); + public function testFindOrderByIdAndCursor(): void + { + $this->initMoviesFixture(); - $this->assertEquals(6, count($documents)); + /** @var Database $database */ + $database = $this->getDatabase(); - // Test notEndsWith with partial suffix - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'vel'), + /** + * 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]) ]); - $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + } - // 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 + public function testFindOrderByCreateDateAndCursor(): void + { + $this->initMoviesFixture(); - // Test notEndsWith with single character - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'l'), - ]); - $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' + /** @var Database $database */ + $database = $this->getDatabase(); - // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' + /** + * ORDER BY CREATE DATE + CURSOR + */ + $documentsTest = $database->find($this->getMoviesCollection(), [ + Query::limit(2), + Query::offset(0), + Query::orderDesc('$createdAt'), ]); - $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively - // Test notEndsWith combined with limit - $documents = $database->find('movies', [ - Query::notEndsWith('name', 'Marvel'), - Query::limit(3) + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(1), + Query::offset(0), + Query::orderDesc('$createdAt'), + Query::cursorAfter($documentsTest[0]) ]); - $this->assertEquals(3, count($documents)); // Limited to 3 results - $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies + + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); } - public function testFindOrderRandom(): void + public function testFindOrderByUpdateDateAndCursor(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForOrderRandom()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Test orderRandom with default limit - $documents = $database->find('movies', [ - Query::orderRandom(), + /** + * 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]) ]); - $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)); + $this->assertEquals($documentsTest[1]['$id'], $documents[0]['$id']); + } - // 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), - ]); + public function testFindCreatedBefore(): void + { + $this->initMoviesFixture(); - // Extract IDs for comparison - $firstIds = array_map(fn ($doc) => $doc['$id'], $firstSet); - $secondIds = array_map(fn ($doc) => $doc['$id'], $secondSet); + /** @var Database $database */ + $database = $this->getDatabase(); - // 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)); + /** + * Test Query::createdBefore wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - // Test orderRandom with more than available documents - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(10), // We only have 6 movies + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBefore($futureDate), + Query::limit(1) ]); - $this->assertLessThanOrEqual(6, count($documents)); // Should return all available documents - // 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']); - } + $this->assertGreaterThan(0, count($documents)); - // Test orderRandom without explicit limit (should use default) - $documents = $database->find('movies', [ - Query::orderRandom(), + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBefore($pastDate), + Query::limit(1) ]); - $this->assertGreaterThan(0, count($documents)); - $this->assertLessThanOrEqual(25, count($documents)); // Default limit is 25 + + $this->assertEquals(0, count($documents)); } - public function testFindNotBetween(): void + public function testFindCreatedAfter(): void { + $this->initMoviesFixture(); + /** @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 Query::createdAfter wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - // Test notBetween with range that includes no documents - should return all documents - $documents = $database->find('movies', [ - Query::notBetween('price', 30, 35), + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdAfter($pastDate), + Query::limit(1) ]); - $this->assertEquals(6, count($documents)); - // 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 + $this->assertGreaterThan(0, count($documents)); - // Test notBetween with narrower date range - $documents = $database->find('movies', [ - Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdAfter($futureDate), + Query::limit(1) ]); - $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - // 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 + $this->assertEquals(0, count($documents)); + } - // 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 + public function testFindUpdatedBefore(): void + { + $this->initMoviesFixture(); - // 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 + /** @var Database $database */ + $database = $this->getDatabase(); - // 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 + /** + * Test Query::updatedBefore wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; - // Test notBetween combined with other filters - $documents = $database->find('movies', [ - Query::notBetween('price', 25.94, 25.99), - Query::orderDesc('year'), - Query::limit(2) + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBefore($futureDate), + Query::limit(1) ]); - $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range - // 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 + $this->assertGreaterThan(0, count($documents)); - // Test notBetween with float precision - $documents = $database->find('movies', [ - Query::notBetween('price', 25.945, 25.955), // Very narrow range + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBefore($pastDate), + Query::limit(1) ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range + + $this->assertEquals(0, count($documents)); } - public function testFindSelect(): void + public function testFindUpdatedAfter(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $documents = $database->find('movies', [ - Query::select(['name', 'year']) + /** + * Test Query::updatedAfter wrapper + */ + $futureDate = '2050-01-01T00:00:00.000Z'; + $pastDate = '1900-01-01T00:00:00.000Z'; + + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedAfter($pastDate), + Query::limit(1) ]); - 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->assertGreaterThan(0, count($documents)); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$id']) + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedAfter($futureDate), + Query::limit(1) ]); - 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(0, count($documents)); + } - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$sequence']) - ]); + public function testFindCreatedBetween(): void + { + $this->initMoviesFixture(); - 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); - } + /** @var Database $database */ + $database = $this->getDatabase(); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$collection']) + /** + * 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'; + + // All documents should be between past and future + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($pastDate, $futureDate), + Query::limit(25) ]); - 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->assertGreaterThan(0, count($documents)); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$createdAt']) + // No documents should exist in this range + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($pastDate, $pastDate), + Query::limit(25) ]); - 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(0, count($documents)); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$updatedAt']) + // Documents created between recent past and near future + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($recentPastDate, $nearFutureDate), + Query::limit(25) ]); - 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); - } + $count = count($documents); - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$permissions']) + // Same count should be returned with expanded range + $documents = $database->find($this->getMoviesCollection(), [ + Query::createdBetween($pastDate, $nearFutureDate), + Query::limit(25) ]); - 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->assertGreaterThanOrEqual($count, count($documents)); } - /** @depends testFind */ - public function testForeach(): void + public function testFindUpdatedBetween(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); /** - * Test, foreach generator on empty collection + * Test Query::updatedBetween wrapper */ - $database->createCollection('moviesEmpty'); - $documents = []; - foreach ($database->iterate('moviesEmpty', queries: [Query::limit(2)]) as $document) { - $documents[] = $document; - } - $this->assertEquals(0, \count($documents)); - $this->assertTrue($database->deleteCollection('moviesEmpty')); + $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'; - /** - * Test, foreach generator - */ - $documents = []; - foreach ($database->iterate('movies', queries: [Query::limit(2)]) as $document) { - $documents[] = $document; - } - $this->assertEquals(6, count($documents)); + // All documents should be between past and future + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($pastDate, $futureDate), + Query::limit(25) + ]); - /** - * 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)); + $this->assertGreaterThan(0, count($documents)); - /** - * Test, foreach with initial cursor - */ + // No documents should exist in this range + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($pastDate, $pastDate), + Query::limit(25) + ]); - $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)); + $this->assertEquals(0, count($documents)); - /** - * Test, foreach with initial offset - */ + // Documents updated between recent past and near future + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($recentPastDate, $nearFutureDate), + Query::limit(25) + ]); - $documents = []; - $database->foreach('movies', queries: [Query::limit(2), Query::offset(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(4, count($documents)); + $count = count($documents); + + // Same count should be returned with expanded range + $documents = $database->find($this->getMoviesCollection(), [ + Query::updatedBetween($pastDate, $nearFutureDate), + Query::limit(25) + ]); + + $this->assertGreaterThanOrEqual($count, count($documents)); + } + + public function testFindLimit(): void + { + $this->initMoviesFixture(); + + /** @var Database $database */ + $database = $this->getDatabase(); /** - * Test, cursor before throws error + * Limit */ - try { - $database->foreach('movies', queries: [Query::cursorBefore($documents[0]), Query::offset(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - - } catch (Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertEquals('Cursor ' . Database::CURSOR_BEFORE . ' not supported in this method.', $e->getMessage()); - } + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(4), + Query::offset(0), + Query::orderAsc('name') + ]); + $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']); } - /** - * @depends testFind - */ - public function testCount(): void + public function testFindLimitAndOffset(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - $count = $database->count('movies'); - $this->assertEquals(6, $count); - $count = $database->count('movies', [Query::equal('year', [2019])]); - - $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); + /** + * Limit + Offset + */ + $documents = $database->find($this->getMoviesCollection(), [ + Query::limit(4), + Query::offset(2), + Query::orderAsc('name') + ]); - $this->getDatabase()->getAuthorization()->removeRole('user:x'); - $count = $database->count('movies'); - $this->assertEquals(5, $count); + $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']); + } - $this->getDatabase()->getAuthorization()->disable(); - $count = $database->count('movies'); - $this->assertEquals(6, $count); - $this->getDatabase()->getAuthorization()->reset(); + public function testFindOrQueries(): void + { + $this->initMoviesFixture(); - $this->getDatabase()->getAuthorization()->disable(); - $count = $database->count('movies', [], 3); - $this->assertEquals(3, $count); - $this->getDatabase()->getAuthorization()->reset(); + /** @var Database $database */ + $database = $this->getDatabase(); /** * Test that OR queries are handled correctly */ - $this->getDatabase()->getAuthorization()->disable(); - $count = $database->count('movies', [ + $documents = $database->find($this->getMoviesCollection(), [ Query::equal('director', ['TBD', 'Joe Johnston']), Query::equal('year', [2025]), ]); - $this->assertEquals(1, $count); - $this->getDatabase()->getAuthorization()->reset(); - } - - /** - * @depends testFind - */ - public function testSum(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $this->getDatabase()->getAuthorization()->addRole('user:x'); - - $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)); - - $sum = $database->sum('movies', 'year', [Query::equal('year', [2019])], 1); - $this->assertEquals(2019, $sum); - - $this->getDatabase()->getAuthorization()->removeRole('user:x'); - - $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)); + $this->assertEquals(1, count($documents)); } - - public function testIntegersBeyondInt32(): void + public function testFindEdgeCases(): void { /** @var Database $database */ $database = $this->getDatabase(); - $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); - - // 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 (['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'); - - $amounts = $document->getAttribute('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); - - // 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' - ); - } + $collection = 'edgeCases'; - // 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); - } + $database->createCollection(new Collection(id: $collection)); - public function testEncodeDecode(): 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], - ] - ], - ]); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'value', size: 256, required: true))); - $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'], - ], - ]); + $values = [ + 'NormalString', + '{"type":"json","somekey":"someval"}', + '{NormalStringInBraces}', + '"NormalStringInDoubleQuotes"', + '{"NormalStringInDoubleQuotesAndBraces"}', + "'NormalStringInSingleQuotes'", + "{'NormalStringInSingleQuotesAndBraces'}", + "SingleQuote'InMiddle", + 'DoubleQuote"InMiddle', + 'Slash/InMiddle', + 'Backslash\InMiddle', + 'Colon:InMiddle', + '"quoted":"colon"' + ]; - /** @var Database $database */ - $database = $this->getDatabase(); + 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 + ])); + } - $result = $database->encode($collection, $document); + /** + * Check Basic + */ + $documents = $database->find($collection); - $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')); + $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')); - $result = $database->decode($collection, $document); + /** + * Check `equals` query + */ + foreach ($values as $value) { + $documents = $database->find($collection, [ + Query::limit(25), + Query::equal('value', [$value]) + ]); - $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')); + $this->assertEquals(1, count($documents)); + $this->assertEquals($value, $documents[0]->getAttribute('value')); + } } - /** - * @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'); - $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); + public function testNestedIDQueries(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $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->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - $oldPermissions = $document->getPermissions(); + $database->createCollection(new Collection(id: 'movies_nested_id', permissions: [ + Permission::create(Role::any()), + Permission::update(Role::users()) + ])); - $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); + $this->assertEquals(true, $database->createAttribute('movies_nested_id', Attribute::string(key: 'name', size: 128, required: true))); - $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); + $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', + ])); - $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); + $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', + ])); - $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()); + $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', + ])); - $new->setAttribute('$permissions', $oldPermissions); + $queries = [ + Query::or([ + Query::equal('$id', ["1"]), + Query::equal('$id', ["2"]) + ]) + ]; - $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); + $documents = $database->find('movies_nested_id', $queries); + $this->assertCount(2, $documents); - $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); + // Make sure the query was not modified by reference + $nested = $queries[0]->getValues()[0]; + $this->assertInstanceOf(Query::class, $nested); + $this->assertEquals($nested->getAttribute(), '$id'); - $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()); + $count = $database->count('movies_nested_id', $queries); + $this->assertEquals(2, $count); + } - // 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()); + public function testFindNotBetween(): void + { + $this->initMoviesFixture(); - // Reset ID - $new->setAttribute('$id', $id); - $new = $this->getDatabase()->updateDocument($new->getCollection(), $newId, $new); - $this->assertEquals($id, $new->getId()); + /** @var Database $database */ + $database = $this->getDatabase(); - return $document; - } + // 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 + // 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)); - /** - * @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); - }); - $this->assertEquals(7, $result->getAttribute('integer_signed')); + // 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 - $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()); - } - } + // 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 - /** - * @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()); - }); - } + // 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 - /** - * @depends testGetDocument - */ - public function testUpdateDocumentDuplicatePermissions(Document $document): Document - { - $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); + // 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 - $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); + // 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 - $this->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); + // 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 - $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); + // 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 - $this->assertContains('guests', $new->getRead()); - $this->assertContains('guests', $new->getCreate()); + // 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 - return $document; + // 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 } - /** - * @depends testUpdateDocument - */ - public function testDeleteDocument(Document $document): void + public function testFindSelect(): void { - $result = $this->getDatabase()->deleteDocument($document->getCollection(), $document->getId()); - $document = $this->getDatabase()->getDocument($document->getCollection(), $document->getId()); - - $this->assertEquals(true, $result); - $this->assertEquals(true, $document->isEmpty()); - } + $this->initMoviesFixture(); - public function testUpdateDocuments(): void - { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collection = 'testUpdateDocuments'; - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - - $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); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year']) + ]); - for ($i = 0; $i < 10; $i++) { - $database->createDocument($collection, new Document([ - '$id' => 'doc' . $i, - 'string' => 'text📝 ' . $i, - 'integer' => $i, - 'boolean' => true - ])); + 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); } - // 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; - }); - - $this->assertEquals(5, $count); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$id']) + ]); - foreach ($results as $document) { - $this->assertEquals('text📝 updated', $document->getAttribute('string')); - $this->assertEquals(true, $document->getAttribute('boolean')); + 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); } - $updatedDocuments = $database->find($collection, [ - Query::greaterThanEqual('integer', 5), + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$sequence']) ]); - $this->assertCount(5, $updatedDocuments); - - foreach ($updatedDocuments as $document) { - $this->assertEquals('text📝 updated', $document->getAttribute('string')); - $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); - $this->assertEquals(true, $document->getAttribute('boolean')); + 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); } - $controlDocuments = $database->find($collection, [ - Query::lessThan('integer', 5), + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$collection']) ]); - $this->assertEquals(count($controlDocuments), 5); - - foreach ($controlDocuments as $document) { - $this->assertNotEquals('text📝 updated', $document->getAttribute('string')); + 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); } - // Test Update all documents - $this->assertEquals(10, $database->updateDocuments($collection, new Document([ - 'string' => 'text📝 updated all', - ]))); - - $updatedDocuments = $database->find($collection); - - $this->assertEquals(count($updatedDocuments), 10); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$createdAt']) + ]); - foreach ($updatedDocuments as $document) { - $this->assertEquals('text📝 updated all', $document->getAttribute('string')); + 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); } - // TEST: Can't delete documents in the past - $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$updatedAt']) + ]); - 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()); + 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); } - // 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); + $documents = $database->find($this->getMoviesCollection(), [ + Query::select(['name', 'year', '$permissions']) + ]); - 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()); + 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); } + } - // Check document level permissions - $database->updateCollection($collection, permissions: [], documentSecurity: true); - - $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')), - ], - ])); - }); + public function testForeach(): void + { + $this->initMoviesFixture(); - $this->getDatabase()->getAuthorization()->addRole(Role::user('asd')->toString()); + /** @var Database $database */ + $database = $this->getDatabase(); - $database->updateDocuments($collection, new Document([ - 'string' => 'permission text', - ])); + /** + * Test, foreach generator on empty collection + */ + $database->createCollection(new Collection(id: 'moviesEmpty')); + $documents = []; + foreach ($database->iterate('moviesEmpty', queries: [Query::limit(2)]) as $document) { + $documents[] = $document; + } + $this->assertEquals(0, \count($documents)); + $this->assertTrue($database->deleteCollection('moviesEmpty')); - $documents = $database->find($collection, [ - Query::equal('string', ['permission text']), - ]); + /** + * Test, foreach generator + */ + $documents = []; + foreach ($database->iterate($this->getMoviesCollection(), queries: [Query::limit(2)]) as $document) { + $documents[] = $document; + } + $this->assertEquals(6, count($documents)); - $this->assertCount(1, $documents); + /** + * Test, foreach goes through all the documents + */ + $documents = []; + $database->foreach($this->getMoviesCollection(), queries: [Query::limit(2)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); + $this->assertEquals(6, count($documents)); - $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { - $unmodifiedDocuments = $database->find($collection, [ - Query::equal('string', ['text📝 updated all']), - ]); + /** + * Test, foreach with initial cursor + */ - $this->assertCount(9, $unmodifiedDocuments); + $first = $documents[0]; + $documents = []; + $database->foreach($this->getMoviesCollection(), queries: [Query::limit(2), Query::cursorAfter($first)], callback: function ($document) use (&$documents) { + $documents[] = $document; }); + $this->assertEquals(5, count($documents)); - $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()), - ], - ])); - }); + /** + * Test, foreach with initial offset + */ - // Test we can update more documents than batchSize - $this->assertEquals(10, $database->updateDocuments($collection, new Document([ - 'string' => 'batchSize Test' - ]), batchSize: 2)); + $documents = []; + $database->foreach($this->getMoviesCollection(), queries: [Query::limit(2), Query::offset(2)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); + $this->assertEquals(4, count($documents)); - $documents = $database->find($collection); + /** + * Test, cursor before throws error + */ + try { + $database->foreach($this->getMoviesCollection(), queries: [Query::cursorBefore($documents[0]), Query::offset(2)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); - foreach ($documents as $document) { - $this->assertEquals('batchSize Test', $document->getAttribute('string')); + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertEquals('Cursor ' . CursorDirection::Before->value . ' not supported in this method.', $e->getMessage()); } - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); } - - public function testUpdateDocumentsWithCallbackSupport(): void + public function testCount(): void { + $this->initMoviesFixture(); + /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $collection = 'update_callback'; - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $count = $database->count($this->getMoviesCollection()); + $this->assertEquals(6, $count); + $count = $database->count($this->getMoviesCollection(), [Query::equal('year', [2019])]); - $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->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); - 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()); - } + $this->getDatabase()->getAuthorization()->removeRole('user:x'); + $count = $database->count($this->getMoviesCollection()); + $this->assertEquals(5, $count); + $this->getDatabase()->getAuthorization()->addRole('user:x'); - // 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()); - }); + $this->getDatabase()->getAuthorization()->disable(); + $count = $database->count($this->getMoviesCollection()); + $this->assertEquals(6, $count); + $this->getDatabase()->getAuthorization()->reset(); - $this->assertEquals(5, $count); + $this->getDatabase()->getAuthorization()->disable(); + $count = $database->count($this->getMoviesCollection(), [], 3); + $this->assertEquals(3, $count); + $this->getDatabase()->getAuthorization()->reset(); - foreach ($results as $document) { - $this->assertEquals('text📝 updated', $document->getAttribute('string')); - } + /** + * 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(); + } - $updatedDocuments = $database->find($collection, [ - Query::greaterThanEqual('integer', 5), + public function testEncodeDecode(): void + { + $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], + ] + ], ]); - $this->assertCount(5, $updatedDocuments); - } - - /** - * @depends testCreateDocument - */ - public function testReadPermissionsSuccess(Document $document): Document - { - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); - - /** @var Database $database */ - $database = $this->getDatabase(); - - $document = $database->createDocument('documents', new Document([ + $document = new Document([ + '$id' => ID::custom('608fdbe51361a'), '$permissions' => [ Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), + Permission::create(Role::user('608fdbe51361a')), + Permission::update(Role::user('608fdbe51361a')), + Permission::delete(Role::user('608fdbe51361a')), ], - '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'], - ])); + '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'], + ], + ]); - $this->assertEquals(false, $document->isEmpty()); + /** @var Database $database */ + $database = $this->getDatabase(); - $this->getDatabase()->getAuthorization()->cleanRoles(); + $result = $database->encode($collection, $document); - $document = $database->getDocument($document->getCollection(), $document->getId()); - $this->assertEquals(true, $document->isEmpty()); + $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); + $this->assertContains('read("any")', $result->getPermissions()); + $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')); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $result = $database->decode($collection, $document); - return $document; + $this->assertEquals('608fdbe51361a', $result->getAttribute('$id')); + $this->assertContains('read("any")', $result->getPermissions()); + $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 testCreateDocument - */ - public function testWritePermissionsSuccess(Document $document): void + public function testUpdateDocumentConflict(): void { - $this->getDatabase()->getAuthorization()->cleanRoles(); + $document = $this->initDocumentsFixture(__FUNCTION__); - /** @var Database $database */ - $database = $this->getDatabase(); + $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')); - $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'], - ])); + $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__); - /** - * @depends testCreateDocument - */ - public function testWritePermissionsUpdateFailure(Document $document): Document + $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 { - $this->expectException(AuthorizationException::class); + $document = $this->initDocumentsFixture(__FUNCTION__); - $this->getDatabase()->getAuthorization()->cleanRoles(); - $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); + $new = $this->getDatabase()->updateDocument($document->getCollection(), $document->getId(), $document); - /** @var Database $database */ - $database = $this->getDatabase(); + $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); - $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->getDatabase()->updateDocument($new->getCollection(), $new->getId(), $new); - $this->getDatabase()->getAuthorization()->cleanRoles(); + $new = $this->getDatabase()->getDocument($new->getCollection(), $new->getId()); - $document = $database->updateDocument('documents', $document->getId(), new Document([ - '$id' => ID::custom($document->getId()), + $this->assertContains('guests', $new->getRead()); + $this->assertContains('guests', $new->getCreate()); + + $database = $this->getDatabase(); + $collection = 'dupCase'; + $database->createCollection(new Collection(id: $collection)); + + $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()), ], - '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'], - ])); - - 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' - ])); - - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - $this->assertInstanceOf(UniqueException::class, $e); - } + ])); + + $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); + + $renamed = $database->updateDocument($collection, 'caseSensitive', $stored); + $this->assertSame('CaseSensitive', $renamed->getId()); + + $updated = $database->getDocument($collection, 'CaseSensitive'); + $this->assertSame('CaseSensitive', $updated->getId()); + $this->assertContains('guests', $updated->getRead()); + $this->assertContains('guests', $updated->getCreate()); + $this->assertContains('any', $updated->getCreate()); } /** - * Test that duplicate exceptions differentiate between + * Test that DuplicateException messages differentiate between * document ID duplicates and unique index violations. */ public function testDuplicateExceptionMessages(): void @@ -6116,16 +7595,15 @@ public function testDuplicateExceptionMessages(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUniqueIndex()) { + if (!$database->getAdapter()->supports(Capability::UniqueIndex)) { $this->expectNotToPerformAssertions(); return; } - $database->createCollection('duplicateMessages'); - $database->createAttribute('duplicateMessages', 'email', Database::VAR_STRING, 128, true); - $database->createIndex('duplicateMessages', 'emailUnique', Database::INDEX_UNIQUE, ['email'], [128]); + $database->createCollection(new Collection(id: 'duplicateMessages')); + $database->createAttribute('duplicateMessages', Attribute::string(key: 'email', size: 128, required: true)); + $database->createIndex('duplicateMessages', Index::unique(key: 'emailUnique', attributes: ['email'], lengths: [128])); - // Create first document $database->createDocument('duplicateMessages', new Document([ '$id' => 'dup_msg_1', '$permissions' => [ @@ -6134,7 +7612,6 @@ public function testDuplicateExceptionMessages(): void 'email' => 'test@example.com', ])); - // Test 1: Duplicate document ID should say "Document already exists" try { $database->createDocument('duplicateMessages', new Document([ '$id' => 'dup_msg_1', @@ -6149,7 +7626,6 @@ public function testDuplicateExceptionMessages(): void $this->assertStringContainsString('Document already exists', $e->getMessage()); } - // Test 2: Unique index violation should use UniqueException try { $database->createDocument('duplicateMessages', new Document([ '$id' => 'dup_msg_2', @@ -6158,14 +7634,13 @@ public function testDuplicateExceptionMessages(): void ], 'email' => 'test@example.com', ])); - $this->fail('Expected DuplicateException for unique index violation'); + $this->fail('Expected UniqueException for unique index violation'); } catch (DuplicateException $e) { $this->assertInstanceOf(UniqueException::class, $e); - $this->assertStringContainsString('Unique index violation', $e->getMessage()); + $this->assertStringContainsString('unique attributes', $e->getMessage()); } - // Test 3: A conflicting value containing "_uid" must not be mistaken - // for a document identifier conflict + // '_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' => [ @@ -6182,323 +7657,37 @@ public function testDuplicateExceptionMessages(): void ], 'email' => 'prefix_uid_suffix@example.com', ])); - $this->fail('Expected DuplicateException for unique index violation'); + $this->fail('Expected UniqueException for unique index violation on a value containing _uid'); } catch (DuplicateException $e) { $this->assertInstanceOf(UniqueException::class, $e); - $this->assertStringContainsString('Unique index violation', $e->getMessage()); + $this->assertStringContainsString('unique attributes', $e->getMessage()); } $database->deleteCollection('duplicateMessages'); } - /** - * @depends testUniqueIndexDuplicate - */ - public function testUniqueIndexDuplicateUpdate(): 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' - ])); - - try { - $database->updateDocument('movies', $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); - } - } - - public function propagateBulkDocuments(string $collection, int $amount = 10, bool $documentSecurity = false): void - { - /** @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()), - ], - ] : []) - )); - } - } - public function testDeleteBulkDocuments(): void { /** @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 - */ - $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; - - $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); - - // TEST: Bulk Delete All Documents - $this->assertEquals(8, $database->deleteDocuments('bulk_delete')); - - $docs = $database->find('bulk_delete'); - $this->assertCount(0, $docs); - - // TEST: Bulk delete documents with queries. - $this->propagateBulkDocuments('bulk_delete'); - - $results = []; - $count = $database->deleteDocuments('bulk_delete', [ - Query::greaterThanEqual('integer', 5) - ], onNext: function ($doc) use (&$results) { - $results[] = $doc; - }); - - $this->assertEquals(5, $count); - - foreach ($results as $document) { - $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); - } - - $docs = $database->find('bulk_delete'); - $this->assertEquals(5, \count($docs)); - - // TEST (FAIL): Can't delete documents in the past - $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); - - 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 (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) { - } - - $database->updateCollection('bulk_delete', [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ], false); - - $this->assertEquals(5, $database->deleteDocuments('bulk_delete')); - $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); - - // 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); - - $this->assertEquals(0, $database->deleteDocuments('bulk_delete')); - - $documents = $this->getDatabase()->getAuthorization()->skip(function () use ($database) { - return $database->find('bulk_delete'); - }); - - $this->assertEquals(10, \count($documents)); - - $database->updateCollection('bulk_delete', [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::delete(Role::any()) - ], false); - - $database->deleteDocuments('bulk_delete'); - - $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); - - // Teardown - $database->deleteCollection('bulk_delete'); - } - - public function testDeleteBulkDocumentsQueries(): void - { - /** @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'))); - - // 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')); - - // Test Offset - $this->propagateBulkDocuments('bulk_delete_queries', 100); - $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries', [Query::offset(50)])); - - $docs = $database->find('bulk_delete_queries', [Query::limit(100)]); - $this->assertEquals(50, \count($docs)); - - $lastDoc = \end($docs); - $this->assertNotEmpty($lastDoc); - $this->assertEquals('doc49', $lastDoc->getId()); - $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries')); - - $database->deleteCollection('bulk_delete_queries'); - } - - public function testDeleteBulkDocumentsWithCallbackSupport(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; - } - - $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->propagateBulkDocuments('bulk_delete_with_callback'); + if (!$database->getAdapter()->supports(Capability::BatchOperations)) { + $this->expectNotToPerformAssertions(); + return; + } - $docs = $database->find('bulk_delete_with_callback'); + $database->createCollection(new Collection(id: 'bulk_delete', attributes: [ + Attribute::string(key: 'text', size: 100, required: true), + Attribute::integer(key: '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); /** @@ -6506,32 +7695,8 @@ public function testDeleteBulkDocumentsWithCallbackSupport(): void */ $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->assertInstanceOf(Exception::class, $e); - $this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage()); - } - - $docs = $database->find('bulk_delete_with_callback'); - $this->assertCount(10, $docs); - $count = $database->deleteDocuments( - collection: 'bulk_delete_with_callback', + collection: 'bulk_delete', queries: [ Query::select([...$selects, '$createdAt']), Query::cursorAfter($docs[6]), @@ -6540,37 +7705,25 @@ public function testDeleteBulkDocumentsWithCallbackSupport(): void 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()); - } + batchSize: 1 ); $this->assertEquals(2, $count); - // TEST: Bulk Delete All Documents without passing callbacks - $this->assertEquals(8, $database->deleteDocuments('bulk_delete_with_callback')); + // TEST: Bulk Delete All Documents + $this->assertEquals(8, $database->deleteDocuments('bulk_delete')); - $docs = $database->find('bulk_delete_with_callback'); + $docs = $database->find('bulk_delete'); $this->assertCount(0, $docs); - // TEST: Bulk delete documents with queries with callbacks - $this->propagateBulkDocuments('bulk_delete_with_callback'); + // TEST: Bulk delete documents with queries. + $this->propagateBulkDocuments('bulk_delete'); $results = []; - $count = $database->deleteDocuments('bulk_delete_with_callback', [ + $count = $database->deleteDocuments('bulk_delete', [ 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(5, $count); @@ -6579,405 +7732,296 @@ public function testDeleteBulkDocumentsWithCallbackSupport(): void $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); } - $docs = $database->find('bulk_delete_with_callback'); + $docs = $database->find('bulk_delete'); $this->assertEquals(5, \count($docs)); - // Teardown - $database->deleteCollection('bulk_delete_with_callback'); - } - - public function testUpdateDocumentsQueries(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + // TEST (FAIL): Can't delete documents in the past + $oneHourAgo = (new \DateTime())->sub(new \DateInterval('PT1H')); - if (!$database->getAdapter()->getSupportForBatchOperations()) { - $this->expectNotToPerformAssertions(); - return; + 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()); } - $collection = 'testUpdateDocumentsQueries'; + // 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) { + } - $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()), + $database->updateCollection('bulk_delete', [ Permission::create(Role::any()), - Permission::update(Role::any()), + Permission::read(Role::any()), Permission::delete(Role::any()) - ], documentSecurity: true); + ], false); - // Test limit - $this->propagateBulkDocuments($collection, 100); + $this->assertEquals(5, $database->deleteDocuments('bulk_delete')); + $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); - $this->assertEquals(10, $database->updateDocuments($collection, new Document([ - 'text' => 'text📝 updated', - ]), [Query::limit(10)])); + // 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); - $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))); + $this->assertEquals(0, $database->deleteDocuments('bulk_delete')); - // Test Offset - $this->propagateBulkDocuments($collection, 100); - $this->assertEquals(50, $database->updateDocuments($collection, new Document([ - 'text' => 'text📝 updated', - ]), [ - Query::offset(50), - ])); + $documents = $this->getDatabase()->getAuthorization()->skip(function () use ($database) { + return $database->find('bulk_delete'); + }); - $docs = $database->find($collection, [Query::equal('text', ['text📝 updated']), Query::limit(100)]); - $this->assertCount(50, $docs); + $this->assertEquals(10, \count($documents)); - $lastDoc = end($docs); - $this->assertNotEmpty($lastDoc); - $this->assertEquals('doc99', $lastDoc->getId()); + $database->updateCollection('bulk_delete', [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], false); - $this->assertEquals(100, $database->deleteDocuments($collection)); + $database->deleteDocuments('bulk_delete'); + + $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); + + // Teardown + $database->deleteCollection('bulk_delete'); } - /** - * @depends testCreateDocument - */ - public function testFulltextIndexWithInteger(): void + public function testDeleteBulkDocumentsQueries(): void { /** @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'); - } - - $database->createIndex('documents', 'fulltext_integer', Database::INDEX_FULLTEXT, ['string','integer_signed']); - } else { + if (!$database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); return; } - } - - public function testEnableDisableValidation(): void - { - $database = $this->getDatabase(); - $database->createCollection('validation', permissions: [ + $database->createCollection(new Collection(id: 'bulk_delete_queries', attributes: [ + Attribute::string(key: 'text', size: 100, required: true), + Attribute::integer(key: 'integer', size: 10, required: true) + ], permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), - Permission::update(Role::any()), Permission::delete(Role::any()) - ]); - - $database->createAttribute( - 'validation', - 'name', - Database::VAR_STRING, - 10, - false - ); - - $database->createDocument('validation', new Document([ - '$id' => 'docwithmorethan36charsasitsidentifier', - 'name' => 'value1', - ])); - - try { - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - } - - $database->disableValidation(); - - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - - $database->enableValidation(); - - try { - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(Exception::class, $e); - } - - $database->skipValidation(function () use ($database) { - $database->find('validation', queries: [ - Query::equal('$id', ['docwithmorethan36charsasitsidentifier']), - ]); - }); - - $database->enableValidation(); - } - - /** - * @depends testGetDocument - */ - public function testExceptionDuplicate(Document $document): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - $document->setAttribute('$id', 'duplicated'); - $document->removeAttribute('$sequence'); - - $database->createDocument($document->getCollection(), $document); - $document->removeAttribute('$sequence'); + ], documentSecurity: false)); - try { - $database->createDocument($document->getCollection(), $document); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - } - } + // Test limit + $this->propagateBulkDocuments('bulk_delete_queries'); - /** - * @depends testGetDocument - */ - public function testExceptionCaseInsensitiveDuplicate(Document $document): Document - { - /** @var Database $database */ - $database = $this->getDatabase(); + $this->assertEquals(5, $database->deleteDocuments('bulk_delete_queries', [Query::limit(5)])); + $this->assertEquals(5, \count($database->find('bulk_delete_queries'))); - $document->setAttribute('$id', 'caseSensitive'); - $document->removeAttribute('$sequence'); + $this->assertEquals(5, $database->deleteDocuments('bulk_delete_queries', [Query::limit(5)])); + $this->assertEquals(0, \count($database->find('bulk_delete_queries'))); - $database->createDocument($document->getCollection(), $document); + // 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')); - $document->setAttribute('$id', 'CaseSensitive'); - $document->removeAttribute('$sequence'); + // Test Offset + $this->propagateBulkDocuments('bulk_delete_queries', 100); + $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries', [Query::offset(50)])); - try { - $database->createDocument($document->getCollection(), $document); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(DuplicateException::class, $e); - } + $docs = $database->find('bulk_delete_queries', [Query::limit(100)]); + $this->assertEquals(50, \count($docs)); - return $document; + $lastDoc = \end($docs); + $this->assertNotEmpty($lastDoc); + $this->assertEquals('doc49', $lastDoc->getId()); + $this->assertEquals(50, $database->deleteDocuments('bulk_delete_queries')); + + $database->deleteCollection('bulk_delete_queries'); } - public function testEmptyTenant(): void + public function testDeleteBulkDocumentsWithCallbackSupport(): void { /** @var Database $database */ $database = $this->getDatabase(); - if ($database->getAdapter()->getSharedTables()) { - $documents = $database->find( - 'documents', - [Query::select(['*'])] // Mongo bug with Integer UID - ); - - $document = $documents[0]; - $doc = $database->getDocument($document->getCollection(), $document->getId()); - $this->assertEquals($document->getTenant(), $doc->getTenant()); + if (!$database->getAdapter()->supports(Capability::BatchOperations)) { + $this->expectNotToPerformAssertions(); return; } - $documents = $database->find( - 'documents', - [Query::notEqual('$id', '56000')] // Mongo bug with Integer UID - ); - - $document = $documents[0]; - $this->assertArrayHasKey('$id', $document); - $this->assertArrayNotHasKey('$tenant', $document); + $database->createCollection(new Collection(id: 'bulk_delete_with_callback', attributes: [ + Attribute::string(key: 'text', size: 100, required: true), + Attribute::integer(key: 'integer', size: 10, required: true) + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()) + ], documentSecurity: false)); - $document = $database->getDocument('documents', $document->getId()); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayNotHasKey('$tenant', $document); + $this->propagateBulkDocuments('bulk_delete_with_callback'); - $document = $database->updateDocument('documents', $document->getId(), $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayNotHasKey('$tenant', $document); - } + $docs = $database->find('bulk_delete_with_callback'); + $this->assertCount(10, $docs); - public function testEmptyOperatorValues(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + /** + * Test Short select query, test pagination as well, Add order to select + */ + $selects = ['$sequence', '$id', '$collection', '$permissions', '$updatedAt']; try { - $database->findOne('documents', [ - Query::equal('string', []), - ]); - $this->fail('Failed to throw exception'); + // 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('Invalid query: Equal queries require at least one value.', $e->getMessage()); + $this->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $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()); - } - } + $docs = $database->find('bulk_delete_with_callback'); + $this->assertCount(10, $docs); - public function testDateTimeDocument(): void - { - /** - * @var Database $database - */ - $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'])); + $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()); + } + ); - $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')); + $this->assertEquals(2, $count); - $doc = $database->getDocument($collection, 'doc1'); - $this->assertNotEmpty($doc->getAttribute('datetime')); - $this->assertNotEmpty($doc->getAttribute('$createdAt')); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); + // TEST: Bulk Delete All Documents without passing callbacks + $this->assertEquals(8, $database->deleteDocuments('bulk_delete_with_callback')); - $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 - ])); + $docs = $database->find('bulk_delete_with_callback'); + $this->assertCount(0, $docs); - $this->assertEquals($doc->getAttribute('$createdAt'), $date); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); + // TEST: Bulk delete documents with queries with callbacks + $this->propagateBulkDocuments('bulk_delete_with_callback'); - $doc = $database->getDocument($collection, 'doc2'); + $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->assertEquals("Error thrown to test that deletion doesn't stop and error is caught", $e->getMessage()); + }); - $this->assertEquals($doc->getAttribute('$createdAt'), $date); - $this->assertNotEmpty($doc->getAttribute('$updatedAt')); - $this->assertNotEquals($doc->getAttribute('$updatedAt'), $date); + $this->assertEquals(5, $count); - $database->setPreserveDates(false); - $database->deleteCollection($collection); + foreach ($results as $document) { + $this->assertGreaterThanOrEqual(5, $document->getAttribute('integer')); + } + + $docs = $database->find('bulk_delete_with_callback'); + $this->assertEquals(5, \count($docs)); + + // Teardown + $database->deleteCollection('bulk_delete_with_callback'); } - public function testDateTimeArrayDocument(): void + public function testUpdateDocumentsQueries(): void { /** @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'])); + if (!$database->getAdapter()->supports(Capability::BatchOperations)) { + $this->expectNotToPerformAssertions(); + return; + } - $d1 = '2000-01-01T10:00:00.000+00:00'; - $d2 = '2001-02-03T05:06:07.000+00:00'; + $collection = 'testUpdateDocumentsQueries'; - $database->createDocument($collection, new Document([ - '$id' => 'doc1', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'dates' => [$d1, $d2], + $database->createCollection(new Collection(id: $collection, attributes: [ + Attribute::string(key: 'text', size: 64, required: true), + Attribute::integer(key: 'integer', size: 64, required: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()) ])); - // 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]); + // Test limit + $this->propagateBulkDocuments($collection, 100); - // 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')); + $this->assertEquals(10, $database->updateDocuments($collection, new Document([ + 'text' => 'text📝 updated', + ]), [Query::limit(10)])); - $database->deleteCollection($collection); + $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))); + + // Test Offset + $this->propagateBulkDocuments($collection, 100); + $this->assertEquals(50, $database->updateDocuments($collection, new Document([ + 'text' => 'text📝 updated', + ]), [ + Query::offset(50), + ])); + + $docs = $database->find($collection, [Query::equal('text', ['text📝 updated']), Query::limit(100)]); + $this->assertCount(50, $docs); + + $lastDoc = end($docs); + $this->assertNotEmpty($lastDoc); + $this->assertEquals('doc99', $lastDoc->getId()); + + $this->assertEquals(100, $database->deleteDocuments($collection)); } - public function testInvalidCreatedAndUpdatedAtThrowStructureException(): void + public function testEmptyOperatorValues(): void { /** @var Database $database */ $database = $this->getDatabase(); + $this->initDocumentsFixture(); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; + 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()); } - $collection = 'invalid_date_attributes'; - - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); - - $database->setPreserveDates(true); - try { - // Outside allowed year range (Structure uses DatetimeValidator min/max, e.g. 0000–9999). - $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); + $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()); } } @@ -6986,8 +8030,8 @@ public function testSingleDocumentDateOperations(): void /** @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->createCollection(new Collection(id: $collection)); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))); $database->setPreserveDates(true); @@ -7034,7 +8078,6 @@ public function testSingleDocumentDateOperations(): void 'string' => 'no_dates' ])); - $doc3->setAttribute('string', 'updated_no_dates'); $doc3->setAttribute('$createdAt', $createDate); $doc3->setAttribute('$updatedAt', $updateDate); @@ -7144,8 +8187,6 @@ public function testSingleDocumentDateOperations(): void $newUpdatedAt = $doc11->getUpdatedAt(); - \usleep(2000); // Ensure $updatedAt differs when adapter timestamp precision is milliseconds - $newDoc11 = new Document([ 'string' => 'no_dates_update', ]); @@ -7161,8 +8202,8 @@ public function testBulkDocumentDateOperations(): void /** @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->createCollection(new Collection(id: $collection)); + $this->assertEquals(true, $database->createAttribute($collection, Attribute::string(key: 'string', size: 128))); $database->setPreserveDates(true); @@ -7251,700 +8292,911 @@ public function testBulkDocumentDateOperations(): void $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"); - } + 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(new Collection(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(new Collection(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->disableValidation(); + + $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->enableValidation(); + + 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(new Collection(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->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()->supports(Capability::BatchOperations)) { + 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(); + } + + // 4) upsertDocumentsWithIncrease with null required should fail when validation enabled, pass when disabled + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + 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); + } + + $database->disableValidation(); + $ucount = $database->upsertDocumentsWithIncrease( + collection: $collection, + attribute: 'value', + documents: [new Document([ + '$id' => 'u1', + 'name' => null, + 'value' => 1, + ])] + ); + $this->assertEquals(1, $ucount); + $database->enableValidation(); + } + + // Cleanup + $database->deleteCollection($collection); + } - // Test 3: Bulk update with preserve dates disabled - $database->setPreserveDates(false); + private function assertReadServedFromCache(Database $database, callable $read, string $message): void + { + $statements = $this->countStatements($database, $read); + if ($statements !== null) { + $this->assertSame(0, $statements, $message); + } + } - $customDate = 'should be ignored anyways so no error'; - $updateDocDisabled = new Document([ - 'string' => 'disabled_update', - '$createdAt' => $customDate, - '$updatedAt' => $customDate - ]); + private function assertReadReachesAdapter(Database $database, callable $read, string $message): void + { + $statements = $this->countStatements($database, $read); + if ($statements !== null) { + $this->assertGreaterThan(0, $statements, $message); + } + } - $countDisabled = $database->updateDocuments($collection, $updateDocDisabled); - $this->assertEquals(6, $countDisabled); + /** + * 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(); - // Test 4: Bulk update with preserve dates re-enabled - $database->setPreserveDates(true); + return null; + } - $newDate = '2000-03-01T20:45:00.000+00:00'; - $updateDocEnabled = new Document([ - 'string' => 'enabled_update', - '$createdAt' => $newDate, - '$updatedAt' => $newDate - ]); + $profiler = $database->enableProfiling()->getProfiler(); + $this->assertNotNull($profiler); - $countEnabled = $database->updateDocuments($collection, $updateDocEnabled); - $this->assertEquals(6, $countEnabled); + try { + $profiler->reset(); + $read(); - $database->setPreserveDates(false); - $database->deleteCollection($collection); + return $profiler->getQueryCount(); + } finally { + $database->disableProfiling(); + } } - public function testUpsertDateOperations(): void + public function testDropUnknownAttributes(): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); return; } - $collection = 'upsert_date_operations'; - $database->createCollection($collection); - $this->assertEquals(true, $database->createAttribute($collection, 'string', Database::VAR_STRING, 128, false)); - - $database->setPreserveDates(true); + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; - $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())]; + $database->createCollection(new Collection(id: __FUNCTION__)); + $this->assertEquals(true, $database->createAttribute(__FUNCTION__, Attribute::string(key: 'known', size: 128))); - // Test 1: Upsert new document with custom createdAt - $upsertResults = []; - $database->upsertDocuments($collection, [ - new Document([ - '$id' => 'upsert1', + try { + $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'strict', '$permissions' => $permissions, - 'string' => 'upsert1_initial', - '$createdAt' => $createDate - ]) - ], onNext: function ($doc) use (&$upsertResults) { - $upsertResults[] = $doc; - }); - $upsertDoc1 = $upsertResults[0]; - - $this->assertEquals($createDate, $upsertDoc1->getAttribute('$createdAt')); - $this->assertNotEquals($createDate, $upsertDoc1->getAttribute('$updatedAt')); + '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()); + } - // 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]; + $this->assertSame(false, $database->getDropUnknownAttributes()); + $database->setDropUnknownAttributes(true); + $this->assertSame(true, $database->getDropUnknownAttributes()); - $this->assertEquals($createDate, $updatedUpsertDoc1->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $updatedUpsertDoc1->getAttribute('$updatedAt')); + 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'); - // Test 3: Upsert new document with both custom dates - $upsertResults2 = []; - $database->upsertDocuments($collection, [ - new Document([ - '$id' => 'upsert2', + $created = $database->createDocument(__FUNCTION__, new Document([ + '$id' => 'lenient', '$permissions' => $permissions, - 'string' => 'upsert2_both_dates', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate - ]) - ], onNext: function ($doc) use (&$upsertResults2) { - $upsertResults2[] = $doc; - }); - $upsertDoc2 = $upsertResults2[0]; + 'known' => 'kept', + 'unknown' => 'dropped', + ])); - $this->assertEquals($createDate, $upsertDoc2->getAttribute('$createdAt')); - $this->assertEquals($updateDate, $upsertDoc2->getAttribute('$updatedAt')); + $this->assertEquals('kept', $created->getAttribute('known')); + $this->assertNull($created->getAttribute('unknown'), 'Unknown attribute survived the create'); - // 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]; + $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'); - $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$createdAt')); - $this->assertEquals($date3, $updatedUpsertDoc2->getAttribute('$updatedAt')); + $updated = $database->updateDocument(__FUNCTION__, 'lenient', new Document([ + '$id' => 'lenient', + '$permissions' => $permissions, + 'known' => 'changed', + 'unknown' => 'dropped', + ])); - // Test 5: Upsert with preserve dates disabled - $database->setPreserveDates(false); + $this->assertEquals('changed', $updated->getAttribute('known')); + $this->assertNull($updated->getAttribute('unknown'), 'Unknown attribute survived the update'); - $customDate = '2000-01-01T10:00:00.000+00:00'; - $upsertResults3 = []; - $database->upsertDocuments($collection, [ - new Document([ - '$id' => 'upsert3', + $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, - 'string' => 'upsert3_disabled', - '$createdAt' => $customDate, - '$updatedAt' => $customDate - ]) - ], onNext: function ($doc) use (&$upsertResults3) { - $upsertResults3[] = $doc; - }); - $upsertDoc3 = $upsertResults3[0]; + 'known' => 'changed', + 'unknown' => 'dropped', + ])); - $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $upsertDoc3->getAttribute('$updatedAt')); + $this->assertEquals( + $stored->getUpdatedAt(), + $unchanged->getUpdatedAt(), + 'A write carrying only a dropped attribute counted as a change' + ); + } finally { + $database->setDropUnknownAttributes(false); + } - // 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]; + $this->assertSame(false, $database->getDropUnknownAttributes()); + } - $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$updatedAt')); + public function testCreateDocumentWithBigIntType(): void + { + $database = $this->getDatabase(); - // Test 6: Bulk upsert operations with custom dates - $database->setPreserveDates(true); + $database->createCollection(new Collection(id: __FUNCTION__)); + $this->assertTrue($database->createAttribute(__FUNCTION__, Attribute::bigInteger(key: 'bigint_signed', required: true))); + $this->assertTrue($database->createAttribute(__FUNCTION__, Attribute::bigInteger(key: 'bigint_unsigned', required: true, signed: false))); - // 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' - ]) - ]; + $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, + ])); - $bulkUpsertResults = []; - $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { - $bulkUpsertResults[] = $doc; - }); + $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')); - // 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"); - } + $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(); - foreach (['bulk_upsert2', 'bulk_upsert3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + if (! $database->getAdapter()->supports(Capability::UnsignedBigInt)) { + $this->markTestSkipped('Adapter does not support unsigned bigint'); } - 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"); - } + $collection = 'bigint_scenarios_filters'; + $database->createCollection(new Collection(id: $collection)); + $this->assertTrue($database->createAttribute($collection, Attribute::bigInteger(key: 'signed_bigint', required: true))); + $this->assertTrue($database->createAttribute($collection, Attribute::bigInteger(key: 'unsigned_bigint', required: true, signed: false))); - // 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 - ]); + $collectionDoc = $database->getCollection($collection); + $this->assertSame($collection, $collectionDoc->getId()); - $upsertIds = []; - foreach ($upsertDocuments as $doc) { - $upsertIds[] = $doc->getId(); + $signedAttribute = null; + $unsignedAttribute = null; + foreach ($collectionDoc->attributes as $attribute) { + if ($attribute->key === 'signed_bigint') { + $signedAttribute = $attribute; + } + if ($attribute->key === 'unsigned_bigint') { + $unsignedAttribute = $attribute; + } } - $database->updateDocuments($collection, $updateUpsertDoc, [ - Query::equal('$id', $upsertIds) - ]); + $this->assertNotNull($signedAttribute); + $this->assertNotNull($unsignedAttribute); + $this->assertSame(0, $signedAttribute->size); + $this->assertSame(0, $unsignedAttribute->size); - 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"); - } + $beyond32Bit = '2147483648'; + $signedMax = (string) \PHP_INT_MAX; + $signedMin = (string) \PHP_INT_MIN; + $unsignedValue = '18446744073709551615'; - // Test 10: checking by passing null to each - $updateUpsertDoc = new Document([ - 'string' => 'bulk_upsert_updated', - '$createdAt' => null, - '$updatedAt' => null + $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()); - $upsertIds = []; - foreach ($upsertDocuments as $doc) { - $upsertIds[] = $doc->getId(); - } + $resultStringFilter = $database->find($collection, [ + Query::equal('signed_bigint', [$signedMax]), + ]); + $this->assertCount(1, $resultStringFilter); + $this->assertSame('bigint-scenarios-doc', $resultStringFilter[0]->getId()); - $database->updateDocuments($collection, $updateUpsertDoc, [ - Query::equal('$id', $upsertIds) + $resultUnsignedFilter = $database->find($collection, [ + Query::equal('unsigned_bigint', [$unsignedValue]), ]); + $this->assertCount(1, $resultUnsignedFilter); + $this->assertSame('bigint-scenarios-doc', $resultUnsignedFilter[0]->getId()); - 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"); - } + $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')); - // 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; - } + $database->deleteCollection($collection); + } - $upsertUpdateResults = []; - $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { - $upsertUpdateResults[] = $doc; - }); - $this->assertEquals(4, $countUpsertUpdate); + public function testWithSignedBigInt(): void + { + $database = $this->getDatabase(); - 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"); - } + $collection = 'signed_bigint_only'; + $database->createCollection(new Collection(id: $collection)); + $this->assertTrue($database->createAttribute($collection, Attribute::bigInteger(key: 'signed_bigint', required: true))); - // Test 12: Bulk upsert with preserve dates disabled - $database->setPreserveDates(false); + $document = $database->createDocument($collection, new Document([ + '$id' => 'signed-bigint-doc', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'signed_bigint' => \PHP_INT_MAX, + ])); - $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; - } + $this->assertIsInt($document->getAttribute('signed_bigint')); + $this->assertSame(\PHP_INT_MAX, $document->getAttribute('signed_bigint')); - $upsertDisabledResults = []; - $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { - $upsertDisabledResults[] = $doc; - }); - $this->assertEquals(4, $countUpsertDisabled); + $updated = $database->updateDocument($collection, $document->getId(), new Document([ + 'signed_bigint' => \PHP_INT_MIN, + ])); - 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"); - } + $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->setPreserveDates(false); $database->deleteCollection($collection); } - public function testUpdateDocumentsCount(): void + public function testCreateUpdateBigIntAndIncrementDecrement(): void { - /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { - $this->expectNotToPerformAssertions(); - return; - } + $collection = 'bigint_update_increase_decrease'; + $database->createCollection(new Collection(id: $collection)); - $collectionName = "update_count"; - $database->createCollection($collectionName); + $this->assertTrue($database->createAttribute($collection, Attribute::bigInteger(key: 'inc', required: true))); + $this->assertTrue($database->createAttribute($collection, Attribute::bigInteger(key: 'dec', required: true))); - $database->createAttribute($collectionName, 'key', Database::VAR_STRING, 60, false); - $database->createAttribute($collectionName, 'value', Database::VAR_STRING, 60, false); + $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()), + ], + ])); - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + $this->assertIsInt($document->getAttribute('inc')); + $this->assertSame(10, $document->getAttribute('inc')); - $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); + $updated = $database->updateDocument($collection, $document->getId(), new Document([ + 'inc' => 20, + ])); + $this->assertSame(20, $updated->getAttribute('inc')); - $updates = new Document(['value' => 'test']); - $newDocs = []; - $count = $database->updateDocuments($collectionName, $updates, onNext:function ($doc) use (&$newDocs) { - $newDocs[] = $doc; - }); + $afterIncrease = $database->increaseDocumentAttribute($collection, $document->getId(), 'inc', 5, 30); + $this->assertSame(25, $afterIncrease->getAttribute('inc')); - $this->assertCount(4, $newDocs); - $this->assertEquals(4, $count); + $afterDecrease = $database->decreaseDocumentAttribute($collection, $document->getId(), 'dec', 3, 7); + $this->assertSame(7, $afterDecrease->getAttribute('dec')); - $database->deleteCollection($collectionName); + $database->deleteCollection($collection); } - public function testCreateUpdateDocumentsMismatch(): void + public function testCreateDocumentsIgnoreDuplicates(): void { - /** @var Database $database */ $database = $this->getDatabase(); - // 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 = [ + $database->createCollection(new Collection(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'name', size: 128, required: true)); + + $database->createDocuments(__FUNCTION__, [ new Document([ '$id' => 'doc1', - 'key' => 'doc1', + 'name' => 'Original A', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], ]), new Document([ '$id' => 'doc2', - 'key' => 'doc2', - 'value' => 'test', - ]), - new Document([ - '$id' => 'doc3', - '$permissions' => $permissions, - 'key' => 'doc3' + 'name' => 'Original B', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], ]), - ]; - $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()); + ]); + + 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->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(); + }); + }); - $database->createDocument($colName, new Document([ - '$id' => 'doc4', - '$permissions' => $permissions, - 'key' => 'doc4' - ])); + $this->assertSame(1, $count, 'A skipped duplicate is not counted as created'); + $this->assertSame(['doc3'], $emittedIds, 'A skipped duplicate is not handed to onNext'); - $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')); + $doc1 = $database->getDocument(__FUNCTION__, 'doc1'); + $this->assertSame('Original A', $doc1->getAttribute('name')); - $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); + $doc3 = $database->getDocument(__FUNCTION__, 'doc3'); + $this->assertSame('New C', $doc3->getAttribute('name')); + + $this->assertCount(3, $database->find(__FUNCTION__)); + + $database->deleteCollection(__FUNCTION__); } - public function testBypassStructureWithSupportForAttributes(): void + public function testCreateDocumentsIgnoreAllDuplicates(): void { - /** @var Database $database */ - $database = static::getDatabase(); - // for schemaless the validation will be automatically skipped - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $database = $this->getDatabase(); - $collectionId = 'successive_update_single'; + $database->createCollection(new Collection(id: __FUNCTION__)); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'name', size: 128, required: true)); - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'attrA', Database::VAR_STRING, 50, true); - $database->createAttribute($collectionId, 'attrB', Database::VAR_STRING, 50, true); + $database->createDocuments(__FUNCTION__, [ + new Document([ + '$id' => 'existing', + 'name' => 'Original', + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]), + ]); - // bypass required - $database->disableValidation(); + $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(); + }); + }); - $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]) - ]); + $this->assertSame(0, $count, 'A skipped duplicate is not counted as created'); + $this->assertSame([], $emittedIds, 'A skipped duplicate is not handed to onNext'); - $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->enableValidation(); + $doc = $database->getDocument(__FUNCTION__, 'existing'); + $this->assertSame('Original', $doc->getAttribute('name')); - 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); - } + $this->assertCount(1, $database->find(__FUNCTION__)); - $database->deleteCollection($collectionId); + $database->deleteCollection(__FUNCTION__); } - public function testValidationGuardsWithNullRequired(): void + public function testCreateDocumentsSkipDuplicatesEmptyBatch(): void { - /** @var Database $database */ - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $collection = 'skipDupEmpty'; + $database->createCollection(new Collection(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); - // 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); + $count = $database->skipDuplicates(fn () => $database->createDocuments($collection, [])); - // 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); - } + $this->assertSame(0, $count); + $this->assertCount(0, $database->find($collection)); - $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(); + $database->deleteCollection($collection); + } - // 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()); + public function testCreateDocumentsSkipDuplicatesNestedScope(): void + { + $database = $this->getDatabase(); - // 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); - } + $collection = 'skipDupNested'; + $database->createCollection(new Collection(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); - $database->disableValidation(); - $updated = $database->updateDocument($collection, 'valid', new Document([ - 'age' => null, - ])); - $this->assertNull($updated->getAttribute('age')); - $database->enableValidation(); + $makeDoc = fn (string $id, string $name) => new Document([ + '$id' => $id, + 'name' => $name, + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); - // 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, - ])); - } + $database->createDocuments($collection, [$makeDoc('seed', 'Seed')]); - // 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); - } + $countOuter = $database->skipDuplicates(function () use ($database, $collection, $makeDoc) { + $countInner = $database->skipDuplicates(function () use ($database, $collection, $makeDoc) { + return $database->createDocuments($collection, [ + $makeDoc('seed', 'Dup'), + $makeDoc('innerNew', 'InnerNew'), + ]); + }); + $this->assertSame(1, $countInner); - $database->disableValidation(); - $count = $database->updateDocuments($collection, new Document([ - 'name' => null, - ])); - $this->assertGreaterThanOrEqual(3, $count); // at least the seeded docs are updated - $database->enableValidation(); + 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'); - // 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); - } + $all = $database->find($collection); + $ids = \array_map(fn (Document $d) => $d->getId(), $all); + \sort($ids); + $this->assertSame(['innerNew', 'outerNew', 'seed'], $ids); - $database->disableValidation(); - $ucount = $database->upsertDocumentsWithIncrease( - collection: $collection, - attribute: 'value', - documents: [new Document([ - '$id' => 'u1', - 'name' => null, - 'value' => 1, - ])] - ); - $this->assertEquals(1, $ucount); - $database->enableValidation(); + $database->deleteCollection($collection); + } + + public function testCreateDocumentsSkipDuplicatesLargeBatch(): void + { + $database = $this->getDatabase(); + + $collection = 'skipDupLarge'; + $database->createCollection(new Collection(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); - // Cleanup - $database->deleteCollection($collection); - } + $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()), + ], + ]); + } - /** - * 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 - { - /** @var Database $database */ - $database = static::getDatabase(); + $emittedIds = []; + $count = $database->skipDuplicates(function () use ($database, $collection, $batch, &$emittedIds) { + return $database->createDocuments($collection, $batch, batchSize: 100, onNext: function (Document $doc) use (&$emittedIds) { + $emittedIds[] = $doc->getId(); + }); + }); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + $this->assertSame(250, $count, 'The 50 seeded ids are skipped and not counted'); + $this->assertCount(250, $emittedIds); + $this->assertNotContains('doc_25', $emittedIds); - // 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); + $seedDoc = $database->getDocument($collection, 'doc_25'); + $this->assertSame(25, $seedDoc->getAttribute('idx'), 'An existing row must not be overwritten by its duplicate'); - $database->createCollection($collectionId); - $this->assertTrue($database->createAttribute($collectionId, $attrId, Database::VAR_STRING, 128, false)); + $newDoc = $database->getDocument($collection, 'doc_100'); + $this->assertSame(1100, $newDoc->getAttribute('idx')); - // 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', - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - ], - ])); + $this->assertSame(300, $database->count($collection)); - $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); + $database->deleteCollection($collection); + } - $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); + public function testCreateDocumentsSkipDuplicatesSecondCallSkipsAll(): void + { + $database = $this->getDatabase(); - $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); + $collection = 'skipDupSecond'; + $database->createCollection(new Collection(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', size: 128, required: true)); - $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); + $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'] + ); - $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); + $firstCount = $database->skipDuplicates( + fn () => $database->createDocuments($collection, $makeBatch('First')) + ); + $this->assertSame(3, $firstCount); - $database->deleteCollection($collectionId); + $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(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 testUpsertWithJSONFilters(): void + public function testCreateDocumentsSkipDuplicatesRelationships(): void { - $database = static::getDatabase(); + $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { $this->expectNotToPerformAssertions(); + return; } - // 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()), - ]); - - $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); - $database->createAttribute($collection, 'metadata', Database::VAR_STRING, 4000, true, filters: ['json']); - + $parent = 'skipDupParent'; + $child = 'skipDupChild'; $permissions = [ Permission::read(Role::any()), Permission::create(Role::any()), @@ -7952,1531 +9204,1396 @@ public function testUpsertWithJSONFilters(): void Permission::delete(Role::any()), ]; - // Test 1: Insertion (createDocument) with JSON filter - $docId1 = 'json-doc-1'; - $initialMetadata = [ - 'version' => '1.0.0', - 'tags' => ['php', 'database'], - 'config' => [ - 'debug' => false, - 'timeout' => 30 - ] - ]; - - $document1 = $database->createDocument($collection, new Document([ - '$id' => $docId1, - 'name' => 'Initial Document', - 'metadata' => $initialMetadata, - '$permissions' => $permissions, - ])); - - $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']); - - // 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 - ]; - - $document1->setAttribute('name', 'Updated Document'); - $document1->setAttribute('metadata', $updatedMetadata); - - $updatedDoc = $database->updateDocument($collection, $docId1, $document1); - - $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']); - - // Test 3: Upsert - Create new document (upsertDocument) - $docId2 = 'json-doc-2'; - $newMetadata = [ - 'version' => '1.5.0', - 'tags' => ['javascript', 'node'], - 'config' => [ - 'debug' => false, - 'timeout' => 45 - ] - ]; + $database->createCollection(new Collection(id: $parent)); + $database->createCollection(new Collection(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(Relationship::oneToMany( + collection: $parent, + relatedCollection: $child, + key: 'children', + )); - $document2 = new Document([ - '$id' => $docId2, - 'name' => 'New Upsert Document', - 'metadata' => $newMetadata, + $database->createDocument($parent, new Document([ + '$id' => 'existingParent', + 'name' => 'ExistingParent', '$permissions' => $permissions, - ]); - - $upsertedDoc = $database->upsertDocument($collection, $document2); - - $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']); - - // 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 + 'children' => [ + new Document([ + '$id' => 'existingChild', + 'name' => 'ExistingChild', + '$permissions' => $permissions, + ]), ], - 'migrated' => true - ]); - - $upsertedDoc2 = $database->upsertDocument($collection, $document2); - - $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']); - - // Test 5: Upsert - Bulk upsertDocuments (create and update) - $docId3 = 'json-doc-3'; - $docId4 = 'json-doc-4'; + ])); - $bulkDocuments = [ + $batch = [ new Document([ - '$id' => $docId3, - 'name' => 'Bulk Upsert 1', - 'metadata' => [ - 'version' => '3.0.0', - 'tags' => ['python', 'flask'], - 'config' => ['debug' => false] - ], + '$id' => 'existingParent', + 'name' => 'ShouldNotOverwrite', '$permissions' => $permissions, - ]), - new Document([ - '$id' => $docId4, - 'name' => 'Bulk Upsert 2', - 'metadata' => [ - 'version' => '3.1.0', - 'tags' => ['go', 'golang'], - 'config' => ['debug' => true] + 'children' => [ + new Document([ + '$id' => 'existingChild', + 'name' => 'ExistingChild', + '$permissions' => $permissions, + ]), + new Document([ + '$id' => 'retryChild', + 'name' => 'RetryChild', + '$permissions' => $permissions, + ]), ], - '$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 - ], + '$id' => 'newParent', + 'name' => 'NewParent', '$permissions' => $permissions, + 'children' => [ + new Document([ + '$id' => 'newChild', + 'name' => 'NewChild', + '$permissions' => $permissions, + ]), + ], ]), ]; - $count = $database->upsertDocuments($collection, $bulkDocuments); - $this->assertEquals(3, $count); + $database->skipDuplicates(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'); - // 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']); + $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()); - $bulkDoc2 = $database->getDocument($collection, $docId4); - $this->assertEquals('Bulk Upsert 2', $bulkDoc2->getAttribute('name')); - $this->assertEquals('3.1.0', $bulkDoc2->getAttribute('metadata')['version']); + $allChildren = $database->find($child); + $allChildIds = \array_map(fn (Document $d) => $d->getId(), $allChildren); + \sort($allChildIds); + $this->assertSame(['existingChild', 'newChild', 'retryChild'], $allChildIds); - $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']); + $database->deleteCollection($parent); + $database->deleteCollection($child); + } + + public function testDateTimeArrayDocument(): void + { + $database = $this->getDatabase(); + + $collection = 'datetime_array_doc'; + $database->createCollection(new Collection(id: $collection)); + $this->assertTrue($database->createAttribute($collection, Attribute::datetime(key: 'dates', array: true))); + + $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')); - // Cleanup $database->deleteCollection($collection); } - public function testFindRegex(): void + /** + * 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 { - /** @var Database $database */ $database = static::getDatabase(); - // Skip test if regex is not supported - if (!$database->getAdapter()->getSupportForRegex()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { $this->expectNotToPerformAssertions(); + return; } - // Determine regex support type - $supportsPCRE = $database->getAdapter()->getSupportForPCRERegex(); - $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); + $collectionId = 'dotkey_'.ID::unique(); + $attributeId = 'pb.e_DSS.FIRMWARE_VERSION'; + $filteredStorageKey = $database->getAdapter()->filter($attributeId); - // 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(new Collection(id: $collectionId)); + $this->assertTrue($database->createAttribute($collectionId, Attribute::string(key: $attributeId, size: 128))); - $database->createCollection('moviesRegex', permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]); + $database->createDocument($collectionId, new Document([ + '$id' => 'dev1', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ])); - 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)); - } + $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); + }; - if ($database->getAdapter()->getSupportForTrigramIndex()) { - $database->createIndex('moviesRegex', 'trigram_name', Database::INDEX_TRIGRAM, ['name']); - $database->createIndex('moviesRegex', 'trigram_director', Database::INDEX_TRIGRAM, ['director']); - } + $doc = $database->getDocument($collectionId, 'dev1'); + $this->assertSame('dev1', $doc->getId()); + $assertOnlySchemaKeys($doc, null); - // 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, - ]), - ]); + $updated = $database->updateDocument($collectionId, 'dev1', new Document([ + $attributeId => '1.0.0', + ])); + $assertOnlySchemaKeys($updated, '1.0.0'); + $assertOnlySchemaKeys($database->getDocument($collectionId, 'dev1'), '1.0.0'); - // 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) . '/'; + $updated = $database->updateDocument($collectionId, 'dev1', new Document([ + $attributeId => '2.0.0', + ])); + $assertOnlySchemaKeys($updated, '2.0.0'); + $assertOnlySchemaKeys($database->getDocument($collectionId, 'dev1'), '2.0.0'); - // Get all documents to manually verify - $allDocuments = $database->find('moviesRegex'); + $database->deleteCollection($collectionId); + } - // Manually filter documents that match the pattern - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $value = $doc->getAttribute($attribute); - if (preg_match($phpPattern, $value)) { - $expectedMatches[] = $doc->getId(); - } - } + /** + * 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(); - // Get IDs from query results - $actualMatches = array_map(fn ($doc) => $doc->getId(), $queryResults); + $database->createCollection(new Collection(id: 'tieBreak', attributes: [ + Attribute::string(key: 'name', size: 128, required: true), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); - // 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}'" - ); - } + $documents = []; + foreach (['alpha', 'bravo', 'charlie'] as $name) { + $documents[] = new Document([ + 'name' => $name, + '$permissions' => [Permission::read(Role::any())], + ]); + } - // 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}'" - ); - }; + $this->assertSame(3, $database->createDocuments('tieBreak', $documents)); - // 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'), - ]); + $created = $database->find('tieBreak', [Query::orderAsc('$sequence')]); + $this->assertCount(3, $created); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '^Captain', $documents); + $this->assertSame($created[0]->getCreatedAt(), $created[2]->getCreatedAt(), 'The batch must share one timestamp, otherwise the tie break is never consulted'); - // 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)); + $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) + ); - // Test regex pattern - match movies containing 'Frozen' - $pattern = '/Frozen/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', 'Frozen'), + $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' + ); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', 'Frozen', $documents); + $database->deleteCollection('tieBreak'); + } - // 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 testIntegersBeyondInt32(): void + { + $database = $this->getDatabase(); - // Test regex pattern - match movies ending with 'Marvel' - $pattern = '/Marvel$/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', 'Marvel$'), - ]); + $database->createCollection(new Collection(id: __FUNCTION__, attributes: [ + Attribute::integer(key: 'amount', size: 8, required: true), + Attribute::integer(key: 'amounts', size: 8, required: true, array: true), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], documentSecurity: false)); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', 'Marvel$', $documents); + $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], + ])); - $this->assertEquals(1, count($documents)); // Only Captain Marvel - $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); + $paths = [ + 'getDocument' => $database->getDocument(__FUNCTION__, 'row1'), + 'find' => $database->find(__FUNCTION__, [Query::equal('$id', ['row1'])])[0], + ]; - // Test regex pattern - match movies with 'Work' in the name - $pattern = '/.*Work.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '.*Work.*'), - ]); + foreach ($paths as $path => $document) { + $this->assertIsInt($document->getAttribute('amount'), $path.' returned a non-int scalar'); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '.*Work.*', $documents); + $amounts = $document->getAttribute('amounts'); + $this->assertIsArray($amounts); + foreach ($amounts as $index => $amount) { + $this->assertIsInt($amount, $path.' returned a non-int at amounts['.$index.']'); + } - // 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->assertSame([-3408048000, -42, 3408048000, Database::MAX_BIG_INT], $amounts); - // Test regex pattern - match movies with 'Buck' in director - $pattern = '/.*Buck.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('director', '.*Buck.*'), - ]); + $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' + ); + } - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('director', '.*Buck.*', $documents); + $sum = $database->sum(__FUNCTION__, 'amount'); + $this->assertIsInt($sum, 'A total past int32 must come back as a native integer'); + $this->assertSame(4000000000, $sum); - // 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->deleteCollection(__FUNCTION__); + } - // 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 - ]); + public function testInvalidCreatedAndUpdatedAtThrowStructureException(): void + { + $database = $this->getDatabase(); - // 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)" - ); - } + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); - // 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(); - } + return; } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($actualMatches); + $collection = 'invalid_date_attributes'; - // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) - // Check which one matches the actual results - sort($expectedMatchesCaseSensitive); - sort($expectedMatchesCaseInsensitive); + $database->createCollection(new Collection(id: $collection)); + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'string', size: 128))); - // Verify that actual results match either case-sensitive or case-insensitive expectations - $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); - $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); + $database->setPreserveDates(true); - $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." - ); + try { + $invalidDate = '10000-01-01T00:00:00.000+00:00'; - // 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 - ]); + 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); + } - // 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'" - ); - } + $database->createDocument($collection, new Document([ + '$id' => 'doc2', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + 'string' => 'x', + ])); - // Verify completeness - $allDocuments = $database->find('moviesRegex'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern, $name)) { - $expectedMatches[] = $doc->getId(); + 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); } - $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'" - ); + } - // Test regex combined with other queries - $pattern = '/^Captain/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '^Captain'), - Query::greaterThan('year', 2010), - ]); + /** + * 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(); - // 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"); - } + $collection = 'update_change_id_perms'; - // 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(); - } - } - $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" - ); + try { + $database->createCollection(new Collection(id: $collection, attributes: [ + Attribute::string(key: 'name', size: 128), + ], permissions: [], documentSecurity: true)); - // Test regex with limit - $pattern = '/.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '.*'), // Match all - Query::limit(3), - ]); + $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()); - $this->assertEquals(3, count($documents)); + $auth->addRole(Role::user('alice')->toString()); + $visible = $database->find($collection); + $this->assertCount(1, $visible, 'As alice the document is visible through the permission rows'); - // 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}'" - ); - } + $renamed = $auth->skip(fn () => $database->updateDocument($collection, 'old_id', new Document(\array_merge( + $document->getArrayCopy(), + ['$id' => 'new_id'], + )))); + $this->assertSame('new_id', $renamed->getId()); - // Note: With limit, we can't verify completeness, but we can verify all returned match + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'old_id'))->isEmpty()); - // Test regex with non-matching pattern - $pattern = '/^NonExistentPattern$/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '^NonExistentPattern$'), - ]); + $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->assertEquals(0, count($documents)); + $this->assertTrue($auth->skip(fn () => $database->getDocument($collection, 'new_id'))->isEmpty()); - // 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}'"); + $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()); - // Verify completeness: no documents should be returned - $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); + $foundAsAlice = $database->find($collection); + $this->assertCount(0, $foundAsAlice, 'The removed permission rows must be gone'); - // Test regex with special characters (should be escaped or handled properly) - $pattern = '/.*:.*/'; - $documents = $database->find('moviesRegex', [ - Query::regex('name', '.*:.*'), // Match movies with colon - ]); + $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()); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '.*:.*', $documents); + $auth->skip(fn () => $database->deleteCollection($collection)); + } + } - // Verify expected document is included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + public function testArrayContainsQueriesOnScalarArrays(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - // 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'); + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { + $this->expectNotToPerformAssertions(); - // 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), - ]); + return; + } - // 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}'" - ); - } + $collection = 'array_contains_scalars'; + $database->createCollection(new Collection( + 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, + )); - // 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(); - } + 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])); } - $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 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'" - ); - } + $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); - // 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(); + $this->assertSame($expected, $ids, $case); + $this->assertSame(\count($expected), $database->count($collection, [$countQuery]), $case); } + } finally { + $database->deleteCollection($collection); } - $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 testRegexInjection(): void + + /** + * 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 = static::getDatabase(); + $database = $this->getDatabase(); + $collection = 'stored_json_permissions'; - // Skip test if regex is not supported - if (!$database->getAdapter()->getSupportForRegex()) { - $this->expectNotToPerformAssertions(); - return; - } + $database->createCollection(new Collection( + id: $collection, + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'prefs', size: 1024, filters: ['json']), + ], + permissions: [Permission::read(Role::any()), Permission::update(Role::any())], + documentSecurity: false, + )); - $collectionName = 'injectionTest'; - $database->createCollection($collectionName, permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]); + try { + $database->getAuthorization()->skip(fn (): Document => $database->getAdapter()->createDocument($database->getCollection($collection), new Document([ + '$id' => 'legacy', + ...($database->getSharedTables() ? ['$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')); - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals(true, $database->createAttribute($collectionName, 'text', Database::VAR_STRING, 1000, true)); + $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); } + } - // 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', - ])); + public function testSkipDuplicatesNeverGrantsAnExistingDocument(): void + { + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $collection = 'skipDupGrants'; - $database->createDocument($collectionName, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'text' => 'other', - ])); + $database->createCollection(new Collection( + id: $collection, + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + $database->createAttribute($collection, Attribute::integer(key: 'rank', required: true)); - // 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 - ]; + $readableIds = fn (): array => \array_map( + fn (Document $document): string => $document->getId(), + $database->find($collection, [Query::orderAsc('$id')]), + ); - // 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 - ]; + try { + $database->createDocument($collection, new Document([ + '$id' => 'existing', + '$permissions' => [Permission::read(Role::user('alice'))], + 'rank' => 5, + ])); - $allInjectionPatterns = array_merge($sqlInjectionPatterns, $mongoInjectionPatterns); + $emittedIds = []; + $created = $database->skipDuplicates(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(); + }); + }); - foreach ($allInjectionPatterns as $pattern) { - try { - $results = $database->find($collectionName, [ - Query::regex('text', $pattern), - ]); + $this->assertSame(1, $created, 'A skipped duplicate is not counted as created'); + $this->assertSame(['fresh'], $emittedIds, 'A skipped duplicate is not handed to onNext'); - // 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; + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); - // 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." - ); - } - } - } + $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()); - // 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); + $authorization->addRole(Role::user('alice')->toString()); - // If pattern is invalid, skip validation - if ($matches === false) { - continue; - } + $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); + } + } - // 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." - ); - } - } + public function testSkipDuplicatesStillThrowsUniqueForAnotherUniqueIndex(): void + { + $database = $this->getDatabase(); - } 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); - } + if (! $database->getAdapter()->supports(Capability::UniqueIndex)) { + $this->expectNotToPerformAssertions(); + + return; } - // Test that legitimate regex patterns still work correctly - $legitimatePatterns = [ - 'target', // Should match "target" - '^target', // Should match "target" (anchored) - 'other', // Should match "other" - ]; + $collection = 'skipDupUnique'; + $permissions = [Permission::read(Role::any())]; + $database->createCollection(new Collection(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])); - foreach ($legitimatePatterns as $pattern) { - try { - $results = $database->find($collectionName, [ - Query::regex('text', $pattern), - ]); + try { + $database->createDocument($collection, new Document(['$id' => 'taken', '$permissions' => $permissions, 'slug' => 'shared'])); - $this->assertIsArray($results); + $created = null; + $thrown = null; + try { + $created = $database->skipDuplicates(fn (): int => $database->createDocuments($collection, [ + new Document(['$id' => 'colliding', '$permissions' => $permissions, 'slug' => 'shared']), + ])); + } catch (DuplicateException $exception) { + $thrown = $exception; + } - // 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()); + 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); } - } - // Cleanup - $database->deleteCollection($collectionName); + $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); + } } /** - * 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. + * Whether the engine behind the database, pooled or not, can skip a stored id without + * skipping other unique collisions. */ - // 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); - // } + private function skipsOnlyStoredIds(Database $database): bool + { + $adapter = $database->getAdapter(); + if ($adapter instanceof Mongo) { + return true; + } - public function testCreateDocumentsIgnoreDuplicates(): void + $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(new Collection( + 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(new Collection( + 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($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(new Collection( + 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 (! $database->getAdapter()->supports(Capability::BitwiseAggregates)) { + $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(new Collection( + 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->find($collection, [Query::equal('category', ['none']), $aggregate()]); + $this->assertCount(1, $empty, $method); + $values = $empty[0]->getArrayCopy(); + $this->assertNotSame([], $values, $method); + foreach ($values as $name => $value) { + $this->assertStringStartsNotWith('$inputs:', (string) $name, $method); + $this->assertNull($value, $method.': '.$name); + } + + $filled = $database->find($collection, [Query::equal('category', ['a']), $aggregate(), $aggregate('named')]); + $this->assertCount(1, $filled, $method); + $values = $filled[0]->getArrayCopy(); + $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(new Collection( + 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->enableProfiling()->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->disableProfiling(); + } - // 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(new Collection( + id: $orders, + attributes: [Attribute::string(key: 'item', size: 16), Attribute::integer(key: 'quantity')], + permissions: $permissions, + )); + $database->createCollection(new Collection( + id: $items, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::integer(key: 'price'), Attribute::integer(key: 'quantity')], + permissions: $permissions, + )); + $database->createCollection(new Collection( + 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, 'item', 'code', '=', 'it'); + $extra = Query::join($extras, 'item', 'code', '=', 'ex'); - $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(new Collection( + id: $orders, + attributes: [Attribute::string(key: 'item', size: 16), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + $database->createCollection(new Collection( + 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"); + $rows = static function (array $documents): array { + /** @var array $documents */ + return \array_map(static fn (Document $document): array => $document->getArrayCopy(), $documents); + }; + + foreach (['join' => Query::join($items, 'item', 'code', '=', 'it'), 'full outer join' => Query::fullOuterJoin($items, 'item', 'code', '=', 'it')] as $case => $join) { + $this->assertEquals( + [['orders' => 2, 'name' => 'x', 'it.name' => 'apple'], ['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $rows($database->find($orders, [$join, Query::count('*', 'orders'), Query::groupBy(['name', 'it.name']), Query::orderAsc('name')])), + $case, + ); + $this->assertEquals( + [['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $rows($database->find($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']], + $rows($database->find($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(new Collection( + id: $orders, + attributes: [Attribute::string(key: 'item', size: 16), Attribute::integer(key: 'quantity'), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + $database->createCollection(new Collection( + id: $items, + attributes: [Attribute::string(key: 'code', size: 16), Attribute::integer(key: 'price'), Attribute::string(key: 'name', size: 16)], + permissions: $permissions, + )); + $database->createCollection(new Collection( + 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, 'item', 'code', '=', 'it'); + $extra = Query::join($extras, 'item', 'code', '=', 'ex'); + $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, 'item', 'code', '=', 'it'), 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']], + \array_map(static fn (Document $row): array => $row->getArrayCopy(), $database->find($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' => [$item, $extra, Query::orderAsc('price')], + 'Invalid query: Attribute not found in schema: weight' => [$item, Query::orderAsc('weight')], + 'Invalid query: Cannot select "it.*": an aggregation query can only select the attributes it groups by' => [$item, Query::count('*', 'orders'), Query::groupBy(['it.name']), Query::select(['it.*'])], + 'Invalid query: Attribute not found in schema: zz' => [$item, Query::select(['name', 'zz.*'])], + ] as $message => $queries) { + try { + $database->find($orders, $queries); + $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->enableProfiling()->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->disableProfiling(); + } - $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(new Collection(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(new Collection(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(new Collection( + 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..645c84e9a6 100644 --- a/tests/e2e/Adapter/Scopes/GeneralTests.php +++ b/tests/e2e/Adapter/Scopes/GeneralTests.php @@ -3,10 +3,20 @@ 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\Attribute; +use Utopia\Database\Cache\QueryCache; +use Utopia\Database\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -17,10 +27,14 @@ 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\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\Mirror; +use Utopia\Database\PDO; +use Utopia\Database\Profiler\QueryProfiler; use Utopia\Database\Query; trait GeneralTests @@ -40,35 +54,30 @@ 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'); + $database->createCollection(new Collection(id: 'global-timeouts')); $this->assertEquals( true, - $database->createAttribute( - collection: 'global-timeouts', - id: 'longtext', - type: Database::VAR_STRING, - size: 100000000, - required: true - ) + $database->createAttribute('global-timeouts', Attribute::string(key: 'longtext', size: 100000000, required: true)) ); for ($i = 0; $i < 20; $i++) { $database->createDocument('global-timeouts', new Document([ - 'longtext' => file_get_contents(__DIR__ . '/../../../resources/longtext.txt'), + 'longtext' => file_get_contents(__DIR__.'/../../../resources/longtext.txt'), '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), - Permission::delete(Role::any()) - ] + Permission::delete(Role::any()), + ], ])); } @@ -86,781 +95,571 @@ public function testQueryTimeout(): void } } - public function testCountTimeout(): void + public function testSharedTablesUpdateTenant(): void { - if (!$this->getDatabase()->getAdapter()->getSupportForTimeouts()) { + $database = $this->getDatabase(); + $sharedTables = $database->getSharedTables(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); + + if (! $database->getAdapter()->supports(Capability::Schemas)) { $this->expectNotToPerformAssertions(); + return; } - /** @var Database $database */ - $database = $this->getDatabase(); - - $database->createCollection('count-timeouts'); - - $this->assertEquals( - true, - $database->createAttribute( - collection: 'count-timeouts', - id: 'longtext', - type: Database::VAR_STRING, - size: 100000000, - required: true - ) - ); + $sharedTablesDb = 'sharedTables_'.static::getTestToken(); - $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()) - ] - ])); + if ($database->exists($sharedTablesDb)) { + $database->setDatabase($sharedTablesDb)->delete(); } + $database + ->setDatabase($sharedTablesDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); + try { - $database->setTimeout(1); + $database->createCollection(new Collection(id: __FUNCTION__, documentSecurity: false)); - $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']), - ]); - } catch (\Exception $e) { - $thrown = $e; - } + $database + ->setTenant(1) + ->updateDocument(Database::METADATA, __FUNCTION__, new Document([ + '$id' => __FUNCTION__, + 'name' => 'Scooby Doo', + ])); - $this->assertInstanceOf(TimeoutException::class, $thrown, 'count() must throw a timeout exception'); + $database->setTenant(null); + $database->purgeCachedDocument(Database::METADATA, __FUNCTION__); + $doc = $database->getDocument(Database::METADATA, __FUNCTION__); + + $this->assertFalse($doc->isEmpty()); + $this->assertEquals(__FUNCTION__, $doc->getId()); } finally { - $database->clearTimeout(); - $database->deleteCollection('count-timeouts'); + $database->setTenant(null)->setSharedTables(false); + if ($database->exists($sharedTablesDb)) { + $database->delete($sharedTablesDb); + } + $database + ->setSharedTables($sharedTables) + ->setTenant($tenant) + ->setNamespace($namespace) + ->setDatabase($schema); } } - public function testPreserveDatesUpdate(): void + public function testSharedTablesTenantPerDocument(): void { - $this->getDatabase()->getAuthorization()->disable(); - /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } - - $database->setPreserveDates(true); + $sharedTables = $database->getSharedTables(); + $tenantPerDocument = $database->getTenantPerDocument(); + $namespace = $database->getNamespace(); + $schema = $database->getDatabase(); + $tenant = $database->getTenant(); - $database->createCollection('preserve_update_dates'); + if (! $database->getAdapter()->supports(Capability::Schemas)) { + $this->markTestSkipped('Tenant per document needs a schema to hold the shared table'); + } - $database->createAttribute('preserve_update_dates', 'attr1', Database::VAR_STRING, 10, false); + $tenantPerDocDb = 'sharedTablesTenantPerDocument_'.static::getTestToken(); - $doc1 = $database->createDocument('preserve_update_dates', new Document([ - '$id' => 'doc1', - '$permissions' => [], - 'attr1' => 'value1', - ])); + if ($database->exists($tenantPerDocDb)) { + $database->delete($tenantPerDocDb); + } - $doc2 = $database->createDocument('preserve_update_dates', new Document([ - '$id' => 'doc2', - '$permissions' => [], - 'attr1' => 'value2', - ])); + $database + ->setDatabase($tenantPerDocDb) + ->setNamespace('') + ->setSharedTables(true) + ->setTenant(null) + ->create(); - $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'); + // Create collection + $database->createCollection(new Collection(id: __FUNCTION__, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], documentSecurity: false)); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertEquals('Invalid document structure: Missing required attribute "$updatedAt"', $e->getMessage()); - } + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'name', size: 100)); + $database->createIndex(__FUNCTION__, Index::key(key: 'nameIndex', attributes: ['name'])); - try { - $this->getDatabase()->updateDocuments( - 'preserve_update_dates', - new Document([ - '$updatedAt' => '' - ]), - [ - Query::equal('$id', [ - $doc2->getId(), - $doc3->getId() - ]) - ] - ); - $this->fail('Failed to throw structure exception'); + $doc1Id = ID::unique(); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); - $this->assertEquals('Invalid document structure: Missing required attribute "$updatedAt"', $e->getMessage()); - } + // Create doc for tenant 1 + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->createDocument(__FUNCTION__, new Document([ + '$id' => $doc1Id, + '$tenant' => 1, + 'name' => 'Spiderman', + ])); - // non empty dates - $newDate = '2000-01-01T10:00:00.000+00:00'; + // Set to tenant 1 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->getDocument(__FUNCTION__, $doc1Id); - $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')); + $this->assertEquals('Spiderman', $doc['name']); + $doc1CreatedAt = $doc->getCreatedAt(); - $this->getDatabase()->updateDocuments( - 'preserve_update_dates', - new Document([ - '$updatedAt' => $newDate - ]), - [ - Query::equal('$id', [ - $doc2->getId(), - $doc3->getId() - ]) - ] - ); + $doc2Id = ID::unique(); - $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')); + // Create doc for tenant 2 + $database + ->setTenant(null) + ->setTenantPerDocument(true) + ->createDocument(__FUNCTION__, new Document([ + '$id' => $doc2Id, + '$tenant' => 2, + 'name' => 'Batman', + ])); - $database->deleteCollection('preserve_update_dates'); + // Set to tenant 2 and read + $doc = $database + ->setTenantPerDocument(false) + ->setTenant(2) + ->getDocument(__FUNCTION__, $doc2Id); - $database->setPreserveDates(false); + $this->assertEquals('Batman', $doc['name']); + $this->assertEquals(2, $doc->getTenant()); + + // Ensure no read cross-tenant + $docs = $database + ->setTenantPerDocument(false) + ->setTenant(1) + ->find(__FUNCTION__); - $this->getDatabase()->getAuthorization()->reset(); + $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->getSharedTables() + || ! $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->getTenantPerDocument(); + $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(new Collection( + 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 QueryCache($database->getCache(), ID::unique())); - // 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->getSharedTables()) { + $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->getTenantPerDocument(); + $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(new Collection( + 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 (! $database->getAdapter()->supports(Capability::CacheSkipOnFailure)) { + $this->expectNotToPerformAssertions(); + + return; + } + $collection = 'cacheFallback_'.uniqid(); - public function testNestedQueryValidation(): void - { - $this->getDatabase()->createCollection(__FUNCTION__, [ - new Document([ - '$id' => ID::custom('name'), - 'type' => Database::VAR_STRING, - 'size' => 255, - 'required' => true, - ]) + $database->createCollection(new Collection(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', - ]), - ]); + $database->createDocument($collection, new Document([ + '$id' => 'doc1', + 'string' => 'text📝', + ])); + + $database->createIndex($collection, Index::key(key: 'index1', attributes: ['string'])); + $this->assertCount(1, $database->find($collection, [Query::equal('string', ['text📝'])])); + + // 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')); + } + + $original = $database->getCache(); + $destination = $database instanceof Mirror ? $database->getDestination() : null; + $destinationCache = $destination?->getCache(); + + $database->setCache(new Cache((new RedisAdapter($unreachable))->setMaxRetries(0))); 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); - } + $this->assertCount(1, $database->find($collection, [Query::equal('string', ['text📝'])])); + $this->assertSame('text📝', $database->getDocument($collection, 'doc1')->getAttribute('string')); + 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()); + } - public function testCacheFallback(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + 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()); + } - if (!$database->getAdapter()->getSupportForCacheSkipOnFailure()) { - $this->expectNotToPerformAssertions(); - return; + $this->assertSame('text📝', $database->getDocument($collection, 'doc1')->getAttribute('string')); + } finally { + $database->setCache($original); + if ($destination !== null && $destinationCache !== null) { + $destination->setCache($destinationCache); + } } - $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()) - ]); + $this->assertCount(1, $database->find($collection, [Query::equal('string', ['text📝'])])); - $database->createDocument('testRedisFallback', new Document([ - '$id' => 'doc1', - 'string' => 'text📝', + $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')); - $database->createIndex('testRedisFallback', 'index1', Database::INDEX_KEY, ['string']); - $this->assertCount(1, $database->find('testRedisFallback', [Query::equal('string', ['text📝'])])); + $this->assertTrue($database->deleteDocument($collection, 'doc1')); + $this->assertTrue($database->getDocument($collection, 'doc1')->isEmpty()); - // 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); - - $mirrorRedis = new \Redis(); - $mirrorRedis->connect('redis-mirror', 6379); - $mirrorCache = new Cache((new RedisAdapter($mirrorRedis))->setMaxRetries(3)); - $database->getDestination()->setCache($mirrorCache); - } - - $database->setCache($cache); - - $database->getAuthorization()->cleanRoles(); - $database->getAuthorization()->addRole(Role::any()->toString()); - - 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()) - ]); - - $database->createDocument('testCacheReconnect', new Document([ - '$id' => 'reconnect_doc', - 'title' => 'Test Document', - ])); - - // Cache the document - $doc = $database->getDocument('testCacheReconnect', 'reconnect_doc'); - $this->assertEquals('Test Document', $doc->getAttribute('title')); - - // Bring down Redis - $stdout = ''; - $stderr = ''; - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker stop', "", $stdout, $stderr); - sleep(1); - - // Bring back Redis - Console::execute('docker ps -a --filter "name=utopia-redis" --format "{{.Names}}" | xargs -r docker start', "", $stdout, $stderr); - $this->waitForRedis(); - - // Cache should reconnect - read should work - $doc = $database->getDocument('testCacheReconnect', 'reconnect_doc'); - $this->assertEquals('Test Document', $doc->getAttribute('title')); - - // Update should work after reconnect - $database->updateDocument('testCacheReconnect', 'reconnect_doc', new Document([ - '$id' => 'reconnect_doc', - 'title' => 'Updated Title', - ])); - - $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 +672,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(new Collection(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,82 +714,276 @@ 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::NestedTransactions)) { $this->expectNotToPerformAssertions(); + return; } - $attempts = 0; + $collection = 'txNestedDocumentCache'; + $this->createCachedUsers($database, $collection); try { - $database->withTransaction(function () use (&$attempts) { - $attempts++; - throw new \RuntimeException('Persistent failure'); + $database->withTransaction(function () use ($database, $collection): void { + $database->withTransaction(function () use ($database, $collection): void { + $database->updateDocument($collection, 'user', new Document(['name' => 'updated'])); + }); + + $this->assertSame('updated', $database->getDocument($collection, 'user')->getAttribute('name'), 'The outer transaction reads the nested write'); }); - } catch (\RuntimeException $e) { - $this->assertEquals('Persistent failure', $e->getMessage()); - } + + $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->enableProfiling()->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->disableProfiling(); + $database->deleteCollection($collection); + } + } + + private function createCachedUsers(Database $database, string $collection): void + { + $database->createCollection(new Collection(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(new Collection(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 QueryCache($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(new Collection(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 generic exception. + * + * MongoDB's withTransaction has no retry logic, so this test + * only applies to SQL-based adapters. + */ + 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 \RuntimeException('Persistent failure'); + }); + } catch (\RuntimeException $e) { + $this->assertEquals('Persistent failure', $e->getMessage()); + } // Should have attempted 3 times (initial + 2 retries) $this->assertEquals(3, $attempts, 'Should have exhausted all retry attempts'); @@ -1014,13 +1007,14 @@ public function testNestedTransactionState(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForNestedTransactions()) { + if (! $database->getAdapter()->supports(Capability::NestedTransactions)) { $this->expectNotToPerformAssertions(); + return; } - $database->createCollection('txNested'); - $database->createAttribute('txNested', 'title', Database::VAR_STRING, 128, true); + $database->createCollection(new Collection(id: 'txNested')); + $database->createAttribute('txNested', Attribute::string(key: 'title', size: 128, required: true)); $database->createDocument('txNested', new Document([ '$id' => 'nested_existing', @@ -1031,7 +1025,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 +1052,6 @@ public function testNestedTransactionState(): void return true; }); - $this->assertTrue($result); - // inTransaction must be false after everything completes $this->assertFalse( $database->getAdapter()->inTransaction(), @@ -1081,17 +1073,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 (! $database->getAdapter()->supports(Capability::CacheSkipOnFailure)) { + $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(new Collection(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(new Collection(id: 'count-timeouts')); + + $this->assertTrue($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()), + ], + ])); + } + + try { + $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->getConnectionId(); + $this->assertMatchesRegularExpression('/^\d+$/', $connection); + $driver->exec('SET SESSION wait_timeout = 1'); + \sleep(3); + + $interrupted = ['statement that reconnects' => $interruptedByTimeout()]; + $this->assertNotSame($connection, $adapter->getConnectionId(), '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(new Collection(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->getLimitForAttributes() === 0) { + $this->assertSame(0, $database->getLimitForAttributes(), 'An adapter without a column limit reports no limit'); + + return; + } + + $this->assertSame($adapter->getLimitForAttributes() - $adapter->getCountOfDefaultAttributes(), $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(new Collection(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(new Collection(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(new Collection(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\ConnectionId::class) || ! $adapter->hasFeature(Feature\RawQuery::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'lostConnectionNestedTransaction'; + $database->createCollection(new Collection(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->enableProfiling()->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->disableProfiling(); + $database->deleteCollection($collection); + } + } + + private function countStatementsAgainst(QueryProfiler $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->enableProfiling()->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(new Collection(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->disableProfiling(); + $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->getSharedTables(); + $tenantPerDocument = $database->getTenantPerDocument(); + $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(new Collection(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 13dd31d154..a6cae93af7 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -2,19 +2,38 @@ namespace Tests\E2E\Adapter\Scopes; +use DateTime as NativeDateTime; use Exception; +use MongoDB\BSON\UTCDateTime; +use stdClass; use Throwable; +use Utopia\Database\Adapter\Feature; +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\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\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\Index; use Utopia\Database\Query; -use Utopia\Database\Validator\Index; +use Utopia\Database\Storage; +use Utopia\Database\Validator\Index as IndexValidator; +use Utopia\Mongo\Client; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; trait IndexTests { @@ -22,29 +41,29 @@ public function testCreateIndex(): void { $database = $this->getDatabase(); - $database->createCollection('indexes'); + $database->createCollection(new Collection(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', size: 8, 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'); @@ -58,24 +77,24 @@ public function testCreateDeleteIndex(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection('indexes'); + $database->createCollection(new Collection(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)); + $this->assertEquals(true, $database->createAttribute('indexes', Attribute::string(key: 'string', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute('indexes', Attribute::string(key: 'order', size: 128, required: true))); + $this->assertEquals(true, $database->createAttribute('indexes', Attribute::integer(key: 'integer', required: true))); + $this->assertEquals(true, $database->createAttribute('indexes', Attribute::double(key: 'float', required: true))); + $this->assertEquals(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])); + $this->assertEquals(true, $database->createIndex('indexes', Index::key(key: 'index1', attributes: ['string', 'integer'], lengths: [128], orders: [Order::Asc]))); + $this->assertEquals(true, $database->createIndex('indexes', Index::key(key: 'index2', attributes: ['float', 'integer'], orders: [Order::Asc, Order::Desc]))); + $this->assertEquals(true, $database->createIndex('indexes', Index::key(key: 'index3', attributes: ['integer', 'boolean'], orders: [Order::Asc, Order::Desc, Order::Desc]))); + $this->assertEquals(true, $database->createIndex('indexes', Index::unique(key: 'index4', attributes: ['string'], lengths: [128], orders: [Order::Asc]))); + $this->assertEquals(true, $database->createIndex('indexes', Index::unique(key: 'index5', attributes: ['$id', 'string'], lengths: [128], orders: [Order::Asc]))); + $this->assertEquals(true, $database->createIndex('indexes', Index::unique(key: 'order', attributes: ['order'], lengths: [128], orders: [Order::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')); @@ -86,546 +105,165 @@ public function testCreateDeleteIndex(): void $this->assertEquals(true, $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: [Order::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: [Order::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])); + $this->assertEquals(true, $database->createIndex('indexes', Index::key(key: 'index1', attributes: ['string', 'integer'], lengths: [128], orders: [Order::Asc]))); $this->assertEquals(true, $this->deleteIndex('indexes', 'index1')); $this->assertEquals(true, $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->createIndex('indexes', Index::key(key: 'index1', attributes: ['string', 'integer'], lengths: [128], orders: [Order::Asc]))); $this->assertEquals(true, $database->deleteAttribute('indexes', 'string')); $this->assertEquals(true, $database->deleteIndex('indexes', 'index1')); $database->deleteCollection('indexes'); } - - - /** - * @throws Exception|Throwable - */ - public function testIndexValidation(): void + public function testIndexLengthZero(): 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::DefinedAttributes)) { + $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' => [], - ]); + $database->createCollection(new Collection(id: __FUNCTION__)); - $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)); - - 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->createAttribute(__FUNCTION__, Attribute::string(key: 'title1', size: $database->getAdapter()->getMaxIndexLength() + 300, required: true)); 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()); - } - } - - - $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()); - } - - $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()); - - try { - $database->createCollection(ID::unique(), $attributes, $indexes); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertEquals($errorMessage, $e->getMessage()); - } + $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()->getMaxIndexLength(), $e->getMessage()); } - } - public function testCreateCollectionWithIndexOnSequence(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); + $database->createAttribute(__FUNCTION__, Attribute::string(key: 'title2', size: 100, required: true)); + $database->createIndex(__FUNCTION__, Index::key(key: 'index_title2', attributes: ['title2'], lengths: [0])); - if (!$database->getAdapter()->getSupportForIndex()) { - $this->expectNotToPerformAssertions(); - return; + try { + $database->updateAttribute(__FUNCTION__, 'title2', ColumnType::String->value, $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()); } - - $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 + /** + * 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 Validator\Index + * 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()->getSupportForIndex()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes) + || ! $database->getAdapter()->supports(Capability::IdenticalIndexes)) { $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', - ])); - - $documents = $database->find(__FUNCTION__, [ - 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(__FUNCTION__, new Document([ - '$permissions' => [ - Permission::read(Role::any()), - ], - 'username' => 'chester', - 'email' => 'chester@example.com', - ])); - $this->assertCount(2, $database->find(__FUNCTION__, [ - Query::equal('email', ['chester@example.com']), - ])); - - $database->deleteCollection(__FUNCTION__); - } - - public function testIndexLengthZero(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); return; } - $database->createCollection(__FUNCTION__); - - $database->createAttribute(__FUNCTION__, 'title1', Database::VAR_STRING, $database->getAdapter()->getMaxIndexLength() + 300, true); + $database->createCollection(new Collection(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]); - - $index = $database->renameIndex('numbers', 'index1', 'index3'); + $numbers = $database->getCollection($collection); - $this->assertTrue($index); + $this->assertCount(2, $numbers->indexes); + $this->assertSame('index3', $numbers->indexes[0]->getId()); + $this->assertSame('index2', $numbers->indexes[1]->getId()); - $numbers = $database->getCollection('numbers'); + $this->assertTrue($database->renameIndex($collection, 'index2', 'index4')); + $this->assertSame('index4', $database->getCollection($collection)->indexes[1]->getId()); - $this->assertEquals('index2', $numbers->getAttribute('indexes')[1]['$id']); - $this->assertEquals('index3', $numbers->getAttribute('indexes')[0]['$id']); - $this->assertCount(2, $numbers->getAttribute('indexes')); + $this->assertTrue($database->renameIndex($collection, 'index4', 'index2')); + $this->assertSame('index2', $database->getCollection($collection)->indexes[1]->getId()); } + 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->createCollection(new Collection(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: [Order::Asc])); + $database->createIndex($collection, Index::key(key: 'index2', attributes: ['symbol'], lengths: [0], orders: [Order::Asc])); + $database->renameIndex($collection, 'index1', 'index3'); - $database->deleteCollection('indexLimit'); + self::$renameIndexFixtureInit = true; } public function testListDocumentSearch(): void { - $fulltextSupport = $this->getDatabase()->getAdapter()->getSupportForFulltextIndex(); - if (!$fulltextSupport) { + $fulltextSupport = $this->getDatabase()->getAdapter()->supports(Capability::Fulltext); + 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()), @@ -647,221 +285,84 @@ 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::Fulltext); + 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::TrigramIndex); + 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(new Collection(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 + $this->assertEquals(true, $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(IndexType::Trigram->value, $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'])); + $this->assertEquals(true, $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 @@ -869,7 +370,7 @@ public function testTrigramIndex(): void $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_description')); $collection = $database->getCollection($collectionId); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes; $this->assertCount(0, $indexes); } finally { @@ -880,73 +381,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::TrigramIndex)) { $this->expectNotToPerformAssertions(); + return; } - /** @var Database $database */ - $database = static::getDatabase(); - $collectionId = 'trigram_validation_test'; + try { - $database->createCollection($collectionId); + $database->createCollection(new Collection(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', size: 8)); - // 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'])); + $this->assertTrue($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->getId() === '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::trigram(key: 'trigram_order', attributes: ['name'], orders: [Order::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::trigram(key: 'trigram_length', 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->getId(), $database->getCollection($collectionId)->indexes) + ); } finally { - // Clean up $database->deleteCollection($collectionId); } } @@ -956,41 +451,34 @@ public function testTTLIndexes(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForTTLIndexes()) { + if (! $database->getAdapter()->supports(Capability::TTLIndexes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl'); - $database->createCollection($col); + $database->createCollection(new Collection(id: $col)); - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); + $database->createAttribute($col, Attribute::datetime(key: 'expiresAt', filters: ['datetime'])); $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', attributes: ['expiresAt'], orders: [Order::Asc], 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(IndexType::Ttl->value, $ttlIndex->getAttribute('type')); $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); $now = new \DateTime(); @@ -1013,49 +501,25 @@ 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->createIndex($col, Index::ttl(key: 'idx_ttl_min', attributes: ['expiresAt'], orders: [Order::Asc])) ); $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', signed: false, filters: ['datetime']); - $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', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 7200); - $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + $database->createCollection(new Collection(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()); @@ -1065,152 +529,1067 @@ public function testTTLIndexes(): void $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(); - $col = uniqid('sl_ttl_dup'); - $database->createCollection($col); + $this->expectException(DuplicateException::class); + $this->expectExceptionMessage('Index name already used'); + $database->renameIndex($this->getNumbersCollection(), 'index3', 'index2'); + } - $database->createAttribute($col, 'expiresAt', Database::VAR_DATETIME, 0, false); - $database->createAttribute($col, 'deletedAt', Database::VAR_DATETIME, 0, false); + /** + * @param array $attributes + * @param array $indexes + */ + private function indexValidator(array $attributes, array $indexes): IndexValidator + { + $adapter = $this->getDatabase()->getAdapter(); - $this->assertTrue( - $database->createIndex( - $col, - 'idx_ttl_expires', - Database::INDEX_TTL, - ['expiresAt'], - [], - [Database::ORDER_ASC], - 3600 // 1 hour - ) + return new IndexValidator( + $attributes, + $indexes, + $adapter->getMaxIndexLength(), + $adapter->getInternalIndexesKeys(), + $adapter->supports(Capability::IndexArray), + $adapter->supports(Capability::SpatialIndexNull), + $adapter->supports(Capability::SpatialIndexOrder), + $adapter->supports(Capability::Vectors), + $adapter->supports(Capability::DefinedAttributes), + $adapter->supports(Capability::MultipleFulltextIndexes), + $adapter->supports(Capability::IdenticalIndexes), + $adapter->supports(Capability::ObjectIndexes), + $adapter->supports(Capability::TrigramIndex), + $adapter->hasFeature(Feature\Spatial::class), + $adapter->supports(Capability::Index), + $adapter->supports(Capability::UniqueIndex), + $adapter->supports(Capability::Fulltext), + $adapter->supports(Capability::TTLIndexes), + $adapter->supports(Capability::Objects), ); + } + + 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::IdenticalIndexes)) { + $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(new Collection(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()); + } + } + + $indexes = [ + Index::key(key: 'index1', attributes: ['title1', 'title2'], lengths: [700]), + ]; + + if ($adapter->supports(Capability::DefinedAttributes) && $adapter->getMaxIndexLength() > 0) { + $errorMessage = 'Index length is longer than the maximum: '.$adapter->getMaxIndexLength(); + $this->assertFalse($validator->isValid($indexes[0])); + $this->assertSame($errorMessage, $validator->getDescription()); + + try { + $database->createCollection(new Collection(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', size: 10000); + + $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::Fulltext)) { + $this->assertSame('Fulltext index is not supported', $validator->getDescription()); + } elseif (! $adapter->supports(Capability::MultipleFulltextIndexes)) { + $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(new Collection(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::Fulltext)) { + $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(new Collection(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(new Collection(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::Index)) { + $this->expectNotToPerformAssertions(); + + return; } - $this->assertTrue($database->deleteIndex($col, 'idx_ttl_expires')); + $collection = $database->createCollection(new Collection(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: [Order::Asc, Order::Desc]), + Index::unique(key: '_index 456', attributes: ['email', '$sequence'], orders: [Order::Asc, Order::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]->getId()); + $this->assertSame(['username', '$sequence'], $indexes[0]->attributes); + $this->assertSame('_index 456', $indexes[1]->getId()); + $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::Index)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $database->createCollection(new Collection(id: __FUNCTION__)); + + $this->assertTrue($database->createAttribute(__FUNCTION__, Attribute::string(key: 'username', size: 128))); + $this->assertTrue($database->createAttribute(__FUNCTION__, Attribute::string(key: 'email', size: 128))); + + $this->assertTrue($database->createIndex(__FUNCTION__, Index::key(key: '_index 123', attributes: ['username', '$sequence'], orders: [Order::Asc, Order::Desc]))); + $this->assertTrue($database->createIndex(__FUNCTION__, Index::unique(key: '_index 456', attributes: ['email', '$sequence'], orders: [Order::Asc, Order::Desc]))); + + $indexes = $database->getCollection(__FUNCTION__)->indexes; + $this->assertCount(2, $indexes); + $this->assertSame('_index 123', $indexes[0]->getId()); + $this->assertSame(['username', '$sequence'], $indexes[0]->attributes); + $this->assertSame('_index 456', $indexes[1]->getId()); + $this->assertSame(['email', '$sequence'], $indexes[1]->attributes); + + $this->assertSequenceIndexesAnswerQueries(__FUNCTION__); - $ttlIndex2 = new Document([ - '$id' => ID::custom('idx_ttl_2'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => 7200 + $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', + ])); + + $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, Order::Desc]); + + $tenant = $database->getSharedTables() ? ['_tenant'] : []; + $expected = match (true) { + $adapter instanceof Postgres => [...$tenant, 'tags', 'status', 'name DESC'], + $adapter->supports(Capability::CastIndexArray) => [...$tenant, '', 'status', 'name(16)'], + default => [...$tenant, 'tags(255)', 'status', 'name(16)'], + }; + + $database->createCollection(new Collection(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(new Collection(id: 'index_array_position_added')); + try { + $this->assertTrue($database->createAttributes('index_array_position_added', $attributes)); + $this->assertTrue($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(new Collection(id: $collection)); + + try { + $this->assertTrue($database->createAttribute($collection, Attribute::object(key: 'data'))); + $this->assertTrue($database->createAttribute($collection, Attribute::string(key: 'status', size: 32))); + $this->assertTrue($database->createIndex($collection, Index::key(key: 'countryfirst', attributes: ['data.country', 'status'], orders: [Order::Desc, null]))); + + $parts = $this->getIndexKeyParts($database, $collection, 'countryfirst'); + $tenant = $database->getSharedTables() ? ['_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->getId() !== $index) { + continue; + } + + $columns = $schemaIndex->getAttribute('columns'); + $lengths = $schemaIndex->getAttribute('lengths'); + $this->assertIsArray($columns); + $this->assertIsArray($lengths); + + $parts = []; + foreach (\array_values($columns) as $position => $column) { + $this->assertIsString($column); + $length = $lengths[$position] ?? null; + $parts[] = \is_int($length) ? $column.'('.$length.')' : $column; + } + + return $parts; + } + + $this->fail('Index '.$index.' was not found on '.$collection); + } + + public function testExceptionIndexLimit(): void + { + $database = $this->getDatabase(); + + $database->createCollection(new Collection(id: 'indexLimit')); + + for ($i = 0; $i < 64; $i++) { + $this->assertTrue($database->createAttribute('indexLimit', Attribute::string(key: "test{$i}", size: 16, required: true))); + } + + for ($i = 0; $i < $database->getLimitForIndexes(); $i++) { + $this->assertTrue($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(new Collection(id: $collectionId)); + + $database->createAttribute($collectionId, Attribute::string(key: 'name', size: 256)); + $database->createAttribute($collectionId, Attribute::integer(key: 'age', size: 8)); + + $database->createIndex($collectionId, Index::key(key: 'index1', attributes: ['name', 'age'], orders: [Order::Asc, Order::Desc])); + + $supportsIdenticalIndexes = $database->getAdapter()->supports(Capability::IdenticalIndexes); + + try { + $database->createIndex($collectionId, Index::key(key: 'index2', attributes: ['name', 'age'], orders: [Order::Asc, Order::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: [Order::Asc, Order::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: [Order::Desc, Order::Asc])); + } catch (Throwable $e) { + $this->assertFalse($supportsIdenticalIndexes, 'Unexpected exception when creating index with different orders: '.$e->getMessage()); + } + + $this->assertTrue($database->createIndex($collectionId, Index::key(key: 'index5', attributes: ['name'], orders: [Order::Asc]))); + $this->assertTrue($database->createIndex($collectionId, Index::key(key: 'index6', attributes: ['name', 'age'], orders: [Order::Asc]))); + } finally { + $database->deleteCollection($collectionId); + } + } + + public function testMaxQueriesValues(): void + { + $database = $this->getDatabase(); + $collection = 'maxQueryValues_'.uniqid(); + + $database->createCollection(new Collection(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::Fulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionId = 'multiple_fulltext_test'; + + try { + $database->createCollection(new Collection(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::MultipleFulltextIndexes); + + 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::TTLIndexes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = uniqid('sl_ttl_dup'); + $database->createCollection(new Collection(id: $collection)); + + $database->createAttribute($collection, Attribute::datetime(key: 'expiresAt')); + $database->createAttribute($collection, Attribute::datetime(key: 'deletedAt')); + + $this->assertTrue($database->createIndex($collection, Index::ttl(key: 'idx_ttl_expires', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 3600))); + + foreach ([ + Index::ttl(key: 'idx_ttl_expires_duplicate', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 7200), + Index::ttl(key: 'idx_ttl_deleted', attributes: ['deletedAt'], orders: [Order::Asc], 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->getId(), $indexes); + $this->assertContains('idx_ttl_expires', $indexIds); + $this->assertNotContains('idx_ttl_deleted', $indexIds); + + try { + $database->createIndex($collection, Index::ttl(key: 'idx_ttl_deleted_duplicate', attributes: ['deletedAt'], orders: [Order::Asc], 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()); + } + + $this->assertTrue($database->deleteIndex($collection, 'idx_ttl_expires')); + + $this->assertTrue($database->createIndex($collection, Index::ttl(key: 'idx_ttl_deleted', attributes: ['deletedAt'], orders: [Order::Asc], ttl: 1800))); + + $indexes = $database->getCollection($collection)->indexes; + $this->assertCount(1, $indexes); + + $indexIds = array_map(fn (Index $index) => $index->getId(), $indexes); + $this->assertNotContains('idx_ttl_expires', $indexIds); + $this->assertContains('idx_ttl_deleted', $indexIds); + + try { + $database->createCollection(new Collection(id: uniqid('sl_ttl_dup_collection'), attributes: [ + Attribute::datetime(key: 'expiresAt', signed: false), + ], indexes: [ + Index::ttl(key: 'idx_ttl_1', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 3600), + Index::ttl(key: 'idx_ttl_2', attributes: ['expiresAt'], orders: [Order::Asc], 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->hasFeature(Feature\SchemaIndexes::class) || ! $adapter->supports(Capability::Fulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'schema_fulltext'; + $database->createCollection(new Collection(id: $collection, attributes: [ + Attribute::string(key: 'title', size: 128), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + + try { + $this->assertTrue($database->createIndex($collection, Index::fullText(key: 'title_search', attributes: ['title']))); + $this->assertSame(['title_search' => ['title']], $this->getFulltextSchemaIndexes($database, $collection)); + + $this->assertTrue($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->hasFeature(Feature\SchemaIndexes::class) || ! $adapter->supports(Capability::Fulltext)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'delete_fulltext'; + $database->createCollection(new Collection(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::MultipleFulltextIndexes); + $this->assertTrue($database->createIndex($collection, Index::fullText(key: 'title_search', attributes: ['title']))); + if ($multiple) { + $this->assertTrue($database->createIndex($collection, Index::fullText(key: 'body_search', attributes: ['body']))); + } + $remaining = $multiple ? ['body_search' => ['body']] : []; + + $this->assertTrue($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')); + } + + $this->assertTrue($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) { + $type = $schemaIndex->getAttribute('indexType'); + $this->assertIsString($type); + if (\strtoupper($type) !== 'FULLTEXT') { + continue; + } + + $columns = $schemaIndex->getAttribute('columns'); + $this->assertIsArray($columns); + $columns = \array_values(\array_filter( + $columns, + static fn (mixed $column): bool => \is_string($column) && $column !== '_tenant', + )); + /** @var list $columns */ + $indexes[$schemaIndex->getId()] = $columns; + } + \ksort($indexes); + + return $indexes; + } + + public function testCreateIndexReplacesMismatchedOrphanIndex(): void + { + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\SchemaIndexes::class)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'orphanIndex'; + $database->createCollection(new Collection(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->getSharedTables()) { + 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; + } + + $this->assertTrue($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(new Collection( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + indexes: [Index::key(key: 'by_name', attributes: ['name'])], + )); + + try { + $updated = $database->updateAttribute($collection, 'name', size: 128); + + $this->assertSame(128, $updated->getAttribute('size')); + $this->assertSame(128, $database->getCollection($collection)->attributes[0]->size); + $this->assertSame(['by_name'], \array_map( + static fn (Index $index): string => $index->key, + \array_values($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->getId() !== $index) { + continue; + } + + $columns = $schemaIndex->getAttribute('columns'); + $this->assertIsArray($columns); + + $names = []; + foreach ($columns as $column) { + $this->assertIsString($column); + if ($column !== Storage::TENANT) { + $names[] = $column; + } + } + + return $names; + } + + 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', size: 8)]); + + $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 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(new Collection(id: $fromCollection, attributes: $attributes, indexes: $indexes, permissions: $permissions, documentSecurity: false)); + + $fromIndex = 'key_scan_index_'.\uniqid(); + $database->createCollection(new Collection(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(new Collection(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) { + $this->assertTrue($database->renameIndex($collection, 'byAge', 'ageIndex'), 'SQLite rebuilds the index under the new name from its definition'); + $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(new Collection(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; + } + + $this->assertTrue($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, + \array_values($database->getCollection($collection)->indexes), + ); + } + + /** + * @param array $filter + */ + private function explainMongoFind(Mongo $adapter, string $collection, array $filter): string + { + if ($adapter->getSharedTables()) { + $filter = [Storage::TENANT => $adapter->getTenant(), ...$filter]; + } + + $client = $adapter->getDriver(); + $this->assertInstanceOf(Client::class, $client); + + $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; + } + + /** + * @param list $attributes + */ + private function createMongoUniqueIndexCollection(Database $database, array $attributes): string + { + $collection = 'unique_types_'.\uniqid(); + + $database->createCollection(new Collection( + 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..9178f4393f --- /dev/null +++ b/tests/e2e/Adapter/Scopes/JoinComboTests.php @@ -0,0 +1,2245 @@ +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, '$id', 'mainId', '=', 'pub'), + Query::join($secCol, '$id', 'mainId', '=', 'sec'), + ]); + + $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, 'tag', 'tag', '=', 'visible'), + Query::join($selfCol, 'tag', 'tag', '=', 'hidden'), + Query::join($cCol, 'visible.$id', 'selfId', '=', 'c'), + 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, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'pub'), + 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, '$id', 'mainId', '=', 'pub'), + 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, '$id', 'mainId', '=', 'rev'), + 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->find($mCol, [ + Query::join($secCol, '$id', 'mainId', '=', 'rev'), + Query::equal('name', ['Main']), + Query::sum('rev.score', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertSame(1, \count($aggregated)); + $this->assertComboSecretsHidden($aggregated); + $total = $aggregated[0]->getAttribute('total'); + $this->assertTrue(\is_numeric($total)); + $this->assertSame(323, (int) $total); + $this->assertNotSame(1100, (int) $total); + $cnt = $aggregated[0]->getAttribute('cnt'); + $this->assertTrue(\is_numeric($cnt)); + $this->assertSame(2, (int) $cnt); + $this->assertNotSame(3, (int) $cnt); + + $havingSum = $database->find($mCol, [ + Query::join($secCol, '$id', 'mainId', '=', 'rev'), + Query::sum('rev.score', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + Query::having([Query::equal('total', [1100])]), + ]); + $this->assertSame(0, \count($havingSum)); + $this->assertComboSecretsHidden($havingSum); + + $havingCount = $database->find($mCol, [ + Query::join($secCol, '$id', 'mainId', '=', 'rev'), + Query::sum('rev.score', 'total'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + Query::having([Query::equal('cnt', [3])]), + ]); + $this->assertSame(0, \count($havingCount)); + $this->assertComboSecretsHidden($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, '$id', 'mainId', '=', 'pub'), + 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, '$id', 'mainId', '=', 'pub'), + 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, '$id', 'mainId', '=', 'pub'), + 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, '$id', 'mainId', '=', 'pub'), + Query::leftJoin($secCol, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'pub'), + Query::join($secCol, '$id', 'mainId', '=', 'sec'), + ]); + $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, '$id', 'mainId', '=', 'pub'), + Query::join($secCol, '$id', 'mainId', '=', 'sec'), + ]); + $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, '$id', 'mainId', '=', 'sec'), + 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, $any, false); + $database->updateCollection($cCol, $any, false); + + $this->withComboRoles($database, [Role::any()->toString()], function () use ($database, $mCol, $selfCol, $cCol, $documentLevel): void { + $visible = $database->find($mCol, [ + Query::join($selfCol, '$id', 'mainId', '=', 'mid'), + Query::join($cCol, 'mid.$id', 'selfId', '=', 'c'), + ]); + $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, $documentLevel, true); + + $hidden = $database->find($mCol, [ + Query::join($selfCol, '$id', 'mainId', '=', 'mid'), + Query::join($cCol, 'mid.$id', 'selfId', '=', 'c'), + ]); + $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, '$id', 'mainId', '=', 'pub'), + Query::join($secCol, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'pub'), + Query::join($secCol, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'pub'), + Query::join($secCol, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'pub'), + 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, '$id', 'mainId', '=', 'pub'), + 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::ObjectIndexes)) { + $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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'alpha'), + Query::join($peerCol, 'peerKey', '$id', '=', 'beta'), + 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, '$id', '$id', '=', 'twin'), + 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, '$id', 'mainId', '=', 'meta'), + Query::join($bCol, '$id', 'mainId', '=', 'mid'), + Query::rightJoin($cCol, '$id', 'mainId', '=', 'tail'), + 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, '$id', 'aId', '=', 'b'), + Query::join($cCol, 'b.$id', 'bId', '=', 'c'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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::Fulltext) + && $this->joinHardcoreHasFulltextIndex($database, $metaCol) + ) { + $searched = $database->find($mCol, [ + Query::leftJoin($metaCol, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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->getSharedTables(); + $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(new Collection(id: $mCol, permissions: $any, documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'mainId', '=', 'sec'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + ]); + $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, '$id', 'mainId', '=', 'alpha'), + Query::join($peerCol, 'peerKey', '$id', '=', 'beta'), + 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, '$id', 'mainId', '=', 'alpha'), + Query::join($peerCol, 'peerKey', '$id', '=', 'beta'), + 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, '$id', 'mainId', '=', 'meta'), + ]); + $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, '$id', 'mainId', '=', 'meta'), + Query::equal('meta.score', [8686]), + ]); + $this->assertSame(0, \count($innerHidden)); + $this->assertComboSecretsHidden($innerHidden); + + $foj = $database->find($mCol, [ + Query::fullOuterJoin($metaCol, '$id', 'mainId', '=', 'meta'), + ]); + $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, '$id', 'mainId', '=', 'meta'), + Query::join($peerCol, '$id', 'mainId', '=', 'peer'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + ]; + $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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'meta'), + 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, '$id', 'mainId', '=', 'peer'), + 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(new Collection(id: $mCol, permissions: $any, documentSecurity: false)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(id: $pubCol, permissions: $any)); + $database->createAttribute($pubCol, Attribute::string(key: 'mainId', required: true)); + $database->createAttribute($pubCol, Attribute::integer(key: 'score', required: true)); + + $database->createCollection(new Collection(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(new Collection(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(new Collection(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, + Document::INTERNAL_ID, + Document::SKIP_PERMISSIONS_UPDATE, + ], 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(new Collection(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(new Collection(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(new Collection(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(new Collection(id: $aCol, permissions: $any)); + $database->createAttribute($aCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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(new Collection(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::Fulltext)) { + $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::Fulltext)) { + return false; + } + + /** @var array $indexes */ + $indexes = $database->getCollection($collection)->indexes; + foreach ($indexes as $index) { + $type = $index->getAttribute('type'); + $typeValue = $type instanceof IndexType ? $type->value : $type; + if ($typeValue === IndexType::Fulltext->value) { + 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..fc864271c6 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/JoinTests.php @@ -0,0 +1,8607 @@ +getAdapter()->supports(Capability::Joins)) { + $this->expectNotToPerformAssertions(); + return; + } + + $pCol = 'ljnm_p'; + $rCol = 'ljnm_r'; + $cols = [$pCol, $rCol]; + $this->cleanupAggCollections($database, $cols); + + $database->createCollection(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($pCol, [ + Query::leftJoin($rCol, '$id', 'prod_uid'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertCount(3, $results); + foreach ($results as $doc) { + $this->assertEquals(1, $doc->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($pCol, [ + Query::leftJoin($rCol, '$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->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc; + } + $this->assertEquals(3, $mapped['Product p1']->getAttribute('cnt')); + $this->assertEqualsWithDelta(4.0, $this->numericAttribute($mapped['Product p1'], 'avg_score'), 0.1); + $this->assertEquals(2, $mapped['Product p2']->getAttribute('cnt')); + $this->assertEqualsWithDelta(3.0, $this->numericAttribute($mapped['Product p2'], 'avg_score'), 0.1); + $this->assertEquals(1, $mapped['Product p3']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('order_count')); + $this->assertEquals(1500, $results[0]->getAttribute('total_amount')); + $this->assertEqualsWithDelta(300.0, $this->numericAttribute($results[0], 'avg_amount'), 0.1); + $this->assertEquals(100, $results[0]->getAttribute('min_amount')); + $this->assertEquals(500, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cust_uid'); + $status = $doc->getAttribute('status'); + $this->assertIsString($custUid); + $this->assertIsString($status); + $key = $custUid . '_' . $status; + $mapped[$key] = $doc; + } + $this->assertEquals(2, $mapped['c1_done']->getAttribute('cnt')); + $this->assertEquals(300, $mapped['c1_done']->getAttribute('total')); + $this->assertEquals(1, $mapped['c1_pending']->getAttribute('cnt')); + $this->assertEquals(50, $mapped['c1_pending']->getAttribute('total')); + $this->assertEquals(1, $mapped['c2_done']->getAttribute('cnt')); + $this->assertEquals(300, $mapped['c2_done']->getAttribute('total')); + $this->assertEquals(2, $mapped['c2_pending']->getAttribute('cnt')); + $this->assertEquals(100, $mapped['c2_pending']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $avgAmt = $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $this->assertEquals(700, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $this->assertEquals(300, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::countDistinct('product', 'uniq_prod'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(10, $mapped['c1']->getAttribute('min_amt')); + $this->assertEquals(50, $mapped['c1']->getAttribute('max_amt')); + $this->assertEquals(100, $mapped['c2']->getAttribute('min_amt')); + $this->assertEquals(200, $mapped['c2']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(1, $mapped['c1']->getAttribute('cnt')); + $this->assertEquals(100, $mapped['c1']->getAttribute('total')); + $this->assertEquals(2, $mapped['c2']->getAttribute('cnt')); + $this->assertEquals(700, $mapped['c2']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::between('amount', 100, 300), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]->getAttribute('cnt')); + $this->assertEquals(400, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::greaterThan('amount', 15), + Query::lessThanEqual('amount', 40), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(3, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::equal('status', ['ghost']), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($pCol, [ + Query::leftJoin($oCol, '$id', 'prod_uid'), + Query::sum('amount', 'total'), + Query::groupBy(['name']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $name = $doc->getAttribute('name'); + $this->assertIsString($name); + $mapped[$name] = $doc; + } + $this->assertEquals(300, $mapped['WithOrders']->getAttribute('total')); + $noOrderTotal = $mapped['NoOrders']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cnt')); + $this->assertEquals(550, $doc->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::notEqual('status', 'cancel'), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]->getAttribute('cnt')); + $this->assertEquals(300, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::startsWith('tag', 'promo'), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]->getAttribute('cnt')); + $this->assertEquals(300, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(2, $mapped['c1']->getAttribute('cnt')); + $this->assertEquals(300, $mapped['c1']->getAttribute('total')); + $this->assertEquals(1, $mapped['c2']->getAttribute('cnt')); + $this->assertEquals(300, $mapped['c2']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($pCol, [ + Query::leftJoin($oCol, '$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]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + + $this->assertEquals(3, $mapped['c1']->getAttribute('cnt')); + $this->assertEquals(600, $mapped['c1']->getAttribute('total')); + $c1Avg = $mapped['c1']->getAttribute('avg_amt'); + $this->assertIsNumeric($c1Avg); + $this->assertEqualsWithDelta(200.0, (float) $c1Avg, 0.1); + $this->assertEquals(100, $mapped['c1']->getAttribute('min_amt')); + $this->assertEquals(300, $mapped['c1']->getAttribute('max_amt')); + + $this->assertEquals(2, $mapped['c2']->getAttribute('cnt')); + $this->assertEquals(200, $mapped['c2']->getAttribute('total')); + $c2Avg = $mapped['c2']->getAttribute('avg_amt'); + $this->assertIsNumeric($c2Avg); + $this->assertEqualsWithDelta(100.0, (float) $c2Avg, 0.1); + $this->assertEquals(50, $mapped['c2']->getAttribute('min_amt')); + $this->assertEquals(150, $mapped['c2']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('cnt')); + } + + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(100, $mapped['c1']->getAttribute('total')); + $this->assertEquals(200, $mapped['c2']->getAttribute('total')); + $this->assertEquals(300, $mapped['c3']->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'prod_uid'), + 'leftJoin' => Query::leftJoin($oCol, '$id', 'prod_uid'), + default => throw new \InvalidArgumentException('Unknown join method: '.$joinMethod), + }; + + $results = $database->find($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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + $aggQuery, + ]); + + $this->assertCount(1, $results); + if ($aggMethod === 'avg') { + $result = $results[0]->getAttribute('result'); + $this->assertIsNumeric($result); + $this->assertEqualsWithDelta($expected, (float) $result, 0.1); + } else { + $this->assertEquals($expected, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::sum('amount', 'total'), + Query::groupBy(['cust_uid']), + Query::orderDesc('total'), + ]); + + $this->assertCount(3, $results); + $totals = array_map(fn (Document $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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::count('*', 'cnt'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(0, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::count('*', 'cnt'), + Query::groupBy(['cust_uid']), + Query::orderDesc('cust_uid'), + ]); + + $this->assertCount(3, $results); + $custIds = array_map(fn ($d) => $d->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'title', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::join($pCol, '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->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(3, $mapped['c1']->getAttribute('order_cnt')); + $this->assertEquals(600, $this->intAttribute($mapped['c1'], 'total')); + $this->assertEquals(2, $mapped['c2']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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 (Document $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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $this->assertEquals(1, $results[0]->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($pCol, [ + Query::leftJoin($oCol, '$id', 'prod_uid'), + Query::count('*', 'cnt'), + Query::groupBy(['name']), + ]); + + $this->assertCount(2, $results); + foreach ($results as $doc) { + $this->assertEquals(1, $doc->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $this->assertEquals(1200, $this->intAttribute($results[0], 'total')); + $this->assertEquals('c1', $results[1]->getAttribute('cust_uid')); + $this->assertEquals(500, $this->intAttribute($results[1], 'total')); + + // Now books only + $results = $database->find($oCol, [ + Query::join($cCol, '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]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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 (Document $document): string { + $custUid = $document->getAttribute('cust_uid'); + $status = $document->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::countDistinct('product', 'unique_products'), + Query::groupBy(['cust_uid']), + ]); + + $this->assertCount(2, $results); + $mapped = []; + foreach ($results as $doc) { + $cust_uid = $doc->getAttribute('cust_uid'); + $this->assertIsString($cust_uid); + $mapped[$cust_uid] = $doc; + } + $this->assertEquals(3, $mapped['c1']->getAttribute('unique_products')); + $this->assertEquals(1, $mapped['c2']->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $this->assertEquals(300, $this->intAttribute($results[0], 'total')); + $this->assertEquals('c3', $results[1]->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($pCol, [ + Query::leftJoin($oCol, '$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->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, 'cust_uid', '$id'), + Query::endsWith('tag', 'express'), + Query::count('*', 'cnt'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertEquals(2, $results[0]->getAttribute('cnt')); + $this->assertEquals(300, $results[0]->getAttribute('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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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->find($oCol, [ + Query::join($cCol, '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]->getAttribute('cust_uid')); + $c1Total = $results[0]->getAttribute('total'); + $this->assertIsNumeric($c1Total); + $this->assertEquals(100, (int) $c1Total); + $this->assertEquals('c2', $results[1]->getAttribute('cust_uid')); + $c2Total = $results[1]->getAttribute('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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $aCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($aCol, Attribute::string(key: 'label', size: 100, required: true)); + + $database->createCollection(new Collection(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), + ]); + + $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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(new Collection(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), + ]); + $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(new Collection(id: $lCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($lCol, Attribute::integer(key: 'value', required: true)); + + $database->createCollection(new Collection(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, '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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'cust_uid', '=', 'ord'), + 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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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(new Collection(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, '$id', 'cust_uid', '=', 'ord'), + Query::join($iCol, 'ord.$id', 'order_uid', '=', 'itm'), + 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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'cust_uid', '=', 'ord'), + ]); + + $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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(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, 'tag', 'tag', '=', 'visible'), + Query::join($col, 'tag', 'tag', '=', 'hidden'), + 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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $cCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($cCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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->getSharedTables(); + $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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'prod_uid', '=', 'rev'), + 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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'prod_uid', '=', 'rev'), + 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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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), + ]); + $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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'prod_uid', '=', 'rev'), + 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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'prod_uid'), + ]); + $this->assertSame(false, $inner->isEmpty()); + $this->assertSame('p1', $inner->getId()); + + $left = $database->getDocument($pCol, 'p1', [ + Query::leftJoin($rCol, '$id', 'prod_uid'), + ]); + $this->assertSame(false, $left->isEmpty()); + $this->assertSame('p1', $left->getId()); + + $unmatched = $database->getDocument($pCol, 'p2', [ + Query::leftJoin($rCol, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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(new Collection(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(new Collection(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, '$id', 'mainId'), + Query::leftJoin($jCol, '$id', 'mainId'), + Query::rightJoin($jCol, '$id', 'mainId'), + Query::fullOuterJoin($jCol, '$id', 'mainId'), + Query::crossJoin($jCol), + ]; + + 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, '$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(new Collection(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(new Collection(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, '$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(new Collection(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(new Collection(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, '$id', 'mainId', '=', 'rev'), + Query::select(['name', 'rev.score']), + ]); + + $this->assertGreaterThanOrEqual(1, \count($results)); + $this->assertContains(999, $this->aliasedScores($results)); + + $rewritten = Query::leftJoin('jp_dsoff_public', '$id', 'mainId', '=', 'rev'); + $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, '$id', 'mainId', '=', 'rev'), + 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, '$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, '$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, '$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, '$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), + ]); + + $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, '$id', 'mainId'), + ]); + $this->assertSame(true, $innerSecret->isEmpty()); + + $innerPublic = $database->getDocument($mCol, 'm2', [ + Query::join($jCol, '$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, '$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, '$id', 'mainId'), + ]); + $this->assertSame(false, $leftPublic->isEmpty()); + $this->assertSame('m2', $leftPublic->getId()); + + $rightSecret = $database->getDocument($mCol, 'm1', [ + Query::rightJoin($jCol, '$id', 'mainId'), + ]); + if (! $rightSecret->isEmpty()) { + $this->assertSame('m1', $rightSecret->getId()); + $this->assertSecretJoinHidden($rightSecret, 'j-secret', 999); + } + + $rightPublic = $database->getDocument($mCol, 'm2', [ + Query::rightJoin($jCol, '$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, '$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, '$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, '$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, '$id', 'mainId', '=', 'rev'), + 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, '$id', 'mainId', '=', 'rev'), + 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->getSharedTables(); + $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, '$id', 'mainId')], + [Query::leftJoin($jCol, '$id', 'mainId')], + [Query::fullOuterJoin($jCol, '$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(new Collection(id: $mCol, permissions: [Permission::create(Role::any())])); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$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, '$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, '$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, '$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, '$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, '$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, '$id', 'mainId', '=', 'rev'); + $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->find($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($aggregated, 'j-secret', 999); + + $maxOnly = $database->find($mCol, [ + $join, + Query::max('rev.score', 'max_score'), + Query::groupBy(['name']), + ]); + $this->assertSecretJoinPayloadHidden($maxOnly, 'j-secret', 999); + foreach ($maxOnly as $document) { + $maxScore = $document->getAttribute('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, '$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, '$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, '$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, '$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, '$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(new Collection(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(new Collection(id: $bCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createAttribute($bCol, Attribute::string(key: 'aId', required: true)); + + $database->createCollection(new Collection(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, '$id', 'aId', '=', 'b'), + Query::join($cCol, 'b.$id', 'bId', '=', 'c'), + ]); + 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, '$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(new Collection(id: $main, permissions: $mainGranted ? $granted : $documentLevel)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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(new Collection(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(new Collection(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, + Document::INTERNAL_ID, + Document::SKIP_PERMISSIONS_UPDATE, + ], 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(new Collection(id: $pCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($pCol, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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, '$id', 'mainId', '=', 'j1'), + Query::join($second, '$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, '$id', 'mainId'), + Query::join($second, '$id', 'mainId', '=', 'j0'), + 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, '$id', 'mainId', '=', 'x'), + Query::join($second, '$id', 'mainId', '=', 'x'), + ], + 'the main collection alias' => [ + Query::join($first, '$id', 'mainId', '=', Query::DEFAULT_ALIAS), + ], + ] 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, '$id', 'mainId', '=', 'ord')]), + 'getDocument' => [$database->getDocument($main, 'm1', [Query::leftJoin($first, '$id', 'mainId', '=', 'ord')])], + ] 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(new Collection(id: $main, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 64, required: true)); + $database->createCollection(new Collection(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: ['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, '$id', 'mainId', '=', 'dec'); + $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, '$id', 'mainId', '=', 'b'), + Query::rightJoin($second, '$id', 'mainId', '=', 'c'), + ], + 'on the full outer joined collection' => [ + Query::fullOuterJoin($first, '$id', 'mainId', '=', 'b'), + Query::rightJoin($second, 'b.mainId', 'mainId', '=', 'c'), + ], + ] 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, '$id', 'mainId', '=', 'b'), + Query::rightJoin($second, 'b.mainId', 'mainId', '=', 'c'), + ]; + $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, '$id', 'mainId', '=', 'b'), + Query::fullOuterJoin($second, 'b.mainId', 'mainId', '=', 'c'), + ]; + + 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(new Collection(id: $main, permissions: $permissions)); + $database->createAttribute($main, Attribute::vector(key: 'embedding', size: 3, required: true)); + $database->createCollection(new Collection(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, '$id', 'mainId', '=', 'meta'), + 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, 'link', 'link', '=', 'b'); + + $rows = $database->find($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->find($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]->getAttribute('total')); + $this->assertNull($empty[0]->getAttribute('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, 'link', 'link', '=', 'b'), + 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->find($main, $grouped)), + ); + $this->assertSame( + [[null, 3, 6, 50], ['p', 2, 9, 10]], + $this->fullOuterJoinGroups($database->find($main, [...$grouped, Query::having([Query::greaterThan('rows', 1)])])), + ); + $this->assertSame( + [['p', 2, 9, 10], ['q', 1, 7, 10]], + $this->fullOuterJoinGroups($database->find($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, 'link', 'link', '=', 'b'); + + $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, 'link', 'link', '=', 'b'), + 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 testFullOuterJoinUnaliasedAggregatesKeepTheNamesTheEngineGivesThem(): 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->find($main, [Query::leftJoin($joined, 'link', 'link', '=', 'b'), ...$aggregates]); + $full = $database->find($main, [Query::fullOuterJoin($joined, 'link', 'link', '=', 'b'), ...$aggregates]); + + $this->assertCount(1, $left); + $this->assertCount(1, $full); + $this->assertSame(\array_keys($left[0]->getArrayCopy()), \array_keys($full[0]->getArrayCopy())); + $this->assertSame( + [6, 22, 30], + \array_map(static fn (mixed $value): int => \is_numeric($value) ? (int) $value : -1, \array_values($full[0]->getArrayCopy())), + ); + + $this->cleanupAggCollections($database, $this->fullOuterJoinAggregateCollections()); + } + + /** + * @param array $rows + * @return list + */ + private function fullOuterJoinGroups(array $rows): array + { + return \array_values(\array_map( + fn (Document $row): array => [ + $row->getAttribute('category'), + $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(new Collection( + 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(new Collection(id: $main, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($main, Attribute::string(key: 'name', size: 64, required: true)); + foreach ([$first, $second] as $joined) { + $database->createCollection(new Collection(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, '$id', 'customerId', '=', 'alpha'), + Query::join($refunds, '$id', 'customerId', '=', 'beta'), + ]; + + foreach ([ + [Query::sum('amount', 'total')], + [Query::count('*', 'rows'), Query::groupBy(['amount'])], + ] as $aggregation) { + try { + $database->find($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->find($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->find($customers, [ + Query::join($notes, '$id', 'customerId', '=', 'note'), + Query::join($orders, '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + ]); + $this->assertCount(1, $results); + $this->assertSame(150, $this->intAttribute($results[0], 'total')); + + $results = $database->find($customers, [ + Query::join($notes, '$id', 'customerId'), + Query::join($orders, '$id', 'customerId'), + Query::sum('amount', 'total'), + Query::groupBy(['status']), + ]); + $totals = []; + foreach ($results as $result) { + $status = $result->getAttribute('status'); + $this->assertIsString($status); + $totals[$status] = $this->intAttribute($result, 'total'); + } + \ksort($totals); + $this->assertSame(['open' => 50, 'paid' => 100], $totals); + + $results = $database->find($customers, [ + Query::leftJoin($notes, '$id', 'customerId', '=', 'note'), + 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::Fulltext)) { + $this->expectNotToPerformAssertions(); + return; + } + + [$customers, $orders, , $notes] = $collections = $this->seedJoinedAttributeCollections($database, 'jsfi'); + $unindexed = [ + Query::join($orders, '$id', 'customerId', '=', 'purchase'), + 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, '$id', 'customerId', '=', 'note'), + 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(new Collection(id: $customers, permissions: $permissions)); + $database->createAttribute($customers, Attribute::string(key: 'name', size: 100, required: true)); + + $database->createCollection(new Collection(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(new Collection(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(new Collection(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::Fulltext)) { + $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(new Collection(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(new Collection(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, '$id', 'mainId', '=', 'profile'); + + $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(new Collection(id: $mCol, permissions: $granted)); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(new Collection(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, '$id', 'mainId', '=', 'ord'); + + $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(new Collection(id: $mCol, permissions: [Permission::create(Role::any()), Permission::read(Role::any())])); + $database->createAttribute($mCol, Attribute::string(key: 'name', size: 100, required: true)); + $database->createCollection(new Collection(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, '$id', 'mainId', '=', 'note'); + + $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(new Collection(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(new Collection(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, '$id', 'mainId', '=', 'ledger'); + $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->getSharedTables() || ! $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); + } + } + + public function testSharedTablesChainedJoinsKeepTheSelectedTenantsUnmatchedRows(): void + { + $database = static::getDatabase(); + if (! $database->getSharedTables() || ! $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(new Collection(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(new Collection(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, '$id', 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collection, '$id', 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collection, '$id', 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collection, '$id', 'authorId', '=', $alias), + 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->getSharedTables() || ! $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, '$id', 'authorId', '=', 'review')], + '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, 'extra.authorId', 'authorId', '=', 'review')], + '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, '$id', 'authorId', '=', 'book'), Query::rightJoin($reviews, 'book.authorId', 'authorId', '=', 'review')], + '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, '$id', 'authorId', '=', 'book'), Query::rightJoin($reviews, '$id', 'authorId', '=', 'review')], + '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, '$id', 'authorId', '=', 'book'), Query::fullOuterJoin($reviews, 'book.authorId', 'authorId', '=', 'review')], + '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(new Collection(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(new Collection(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], $on, 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collections[$collection], $on, 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collections[$collection], $on, 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collections[$collection], $on, 'authorId', '=', $alias), + 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(new Collection( + 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], $left, 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collections[$collection], $left, 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collections[$collection], $left, 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collections[$collection], $left, 'authorId', '=', $alias), + 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, 'link', 'link', '=', $alias)]), $alias), + 'find', + ); + $this->assertSame( + [['m1', 'first', 'j1', 1]], + $this->orderColumnSummaries([$database->getDocument($main, 'm1', [Query::leftJoin($joined, 'link', 'link', '=', $alias)])], $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, 'link', 'link', '=', $alias)]); + $this->assertCount(2, $leftJoined); + $columns = $this->orderColumnKeys($leftJoined[0]); + + foreach ($orders as $label => [$order, $key, $ordered]) { + $rows = $database->find($main, [Query::fullOuterJoin($joined, 'link', 'link', '=', $alias), $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(new Collection( + 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(new Collection( + 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, '$id', 'customerId', '=', 'purchase'); + + $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->find($customers, [$join, Query::countDistinct('purchase.nothing', 'total')]); + $reads['groupBy'] = fn () => $database->find($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, '$id', 'customerId', '=', 'purchase'), + Query::join($notes, '$id', 'customerId', '=', 'note'), + ]; + + 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->find($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->find($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, '$id', 'customerId', '=', 'purchase'), + 'left join' => Query::leftJoin($orders, '$id', 'customerId', '=', 'purchase'), + 'full outer join' => Query::fullOuterJoin($orders, '$id', 'customerId', '=', 'purchase'), + ] as $type => $join) { + foreach ([Document::ID, Document::SEQUENCE, Document::CREATED_AT, Document::UPDATED_AT, Document::PERMISSIONS] as $attribute) { + $total = 0; + foreach ($database->find($customers, [$join, Query::count('*', 'rows'), Query::groupBy(['purchase.'.$attribute])]) as $group) { + $this->assertArrayHasKey(Storage::column($attribute), $group->getArrayCopy(), $type.' grouped by purchase.'.$attribute); + $total += $this->intAttribute($group, 'rows'); + } + $this->assertSame(3, $total, $type.' grouped by purchase.'.$attribute); + } + } + + $groups = $database->find($customers, [ + Query::join($orders, '$id', 'customerId', '=', 'purchase'), + Query::sum('purchase.amount', 'total'), + Query::groupBy(['purchase.$id']), + Query::orderAsc('purchase.$id'), + ]); + $this->assertSame(['open', 'other', 'paid'], \array_map(static fn (Document $group): mixed => $group->getAttribute(Storage::UID), $groups)); + $this->assertSame([50, 7, 100], \array_map(fn (Document $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, '$id', 'customerId', '=', 'purchase'); + $notFound = 'Invalid query: Attribute not found in schema: '; + + foreach ([ + 'an unknown right column' => [[Query::join($orders, '$id', 'nothing', '=', 'purchase')], $notFound.'nothing'], + 'an unknown left column' => [[Query::join($orders, 'nothing', 'customerId', '=', 'purchase')], $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, 'purchase.nothing', 'customerId', '=', 'note')], $notFound.'purchase.nothing'], + 'a join declared after it' => [ + [Query::join($notes, 'purchase.customerId', 'customerId', '=', 'note'), $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, 'purchase.customerId', 'customerId', '=', 'note')]), '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, '$id', 'customerId', '=', 'purchase'); + $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, '$id', 'customerId', '=', 'purchase'); + + foreach ([ + 'count' => [Query::count('$collection', 'total')], + 'groupBy' => [Query::count('*', 'rows'), Query::groupBy(['$collection'])], + ] as $shape => $queries) { + try { + $database->find($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->find($customers, [Query::count('$tenant', 'total')]), + 'groupBy' => fn (): array => $database->find($customers, [Query::count('*', 'total'), Query::groupBy(['$tenant'])]), + ], + 'purchase.$tenant' => [ + 'count' => fn (): array => $database->find($customers, [$purchase, Query::count('purchase.$tenant', 'total')]), + 'groupBy' => fn (): array => $database->find($customers, [$purchase, Query::count('*', 'total'), Query::groupBy(['purchase.$tenant'])]), + ], + ]; + + if (! $database->getSharedTables()) { + 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]->getAttribute(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(new Collection(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->enableProfiling()->getProfiler(); + $this->assertNotNull($profiler); + + try { + for ($joins = 1; $joins <= 4; $joins++) { + $queries = []; + for ($join = 1; $join <= $joins; $join++) { + $queries[] = Query::join($collection, '$id', '$id', '=', 'p'.$join); + } + + $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->disableProfiling(); + $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(new Collection(id: $authors, permissions: $permissions)); + $database->createAttribute($authors, Attribute::string(key: 'name', size: 64, required: true)); + $database->createCollection(new Collection(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, '$id', 'authorId', '=', 'post'); + $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(new Collection(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(new Collection(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, 'theme', '$id', '=', 'th'); + $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->find($tickets, [ + Query::join($themes, 'theme', '$id', '=', 'th'), + 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, ['$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, '$id', 'author', '=', 'n'); + + $withoutValue = $database->find($authors, [$join, Query::orderAsc('n.score'), Query::limit(1)])[0]; + $withoutValue->removeAttribute('n.score'); + $otherShape = $database->find($authors, [Query::join($notes, '$id', 'author', '=', 'other'), 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, '$id', 'author', '=', 'n'), 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->iterate($notes, [Query::distinct(), Query::select(['label']), Query::orderAsc('label'), Query::limit(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(new Collection( + 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, ['$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(new Collection( + 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(new Collection( + id: $authors, + attributes: [Attribute::string(key: 'name', size: 16), Attribute::integer(key: 'score')], + permissions: $permissions, + )); + $database->createCollection(new Collection( + 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, ['$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, '$id', 'author', '=', 'n'), Query::leftJoin($notes, '$id', 'author', '=', 'm')]; + $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]); + } + + 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(new Collection(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(new Collection(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, '$id', 'customerId', '=', 'note'); + $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]); + } + } +} diff --git a/tests/e2e/Adapter/Scopes/MetadataCacheTests.php b/tests/e2e/Adapter/Scopes/MetadataCacheTests.php new file mode 100644 index 0000000000..87dcdc30e7 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/MetadataCacheTests.php @@ -0,0 +1,446 @@ +createCollection(new Collection( + 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 array + */ + private function definedAttributes(Database $database, string $collection): array + { + $attributes = $database->getCollection($collection)->getAttribute('attributes', []); + $this->assertIsArray($attributes); + + $defined = []; + foreach ($attributes as $attribute) { + $this->assertInstanceOf(Attribute::class, $attribute); + $defined[] = $attribute; + } + + return $defined; + } + + /** + * @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 + { + $indexes = $database->getCollection($collection)->getAttribute('indexes', []); + $this->assertIsArray($indexes); + + $keys = []; + foreach ($indexes as $index) { + $this->assertInstanceOf(Index::class, $index); + $keys[] = $index->key; + } + + return $keys; + } + + 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', 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, [Permission::read(Role::any())], true); + + $updated = $database->getCollection($collection); + $this->assertTrue($updated->getAttribute('documentSecurity')); + $this->assertSame(['any'], $updated->getRead()); + } + + public function testDeleteCollectionIsVisibleToTheNextRead(): void + { + $database = $this->getDatabase(); + + $collection = $this->warmedCollection($database, 'delcoll'); + + $database->deleteCollection($collection); + + $this->assertTrue($database->getCollection($collection)->isEmpty()); + + $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(Relationship::oneToOne( + collection: $parent, + 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(Relationship::oneToOne( + collection: $parent, + 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(new Collection( + 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(new Collection( + 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 (! $database->getAdapter()->supports(Capability::AtomicTransactions)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = $this->warmedCollection($database, 'rollback'); + + $rolledBack = false; + try { + $database->withTransaction(function () use ($database, $collection): void { + $database->updateCollection($collection, [Permission::read(Role::any())], 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, [Permission::read(Role::any())], 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..7558a93cb6 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/MongoReadFilterTests.php @@ -0,0 +1,348 @@ +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->setSupportForAttributes($lane->getAdapter()->supports(Capability::DefinedAttributes)); + + $database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization(new Authorization()) + ->setDatabase($this->testDatabase) + ->setSharedTables($lane->getSharedTables()) + ->setTenant($lane->getTenant()) + ->setNamespace('unhooked_'.\uniqid()); + + $database->create(); + + $this->assertFalse($adapter->hasPermissionHook()); + + return $database; + } + + private function createAliceOnlyCollection(Database $database): string + { + $collection = 'aliceOnly'; + + $database->createCollection(new Collection( + 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(new Collection( + 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', newKey: '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(new Collection( + 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(new Collection( + 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(new Collection( + 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(); + $this->assertTrue($database->getAdapter()->supports(Capability::QueryContains)); + + $collection = 'contains_family'; + $database->createCollection(new Collection( + 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..2757c719d0 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/MySQLJoinPlanTests.php @@ -0,0 +1,257 @@ +|semijoin|weedout|Remove duplicates from input/i'; + + public function testEightCheckedSelfJoinsKeepTheJoinOrderSearchSmall(): void + { + $database = $this->getDatabase(); + $customers = 'checked_self_joins'; + $database->createCollection(new Collection(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, '$id', '$id', '=', 'peer'.$peer), + \range(1, 8), + )); + } finally { + $database->deleteCollection($customers); + } + } + + public function testEightJoinsOfWhichFourAreCheckedKeepTheJoinOrderSearchSmall(): void + { + $database = $this->getDatabase(); + $customers = 'partly_checked_joins'; + $labels = 'partly_checked_labels'; + $database->createCollection(new Collection(id: $customers, permissions: [Permission::create(Role::any())], documentSecurity: true)); + $database->createCollection(new Collection(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, '$id', '$id', '=', 'peer'.$peer), + \range(1, 4), + ), + ...\array_map( + static fn (int $label): Query => Query::join($labels, 'name', 'name', '=', 'label'.$label), + \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(new Collection(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(new Collection(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], 'name', 'name', '=', 'c'.$link), + \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); + } + } + + /** + * 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..0b88778774 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; @@ -13,7 +16,11 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\Query; +use Utopia\Query\Method; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\Order; trait ObjectAttributeTests { @@ -21,41 +28,56 @@ trait ObjectAttributeTests * 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 mixed $default */ - private function createAttribute(Database $database, string $collectionId, string $attributeId, string $type, int $size, bool $required, $default = null): bool + private function createAttribute(Database $database, string $collectionId, string $attributeId, ColumnType $type, int $size, bool $required, $default = null): bool { - if (!$database->getAdapter()->getSupportForAttributes()) { + if (! $database->getAdapter()->supports(Capability::DefinedAttributes)) { return true; } - $result = $database->createAttribute($collectionId, $attributeId, $type, $size, $required, $default); + $result = $database->createAttribute($collectionId, new Attribute(key: $attributeId, type: $type, size: $size, required: $required, default: $default)); $this->assertEquals(true, $result); + return $result; } + /** + * @param array $data + * @param non-empty-list $path + */ + private function nestedMetaValue(array $data, array $path): mixed + { + $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; + } + + return $current; + } + public function testObjectAttribute(): void { /** @var Database $database */ $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); + $database->createCollection(new Collection(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 +88,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 +116,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 +140,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 +158,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 +175,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 +221,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 +233,10 @@ public function testObjectAttribute(): void Query::notEqual('meta', [ 'user' => [ 'info' => [ - 'country' => 'CA' - ] - ] - ]) + 'country' => 'CA', + ], + ], + ]), ]); // Should return doc2 only $this->assertCount(1, $results); @@ -214,24 +246,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 +279,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 +287,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 +301,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 +317,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 +334,12 @@ public function testObjectAttribute(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'level5' => 'deep_value' - ] - ] - ] - ] - ]]) + 'level5' => 'deep_value', + ], + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); @@ -317,73 +354,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 +439,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 +471,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 +491,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 +511,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 +532,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 +545,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 +563,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 +579,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 +608,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,18 +623,18 @@ public function testObjectAttributeGinIndex(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForObjectIndexes()) { + if (! $database->getAdapter()->supports(Capability::ObjectIndexes)) { $this->markTestSkipped('Adapter does not support object indexes'); } $collectionId = ID::unique(); - $database->createCollection($collectionId); + $database->createCollection(new Collection(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']); + $ginIndex = $database->createIndex($collectionId, Index::object(key: 'idx_data_gin', attributes: ['data'])); $this->assertTrue($ginIndex); // Test 2: Create documents with JSONB data @@ -604,10 +645,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 +658,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', attributes: ['name'])); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -658,11 +699,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::object(key: 'idx_multi_gin', attributes: ['data', 'metadata'])); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -673,7 +714,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::object(key: 'idx_ordered_gin', attributes: ['metadata'], orders: [Order::Asc])); } catch (\Exception $e) { $exceptionThrown = true; $this->assertInstanceOf(IndexException::class, $e); @@ -690,60 +731,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); + $database->createCollection(new Collection(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: 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'); - - // 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 +766,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 +899,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); + $database->createCollection(new Collection(id: $collectionId)); - // 3) Required without default (should fail when missing) - $this->createAttribute($database, $collectionId, 'profile', Database::VAR_OBJECT, 0, true, 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); - // 4) Required with default (should auto-populate) - $this->createAttribute($database, $collectionId, 'profile2', Database::VAR_OBJECT, 0, false, ['name' => 'anon']); - - // 5) Explicit null default - $this->createAttribute($database, $collectionId, 'misc', Database::VAR_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']); + $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')); - // profile provided explicitly - $this->assertEquals('provided', $doc->getAttribute('profile')['name']); - - // 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 +952,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); + $database->createCollection(new Collection(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 +974,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 +1000,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); + $database->createCollection(new Collection(id: $collectionId)); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); $created = $database->createDocument($collectionId, new Document([ '$id' => 'emptyObject', @@ -1060,13 +1046,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); + $database->createCollection(new Collection(id: $collectionId)); + $this->createAttribute($database, $collectionId, 'meta', ColumnType::Object, 0, false); $created = $database->createDocument($collectionId, new Document([ '$id' => 'nestedEmptyObjects', @@ -1078,28 +1064,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 +1110,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); + $database->createCollection(new Collection(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 +1133,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 +1158,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 +1180,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 +1210,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 +1233,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 +1248,11 @@ public function testMetadataWithVector(): void 'settings' => [ 'prefs' => [ 'features' => [ - 'experimental' => true - ] - ] - ] - ]]) + 'experimental' => true, + ], + ], + ], + ]]), ]); $this->assertCount(1, $results); $this->assertEquals('vecA', $results[0]->getId()); @@ -1269,26 +1266,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::ObjectIndexes)) { $this->markTestSkipped('Adapter does not support object attributes'); } $collectionId = ID::unique(); - $database->createCollection($collectionId); + $database->createCollection(new Collection(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']); + $created = $database->createIndex($collectionId, Index::unique(key: 'idx_profile_email_unique', attributes: ['profile.user.email'])); $this->assertTrue($created); $database->createDocument($collectionId, new Document([ @@ -1298,10 +1294,10 @@ public function testNestedObjectAttributeIndexes(): void 'user' => [ 'email' => 'a@example.com', 'info' => [ - 'country' => 'IN' - ] - ] - ] + 'country' => 'IN', + ], + ], + ], ])); try { @@ -1312,10 +1308,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 +1320,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', attributes: ['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,23 +1341,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::ObjectIndexes)) { $this->markTestSkipped('Adapter does not support object attributes'); } $collectionId = ID::unique(); - $database->createCollection($collectionId); + $database->createCollection(new Collection(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']); + $created = $database->createIndex($collectionId, Index::key(key: 'idx_profile_email', attributes: ['profile.user.email'])); $this->assertTrue($created); // Seed documents with different nested values @@ -1374,11 +1370,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 +1384,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 +1398,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 +1439,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 +1449,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 +1458,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()); @@ -1475,20 +1471,20 @@ 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); + $database->createCollection(new Collection(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']); + $created = $database->createIndex($collectionId, Index::key(key: 'idx_deep_nest', attributes: ['profile.level1.level2.level3.level4.value'])); $this->assertTrue($created); $database->createDocuments($collectionId, [ @@ -1500,12 +1496,12 @@ public function testNestedObjectAttributeEdgeCases(): void 'level2' => [ 'level3' => [ 'level4' => [ - 'value' => 'deep_value_1' - ] - ] - ] - ] - ] + 'value' => 'deep_value_1', + ], + ], + ], + ], + ], ]), new Document([ '$id' => 'deep2', @@ -1515,19 +1511,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,17 +1533,17 @@ 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']); + $created = $database->createIndex($collectionId, Index::key(key: 'idx_email', attributes: ['profile.user.email'])); $this->assertTrue($created); - $created = $database->createIndex($collectionId, 'idx_country', Database::INDEX_KEY, ['profile.user.info.country']); + $created = $database->createIndex($collectionId, Index::key(key: 'idx_country', attributes: ['profile.user.info.country'])); $this->assertTrue($created); - $created = $database->createIndex($collectionId, 'idx_city', Database::INDEX_KEY, ['profile.user.info.city']); + $created = $database->createIndex($collectionId, Index::key(key: 'idx_city', attributes: ['profile.user.info.city'])); $this->assertTrue($created); $database->createDocuments($collectionId, [ @@ -1559,10 +1555,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'email' => 'multi1@test.com', 'info' => [ 'country' => 'US', - 'city' => 'NYC' - ] - ] - ] + 'city' => 'NYC', + ], + ], + ], ]), new Document([ '$id' => 'multi2', @@ -1572,30 +1568,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 +1605,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => null, // null value 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'null2', @@ -1621,21 +1617,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 +1651,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'alice.mixed@test.com', 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'mixed2', @@ -1669,21 +1665,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 +1688,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 +1704,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 +1720,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 +1741,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 +1754,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 +1779,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'info' => [ 'country' => 'US', 'city' => 'NYC', - 'zip' => '10001' - ] - ] - ] + 'zip' => '10001', + ], + ], + ], ]), new Document([ '$id' => 'complex2', @@ -1797,10 +1793,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'info' => [ 'country' => 'US', 'city' => 'LAX', - 'zip' => '90001' - ] - ] - ] + 'zip' => '90001', + ], + ], + ], ]), new Document([ '$id' => 'complex3', @@ -1811,19 +1807,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 +1828,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 +1842,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 +1860,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'a@order.com', 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'order2', @@ -1876,10 +1872,10 @@ public function testNestedObjectAttributeEdgeCases(): void 'user' => [ 'email' => 'b@order.com', 'info' => [ - 'country' => 'US' - ] - ] - ] + 'country' => 'US', + ], + ], + ], ]), new Document([ '$id' => 'order3', @@ -1888,17 +1884,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 +1902,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 +1915,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,23 +1941,23 @@ 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']); + $created = $database->createIndex($collectionId, Index::key(key: 'idx_email_recreated', attributes: ['profile.user.email'])); $this->assertTrue($created); // 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']); + if ($database->getAdapter()->supports(Capability::IdenticalIndexes)) { + $created = $database->createIndex($collectionId, Index::unique(key: 'idx_unique_email', attributes: ['profile.user.email'])); $this->assertTrue($created); // Try to create duplicate @@ -1973,10 +1969,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 +1990,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 +2003,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..244f4df9c9 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; @@ -19,23 +22,22 @@ 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(new Collection(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(new Collection(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(new Collection(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', filters: ['datetime'])); + $database->createAttribute($collectionId, Attribute::datetime(key: 'next_update', filters: ['datetime'])); + $database->createAttribute($collectionId, Attribute::datetime(key: 'now_field', filters: ['datetime'])); // 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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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', filters: ['datetime'])); // 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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::integer(key: 'nullable_int', signed: false)); + $database->createAttribute($collectionId, Attribute::string(key: 'nullable_string', size: 100, signed: false)); + $database->createAttribute($collectionId, Attribute::boolean(key: 'nullable_bool', signed: false)); $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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date', filters: ['datetime'])); // 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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date', filters: ['datetime'])); // 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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'timestamp', filters: ['datetime'])); // 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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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,24 +3004,23 @@ 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(new Collection(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', size: 4, default: 0, signed: false)); // Get the collection to verify attribute was created $collection = $database->getCollection($collectionId); - $attributes = $collection->getAttribute('attributes', []); + $attributes = $collection->attributes; $scoreAttr = null; foreach ($attributes as $attr) { if ($attr['$id'] === 'score') { @@ -3087,6 +3066,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 +3092,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(new Collection(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 +3111,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 +3127,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 +3153,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(new Collection(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', size: 4, default: 1, signed: false)); // Create a document with quantity that when multiplied will exceed MAX_INT $doc = $database->createDocument($collectionId, new Document([ @@ -3204,6 +3185,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 +3215,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value')); // Test negative multiplier without max limit $doc1 = $database->createDocument($collectionId, new Document([ @@ -3315,17 +3296,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value')); // Test negative divisor without min limit $doc1 = $database->createDocument($collectionId, new Document([ @@ -3387,20 +3367,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(new Collection(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', size: 4, array: true)); // Create a document with valid integer array $doc = $database->createDocument($collectionId, new Document([ @@ -3428,8 +3407,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 +3443,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 +3476,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(new Collection(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 +3524,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); // Create document with value 8 $doc = $database->createDocument($collectionId, new Document([ @@ -3579,17 +3559,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); // Create document with value 16 $doc = $database->createDocument($collectionId, new Document([ @@ -3626,17 +3605,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'empty_str_doc', @@ -3683,17 +3661,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', size: 500, default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'unicode_doc', @@ -3721,7 +3698,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 +3712,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(new Collection(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 +3781,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(new Collection(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 +3802,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 +3815,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 +3828,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(new Collection(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 +3875,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'precision_doc', @@ -3951,17 +3926,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(new Collection(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 +3952,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 +3963,7 @@ public function testOperatorWithVeryLongStrings(): void ])); $result = $updated->getAttribute('text'); + $this->assertIsString($result); $this->assertStringNotContainsString('A', $result); $this->assertStringContainsString('X', $result); @@ -4001,17 +3977,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::datetime(key: 'date', filters: ['datetime'])); // Test date at end of year $doc = $database->createDocument($collectionId, new Document([ @@ -4026,6 +4001,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 +4014,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 +4027,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 +4040,7 @@ public function testOperatorDateAtYearBoundaries(): void ])); $resultDate = $updated->getAttribute('date'); + $this->assertIsString($resultDate); $this->assertStringStartsWith('2024-01-01', $resultDate); $database->deleteCollection($collectionId); @@ -4074,17 +4053,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(new Collection(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 +4096,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(new Collection(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 +4162,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'zero_doc', @@ -4241,17 +4217,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(new Collection(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 +4266,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::string(key: 'text', default: '')); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'replace_multi_doc', @@ -4335,17 +4309,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(new Collection(id: $collectionId)); + $database->createAttribute($collectionId, Attribute::double(key: 'value', required: true)); $doc = $database->createDocument($collectionId, new Document([ '$id' => 'precise_doc', @@ -4379,17 +4352,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(new Collection(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 +4411,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(new Collection(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 +4453,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(new Collection(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 +4493,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(new Collection(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++) { @@ -4567,9 +4536,13 @@ function (Document $doc, Document $old) use (&$callbackResults) { $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']); + $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(new Collection(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([ @@ -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(new Collection(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(new Collection(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', filters: ['datetime']), + Attribute::datetime(key: 'date_field2', filters: ['datetime']), + Attribute::datetime(key: 'date_field3', filters: ['datetime']), ]; - $database->createCollection($collectionId, $attributes); + $database->createCollection(new Collection(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(new Collection(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(new Collection(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(new Collection(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->exists($database->getDatabase(), $collectionId)) { + $database->deleteCollection($collectionId); + } + $database->createCollection(new Collection(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..8cb27d93e4 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -3,7 +3,12 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use Utopia\Database\Adapter\Feature; use Utopia\Database\Adapter\SQL; +use Utopia\Database\Attribute; +use Utopia\Database\Capability; +use Utopia\Database\Change; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; @@ -11,7 +16,13 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Hook\Tenancy; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Database\Storage; +use Utopia\Query\Schema\ForeignKeyAction; trait PermissionTests { @@ -22,8 +33,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->getSharedTables() || ! $database->getAdapter() instanceof SQL) { $this->expectNotToPerformAssertions(); + return; } @@ -34,7 +46,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(new Collection( + 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 +61,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->getSharedTables() || ! $adapter instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'upsertPermsTenant'; + $reader = Role::user('upsertReader'); + + $database->createCollection(new Collection( + 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->getBuilder(Storage::permissionsTable($collection)) + ->select([Storage::PERM_TYPE, Storage::PERM_PERMISSION]) + ->filter([Query::equal(Storage::PERM_DOCUMENT, ['upserted'])]) + ->build(); + + return \array_map( + static fn (Document $row): array => $row->getArrayCopy(), + $adapter->rawQuery($statement->query, $statement->bindings), + ); + }); + + $this->assertSame( + [[Storage::PERM_TYPE => PermissionType::Read->value, Storage::PERM_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->getSharedTables() || ! $adapter instanceof SQL) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'upsertRowTenant'; + + $database->createCollection(new Collection( + 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->getSharedTables() + || ! $adapter->hasFeature(Feature\Upserts::class) + || ! $adapter->supports(Capability::Schemas) + ) { + $this->expectNotToPerformAssertions(); + + return; + } + + $tenantPerDocument = $database->getTenantPerDocument(); + $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(new Collection( + 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->getBuilder(Storage::permissionsTable('notes')) + ->select([Storage::PERM_PERMISSION]) + ->filter([ + Query::equal(Storage::PERM_DOCUMENT, ['note']), + Query::equal(Storage::PERM_TYPE, [PermissionType::Read->value]), + ]) + ->build()); + + $permissions = []; + foreach ($adapter->rawQuery($statement->query, $statement->bindings) as $row) { + $permission = $row->getAttribute(Storage::PERM_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(new Collection(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\Helpers\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(new Collection(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(new Collection(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(Relationship::oneToOne(collection: $collection->getId(), relatedCollection: $collectionOneToOne->getId(), key: RelationType::OneToOne->value, onDelete: ForeignKeyAction::Cascade)); + + $collectionOneToMany = $database->createCollection(new Collection(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(Relationship::oneToMany(collection: $collection->getId(), relatedCollection: $collectionOneToMany->getId(), key: RelationType::OneToMany->value, onDelete: ForeignKeyAction::Cascade)); + + $this->getDatabase()->getAuthorization()->cleanRoles(); + $this->getDatabase()->getAuthorization()->addRole(Role::users()->toString()); + + $document = $database->createDocument($collection->getId(), new Document([ + '$id' => \Utopia\Database\Helpers\ID::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem', + RelationType::OneToOne->value => [ + '$id' => \Utopia\Database\Helpers\ID::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem ipsum', + ], + RelationType::OneToMany->value => [ + [ + '$id' => \Utopia\Database\Helpers\ID::unique(), + '$permissions' => [ + Permission::read(Role::user('random')), + Permission::update(Role::user('random')), + Permission::delete(Role::user('random')), + ], + 'test' => 'lorem ipsum', + ], [ + '$id' => \Utopia\Database\Helpers\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(new Collection(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(), [], 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(new Collection(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->assertTrue($database->createAttribute($collection->getId(), Attribute::string(key: 'test'))); + + $collectionOneToOne = $database->createCollection(new Collection(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->assertTrue($database->createAttribute($collectionOneToOne->getId(), Attribute::string(key: 'test'))); + + $this->assertTrue($database->createRelationship(Relationship::oneToOne(collection: $collection->getId(), relatedCollection: $collectionOneToOne->getId(), key: RelationType::OneToOne->value, onDelete: ForeignKeyAction::Cascade))); + + $collectionOneToMany = $database->createCollection(new Collection(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->assertTrue($database->createAttribute($collectionOneToMany->getId(), Attribute::string(key: 'test'))); + + $this->assertTrue($database->createRelationship(Relationship::oneToMany(collection: $collection->getId(), relatedCollection: $collectionOneToMany->getId(), key: RelationType::OneToMany->value, onDelete: ForeignKeyAction::Cascade))); } public function testUnsetPermissions(): void @@ -65,14 +729,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(new Collection(id: __FUNCTION__)); + $this->assertTrue($database->createAttribute(__FUNCTION__, Attribute::string(key: 'president'))); $permissions = [ Permission::read(Role::any()), @@ -219,7 +877,7 @@ public function testCreateDocumentsEmptyPermission(): void /** @var Database $database */ $database = $this->getDatabase(); - $database->createCollection(__FUNCTION__); + $database->createCollection(new Collection(id: __FUNCTION__)); /** * Validate the decode function does not add $permissions null entry when no permissions are provided @@ -248,15 +906,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,16 +940,16 @@ 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([ + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ '$id' => ID::unique(), '$permissions' => [ Permission::read(Role::any()) @@ -307,7 +966,7 @@ public function testNoChangeUpdateDocumentWithoutPermission(): Document ])); $updatedDocument = $database->updateDocument( - 'documents', + $this->getDocumentsCollection(), $document->getId(), $document ); @@ -316,7 +975,7 @@ 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([ + $document = $database->createDocument($this->getDocumentsCollection(), new Document([ '$id' => ID::unique(), '$permissions' => [], 'string' => 'text📝', @@ -333,15 +992,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 +1006,16 @@ public function testUpdateDocumentsPermissions(): void /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForBatchOperations()) { + if (!$database->getAdapter()->supports(Capability::BatchOperations)) { $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(new Collection(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 +1120,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(new Collection(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->assertTrue($database->createAttribute($collection->getId(), Attribute::string(key: 'test'))); } - /** - * @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 +1149,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 +1168,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,7 +1184,7 @@ public function testCollectionPermissionsCreateThrowsException(Document $collect /** @var Database $database */ $database = $this->getDatabase(); - $database->createDocument($collection->getId(), new Document([ + $database->createDocument($collectionId, new Document([ '$id' => ID::unique(), '$permissions' => [ Permission::read(Role::any()), @@ -562,19 +1195,17 @@ 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([ + $document = $database->createDocument($collectionId, new Document([ '$id' => ID::unique(), '$permissions' => [ Permission::read(Role::user('random')), @@ -583,18 +1214,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 +1234,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 +1252,8 @@ public function testCollectionPermissionsDeleteWorks(array $data): void $database = $this->getDatabase(); $this->assertTrue($database->deleteDocument( - $collection->getId(), - $document->getId() + $collectionId, + $docId )); } @@ -636,18 +1263,15 @@ public function testCollectionPermissionsExceptions(): void $database = $this->getDatabase(); $this->expectException(DatabaseException::class); - $database->createCollection('collectionSecurity', permissions: [ + $database->createCollection(new Collection(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 +1281,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 +1295,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 +1321,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 +1340,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 +1363,7 @@ public function testCollectionPermissionsRelationshipsCountWorks(array $data): v $database = $this->getDatabase(); $documents = $database->count( - $collection->getId() + $collectionId ); $this->assertEquals(1, $documents); @@ -839,7 +1372,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 +1381,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,7 +1404,7 @@ public function testCollectionPermissionsRelationshipsCreateThrowsException(arra /** @var Database $database */ $database = $this->getDatabase(); - $database->createDocument($collection->getId(), new Document([ + $database->createDocument($collectionId, new Document([ '$id' => ID::unique(), '$permissions' => [ Permission::read(Role::any()), @@ -879,13 +1414,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,27 +1433,29 @@ 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([ + $document = $database->createDocument($collectionId, new Document([ '$id' => ID::unique(), '$permissions' => [ Permission::read(Role::user('random')), @@ -923,7 +1463,7 @@ public function testCollectionPermissionsRelationshipsCreateWorks(array $data): Permission::delete(Role::user('random')) ], 'test' => 'lorem', - Database::RELATION_ONE_TO_ONE => [ + RelationType::OneToOne->value => [ '$id' => ID::unique(), '$permissions' => [ Permission::read(Role::user('random')), @@ -932,7 +1472,7 @@ public function testCollectionPermissionsRelationshipsCreateWorks(array $data): ], 'test' => 'lorem ipsum' ], - Database::RELATION_ONE_TO_MANY => [ + RelationType::OneToMany->value => [ [ '$id' => ID::unique(), '$permissions' => [ @@ -952,18 +1492,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 +1515,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 +1536,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(RelationType::OneToOne->value)->isEmpty()); + $this->assertCount(2, $document->getDocuments(RelationType::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(RelationType::OneToOne->value)->isEmpty()); + $this->assertCount(1, $document->getDocuments(RelationType::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 +1587,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 +1610,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(RelationType::OneToOne->value)->isEmpty()); + $this->assertCount(2, $document->getDocuments(RelationType::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(RelationType::OneToOne->value)->isEmpty()); + $this->assertCount(1, $document->getDocuments(RelationType::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 +1680,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,26 +1736,25 @@ 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, permissions: [ 'i dont work' ], documentSecurity: false); } @@ -1232,11 +1764,11 @@ public function testWritePermissions(): void $this->getDatabase()->getAuthorization()->addRole(Role::any()->toString()); $database = $this->getDatabase(); - $database->createCollection('animals', permissions: [ + $database->createCollection(new Collection(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 +1838,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 +1846,20 @@ public function testCreateRelationDocumentWithoutUpdatePermission(): void $this->getDatabase()->getAuthorization()->cleanRoles(); $this->getDatabase()->getAuthorization()->addRole(Role::user('a')->toString()); - $database->createCollection('parentRelationTest', [], [], [ + $database->createCollection(new Collection(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(new Collection(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(Relationship::oneToMany(collection: 'parentRelationTest', relatedCollection: 'childRelationTest', key: 'children')); // Create document with relationship with nested data $parent = $database->createDocument('parentRelationTest', new Document([ @@ -1345,7 +1872,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 +1882,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 +1895,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(new Collection(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 +1935,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 +1965,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(new Collection(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(new Collection(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..311a671a96 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -3,56 +3,70 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; +use PHPUnit\Framework\Attributes\DataProvider; +use Swoole\Coroutine; +use Swoole\Runtime; 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\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\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Method; +use Utopia\Query\Schema\ForeignKeyAction; + +use function Swoole\Coroutine\run; trait RelationshipTests { - use OneToOneTests; - use OneToManyTests; - use ManyToOneTests; 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 (RelationType::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'] = [RelationType::ManyToMany, 'parents', 1]; return $cases; } - /** - * @dataProvider relationshipQueryValueLimitProvider - */ - public function testRelationshipQueryValueLimit(string $type, string $collection, int $limit): void + #[DataProvider('relationshipQueryValueLimitProvider')] + public function testRelationshipQueryValueLimit(RelationType $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; } @@ -64,9 +78,16 @@ public function testRelationshipQueryValueLimit(string $type, string $collection 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(new Collection(id: $parents, permissions: $permissions)); + $database->createCollection(new Collection(id: $children, permissions: $permissions)); + $database->createRelationship(new Relationship( + collection: $parents, + relatedCollection: $children, + type: $type, + twoWay: true, + key: 'children', + twoWayKey: 'parents', + )); foreach (['child1', 'child2', 'child3'] as $id) { $database->createDocument($children, new Document(['$id' => $id])); @@ -74,8 +95,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], + RelationType::ManyToMany => ['child1', 'child2', 'child3'], + RelationType::OneToMany => ['child' . $i], default => 'child' . $i, }; $database->createDocument($parents, new Document([ @@ -92,11 +113,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 === RelationType::ManyToMany ? [$prefix . '1', $prefix . '2', $prefix . '3'] : [$prefix . \substr($document->getId(), -1)]; $this->assertEqualsCanonicalizing($expected, $ids); @@ -116,68 +141,65 @@ 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(new Collection(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(new Collection(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( + $database->createCollection(new Collection(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(Relationship::manyToMany( collection: 'presidents', 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( + )); + + $database->createCollection(new Collection(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, filters: ['datetime'])); + $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(Relationship::oneToOne( collection: 'presidents', relatedCollection: '__animals', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'animal', + key: 'animal', twoWayKey: 'president' - ); + )); - $database->createRelationship( + $database->createRelationship(Relationship::oneToMany( collection: 'veterinarians', relatedCollection: '__animals', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'animals', + key: 'animals', twoWayKey: 'veterinarian' - ); + )); - $database->createRelationship( + $database->createRelationship(Relationship::manyToOne( collection: '__animals', 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 +207,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 +336,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 +356,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 +383,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 +411,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 +444,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 +469,7 @@ public function testZoo(): void [ Query::select([ 'animals.*', - ]) + ]), ] ); @@ -455,7 +477,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 +491,7 @@ public function testZoo(): void 'animals.*', 'animals.zoo.*', 'animals.president.*', - ]) + ]), ] ); @@ -477,12 +499,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 +512,26 @@ 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(new Collection(id: 'usersSimple')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'usersSimple', 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 +556,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 +576,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(new Collection(id: 'c1')); + $database->createCollection(new Collection(id: 'c2')); // ONE_TO_ONE - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_ONE_TO_ONE, - ); + $database->createRelationship(Relationship::oneToOne(collection: 'c1', relatedCollection: 'c2')); $this->assertEquals(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, - ); + $this->assertCount(0, $collection->attributes); + $this->assertCount(0, $collection->indexes); + + $database->createCollection(new Collection(id: 'c1')); + $database->createRelationship(Relationship::oneToOne(collection: 'c1', relatedCollection: 'c2')); $this->assertEquals(true, $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); + + $database->createCollection(new Collection(id: 'c2')); + $database->createRelationship(Relationship::oneToOne(collection: 'c1', relatedCollection: 'c2', twoWay: true)); $this->assertEquals(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); + + $database->createCollection(new Collection(id: 'c1')); + $database->createRelationship(Relationship::oneToOne(collection: 'c1', relatedCollection: 'c2', twoWay: true)); $this->assertEquals(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(new Collection(id: 'c2')); + $database->createRelationship(Relationship::oneToMany(collection: 'c1', relatedCollection: 'c2')); $this->assertEquals(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_MANY, - ); + $this->assertCount(0, $collection->attributes); + $this->assertCount(0, $collection->indexes); + + $database->createCollection(new Collection(id: 'c1')); + $database->createRelationship(Relationship::oneToMany(collection: 'c1', relatedCollection: 'c2')); $this->assertEquals(true, $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(new Collection(id: 'c2')); + $database->createRelationship(Relationship::oneToMany(collection: 'c1', relatedCollection: 'c2', twoWay: true)); $this->assertEquals(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_MANY, - twoWay: true - ); + $this->assertCount(0, $collection->attributes); + $this->assertCount(0, $collection->indexes); + + $database->createCollection(new Collection(id: 'c1')); + $database->createRelationship(Relationship::oneToMany(collection: 'c1', relatedCollection: 'c2', twoWay: true)); $this->assertEquals(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); // RELATION_MANY_TO_ONE - $database->createCollection('c2'); - $database->createRelationship( - collection: 'c1', - relatedCollection: 'c2', - type: Database::RELATION_MANY_TO_ONE, - ); + $database->createCollection(new Collection(id: 'c2')); + $database->createRelationship(Relationship::manyToOne(collection: 'c1', relatedCollection: 'c2')); $this->assertEquals(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, - ); + $this->assertCount(0, $collection->attributes); + $this->assertCount(0, $collection->indexes); + + $database->createCollection(new Collection(id: 'c1')); + $database->createRelationship(Relationship::manyToOne(collection: 'c1', relatedCollection: 'c2')); $this->assertEquals(true, $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); + + $database->createCollection(new Collection(id: 'c2')); + $database->createRelationship(Relationship::manyToOne(collection: 'c1', relatedCollection: 'c2', twoWay: true)); $this->assertEquals(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); + + $database->createCollection(new Collection(id: 'c1')); + $database->createRelationship(Relationship::manyToOne(collection: 'c1', relatedCollection: 'c2', twoWay: true)); $this->assertEquals(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 +689,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(new Collection(id: 'v1')); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: 'v1', relatedCollection: 'v2')); try { $database->createDocument('v2', new Document([ @@ -765,7 +722,7 @@ public function testVirtualRelationsAttributes(): void 'v1' => [ '$id' => 'test', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -794,9 +751,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 +763,8 @@ public function testVirtualRelationsAttributes(): void '$permissions' => [], 'v2' => [[ '$id' => 'woman', - '$permissions' => [] - ]] + '$permissions' => [], + ]], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -820,12 +777,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(Relationship::oneToMany(collection: 'v1', relatedCollection: 'v2', twoWay: true)); try { $database->createDocument('v1', new Document([ @@ -834,7 +786,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 +810,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 +828,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 +838,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 +854,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 +867,7 @@ public function testVirtualRelationsAttributes(): void 'v1' => [ '$id' => null, // Invalid value '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -929,7 +880,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 +903,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(Relationship::manyToOne(collection: 'v1', relatedCollection: 'v2', twoWay: true)); try { $database->createDocument('v1', new Document([ @@ -966,7 +912,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 +936,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 +967,7 @@ public function testVirtualRelationsAttributes(): void Permission::update(Role::any()), Permission::read(Role::any()), ], - ] + ], ])); $this->assertEquals('doc1', $doc->getId()); @@ -1042,7 +988,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 +1001,13 @@ public function testVirtualRelationsAttributes(): void * RELATION_MANY_TO_MANY * No attribute on V1/v2 collections only on junction table */ - $database->createRelationship( + $database->createRelationship(Relationship::manyToMany( collection: 'v1', relatedCollection: 'v2', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'students', + key: 'students', twoWayKey: 'classes' - ); + )); try { $database->createDocument('v1', new Document([ @@ -1091,7 +1036,7 @@ public function testVirtualRelationsAttributes(): void 'classes' => [ // Expected array, object provided '$id' => 'test', '$permissions' => [], - ] + ], ])); $this->fail('Failed to throw exception'); } catch (Exception $e) { @@ -1119,7 +1064,6 @@ public function testVirtualRelationsAttributes(): void /** * Success for later test update */ - $doc = $database->createDocument('v1', new Document([ '$id' => 'class1', '$permissions' => [ @@ -1131,17 +1075,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 +1100,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 +1115,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 +1123,39 @@ 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(new Collection( + id: "unchanged_level{$level}", + attributes: [Attribute::string(key: 'name', size: 100, signed: false)], + 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(Relationship::oneToOne( + collection: "unchanged_level{$level}", + 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 +1175,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', [ 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'); - $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(new Collection(id: 'rnRsTestA')); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: 'rnRsTestA', relatedCollection: 'rnRsTestB', twoWay: true)); $docA = $database->createDocument('rnRsTestA', new Document([ '$permissions' => [ @@ -1350,8 +1237,8 @@ public function testUpdateAttributeRenameRelationshipTwoWay(): void ], 'rnRsTestB' => [ '$id' => 'b1', - 'name' => 'B1' - ] + 'name' => 'B1', + ], ])); $docB = $database->getDocument('rnRsTestB', 'b1'); @@ -1375,7 +1262,7 @@ public function testUpdateAttributeRenameRelationshipTwoWay(): void // 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,34 +1270,31 @@ 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(new Collection(id: $collection)); + $database->createAttribute($collection, Attribute::string(key: 'name', required: true)); + } - $database->createRelationship( + $database->createRelationship(Relationship::oneToOne( collection: 'species', relatedCollection: 'creatures', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'creature', - twoWayKey:'species' - ); - $database->createRelationship( + key: 'creature', + twoWayKey: 'species', + )); + $database->createRelationship(Relationship::oneToOne( collection: 'creatures', 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'), @@ -1431,9 +1315,10 @@ public function testNoInvalidKeysWithRelationships(): void Permission::update(Role::any()), ], 'name' => 'active', - ] - ] + ], + ], ])); + $database->updateDocument('species', $species->getId(), new Document([ '$id' => ID::custom('1'), '$collection' => 'species', @@ -1444,13 +1329,15 @@ public function testNoInvalidKeysWithRelationships(): void '$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 +1345,27 @@ 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(new Collection(id: 'make')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'make', 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 +1404,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 +1523,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 +1539,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 +1556,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 +1579,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 +1591,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 +1602,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 +1611,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 +1630,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 +1639,28 @@ 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(new Collection(id: 'lawns', permissions: [Permission::create(Role::any())])); + $database->createCollection(new Collection(id: 'trees', permissions: [Permission::create(Role::any())])); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'lawns', 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: ForeignKeyAction::Cascade + )); + $database->createRelationship(Relationship::manyToMany(collection: 'trees', relatedCollection: 'birds', twoWay: true, onDelete: ForeignKeyAction::SetNull)); $permissions = [ Permission::read(Role::any()), @@ -1812,595 +1693,249 @@ 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(new Collection(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(Relationship::oneToMany( + collection: $lawns, + relatedCollection: $trees, + twoWay: true, + key: 'trees', + twoWayKey: 'lawn', + onDelete: ForeignKeyAction::Cascade, + )); + $database->createRelationship(Relationship::manyToMany( + collection: $trees, + relatedCollection: $birds, + twoWay: true, + key: 'birds', + twoWayKey: 'trees', + onDelete: ForeignKeyAction::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(new Collection(id: 'null1')); + $database->createCollection(new Collection(id: 'null2')); - $database->createRelationship( - collection: 'missing', - relatedCollection: 'missing', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship(Relationship::oneToOne( + collection: 'null1', + relatedCollection: 'null2', twoWay: true, - ); - } + )); + $database->createRelationship(Relationship::oneToMany( + collection: 'null1', + relatedCollection: 'null2', + twoWay: true, + key: 'null3', + twoWayKey: 'null4', + )); + $database->createRelationship(Relationship::manyToOne( + collection: 'null1', + relatedCollection: 'null2', + twoWay: true, + key: 'null4', + twoWayKey: 'null5', + )); + $database->createRelationship(Relationship::manyToMany( + collection: 'null1', + 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->getDatabase()->getAdapter()->supports(Capability::BatchOperations) || ! ($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->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, - ]) + $this->getDatabase()->createCollection(new Collection(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()) - ]); + 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(new Collection(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(Relationship::oneToOne(collection: 'testUpdateDocumentsRelationships1', relatedCollection: 'testUpdateDocumentsRelationships2', twoWay: true)); $this->getDatabase()->createDocument('testUpdateDocumentsRelationships1', new Document([ '$id' => 'doc1', @@ -2410,11 +1945,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 +1957,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(Relationship::oneToMany(collection: 'testUpdateDocumentsRelationships1', 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 +2004,95 @@ 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(new Collection(id: 'userProfiles', attributes: [ + Attribute::string(key: 'username', size: 700, format: ''), + ], 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(new Collection(id: 'links', attributes: [ + Attribute::string(key: 'title', size: 700, format: ''), + ], 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(new Collection(id: 'videos', attributes: [ + Attribute::string(key: 'title', size: 700, format: ''), + ], 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(new Collection(id: 'products', attributes: [ + Attribute::string(key: 'title', size: 700, format: ''), + ], 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(new Collection(id: 'settings', attributes: [ + Attribute::string(key: 'metaTitle', size: 700, format: ''), + ], 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(new Collection(id: 'appearance', attributes: [ + Attribute::string(key: 'metaTitle', size: 700, format: ''), + ], 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(new Collection(id: 'group', attributes: [ + Attribute::string(key: 'name', size: 700, format: ''), + ], 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(new Collection(id: 'community', attributes: [ + Attribute::string(key: 'name', size: 700, format: ''), + ], 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(Relationship::oneToMany(collection: 'userProfiles', relatedCollection: 'links', key: 'links')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'videos', - type: Database::RELATION_ONE_TO_MANY, - id: 'videos' - ); + $database->createRelationship(Relationship::oneToMany(collection: 'userProfiles', relatedCollection: 'videos', key: 'videos')); - $database->createRelationship( + $database->createRelationship(Relationship::oneToMany( collection: 'userProfiles', 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(Relationship::oneToOne(collection: 'userProfiles', relatedCollection: 'settings', key: 'settings')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'appearance', - type: Database::RELATION_ONE_TO_ONE, - id: 'appearance' - ); + $database->createRelationship(Relationship::oneToOne(collection: 'userProfiles', relatedCollection: 'appearance', key: 'appearance')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'group', - type: Database::RELATION_MANY_TO_ONE, - id: 'group' - ); + $database->createRelationship(Relationship::manyToOne(collection: 'userProfiles', relatedCollection: 'group', key: 'group')); - $database->createRelationship( - collection: 'userProfiles', - relatedCollection: 'community', - type: Database::RELATION_MANY_TO_ONE, - id: 'community' - ); + $database->createRelationship(Relationship::manyToOne(collection: 'userProfiles', relatedCollection: 'community', key: 'community')); $profile = $database->createDocument('userProfiles', new Document([ '$id' => '1', @@ -2712,13 +2132,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 +2159,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 +2176,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 +2200,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(new Collection(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(new Collection(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(new Collection(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(Relationship::manyToOne(collection: 'car', 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(Relationship::oneToMany(collection: 'customer', relatedCollection: 'inspection', key: 'inspections')); // Create test data - customers with inspections first $database->createDocument('inspection', new Document([ @@ -2928,21 +2337,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 +2389,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 +2409,38 @@ 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(new Collection(id: 'orderDepthTest')); + $database->createCollection(new Collection(id: 'productDepthTest')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'orderDepthTest', relatedCollection: 'productDepthTest', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'product', + key: 'product', twoWayKey: 'orders' - ); + )); // Product -> Store (many-to-one) - $database->createRelationship( + $database->createRelationship(Relationship::manyToOne( collection: 'productDepthTest', 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 +2543,28 @@ 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(new Collection(id: 'authorsFilter')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'authorsFilter', 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 +2630,20 @@ public function testRelationshipTypeQueries(): void $database->deleteCollection('authorsFilter'); $database->deleteCollection('postsFilter'); - $database->createCollection('usersOto'); - $database->createCollection('profilesOto'); + $database->createCollection(new Collection(id: 'usersOto')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'usersOto', 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 +2676,20 @@ public function testRelationshipTypeQueries(): void $database->deleteCollection('usersOto'); $database->deleteCollection('profilesOto'); - $database->createCollection('commentsMto'); - $database->createCollection('usersMto'); + $database->createCollection(new Collection(id: 'commentsMto')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'commentsMto', 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 +2728,20 @@ public function testRelationshipTypeQueries(): void $database->deleteCollection('commentsMto'); $database->deleteCollection('usersMto'); - $database->createCollection('studentsMtm'); - $database->createCollection('coursesMtm'); + $database->createCollection(new Collection(id: 'studentsMtm')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany( collection: 'studentsMtm', 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 +2783,25 @@ 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(new Collection(id: 'usersRelId')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'postsRelId', 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 +2886,19 @@ 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(new Collection(id: 'usersOtoId')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'usersOtoId', 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 +2938,19 @@ 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(new Collection(id: 'developersMtmId')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany( collection: 'developersMtmId', 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 +3089,30 @@ 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->createCollection(new Collection(id: 'productsQt')); + $database->createCollection(new Collection(id: '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->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( + $database->createRelationship(Relationship::manyToOne( collection: 'productsQt', 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 +3169,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 +3241,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 +3256,37 @@ 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(new Collection(id: 'restaurantsSpatial')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'restaurantsSpatial', 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 +3299,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 +3318,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 +3337,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 +3352,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 +3360,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 +3368,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 +3418,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 +3430,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 +3441,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 +3454,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 +3468,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 +3479,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 +3487,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 +3511,29 @@ 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(new Collection(id: 'teamsParent')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'teamsParent', 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 +3581,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 +3603,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 +3620,28 @@ 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(new Collection(id: 'ordersEdge')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'ordersEdge', 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 +3662,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 +3690,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 +3706,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 +3722,29 @@ 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(new Collection(id: 'developersMtm')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany( collection: 'developersMtm', 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 +3782,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 +3823,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 +3834,67 @@ 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(new Collection(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(new Collection(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(new Collection(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(new Collection(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(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'companiesNested', relatedCollection: 'employeesNested', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'employees', + key: 'employees', twoWayKey: 'company' - ); + )); // Employees -> Department (MANY_TO_ONE) - $database->createRelationship( + $database->createRelationship(Relationship::manyToOne( collection: 'employeesNested', relatedCollection: 'departmentsNested', - type: Database::RELATION_MANY_TO_ONE, twoWay: true, - id: 'department', + key: 'department', twoWayKey: 'employees' - ); + )); // Employees -> Projects (ONE_TO_MANY) - $database->createRelationship( + $database->createRelationship(Relationship::oneToMany( collection: 'employeesNested', relatedCollection: 'projectsNested', - type: Database::RELATION_ONE_TO_MANY, twoWay: true, - id: 'projects', + key: 'projects', twoWayKey: 'employee' - ); + )); // Projects -> Tasks (ONE_TO_MANY) - $database->createRelationship( + $database->createRelationship(Relationship::oneToMany( collection: 'projectsNested', 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 +4076,29 @@ 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(new Collection(id: 'authorsCount')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'authorsCount', 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 +4234,30 @@ 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(new Collection(id: 'authorsOrder')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'postsOrder', 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 +4302,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 +4314,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,275 +4327,1030 @@ 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 function testDeleteDocumentRelatedUpdateEvent(): void + public static function relatedDocumentsBeyondQueryValueLimitProvider(): array + { + return [ + 'oneToMany cascade' => [RelationType::OneToMany, ForeignKeyAction::Cascade], + 'manyToOne cascade' => [RelationType::ManyToOne, ForeignKeyAction::Cascade], + 'manyToMany cascade' => [RelationType::ManyToMany, ForeignKeyAction::Cascade], + 'manyToMany setNull' => [RelationType::ManyToMany, ForeignKeyAction::SetNull], + ]; + } + + #[DataProvider('relatedDocumentsBeyondQueryValueLimitProvider')] + public function testDeleteReachesMoreRelatedDocumentsThanTheQueryValueLimit(RelationType $type, ForeignKeyAction $onDelete): void { /** @var Database $database */ $database = $this->getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { $this->expectNotToPerformAssertions(); + return; } - $collectionPermissions = [ + $parents = ID::unique(); + $children = ID::unique(); + $permissions = [ Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), ]; - $documentPermissions = [ + $database->createCollection(new Collection(id: $parents, permissions: $permissions)); + $database->createCollection(new Collection(id: $children, permissions: $permissions)); + $database->createRelationship($type === RelationType::ManyToOne + ? new Relationship(collection: $children, relatedCollection: $parents, type: $type, twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: $onDelete) + : new Relationship(collection: $parents, relatedCollection: $children, type: $type, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: $onDelete)); + + $childIds = ['child1', 'child2', 'child3']; + if ($type === RelationType::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 === ForeignKeyAction::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(new Collection(id: $parents, permissions: [ + Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ]; - - $database->createCollection('related_parent', permissions: $collectionPermissions, documentSecurity: true); - $database->createCollection('related_child', permissions: $collectionPermissions, documentSecurity: true); - - $database->createRelationship( - collection: 'related_parent', - relatedCollection: 'related_child', - type: Database::RELATION_ONE_TO_MANY, + ])); + $database->createCollection(new Collection(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship(Relationship::oneToMany( + collection: $parents, + relatedCollection: $children, twoWay: true, - id: 'children', + key: 'children', twoWayKey: 'parent', - onDelete: Database::RELATION_MUTATE_SET_NULL, - ); + onDelete: ForeignKeyAction::Cascade, + )); - foreach (['child1', 'child2'] as $childId) { - $database->createDocument('related_child', new Document([ - '$id' => $childId, - '$permissions' => $documentPermissions, - ])); + $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); } + } - $database->createDocument('related_parent', new Document([ - '$id' => 'parent1', - '$permissions' => $documentPermissions, - 'children' => ['child1', 'child2'], + 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(new Collection(id: $parents, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ])); + $database->createCollection(new Collection(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship(Relationship::oneToMany( + collection: $parents, + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: ForeignKeyAction::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'))], ])); - // 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(); - }); + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); - // 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'], - ])); + 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()); + } - $fired = []; - $database->deleteDocument('related_child', 'child1'); + $this->assertSame([], $database->getDocument($parents, 'parent1')->getAttribute('children')); + $this->assertNull($database->getDocument($children, 'updatable')->getAttribute('parent')); - $this->assertEquals(['parent2'], $fired); - $this->assertEquals('related_parent', $reported['parent2']->getCollection()); + $authorization->addRole(Role::user('linkAdmin')->toString()); - // A cascaded document is gone, so it is not reported as changed - $database->updateRelationship( - collection: 'related_parent', - id: 'children', - onDelete: Database::RELATION_MUTATE_CASCADE, - ); + $database->updateDocument($parents, 'parent1', new Document(['children' => ['updatable', 'readonly']])); - $database->createDocument('related_child', new Document([ - '$id' => 'child3', - '$permissions' => $documentPermissions, - 'parent' => 'parent2', - ])); + $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); + } - $fired = []; - $database->deleteDocument('related_parent', 'parent2'); + $database->deleteCollection($parents); + $database->deleteCollection($children); + } + } - $this->assertEquals([], $fired); - $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + public function testTwoWayOneToOneLinkByDocumentStoresTheBackReference(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - // 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, - ); + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); - $database->createDocument('related_parent', new Document([ - '$id' => 'parent4', - '$permissions' => $documentPermissions, - ])); + return; + } - $database->createDocument('related_child', new Document([ - '$id' => 'child4', - '$permissions' => $documentPermissions, - 'parent' => 'parent4', - ])); + $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(new Collection(id: $collection, permissions: $permissions)); + } + $database->createRelationship(Relationship::oneToOne(collection: $parents, relatedCollection: $children, twoWay: true, key: 'partner', twoWayKey: 'parent')); + $database->createRelationship(Relationship::oneToOne(collection: $children, relatedCollection: $toys, twoWay: true, key: 'toy', twoWayKey: 'owner')); + $database->createRelationship(Relationship::oneToOne(collection: $toys, 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)); - $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, - ])); + return $value; + }; - $database->createDocument('related_oneway', new Document([ - '$id' => 'stray1', - '$permissions' => $documentPermissions, - ])); + 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('related_parent', 'parent3', new Document([ - 'strays' => ['stray1'], - 'stray' => 'stray1', - ])); + $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')); - $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, - ])); + $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->createDocument('related_parent', new Document([ - '$id' => 'parent5', - '$permissions' => $documentPermissions, - 'owner' => 'pair1', - 'buddy' => 'pair1', - ])); + $database->updateDocument($parents, 'p4', new Document(['partner' => 'c4'])); + $this->assertSame('c4', $link($parents, 'p4', 'partner')); + $this->assertSame('p4', $link($children, 'c4', 'parent')); - $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, - ); + $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('related_child', new Document([ - '$id' => 'child5', - '$permissions' => $documentPermissions, + $database->createDocument($parents, new Document([ + '$id' => 'p8', + 'partner' => ['$id' => 'c8', 'toy' => ['$id' => 't8', 'part' => ['$id' => 'x8']]], ])); - - $database->createDocument('related_child', new Document([ - '$id' => 'child6', - '$permissions' => $documentPermissions, + $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); + } + } + } - $database->createDocument('related_pair', new Document([ - '$id' => 'pair2', - '$permissions' => $documentPermissions, - 'tail' => 'child5', - ])); + public function testNestedUpdateCannotLinkARelatedDocumentWithoutUpdatePermission(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); - $database->createDocument('related_parent', new Document([ - '$id' => 'parent6', - '$permissions' => $documentPermissions, - 'children' => ['child5', 'child6'], - 'buddy' => 'pair2', - ])); + 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(new Collection(id: $grandparents, permissions: $permissions)); + $database->createCollection(new Collection(id: $parents, permissions: $permissions)); + $database->createCollection(new Collection(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship(Relationship::oneToOne( + collection: $grandparents, + relatedCollection: $parents, + key: 'parent', + onDelete: ForeignKeyAction::SetNull, + )); + $database->createRelationship(Relationship::oneToMany( + collection: $parents, + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: ForeignKeyAction::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'])); - $fired = []; - $database->deleteDocument('related_parent', 'parent6'); + $authorization = $database->getAuthorization(); + $roles = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); - $this->assertEquals(['child6'], $fired); - $this->assertTrue($database->getDocument('related_child', 'child5')->isEmpty()); + 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 { - $database->on(Database::EVENT_DOCUMENT_UPDATE, 'related-test', null); + $authorization->cleanRoles(); + foreach ($roles as $role) { + $authorization->addRole($role); + } + + $database->deleteCollection($grandparents); + $database->deleteCollection($parents); + $database->deleteCollection($children); } + } - $database->deleteCollection('related_parent'); - $database->deleteCollection('related_child'); - $database->deleteCollection('related_oneway'); - $database->deleteCollection('related_pair'); + 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(new Collection(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(new Collection(id: $children, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], documentSecurity: false)); + $database->createRelationship(Relationship::oneToMany( + collection: $parents, + relatedCollection: $children, + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: ForeignKeyAction::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(new Collection(id: $parents, permissions: $permissions)); + $database->createCollection(new Collection(id: $children, permissions: $permissions)); + $database->createRelationship(Relationship::oneToOne( + collection: $parents, + 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()->hasFeature(Feature\Relationships::class))) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionPermissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $documentPermissions = [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $database->createCollection(new Collection(id: 'related_parent', permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(new Collection(id: 'related_child', permissions: $collectionPermissions, documentSecurity: true)); + + $database->createRelationship(Relationship::oneToMany( + collection: 'related_parent', + relatedCollection: 'related_child', + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: ForeignKeyAction::SetNull, + )); + + foreach (['child1', 'child2'] as $childId) { + $database->createDocument('related_child', new Document([ + '$id' => $childId, + '$permissions' => $documentPermissions, + ])); + } + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent1', + '$permissions' => $documentPermissions, + 'children' => ['child1', 'child2'], + ])); + + $ids = static fn (Document ...$documents): array => \array_map(fn (Document $document): string => $document->getId(), $documents); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent1'); + $recorder->stop(); + $related = $recorder->getDocuments(Event::DocumentUpdate); + + $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()); + } + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent2', + '$permissions' => $documentPermissions, + 'children' => ['child1'], + ])); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_child', 'child1'); + $recorder->stop(); + $related = $recorder->getDocuments(Event::DocumentUpdate); + + $this->assertSame(['parent2'], $ids(...$related)); + $this->assertSame('related_parent', $related[0]->getCollection()); + + $database->updateRelationship( + collection: 'related_parent', + id: 'children', + onDelete: ForeignKeyAction::Cascade, + ); + + $database->createDocument('related_child', new Document([ + '$id' => 'child3', + '$permissions' => $documentPermissions, + 'parent' => 'parent2', + ])); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent2'); + $recorder->stop(); + + $this->assertSame([], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + $this->assertTrue($database->getDocument('related_child', 'child3')->isEmpty()); + + $database->updateRelationship( + collection: 'related_parent', + id: 'children', + onDelete: ForeignKeyAction::Restrict, + ); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent4', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_child', new Document([ + '$id' => 'child4', + '$permissions' => $documentPermissions, + 'parent' => 'parent4', + ])); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_child', 'child4'); + $recorder->stop(); + + $this->assertSame(['parent4'], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + + $database->createCollection(new Collection(id: 'related_oneway', permissions: $collectionPermissions, documentSecurity: true)); + + $database->createRelationship(Relationship::oneToMany( + collection: 'related_parent', + relatedCollection: 'related_oneway', + key: 'strays', + onDelete: ForeignKeyAction::SetNull, + )); + + $database->createRelationship(Relationship::manyToOne( + collection: 'related_parent', + relatedCollection: 'related_oneway', + key: 'stray', + twoWayKey: 'strayOf', + onDelete: ForeignKeyAction::SetNull, + )); + + $database->createDocument('related_parent', new Document([ + '$id' => 'parent3', + '$permissions' => $documentPermissions, + ])); + + $database->createDocument('related_oneway', new Document([ + '$id' => 'stray1', + '$permissions' => $documentPermissions, + ])); + + $database->updateDocument('related_parent', 'parent3', new Document([ + 'strays' => ['stray1'], + 'stray' => 'stray1', + ])); + + $recorder = new EventRecorder('related-update-test'); + $database->addHook($recorder); + $database->deleteDocument('related_parent', 'parent3'); + $recorder->stop(); + + $this->assertSame([], $ids(...$recorder->getDocuments(Event::DocumentUpdate))); + $this->assertFalse($database->getDocument('related_oneway', 'stray1')->isEmpty()); + + $database->createCollection(new Collection(id: 'related_pair', permissions: $collectionPermissions, documentSecurity: true)); + + $database->createRelationship(Relationship::manyToOne( + collection: 'related_parent', + relatedCollection: 'related_pair', + twoWay: true, + key: 'owner', + twoWayKey: 'owned', + onDelete: ForeignKeyAction::SetNull, + )); + + $database->createRelationship(Relationship::oneToOne( + collection: 'related_parent', + relatedCollection: 'related_pair', + twoWay: true, + key: 'buddy', + twoWayKey: 'buddyOf', + onDelete: ForeignKeyAction::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', + id: 'children', + onDelete: ForeignKeyAction::SetNull, + ); + + $database->createRelationship(Relationship::oneToOne( + collection: 'related_pair', + relatedCollection: 'related_child', + twoWay: true, + key: 'tail', + twoWayKey: 'tailOf', + onDelete: ForeignKeyAction::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(new Collection(id: $parents, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions)); + $database->createCollection(new Collection(id: $children, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $permissions)); + $database->createRelationship(Relationship::manyToMany( + collection: $parents, + 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(new Collection(id: $parents, permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(new Collection(id: $children, permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(new Collection(id: $labels, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createRelationship(Relationship::oneToMany(collection: $parents, relatedCollection: $children, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::oneToMany(collection: $children, relatedCollection: $labels, twoWay: true, key: 'labels', twoWayKey: 'child', onDelete: ForeignKeyAction::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(new Collection(id: $stores, attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(new Collection(id: $products, attributes: [Attribute::string(key: 'name', size: 64), Attribute::string(key: 'sku', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createRelationship(Relationship::manyToOne(collection: $products, relatedCollection: $stores, twoWay: true, key: 'store', twoWayKey: 'products', onDelete: ForeignKeyAction::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(new Collection(id: $projects, permissions: $this->relationshipCoveragePermissions())); + $database->createCollection(new Collection(id: $developers, attributes: [Attribute::string(key: 'devName', size: 64)], permissions: $this->relationshipCoveragePermissions())); + $database->createRelationship(Relationship::manyToMany(collection: $projects, relatedCollection: $developers, twoWay: true, key: 'developers', twoWayKey: 'projects', onDelete: ForeignKeyAction::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 array + */ + 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/ManyToManyTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php index 73783270e2..0007423e4b 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToManyTests.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\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\Operator; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKeyAction; trait ManyToManyTests { @@ -19,38 +29,34 @@ 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(new Collection(id: 'playlist')); + $database->createCollection(new Collection(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(Relationship::manyToMany(collection: 'playlist', 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') { + $options = $attribute->options ?? []; + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('songs', $attribute->getId()); + $this->assertEquals('songs', $attribute->key); + $this->assertEquals('song', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToMany->value, $options['relationType'] ?? null); + $this->assertEquals(false, $options['twoWay'] ?? null); + $this->assertEquals('playlist', $options['twoWayKey'] ?? null); } } @@ -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( @@ -244,8 +265,9 @@ public function testManyToManyOneWayRelationship(): void // 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([ @@ -277,7 +299,7 @@ public function testManyToManyOneWayRelationship(): void $database->updateRelationship( collection: 'playlist', id: 'newSongs', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::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 + onDelete: ForeignKeyAction::Cascade ); // Delete parent, will delete child @@ -330,52 +352,48 @@ 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->createCollection(new Collection(id: 'students')); + $database->createCollection(new Collection(id: '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->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( - collection: 'students', - relatedCollection: 'classes', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $database->createRelationship(Relationship::manyToMany(collection: 'students', relatedCollection: 'classes', 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']); + foreach ($collection->attributes as $attribute) { + if ($attribute->key === 'students') { + $options = $attribute->options ?? []; + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('students', $attribute->getId()); + $this->assertEquals('students', $attribute->key); + $this->assertEquals('students', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToMany->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('classes', $options['twoWayKey'] ?? null); } } // 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']); + foreach ($collection->attributes as $attribute) { + if ($attribute->key === 'classes') { + $options = $attribute->options ?? []; + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('classes', $attribute->getId()); + $this->assertEquals('classes', $attribute->key); + $this->assertEquals('classes', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToMany->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('students', $options['twoWayKey'] ?? null); } } @@ -407,7 +425,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 +448,7 @@ public function testManyToManyTwoWayRelationship(): void ], 'name' => 'Student 2', 'classes' => [ - 'class2' + 'class2', ], ])); @@ -453,7 +471,7 @@ public function testManyToManyTwoWayRelationship(): void Permission::delete(Role::any()), ], 'name' => 'Student 3', - ] + ], ], ])); $database->createDocument('students', new Document([ @@ -463,7 +481,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 +494,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 +595,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 +605,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 +624,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 +659,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 +694,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( @@ -679,14 +727,16 @@ public function testManyToManyTwoWayRelationship(): void ); // 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([ @@ -718,7 +768,7 @@ public function testManyToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'students', id: 'newClasses', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); $student1 = $database->getDocument('students', 'student1'); @@ -735,13 +785,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 + onDelete: ForeignKeyAction::Cascade ); // Delete parent, will delete child @@ -784,33 +834,28 @@ 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(new Collection(id: 'stones')); + $database->createCollection(new Collection(id: 'hearths')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany(collection: 'stones', relatedCollection: 'hearths', twoWay: true)); + $database->createRelationship(Relationship::oneToOne( collection: 'hearths', 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 +896,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 +930,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 +944,28 @@ 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(new Collection(id: 'groups')); + $database->createCollection(new Collection(id: 'tounaments')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany(collection: 'groups', relatedCollection: 'tounaments', twoWay: true)); + $database->createRelationship(Relationship::oneToMany( collection: 'tounaments', 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 +1024,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 +1041,28 @@ 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(new Collection(id: 'platforms')); + $database->createCollection(new Collection(id: 'games')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany(collection: 'platforms', relatedCollection: 'games', twoWay: true)); + $database->createRelationship(Relationship::manyToOne( collection: 'games', 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 +1099,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 +1132,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 +1154,28 @@ 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(new Collection(id: 'sauces')); + $database->createCollection(new Collection(id: 'pizzas')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany(collection: 'sauces', relatedCollection: 'pizzas', twoWay: true)); + $database->createRelationship(Relationship::manyToMany( collection: 'pizzas', 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 +1230,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 +1256,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(new Collection(id: '$symbols_coll.ection7')); + $database->createCollection(new Collection(id: '$symbols_coll.ection8')); - $database->createRelationship( - collection: '$symbols_coll.ection7', - relatedCollection: '$symbols_coll.ection8', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true, - ); + $database->createRelationship(Relationship::manyToMany(collection: '$symbols_coll.ection7', relatedCollection: '$symbols_coll.ection8', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection8', new Document([ '$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()], + '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 +1299,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToMany(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - ); + $result = $database->createRelationship(Relationship::manyToMany(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToManyTwoWayRelationshipFromParent(): void @@ -1322,67 +1342,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToMany(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::manyToMany(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToManyTwoWayRelationshipFromChild(): void @@ -1390,67 +1385,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToMany(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::manyToMany(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateManyToManyOneWayRelationshipFromParent(): void @@ -1458,65 +1428,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToMany(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_MANY, - ); + $result = $database->createRelationship(Relationship::manyToMany(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testSelectManyToMany(): void @@ -1524,26 +1471,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(new Collection(id: 'select_m2m_collection1')); + $database->createCollection(new Collection(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(Relationship::manyToMany(collection: 'select_m2m_collection1', relatedCollection: 'select_m2m_collection2', twoWay: true)); // Create documents in the first collection $doc1 = $database->createDocument('select_m2m_collection1', new Document([ @@ -1588,7 +1531,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 +1545,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(new Collection(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(new Collection(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(new Collection(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(Relationship::manyToMany(collection: 'artists', relatedCollection: 'albums', twoWay: true)); - $database->createRelationship( - collection: 'albums', - relatedCollection: 'tracks', - type: Database::RELATION_MANY_TO_MANY, - twoWay: true - ); + $database->createRelationship(Relationship::manyToMany(collection: 'albums', relatedCollection: 'tracks', twoWay: true)); // Create documents $database->createDocument('artists', new Document([ @@ -1666,8 +1600,8 @@ public function testSelectAcrossMultipleCollections(): void '$id' => 'track2', 'title' => 'Hit Song 2', 'duration' => 220, - ] - ] + ], + ], ], [ '$id' => 'album2', @@ -1677,15 +1611,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 +1627,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 +1657,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_m2m'); - $this->getDatabase()->createCollection('bulk_delete_library_m2m'); + $this->getDatabase()->createCollection(new Collection(id: 'bulk_delete_person_m2m')); + $this->getDatabase()->createCollection(new Collection(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(Relationship::manyToMany(collection: 'bulk_delete_person_m2m', relatedCollection: 'bulk_delete_library_m2m')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_m2m', new Document([ '$id' => 'person1', @@ -1776,7 +1706,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 +1725,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)) || + ! $database->getAdapter()->supports(Capability::BatchOperations) ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_m2m'; $childCollection = 'child_combined_m2m'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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_MANY_TO_MANY, - id: 'parentNumber' - ); + $database->createRelationship(Relationship::manyToMany(collection: $parentCollection, relatedCollection: $childCollection, key: 'parentNumber')); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -1879,31 +1805,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $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(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::manyToMany(collection: $parentCollection, relatedCollection: $childCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -1922,8 +1843,8 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_ManyToMa Permission::delete(Role::any()), ], 'name' => 'Child 1', - ] - ] + ], + ], ])); try { @@ -1945,27 +1866,27 @@ 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(new Collection(id: 'partial_students')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany( collection: 'partial_students', 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 +1911,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 +1924,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 +1935,27 @@ 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(new Collection(id: 'tags')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany( collection: 'articles', 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 +1986,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 +2009,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 +2018,91 @@ 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(new Collection(id: 'operator_library')); + $database->createCollection(new Collection(id: 'operator_book')); - $database->createCollection('library'); - $database->createCollection('book'); + $database->createAttribute('operator_library', Attribute::string(key: 'name', required: true)); + $database->createAttribute('operator_book', Attribute::string(key: 'title', required: true)); - $database->createAttribute('library', 'name', Database::VAR_STRING, 255, true); - $database->createAttribute('book', 'title', Database::VAR_STRING, 255, true); - - $database->createRelationship( - collection: 'library', - relatedCollection: 'book', - type: Database::RELATION_MANY_TO_MANY, + $database->createRelationship(Relationship::manyToMany( + collection: 'operator_library', + relatedCollection: 'operator_book', twoWay: true, - id: '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', - ])); + key: 'books', + twoWayKey: 'libraries', + )); - $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 +2115,77 @@ 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(new Collection(id: 'brands')); + $database->createCollection(new Collection(id: 'products')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToMany( collection: 'brands', relatedCollection: 'products', - type: Database::RELATION_MANY_TO_MANY, twoWay: true, - id: 'products', - twoWayKey: 'brands', - ); + key: 'products', + twoWayKey: 'brands' + )); - $database->createRelationship( + $database->createRelationship(Relationship::manyToMany( collection: 'products', 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 +2203,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 +2234,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 +2250,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 +2261,228 @@ 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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::manyToMany(collection: $parentCollection, relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::manyToMany(collection: $parentCollection, relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(new Collection(id: $grandparents, permissions: $permissions)); + $database->createCollection(new Collection(id: $parents, permissions: $permissions)); + $database->createCollection(new Collection(id: $tags, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship(Relationship::oneToOne(collection: $grandparents, relatedCollection: $parents, key: 'parent', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::manyToMany(collection: $parents, relatedCollection: $tags, twoWay: true, key: 'tags', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(new Collection(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(new Collection(id: $tags, permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + $database->createRelationship(Relationship::manyToMany(collection: $parents, relatedCollection: $tags, twoWay: true, key: 'tags', twoWayKey: 'parents', onDelete: ForeignKeyAction::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); + } + } } diff --git a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php index 498780a564..f0e920feed 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/ManyToOneTests.php @@ -3,6 +3,10 @@ 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; @@ -11,6 +15,10 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKeyAction; 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(new Collection(id: 'review')); + $database->createCollection(new Collection(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, formatOptions: ['min' => 0, 'max' => 999])); + $database->createAttribute('movie', Attribute::datetime(key: 'date', filters: ['datetime'])); + $database->createAttribute('review', Attribute::datetime(key: 'date', filters: ['datetime'])); + $database->createRelationship(Relationship::manyToOne(collection: 'review', 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->getId()); + $this->assertEquals('movie', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('movie', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToOne->value, $options['relationType'] ?? null); + $this->assertEquals(false, $options['twoWay'] ?? null); + $this->assertEquals('reviews', $options['twoWayKey'] ?? null); } } // 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->getId()); + $this->assertEquals('reviews', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('review', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToOne->value, $options['relationType'] ?? null); + $this->assertEquals(false, $options['twoWay'] ?? null); + $this->assertEquals('movie', $options['twoWayKey'] ?? null); } } @@ -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( @@ -265,8 +273,8 @@ public function testManyToOneOneWayRelationship(): void // 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'); @@ -308,7 +316,7 @@ public function testManyToOneOneWayRelationship(): void $database->updateRelationship( collection: 'review', id: 'newMovie', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); // Delete child, set parent relationship to null @@ -322,7 +330,7 @@ public function testManyToOneOneWayRelationship(): void $database->updateRelationship( collection: 'review', id: 'newMovie', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::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(new Collection(id: 'product')); + $database->createCollection(new Collection(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(Relationship::manyToOne(collection: 'product', 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->getId()); + $this->assertEquals('store', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('store', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToOne->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('products', $options['twoWayKey'] ?? null); } } // 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->getId()); + $this->assertEquals('products', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('product', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::ManyToOne->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('store', $options['twoWayKey'] ?? null); } } @@ -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( @@ -721,13 +717,13 @@ public function testManyToOneTwoWayRelationship(): void // 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'); @@ -772,7 +768,7 @@ public function testManyToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'product', id: 'newStore', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); // Delete child, set parent relationship to null @@ -804,7 +800,7 @@ public function testManyToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'product', id: 'newStore', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::Cascade ); // Delete child, will delete parent @@ -839,34 +835,28 @@ 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(new Collection(id: 'towns')); + $database->createCollection(new Collection(id: 'homelands')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne(collection: 'towns', relatedCollection: 'homelands', twoWay: true, key: 'homeland')); + $database->createRelationship(Relationship::oneToOne( collection: 'homelands', 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 +881,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 +920,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 +934,28 @@ 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(new Collection(id: 'players')); + $database->createCollection(new Collection(id: 'teams')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne(collection: 'players', relatedCollection: 'teams', twoWay: true, key: 'team')); + $database->createRelationship(Relationship::oneToMany( collection: 'teams', 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(new Collection(id: 'cows')); + $database->createCollection(new Collection(id: 'farms')); + $database->createCollection(new Collection(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(Relationship::manyToOne(collection: 'cows', relatedCollection: 'farms', twoWay: true, key: 'farm')); + $database->createRelationship(Relationship::manyToOne(collection: 'farms', 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(new Collection(id: 'books')); + $database->createCollection(new Collection(id: 'entrants')); + $database->createCollection(new Collection(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(Relationship::manyToOne(collection: 'books', relatedCollection: 'entrants', twoWay: true, key: 'entrant')); + $database->createRelationship(Relationship::manyToMany(collection: 'entrants', 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(new Collection(id: $level1Collection)); + $database->createCollection(new Collection(id: $level2Collection)); + $database->createCollection(new Collection(id: $level3Collection)); + $database->createCollection(new Collection(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(Relationship::manyToOne(collection: $level1Collection, relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship(Relationship::manyToOne(collection: $level2Collection, relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship(Relationship::manyToOne(collection: $level3Collection, 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(new Collection(id: '$symbols_coll.ection5')); + $database->createCollection(new Collection(id: '$symbols_coll.ection6')); - $database->createRelationship( - collection: '$symbols_coll.ection5', - relatedCollection: '$symbols_coll.ection6', - type: Database::RELATION_MANY_TO_ONE, - twoWay: true, - ); + $database->createRelationship(Relationship::manyToOne(collection: '$symbols_coll.ection5', relatedCollection: '$symbols_coll.ection6', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection6', new Document([ '$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(), + '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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToOne(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - ); + $result = $database->createRelationship(Relationship::manyToOne(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToOne(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_MANY_TO_ONE, - ); + $result = $database->createRelationship(Relationship::manyToOne(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToOne(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::manyToOne(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::manyToOne(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::manyToOne(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_m2o'); - $this->getDatabase()->createCollection('bulk_delete_library_m2o'); + $this->getDatabase()->createCollection(new Collection(id: 'bulk_delete_person_m2o')); + $this->getDatabase()->createCollection(new Collection(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(Relationship::manyToOne(collection: 'bulk_delete_person_m2o', 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)) || + ! $database->getAdapter()->supports(Capability::BatchOperations) ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_m2o'; $childCollection = 'child_combined_m2o'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::manyToOne(collection: $childCollection, 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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $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(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::manyToOne(collection: $childCollection, 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,26 @@ 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(new Collection(id: 'companies')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'employees', 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 +1750,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 +1763,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 +1774,26 @@ 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(new Collection(id: 'departments')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'staff', 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 +1818,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 +1843,19 @@ 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(new Collection(id: 'mto_select_parent', permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'mto_select_parent', 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: ForeignKeyAction::SetNull, + )); $database->createDocument('mto_select_child', new Document([ '$id' => 'child1', @@ -2039,27 +1891,26 @@ public function testManyToOneSetNullClearsUnreadableReferences(): void $database = static::getDatabase(); // No collection-level read: only document permissions grant access - $database->createCollection('mto_hidden_product', permissions: [ + $database->createCollection(new Collection(id: 'mto_hidden_product', permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); - $database->createCollection('mto_hidden_store', permissions: [ + ], documentSecurity: true)); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::manyToOne( collection: 'mto_hidden_product', 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: ForeignKeyAction::SetNull, + )); $database->createDocument('mto_hidden_store', new Document([ '$id' => 'store1', @@ -2091,4 +1942,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::manyToOne(collection: $childCollection, relatedCollection: $parentCollection, twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: ForeignKeyAction::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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::manyToOne(collection: $childCollection, relatedCollection: $parentCollection, twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: ForeignKeyAction::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..3b0202ae51 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToManyTests.php @@ -3,6 +3,10 @@ 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; @@ -10,7 +14,12 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Operator; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKeyAction; trait OneToManyTests { @@ -19,38 +28,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(new Collection(id: 'artist')); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: 'artist', 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->getId()); + $this->assertEquals('albums', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('album', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::OneToMany->value, $options['relationType'] ?? null); + $this->assertEquals(false, $options['twoWay'] ?? null); + $this->assertEquals('artist', $options['twoWayKey'] ?? null); } } @@ -68,7 +72,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 +85,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 +116,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 +152,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 +181,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 +190,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 +232,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( @@ -255,8 +259,8 @@ public function testOneToManyOneWayRelationship(): void // 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([ @@ -288,7 +292,7 @@ public function testOneToManyOneWayRelationship(): void $database->updateRelationship( collection: 'artist', id: 'newAlbums', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); // Delete parent, set child relationship to null @@ -309,7 +313,7 @@ public function testOneToManyOneWayRelationship(): void $database->updateRelationship( collection: 'artist', id: 'newAlbums', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::Cascade ); // Delete parent, will delete child @@ -323,15 +327,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 +346,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 +367,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 +395,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(new Collection(id: 'customer')); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: 'customer', 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->getId()); + $this->assertEquals('accounts', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('account', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::OneToMany->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('customer', $options['twoWayKey'] ?? null); } } // 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->getId()); + $this->assertEquals('customer', $attribute->key); + $options = $attribute->options ?? []; + $this->assertEquals('customer', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::OneToMany->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('accounts', $options['twoWayKey'] ?? null); } } @@ -465,11 +464,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 +490,8 @@ public function testOneToManyTwoWayRelationship(): void ], 'name' => 'Customer 2', 'accounts' => [ - 'account2' - ] + 'account2', + ], ])); // Create from child side @@ -512,8 +511,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 +532,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 +583,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 +625,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 +634,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 +645,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 +681,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 +712,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( @@ -748,13 +747,13 @@ public function testOneToManyTwoWayRelationship(): void // 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([ @@ -786,7 +785,7 @@ public function testOneToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'customer', id: 'newAccounts', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); // Delete parent, set child relationship to null @@ -807,7 +806,7 @@ public function testOneToManyTwoWayRelationship(): void $database->updateRelationship( collection: 'customer', id: 'newAccounts', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::Cascade ); // Delete parent, will delete child @@ -842,34 +841,28 @@ 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(new Collection(id: 'countries')); + $database->createCollection(new Collection(id: 'cities')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany(collection: 'countries', relatedCollection: 'cities', twoWay: true, twoWayKey: 'country')); + $database->createRelationship(Relationship::oneToOne( collection: 'cities', 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 +906,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 +951,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 +983,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 +994,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(new Collection(id: 'dormitories')); + $database->createCollection(new Collection(id: 'occupants')); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: 'dormitories', relatedCollection: 'occupants', twoWay: true, twoWayKey: 'dormitory')); + $database->createRelationship(Relationship::oneToMany(collection: 'occupants', relatedCollection: 'pets', twoWay: true, twoWayKey: 'occupant')); $database->createDocument('dormitories', new Document([ '$id' => 'dormitory1', @@ -1086,18 +1068,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 +1104,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 +1115,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(new Collection(id: 'home')); + $database->createCollection(new Collection(id: 'renters')); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: 'home', relatedCollection: 'renters', twoWay: true)); + $database->createRelationship(Relationship::manyToOne(collection: 'renters', relatedCollection: 'floors', twoWay: true, key: 'floor')); $database->createDocument('home', new Document([ '$id' => 'home1', @@ -1185,10 +1157,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 +1187,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 +1198,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(new Collection(id: 'owners')); + $database->createCollection(new Collection(id: 'cats')); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: 'owners', relatedCollection: 'cats', twoWay: true, twoWayKey: 'owner')); + $database->createRelationship(Relationship::manyToMany(collection: 'cats', relatedCollection: 'toys', twoWay: true)); $database->createDocument('owners', new Document([ '$id' => 'owner1', @@ -1280,10 +1242,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 +1272,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 +1283,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 +1294,14 @@ public function testExceedMaxDepthOneToMany(): void $level3Collection = 'level3OneToMany'; $level4Collection = 'level4OneToMany'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(new Collection(id: $level1Collection)); + $database->createCollection(new Collection(id: $level2Collection)); + $database->createCollection(new Collection(id: $level3Collection)); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: $level1Collection, relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToMany(collection: $level2Collection, relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToMany(collection: $level3Collection, relatedCollection: $level4Collection, twoWay: true)); // Exceed create depth $level1 = $database->createDocument($level1Collection, new Document([ @@ -1378,26 +1326,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 +1365,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 +1394,14 @@ public function testExceedMaxDepthOneToManyChild(): void $level3Collection = 'level3OneToManyChild'; $level4Collection = 'level4OneToManyChild'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(new Collection(id: $level1Collection)); + $database->createCollection(new Collection(id: $level2Collection)); + $database->createCollection(new Collection(id: $level3Collection)); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: $level1Collection, relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToMany(collection: $level2Collection, relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToMany(collection: $level3Collection, relatedCollection: $level4Collection, twoWay: true)); $level1 = $database->createDocument($level1Collection, new Document([ '$id' => 'level1', @@ -1485,41 +1419,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 +1461,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(new Collection(id: '$symbols_coll.ection3')); + $database->createCollection(new Collection(id: '$symbols_coll.ection4')); - $database->createRelationship( - collection: '$symbols_coll.ection3', - relatedCollection: '$symbols_coll.ection4', - type: Database::RELATION_ONE_TO_MANY, - twoWay: true, - ); + $database->createRelationship(Relationship::oneToMany(collection: '$symbols_coll.ection3', relatedCollection: '$symbols_coll.ection4', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection4', new Document([ '$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()], + '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 +1500,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToMany(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - ); + $result = $database->createRelationship(Relationship::oneToMany(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToManyTwoWayRelationshipFromParent(): void @@ -1636,67 +1543,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToMany(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::oneToMany(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToManyTwoWayRelationshipFromChild(): void @@ -1704,67 +1586,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToMany(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::oneToMany(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToManyOneWayRelationshipFromParent(): void @@ -1772,65 +1629,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToMany(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_MANY, - ); + $result = $database->createRelationship(Relationship::oneToMany(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testDeleteBulkDocumentsOneToManyRelationship(): void @@ -1838,25 +1672,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_o2m'); - $this->getDatabase()->createCollection('bulk_delete_library_o2m'); + $this->getDatabase()->createCollection(new Collection(id: 'bulk_delete_person_o2m')); + $this->getDatabase()->createCollection(new Collection(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(Relationship::oneToMany(collection: 'bulk_delete_person_o2m', relatedCollection: 'bulk_delete_library_o2m')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ '$id' => 'person1', @@ -1891,7 +1721,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 @@ -1913,7 +1743,7 @@ public function testDeleteBulkDocumentsOneToManyRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2m', id: 'bulk_delete_library_o2m', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ @@ -1949,26 +1779,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 + onDelete: ForeignKeyAction::Cascade ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2m', new Document([ @@ -2004,104 +1833,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(new Collection(id: $relation1)); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: $relation1, 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); + + $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(Relationship::manyToOne(collection: $relation1, relatedCollection: $relation2)); - $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(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)) || + ! $database->getAdapter()->supports(Capability::BatchOperations) ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_o2m'; $childCollection = 'child_combined_o2m'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: $parentCollection, relatedCollection: $childCollection, key: 'parentNumber')); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2155,30 +1979,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $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(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::oneToMany(collection: $parentCollection, relatedCollection: $childCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2197,8 +2018,8 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToMan Permission::delete(Role::any()), ], 'name' => 'Child 1', - ] - ] + ], + ], ])); try { @@ -2220,30 +2041,32 @@ public function testPartialBatchUpdateWithRelationships(): void /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForRelationships() || !$database->getAdapter()->getSupportForBatchOperations()) { + if (! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - // Setup collections with relationships - $database->createCollection('products'); - $database->createCollection('categories'); + $products = 'products_' . uniqid(); + $categories = 'categories_' . uniqid(); + + $database->createCollection(new Collection(id: $products)); + $database->createCollection(new Collection(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(Relationship::oneToMany( + collection: $categories, + 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 +2096,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 +2146,32 @@ 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->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->createCollection(new Collection(id: $authors)); + $database->createCollection(new Collection(id: $books)); - $database->createRelationship( - collection: 'authors', - relatedCollection: 'books', - type: Database::RELATION_ONE_TO_MANY, + $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(Relationship::oneToMany( + collection: $authors, + 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,33 @@ 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(new Collection(id: $teams)); + $database->createCollection(new Collection(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(Relationship::oneToMany( + collection: $teams, + 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 +2301,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 +2311,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 +2332,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 +2362,33 @@ 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->createCollection(new Collection(id: $blogs)); + $database->createCollection(new Collection(id: $posts)); - $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->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( - collection: 'blogs', - relatedCollection: 'posts', - type: Database::RELATION_ONE_TO_MANY, + $database->createRelationship(Relationship::oneToMany( + collection: $blogs, + 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 +2399,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 +2420,32 @@ 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(new Collection(id: $libraries)); + $database->createCollection(new Collection(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(Relationship::oneToMany( + collection: $libraries, + 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 +2456,127 @@ 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(new Collection(id: 'operator_author')); + $database->createCollection(new Collection(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(Relationship::oneToMany( + collection: 'operator_author', + 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 +2598,19 @@ 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(new Collection(id: 'otm_select_parent', permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToMany( collection: 'otm_select_parent', 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: ForeignKeyAction::SetNull, + )); $database->createDocument('otm_select_child', new Document([ '$id' => 'child1', @@ -2922,4 +2636,137 @@ 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(new Collection(id: 'otm_setnull_limit_parent', permissions: $permissions)); + $database->createCollection(new Collection(id: 'otm_setnull_limit_child', permissions: $permissions)); + + $database->createRelationship(Relationship::oneToMany( + collection: 'otm_setnull_limit_parent', + relatedCollection: 'otm_setnull_limit_child', + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: ForeignKeyAction::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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::oneToMany(collection: $parentCollection, relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::oneToMany(collection: $parentCollection, relatedCollection: $childCollection, twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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..bc04646838 100644 --- a/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php +++ b/tests/e2e/Adapter/Scopes/Relationships/OneToOneTests.php @@ -3,6 +3,10 @@ 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; @@ -14,6 +18,10 @@ use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Database\RelationType; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\ForeignKeyAction; 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(new Collection(id: 'person')); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: 'person', 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') { + $options = $attribute->options ?? []; + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('library', $attribute->getId()); + $this->assertEquals('library', $attribute->key); + $this->assertEquals('library', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::OneToOne->value, $options['relationType'] ?? null); + $this->assertEquals(false, $options['twoWay'] ?? null); + $this->assertEquals('person', $options['twoWayKey'] ?? null); } } @@ -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 { @@ -329,8 +332,8 @@ public function testOneToOneOneWayRelationship(): void // 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([ @@ -386,7 +389,7 @@ public function testOneToOneOneWayRelationship(): void $database->updateRelationship( collection: 'person', id: 'newLibrary', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); // Delete parent, no effect on children for one-way @@ -410,7 +413,7 @@ public function testOneToOneOneWayRelationship(): void $database->updateRelationship( collection: 'person', id: 'newLibrary', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::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(new Collection(id: 'country')); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: 'country', 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') { + $options = $attribute->options ?? []; + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('city', $attribute->getId()); + $this->assertEquals('city', $attribute->key); + $this->assertEquals('city', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::OneToOne->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('country', $options['twoWayKey'] ?? null); } } $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') { + $options = $attribute->options ?? []; + $this->assertEquals(ColumnType::Relationship, $attribute->type); + $this->assertEquals('country', $attribute->getId()); + $this->assertEquals('country', $attribute->key); + $this->assertEquals('country', $options['relatedCollection'] ?? null); + $this->assertEquals(RelationType::OneToOne->value, $options['relationType'] ?? null); + $this->assertEquals(true, $options['twoWay'] ?? null); + $this->assertEquals('city', $options['twoWayKey'] ?? null); } } @@ -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 @@ -872,13 +871,13 @@ public function testOneToOneTwoWayRelationship(): void // 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 +887,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 +897,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'); @@ -911,14 +909,14 @@ public function testOneToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'country', id: 'newCity', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::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 && ForeignKeyAction::SetNull && related value NULL $this->assertTrue($database->deleteDocument('city', 'city1')); $city1 = $database->getDocument('city', 'city1'); $this->assertTrue($city1->isEmpty()); @@ -948,7 +946,7 @@ public function testOneToOneTwoWayRelationship(): void $database->updateRelationship( collection: 'country', id: 'newCity', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::Cascade ); // Delete parent, will delete child @@ -983,8 +981,8 @@ public function testOneToOneTwoWayRelationship(): void 'code' => 'MUC', 'newCountry' => [ '$id' => 'country7', - 'name' => 'Germany' - ] + 'name' => 'Germany', + ], ])); // Delete relationship @@ -1009,50 +1007,35 @@ 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(new Collection(id: 'parent')); + $database->createCollection(new Collection(id: 'child')); - $database->createRelationship( - collection: 'parent', - relatedCollection: 'child', - type: Database::RELATION_ONE_TO_ONE, - id: 'child1' - ); + $database->createRelationship(Relationship::oneToOne(collection: 'parent', relatedCollection: 'child', key: 'child1')); try { - $database->createRelationship( - collection: 'parent', - relatedCollection: 'child', - type: Database::RELATION_ONE_TO_MANY, - id: 'children', - ); + $database->createRelationship(Relationship::oneToMany(collection: 'parent', 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(Relationship::oneToMany(collection: 'parent', 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) { + $options = $attribute->options ?? []; + if ($attribute->key === 'child1') { + $this->assertEquals('parent', $options['twoWayKey'] ?? null); } - if ($attribute['key'] === 'children') { - $this->assertEquals('parent_id', $attribute['options']['twoWayKey']); + if ($attribute->key === 'children') { + $this->assertEquals('parent_id', $options['twoWayKey'] ?? null); } } @@ -1075,11 +1058,14 @@ 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( @@ -1109,35 +1095,34 @@ 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(new Collection(id: 'pattern')); + $database->createCollection(new Collection(id: 'shirt')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'pattern', relatedCollection: 'shirt', - type: Database::RELATION_ONE_TO_ONE, twoWay: true, - id: 'shirt', + key: 'shirt', twoWayKey: 'pattern' - ); - $database->createRelationship( + )); + $database->createRelationship(Relationship::oneToOne( collection: 'shirt', 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 +1147,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 +1177,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 +1190,28 @@ 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(new Collection(id: 'teachers')); + $database->createCollection(new Collection(id: 'classrooms')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'teachers', 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(Relationship::oneToMany(collection: 'classrooms', relatedCollection: 'children', twoWay: true, twoWayKey: 'classroom')); $database->createDocument('teachers', new Document([ '$id' => 'teacher1', @@ -1262,11 +1245,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 +1276,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 +1289,28 @@ 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(new Collection(id: 'users')); + $database->createCollection(new Collection(id: 'profiles')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'users', 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(Relationship::manyToOne(collection: 'profiles', relatedCollection: 'avatars', twoWay: true, key: 'avatar')); $database->createDocument('users', new Document([ '$id' => 'user1', @@ -1354,10 +1335,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 +1362,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 +1380,28 @@ 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(new Collection(id: 'addresses')); + $database->createCollection(new Collection(id: 'houses')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'addresses', 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(Relationship::manyToMany(collection: 'houses', relatedCollection: 'buildings', twoWay: true)); $database->createDocument('addresses', new Document([ '$id' => 'address1', @@ -1455,12 +1435,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 +1475,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 +1486,14 @@ public function testExceedMaxDepthOneToOne(): void $level3Collection = 'level3OneToOne'; $level4Collection = 'level4OneToOne'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(new Collection(id: $level1Collection)); + $database->createCollection(new Collection(id: $level2Collection)); + $database->createCollection(new Collection(id: $level3Collection)); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: $level1Collection, relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToOne(collection: $level2Collection, relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToOne(collection: $level3Collection, relatedCollection: $level4Collection, twoWay: true)); // Exceed create depth $level1 = $database->createDocument($level1Collection, new Document([ @@ -1543,11 +1512,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 +1529,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 +1547,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 +1558,14 @@ public function testExceedMaxDepthOneToOneNull(): void $level3Collection = 'level3OneToOneNull'; $level4Collection = 'level4OneToOneNull'; - $database->createCollection($level1Collection); - $database->createCollection($level2Collection); - $database->createCollection($level3Collection); - $database->createCollection($level4Collection); + $database->createCollection(new Collection(id: $level1Collection)); + $database->createCollection(new Collection(id: $level2Collection)); + $database->createCollection(new Collection(id: $level3Collection)); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: $level1Collection, relatedCollection: $level2Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToOne(collection: $level2Collection, relatedCollection: $level3Collection, twoWay: true)); + $database->createRelationship(Relationship::oneToOne(collection: $level3Collection, relatedCollection: $level4Collection, twoWay: true)); $level1 = $database->createDocument($level1Collection, new Document([ '$id' => 'level1', @@ -1624,11 +1583,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 +1600,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 +1620,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(new Collection(id: '$symbols_coll.ection1')); + $database->createCollection(new Collection(id: '$symbols_coll.ection2')); - $database->createRelationship( - collection: '$symbols_coll.ection1', - relatedCollection: '$symbols_coll.ection2', - type: Database::RELATION_ONE_TO_ONE, - twoWay: true, - ); + $database->createRelationship(Relationship::oneToOne(collection: '$symbols_coll.ection1', relatedCollection: '$symbols_coll.ection2', twoWay: true)); $doc1 = $database->createDocument('$symbols_coll.ection2', new Document([ '$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(), + '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 +1659,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToOne(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('two', 'one'); + $database->deleteRelationship($two, $one); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - ); + $result = $database->createRelationship(Relationship::oneToOne(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToOneTwoWayRelationshipFromParent(): void @@ -1766,67 +1702,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToOne(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::oneToOne(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToOneTwoWayRelationshipFromChild(): void @@ -1834,67 +1745,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToOne(collection: $one, 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, - ); + $result = $database->createRelationship(Relationship::oneToOne(collection: $one, relatedCollection: $two, twoWay: true)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testRecreateOneToOneOneWayRelationshipFromParent(): void @@ -1902,65 +1788,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(new Collection(id: $one, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(new Collection(id: $two, attributes: [ + Attribute::string(key: 'name', size: 100, format: ''), + ], 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(Relationship::oneToOne(collection: $one, relatedCollection: $two)); - $database->deleteRelationship('one', 'two'); + $database->deleteRelationship($one, $two); - $result = $database->createRelationship( - collection: 'one', - relatedCollection: 'two', - type: Database::RELATION_ONE_TO_ONE, - ); + $result = $database->createRelationship(Relationship::oneToOne(collection: $one, relatedCollection: $two)); $this->assertTrue($result); - $database->deleteCollection('one'); - $database->deleteCollection('two'); + $database->deleteCollection($one); + $database->deleteCollection($two); } public function testDeleteBulkDocumentsOneToOneRelationship(): void @@ -1968,25 +1831,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $this->getDatabase()->createCollection('bulk_delete_person_o2o'); - $this->getDatabase()->createCollection('bulk_delete_library_o2o'); + $this->getDatabase()->createCollection(new Collection(id: 'bulk_delete_person_o2o')); + $this->getDatabase()->createCollection(new Collection(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(Relationship::oneToOne(collection: 'bulk_delete_person_o2o', relatedCollection: 'bulk_delete_library_o2o')); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ '$id' => 'person1', @@ -2009,8 +1868,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 @@ -2041,7 +1900,7 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2o', id: 'bulk_delete_library_o2o', - onDelete: Database::RELATION_MUTATE_SET_NULL + onDelete: ForeignKeyAction::SetNull ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ @@ -2065,8 +1924,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'); @@ -2089,7 +1948,7 @@ public function testDeleteBulkDocumentsOneToOneRelationship(): void $this->getDatabase()->updateRelationship( collection: 'bulk_delete_person_o2o', id: 'bulk_delete_library_o2o', - onDelete: Database::RELATION_MUTATE_CASCADE + onDelete: ForeignKeyAction::Cascade ); $person1 = $this->getDatabase()->createDocument('bulk_delete_person_o2o', new Document([ @@ -2113,8 +1972,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 +2012,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,114 +2026,111 @@ 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(new Collection(id: 'drivers')); + $database->createCollection(new Collection(id: 'licenses')); - $database->createRelationship( + $database->createRelationship(Relationship::oneToOne( collection: 'drivers', 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( + $database->createRelationship(Relationship::oneToMany( collection: 'drivers', 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( + $database->createRelationship(Relationship::manyToOne( collection: 'licenses', 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( + $database->createRelationship(Relationship::manyToMany( collection: 'licenses', 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'); @@ -2282,42 +2138,39 @@ public function testDeleteTwoWayRelationshipFromChild(): void $licenses = $database->getCollection('licenses'); $junction = $database->getCollection('_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()); } + public function testUpdateParentAndChild_OneToOne(): void { /** @var Database $database */ $database = $this->getDatabase(); if ( - !$database->getAdapter()->getSupportForRelationships() || - !$database->getAdapter()->getSupportForBatchOperations() + ! ($database->getAdapter()->hasFeature(Feature\Relationships::class)) || + ! $database->getAdapter()->supports(Capability::BatchOperations) ) { $this->expectNotToPerformAssertions(); + return; } $parentCollection = 'parent_combined_o2o'; $childCollection = 'child_combined_o2o'; - $database->createCollection($parentCollection); - $database->createCollection($childCollection); + $database->createCollection(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: $parentCollection, relatedCollection: $childCollection, key: 'parentNumber')); $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2377,25 +2230,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { $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(new Collection(id: $parentCollection)); + $database->createCollection(new Collection(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(Relationship::oneToOne(collection: $parentCollection, relatedCollection: $childCollection)); $parent = $database->createDocument($parentCollection, new Document([ '$id' => 'parent1', @@ -2413,7 +2262,7 @@ public function testDeleteDocumentsRelationshipErrorDoesNotDeleteParent_OneToOne Permission::delete(Role::any()), ], 'name' => 'Child 1', - ] + ], ])); try { @@ -2435,27 +2284,27 @@ 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(new Collection(id: 'cities_partial')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'cities_partial', 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 +2329,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 +2353,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 +2370,26 @@ 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(new Collection(id: 'cities_strict')); + $database->createCollection(new Collection(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( + $database->createRelationship(Relationship::oneToOne( collection: 'cities_strict', 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 +2412,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 +2434,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 +2469,21 @@ 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(new Collection(id: 'oto_select_parent', attributes: [ + Attribute::string(key: 'name', size: 255), + ], permissions: $collectionPermissions, documentSecurity: true)); + $database->createCollection(new Collection(id: 'oto_select_child', attributes: [ + Attribute::string(key: 'name', size: 255), + ], permissions: $collectionPermissions, documentSecurity: true)); - $database->createRelationship( + $database->createRelationship(Relationship::oneToOne( collection: 'oto_select_parent', 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: ForeignKeyAction::SetNull, + )); $database->createDocument('oto_select_child', new Document([ '$id' => 'child1', @@ -2721,8 +2498,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 +2505,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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::oneToOne(collection: $parentCollection, relatedCollection: $childCollection, key: 'child', twoWayKey: 'parent', onDelete: ForeignKeyAction::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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createAttribute($parentCollection, Attribute::string(key: 'name', size: 64)); + $database->createRelationship(Relationship::oneToOne(collection: $parentCollection, relatedCollection: $childCollection, key: 'child', twoWayKey: 'parent', onDelete: ForeignKeyAction::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)) || ! $database->getAdapter()->supports(Capability::BatchOperations)) { + $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(new Collection(id: $parentCollection, permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: $childCollection, permissions: $permissions, documentSecurity: false)); + $database->createRelationship(Relationship::oneToOne(collection: $parentCollection, relatedCollection: $childCollection, twoWay: true, key: 'child', twoWayKey: 'parent', onDelete: ForeignKeyAction::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/SchemalessTests.php b/tests/e2e/Adapter/Scopes/SchemalessTests.php index 9f8d150bf6..6f8bdfda94 100644 --- a/tests/e2e/Adapter/Scopes/SchemalessTests.php +++ b/tests/e2e/Adapter/Scopes/SchemalessTests.php @@ -3,7 +3,9 @@ namespace Tests\E2E\Adapter\Scopes; use Exception; -use Throwable; +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 +13,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\Exception\Type as TypeException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\Query; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; 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(new Collection(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 +143,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(new Collection(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 +170,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 +180,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 +196,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(new Collection(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 +234,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 +249,7 @@ public function testSchemalessIncrement(): void } $allDocs = $database->find($colName); - $this->assertCount(3, $allDocs); + $this->assertCount(4, $allDocs); $database->deleteCollection($colName); } @@ -260,19 +259,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(new Collection(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 +314,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(new Collection(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 +341,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 +357,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 +373,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(new Collection(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 +417,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 (! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_bulk_update"); - $database->createCollection($colName); + $colName = uniqid('schemaless_bulk_update'); + $database->createCollection(new Collection(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 +447,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 +455,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 +483,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 +493,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 +514,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 (! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_bulk_delete"); - $database->createCollection($colName); + $colName = uniqid('schemaless_bulk_delete'); + $database->createCollection(new Collection(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 +545,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 +572,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 +598,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 (! $database->getAdapter()->supports(Capability::BatchOperations)) { $this->expectNotToPerformAssertions(); + return; } - $colName = uniqid("schemaless_callbacks"); - $database->createCollection($colName); + $colName = uniqid('schemaless_callbacks'); + $database->createCollection(new Collection(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 +627,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 +652,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 +661,7 @@ public function testSchemalessOperationsWithCallback(): void $deleteResults[] = [ 'id' => $doc->getId(), 'value' => $doc->getAttribute('value'), - 'customData' => $doc->getAttribute('customData') + 'customData' => $doc->getAttribute('customData'), ]; } ); @@ -662,7 +671,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 +689,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(new Collection(id: $col)); $database->createDocument($col, new Document([ '$id' => 'a', @@ -702,11 +712,11 @@ 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])); + $this->assertTrue($database->createIndex($col, Index::unique(key: 'idx_title_unique', attributes: ['title'], lengths: [128], orders: [Order::Asc]))); + $this->assertTrue($database->createIndex($col, Index::key(key: 'idx_rank_key', attributes: ['rank'], lengths: [0], orders: [Order::Asc]))); $collection = $database->getCollection($col); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes; $this->assertCount(2, $indexes); $ids = array_map(fn ($i) => $i['$id'], $indexes); $this->assertContains('idx_rank_key', $ids); @@ -714,89 +724,45 @@ public function testSchemalessIndexCreateListDelete(): void $this->assertTrue($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->assertCount(1, $collection->indexes); + $this->assertEquals('idx_title_unique', $collection->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); - } - - $database->deleteCollection($col); - } - public function testSchemalessObjectIndexes(): void { /** @var Database $database */ $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(new Collection(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: [Order::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: [Order::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); $this->assertContains('idx_meta_key', $ids); @@ -813,21 +779,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(new Collection(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 +825,7 @@ public function testSchemalessPermissions(): void '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), - ] + ], ])); }); @@ -869,7 +836,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 +852,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(new Collection(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 +957,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(new Collection(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(function () use ($database, $col, $permissions, $createdAt1, $updatedAt1, $curDate1) { return $database->createDocument($col, new Document([ '$id' => 'd1', '$permissions' => $permissions, @@ -1025,13 +988,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 +1002,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,25 +1012,25 @@ 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) { return $database->createDocuments($col, [ @@ -1091,13 +1054,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 +1068,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(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,13 +1100,13 @@ 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) { return $database->updateDocuments( @@ -1162,10 +1125,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 +1139,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(function () use ($database, $col, $permissions, $createdAt4, $updatedAt4, $curDate4) { return $database->upsertDocument($col, new Document([ '$id' => 'd4', '$permissions' => $permissions, @@ -1185,14 +1148,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 +1164,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(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,17 +1183,17 @@ 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) { return $database->upsertDocuments($col, [ @@ -1254,9 +1217,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 +1230,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 +1270,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(new Collection(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 +1388,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(new Collection(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 +1499,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); + $database->createCollection(new Collection(id: $collectionId)); // Create documents with array of objects $doc1 = $database->createDocument($collectionId, new Document([ @@ -1548,7 +1514,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 +1523,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 +1531,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 +1539,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 +1549,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 +1558,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 +1570,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 +1583,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 +1593,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 +1603,7 @@ public function testElemMatch(): void Query::elemMatch('items', [ Query::equal('sku', ['ABC']), Query::greaterThanEqual('qty', 1), - ]) + ]), ]); $this->assertCount(2, $results); @@ -1645,7 +1611,7 @@ public function testElemMatch(): void $results = $database->find($collectionId, [ Query::elemMatch('items', [ Query::equal('sku', ['NONEXISTENT']), - ]) + ]), ]); $this->assertCount(0, $results); @@ -1654,7 +1620,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 +1632,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 +1653,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); + $database->createCollection(new Collection(id: $collectionId)); // Create documents with complex nested structures $doc1 = $database->createDocument($collectionId, new Document([ @@ -1701,7 +1668,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 +1677,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 +1687,7 @@ public function testElemMatchComplex(): void Query::greaterThan('stock', 50), Query::equal('category', ['A']), Query::equal('active', [true]), - ]) + ]), ]); $this->assertCount(2, $results); @@ -1729,7 +1696,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 +1709,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 +1730,7 @@ public function testElemMatchComplex(): void ]), ]), Query::equal('active', [true]), - ]) + ]), ]); // Only store2 matches: // - Widget with stock 200 (>150) and active true @@ -1782,19 +1749,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(new Collection(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 +1868,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 +1928,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: [ + $database->createCollection(new Collection(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 +1958,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 +2019,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 +2034,7 @@ public function testUpsertFieldRemoval(): void 'name' => 'Updated Product', 'specs' => [ 'cpu' => 'AMD', - 'ram' => '16GB' + 'ram' => '16GB', ], // details is removed ])); @@ -2074,7 +2042,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 +2052,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,39 +2213,32 @@ 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(new Collection(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', attributes: ['expiresAt'], orders: [Order::Asc], 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(IndexType::Ttl->value, $ttlIndex->getAttribute('type')); $this->assertEquals(3600, $ttlIndex->getAttribute('ttl')); $now = new \DateTime(); @@ -2289,21 +2250,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 @@ -2314,43 +2275,19 @@ public function testSchemalessTTLIndexes(): void $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->createIndex($col, Index::ttl(key: 'idx_ttl_min', attributes: ['expiresAt'], orders: [Order::Asc])) ); $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', signed: false, filters: ['datetime']); - $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', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 7200); - $database->createCollection($col2, [$expiresAtAttr], [$ttlIndexDoc]); + $database->createCollection(new Collection(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()); @@ -2365,144 +2302,71 @@ 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(new Collection(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->getId(), $database->getCollection($collectionId)->indexes); try { - $database->createIndex( - $col, - 'idx_ttl_deleted', - Database::INDEX_TTL, - ['deletedAt'], - [], - [Database::ORDER_ASC], - 86400 // 24 hours + $this->assertTrue( + $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_expires', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 3600)) ); - $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); - - 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', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 7200)), + 'a second TTL index on the same attribute', + ); + $assertRejected( + fn () => $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_deleted', attributes: ['deletedAt'], orders: [Order::Asc], 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', attributes: ['deletedAt'], orders: [Order::Asc], 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'], - ]); + $this->assertTrue($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 - ]); + $this->assertTrue( + $database->createIndex($collectionId, Index::ttl(key: 'idx_ttl_deleted', attributes: ['deletedAt'], orders: [Order::Asc], 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(new Collection( + id: $collectionWithTwoTTLIndexes, + attributes: [Attribute::datetime(key: 'expiresAt', signed: false, filters: ['datetime'])], + indexes: [ + Index::ttl(key: 'idx_ttl_1', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 3600), + Index::ttl(key: 'idx_ttl_2', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 7200), + ], + )), + 'two TTL indexes in createCollection', + ); + $this->assertTrue($database->getCollection($collectionWithTwoTTLIndexes)->isEmpty(), 'A rejected collection must not be created'); + } finally { + $database->deleteCollection($collectionId); } - - $database->deleteCollection($col); } public function testSchemalessDatetimeCreationAndFetching(): void @@ -2510,19 +2374,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(new Collection(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 +2400,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 +2432,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 +2448,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 +2466,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 +2477,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,37 +2488,31 @@ 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::TTLIndexes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl_expiry'); - $database->createCollection($col); + $database->createCollection(new Collection(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', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 10)) ); $now = new \DateTime(); @@ -2666,7 +2525,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 +2533,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 +2570,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,37 +2623,31 @@ 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::TTLIndexes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_ttl_cache_expiry'); - $database->createCollection($col); + $database->createCollection(new Collection(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', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 10)) ); $now = new \DateTime(); @@ -2831,8 +2683,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 +2709,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(new Collection(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 +2732,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 +2776,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 +2802,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 +2811,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,37 +2832,31 @@ 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::TTLIndexes)) { $this->expectNotToPerformAssertions(); + return; } $col = uniqid('sl_str_date_ttl'); - $database->createCollection($col); + $database->createCollection(new Collection(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', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 10)) ); $now = new \DateTime(); @@ -3032,35 +2870,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 +2916,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 +2943,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 +2993,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(new Collection(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 +3013,9 @@ public function testSchemalessMongoDotNotationIndexes(): void 'profile' => [ 'user' => [ 'email' => 'alice@example.com', - 'id' => 'alice' - ] - ] + 'id' => 'alice', + ], + ], ]), new Document([ '$id' => 'u2', @@ -3187,34 +3023,20 @@ 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: [Order::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: [Order::Asc])) ); try { @@ -3224,9 +3046,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 +3057,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 +3070,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(new Collection(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 +3093,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 +3107,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 +3128,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 +3136,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 +3146,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 +3155,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 +3166,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 +3177,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 +3187,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,18 +3199,19 @@ 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(new Collection(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(), @@ -3400,7 +3224,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 +3237,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 +3250,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 +3263,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 +3276,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 +3301,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..d8cdc6b035 100644 --- a/tests/e2e/Adapter/Scopes/SpatialTests.php +++ b/tests/e2e/Adapter/Scopes/SpatialTests.php @@ -2,6 +2,12 @@ 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\Capability; +use Utopia\Database\Collection; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; @@ -11,7 +17,13 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Database\Relationship; +use Utopia\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; trait SpatialTests { @@ -19,73 +31,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', attributes: ['attribute2']), ]; - $col = $database->createCollection($collectionName, $attributes, $indexes); + $col = $database->createCollection(new Collection(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'), attributes: ['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 +73,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 +83,17 @@ public function testSpatialTypeDocuments(): void try { // Create collection first - $database->createCollection($collectionName); + $database->createCollection(new Collection(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)); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::point(key: 'pointAttr', required: $database->getAdapter()->supports(Capability::SpatialIndexNull) ? false : true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::linestring(key: 'lineAttr', required: $database->getAdapter()->supports(Capability::SpatialIndexNull) ? false : true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::polygon(key: 'polyAttr', required: $database->getAdapter()->supports(Capability::SpatialIndexNull) ? 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'])); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'point_spatial', attributes: ['pointAttr']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'line_spatial', attributes: ['lineAttr']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'poly_spatial', attributes: ['polyAttr']))); $point = [5.0, 5.0]; $linestring = [[1.0, 2.0], [3.0, 4.0]]; @@ -125,16 +105,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 +123,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 +131,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 (! $database->getAdapter()->supports(Capability::BoundaryInclusive) && 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 +164,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 +185,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 (! $database->getAdapter()->supports(Capability::BoundaryInclusive) && 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 +207,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 +225,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(new Collection(id: 'location')); + $database->createCollection(new Collection(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', attributes: ['coordinates'])); // Create building document first $building1 = $database->createDocument('building', new Document([ @@ -276,13 +254,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(Relationship::oneToOne(collection: 'location', relatedCollection: 'building', key: 'building')); // Create location with spatial data and relationship $location1 = $database->createDocument('location', new Document([ @@ -297,7 +269,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 +282,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 +296,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,39 +317,74 @@ 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::SpatialIndexNull)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collectionName = 'spatial_required_drop_'; + try { + $database->createCollection(new Collection(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', required: false); + $this->assertFalse($updated->getAttribute('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(new Collection(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::SpatialIndexNull) ? false : true; + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::point(key: 'pointAttr', required: $required))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::linestring(key: 'lineAttr', required: $required))); + $this->assertEquals(true, $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'])); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_point', attributes: ['pointAttr']))); + if ($database->getAdapter()->supports(Capability::SpatialIndexNull)) { + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_line', attributes: ['lineAttr']))); } else { // Attribute was created as required above; directly create index once - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_line', Database::INDEX_SPATIAL, ['lineAttr'])); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_line', attributes: ['lineAttr']))); } - $this->assertEquals(true, $database->createIndex($collectionName, 'idx_poly', Database::INDEX_SPATIAL, ['polyAttr'])); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_poly', attributes: ['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([ @@ -388,9 +392,8 @@ public function testSpatialAttributes(): void '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 +403,103 @@ 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(new Collection(id: $parent)); + $database->createCollection(new Collection(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', attributes: ['coord'])); - $database->createRelationship( + $database->createRelationship(Relationship::oneToMany( collection: $parent, 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,97 @@ 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(new Collection(id: $parent)); + $database->createCollection(new Collection(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', attributes: ['coord'])); - $database->createRelationship( + $database->createRelationship(Relationship::manyToOne( collection: $child, 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 +611,34 @@ 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->createCollection(new Collection(id: $a)); + $database->createCollection(new Collection(id: $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->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', attributes: ['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', attributes: ['area'])); - $database->createRelationship( + $database->createRelationship(Relationship::manyToMany( collection: $a, 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,66 +646,64 @@ 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); @@ -722,61 +714,57 @@ 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(new Collection(id: $collectionName)); + $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true)); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'loc_spatial', attributes: ['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); + $index = $indexes[0] ?? null; + $this->assertInstanceOf(Document::class, $index); + $this->assertSame('loc_spatial', $index->getId()); + $this->assertSame(IndexType::Spatial->value, $index->getAttribute('type')); $this->assertEquals(true, $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::SpatialIndexOrder); // 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', + attributes: ['loc'], + orders: [Order::Asc], + )]; if ($orderSupported) { - $database->createCollection($collOrderCreate, $attributes, $indexes); + $database->createCollection(new Collection(id: $collOrderCreate, attributes: $attributes, indexes: $indexes)); $meta = $database->getCollection($collOrderCreate); - $this->assertEquals('idx_loc', $meta->getAttribute('indexes')[0]['$id']); + $createdIndexes = $meta->indexes; + $createdIndex = $createdIndexes[0] ?? null; + $this->assertInstanceOf(Document::class, $createdIndex); + $this->assertSame('idx_loc', $createdIndex->getId()); } else { try { - $database->createCollection($collOrderCreate, $attributes, $indexes); + $database->createCollection(new Collection(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 +777,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(new Collection(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])); + $this->assertTrue($database->createIndex($collOrderIndex, Index::spatial(key: 'idx_loc', attributes: ['loc'], orders: [Order::Desc]))); } else { try { - $database->createIndex($collOrderIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'], [], ['DESC']); + $database->createIndex($collOrderIndex, Index::spatial(key: 'idx_loc', attributes: ['loc'], orders: [Order::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 +796,27 @@ public function testSpatialIndex(): void } // Edge cases: Spatial Index Nullability (createCollection and createIndex) - $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + $nullSupported = $database->getAdapter()->supports(Capability::SpatialIndexNull); // 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', attributes: ['loc'])]; if ($nullSupported) { - $database->createCollection($collNullCreate, $attributes, $indexes); + $database->createCollection(new Collection(id: $collNullCreate, attributes: $attributes, indexes: $indexes)); $meta = $database->getCollection($collNullCreate); - $this->assertEquals('idx_loc', $meta->getAttribute('indexes')[0]['$id']); + $createdIndexes = $meta->indexes; + $createdIndex = $createdIndexes[0] ?? null; + $this->assertInstanceOf(Document::class, $createdIndex); + $this->assertSame('idx_loc', $createdIndex->getId()); } else { try { - $database->createCollection($collNullCreate, $attributes, $indexes); + $database->createCollection(new Collection(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 +826,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(new Collection(id: $collNullIndex)); + $database->createAttribute($collNullIndex, Attribute::point(key: 'loc')); if ($nullSupported) { - $this->assertTrue($database->createIndex($collNullIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc'])); + $this->assertTrue($database->createIndex($collNullIndex, Index::spatial(key: 'idx_loc', attributes: ['loc']))); } else { try { - $database->createIndex($collNullIndex, 'idx_loc', Database::INDEX_SPATIAL, ['loc']); + $database->createIndex($collNullIndex, Index::spatial(key: 'idx_loc', attributes: ['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 +846,46 @@ public function testSpatialIndex(): void $collUpdateNull = 'spatial_idx_req'; try { - $database->createCollection($collUpdateNull); + $database->createCollection(new Collection(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', attributes: ['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'])); + $this->assertTrue($database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attributes: ['loc']))); } $database->updateAttribute($collUpdateNull, 'loc', required: true); - $this->assertTrue($database->createIndex($collUpdateNull, 'idx_loc_req', Database::INDEX_SPATIAL, ['loc'])); + $this->assertTrue($database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc_req', attributes: ['loc']))); } finally { $database->deleteCollection($collUpdateNull); } - $collUpdateNull = 'spatial_idx_index_null_required_true'; try { - $database->createCollection($collUpdateNull); + $database->createCollection(new Collection(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', attributes: ['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'])); + $this->assertTrue($database->createIndex($collUpdateNull, Index::spatial(key: 'idx_loc', attributes: ['loc']))); } $database->updateAttribute($collUpdateNull, 'loc', required: true); - $this->assertTrue($database->createIndex($collUpdateNull, 'new index', Database::INDEX_SPATIAL, ['loc'])); + $this->assertTrue($database->createIndex($collUpdateNull, Index::spatial(key: 'new index', attributes: ['loc']))); } finally { $database->deleteCollection($collUpdateNull); } @@ -919,30 +895,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(new Collection(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)); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::polygon(key: 'rectangle', required: true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::polygon(key: 'square', required: true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::polygon(key: 'triangle', required: true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::point(key: 'circle_center', required: true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::polygon(key: 'complex_polygon', required: true))); + $this->assertEquals(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'])); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_rectangle', attributes: ['rectangle']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_square', attributes: ['square']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_triangle', attributes: ['triangle']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_circle_center', attributes: ['circle_center']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_complex_polygon', attributes: ['complex_polygon']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_multi_linestring', attributes: ['multi_linestring']))); // Create documents with different geometric shapes $doc1 = new Document([ @@ -953,7 +930,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 +941,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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 +984,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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 +1323,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(new Collection(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)); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::point(key: 'location', required: true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::polygon(key: 'area', required: true))); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::linestring(key: 'route', required: true))); + $this->assertEquals(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'])); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_location', attributes: ['location']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_area', attributes: ['area']))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_route', attributes: ['route']))); // Create test documents $doc1 = new Document([ @@ -1377,7 +1351,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 +1360,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 +1369,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 +1378,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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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 +1393,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 +1442,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 +1455,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(new Collection(id: $collectionName)); - $this->assertEquals(true, $database->createAttribute($collectionName, 'name', Database::VAR_STRING, 255, true)); + $this->assertTrue($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)); + $this->assertTrue($database->createAttribute($collectionName, Attribute::point(key: 'location'))); $database->createDocument($collectionName, new Document([ '$id' => 'withLocation', @@ -1510,13 +1484,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 +1502,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 +1512,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', attributes: ['location']), ]; - $database->createCollection($collectionName, $attributes, $indexes); + $database->createCollection(new Collection(id: $collectionName, attributes: $attributes, indexes: $indexes)); // Test 1: createDocuments with spatial data $spatialDocuments = []; @@ -1585,15 +1533,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 +1558,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 +1571,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 +1585,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 +1627,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 +1640,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 +1659,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 +1680,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 +1698,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 +1715,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 +1793,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(new Collection(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', attributes: ['loc'])); + $database->createIndex($collectionName, Index::spatial(key: 'idx_area', attributes: ['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 +1850,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 ($database->getAdapter()->supports(Capability::BoundaryInclusive)) { $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,33 +1878,34 @@ 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(new Collection(id: $collectionName)); // 0) Disallow creation of spatial attributes with size or array try { - $database->createAttribute($collectionName, 'geom_bad_size', Database::VAR_POINT, 10, true); + $database->createAttribute($collectionName, Attribute::point(key: 'geom_bad_size', size: 10, required: true)); $this->fail('Expected DatabaseException when creating spatial attribute with non-zero size'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } try { - $database->createAttribute($collectionName, 'geom_bad_array', Database::VAR_POINT, 0, true, array: true); + $database->createAttribute($collectionName, Attribute::point(key: 'geom_bad_array', required: true, array: true)); $this->fail('Expected DatabaseException when creating spatial attribute with array=true'); } catch (\Throwable $e) { $this->assertInstanceOf(Exception::class, $e); } // 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'])); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::point(key: 'geom', required: true))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_geom', attributes: ['geom']))); // 1) Disallow size and array updates on spatial attributes: expect DatabaseException try { @@ -1963,12 +1923,15 @@ public function testUpdateSpatialAttributes(): void } // 2) required=true -> create index -> update required=false - $nullSupported = $database->getAdapter()->getSupportForSpatialIndexNull(); + $nullSupported = $database->getAdapter()->supports(Capability::SpatialIndexNull); if ($nullSupported) { // Should succeed on adapters that allow nullable spatial indexes $database->updateAttribute($collectionName, 'geom', required: false); $meta = $database->getCollection($collectionName); - $this->assertEquals(false, $meta->getAttribute('attributes')[0]['required']); + $attributes = $meta->attributes; + $attribute = $attributes[0] ?? null; + $this->assertInstanceOf(Document::class, $attribute); + $this->assertFalse($attribute->getAttribute('required')); } else { // Should error (index constraint) when making required=false while spatial index exists $threw = false; @@ -1980,21 +1943,24 @@ public function testUpdateSpatialAttributes(): void $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; + $attribute = $attributes[0] ?? null; + $this->assertInstanceOf(Document::class, $attribute); + $this->assertTrue($attribute->getAttribute('required')); } // 3) Spatial index order support: providing orders should fail if not supported - $orderSupported = $database->getAdapter()->getSupportForSpatialIndexOrder(); + $orderSupported = $database->getAdapter()->supports(Capability::SpatialIndexOrder); if ($orderSupported) { - $this->assertTrue($database->createIndex($collectionName, 'idx_geom_desc', Database::INDEX_SPATIAL, ['geom'], [], [Database::ORDER_DESC])); + $this->assertTrue($database->createIndex($collectionName, Index::spatial(key: 'idx_geom_desc', attributes: ['geom'], orders: [Order::Desc]))); // cleanup $this->assertTrue($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', attributes: ['geom'], orders: [Order::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,63 +1972,65 @@ 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_'; - try { - $database->createCollection($collectionName); + $database->createCollection(new Collection(id: $collectionName)); - // 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]]])); - - // 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)); + try { + $this->assertTrue($database->createAttribute($collectionName, Attribute::point(key: 'pt', default: [1.0, 2.0]))); + $this->assertTrue($database->createAttribute($collectionName, Attribute::linestring(key: 'ln', default: [[0.0, 0.0], [1.0, 1.0]]))); + $this->assertTrue($database->createAttribute($collectionName, Attribute::polygon(key: 'pg', default: [[[0.0, 0.0], [0.0, 2.0], [2.0, 2.0], [0.0, 0.0]]]))); + + $this->assertTrue($database->createAttribute($collectionName, Attribute::string(key: 'title', size: 255, default: 'Untitled'))); + $this->assertTrue($database->createAttribute($collectionName, Attribute::integer(key: 'count', default: 0))); + $this->assertTrue($database->createAttribute($collectionName, Attribute::double(key: 'rating'))); + $this->assertTrue($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 document without providing spatial values, expect defaults applied - $doc = $database->createDocument($collectionName, new Document([ + $document = $database->createDocument($collectionName, new Document([ '$id' => ID::custom('d1'), - '$permissions' => [Permission::read(Role::any())] + '$permissions' => [Permission::read(Role::any()), Permission::update(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())], + $this->assertSpatialDefaults($defaults, $document, 'created'); + + $database->purgeCachedDocument($collectionName, 'd1'); + $this->assertSpatialDefaults($defaults, $database->getDocument($collectionName, 'd1'), 'stored'); + + $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 + $this->assertSpatialDefaults($overrides, $overridden, 'overridden'); + $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]]]); @@ -2070,213 +2038,148 @@ public function testSpatialAttributeDefaults(): void $database->updateAttributeDefault($collectionName, 'count', 10); $database->updateAttributeDefault($collectionName, 'active', false); - $doc3 = $database->createDocument($collectionName, new Document([ + $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())] + '$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(new Collection(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(new Collection(id: $collectionName)); + $this->assertEquals(true, $database->createAttribute($collectionName, Attribute::point(key: 'loc', required: true))); + $this->assertEquals(true, $database->createIndex($collectionName, Index::spatial(key: 'idx_loc', attributes: ['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 +2191,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 (! $database->getAdapter()->supports(Capability::MultiDimensionDistance)) { $this->expectNotToPerformAssertions(); + return; } $multiCollection = 'spatial_distance_meters_multi_'; try { - $database->createCollection($multiCollection); + $database->createCollection(new Collection(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)); + $this->assertEquals(true, $database->createAttribute($multiCollection, Attribute::point(key: 'loc', required: true))); + $this->assertEquals(true, $database->createAttribute($multiCollection, Attribute::linestring(key: 'line', required: true))); + $this->assertEquals(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'])); + $this->assertEquals(true, $database->createIndex($multiCollection, Index::spatial(key: 'idx_loc', attributes: ['loc']))); + $this->assertEquals(true, $database->createIndex($multiCollection, Index::spatial(key: 'idx_line', attributes: ['line']))); + $this->assertEquals(true, $database->createIndex($multiCollection, Index::spatial(key: 'idx_poly', attributes: ['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 +2241,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 +2253,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 +2265,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 +2276,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 +2287,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 +2305,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,63 +2318,6 @@ 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([ @@ -2482,41 +2327,42 @@ public function testSpatialEncodeDecode(): void 'attributes' => [ [ '$id' => ID::custom('point'), - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'required' => false, - 'filters' => [Database::VAR_POINT], + 'filters' => [ColumnType::Point->value], ], [ '$id' => ID::custom('line'), - 'type' => Database::VAR_LINESTRING, + 'type' => ColumnType::Linestring->value, 'format' => '', 'required' => false, - 'filters' => [Database::VAR_LINESTRING], + 'filters' => [ColumnType::Linestring->value], ], [ '$id' => ID::custom('poly'), - 'type' => Database::VAR_POLYGON, + '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 +2370,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 +2392,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(new Collection(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); - } + $this->assertTrue($database->createIndex($collectionName, Index::spatial(key: 'idx_loc', attributes: ['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::spatial(key: 'idx_multi', 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::spatial(key: 'idx_mix', 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->getId(), $database->getCollection($collectionName)->indexes)); } finally { $database->deleteCollection($collectionName); } @@ -2599,28 +2432,30 @@ 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::SpatialIndexNull)) { $this->expectNotToPerformAssertions(); + return; } try { $collUpdateNull = 'spatial_idx_toggle'; - $database->createCollection($collUpdateNull); + $database->createCollection(new Collection(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', attributes: ['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->createIndex($collUpdateNull, Index::spatial(key: 'new index', attributes: ['loc']))); $this->assertTrue($database->deleteIndex($collUpdateNull, 'new index')); $database->updateAttribute($collUpdateNull, 'loc', required: false); @@ -2634,61 +2469,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); + $collectionName = 'spatial_idx_non_spatial'; + $database->createCollection(new Collection(id: $collectionName)); - $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); - } - - 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', attributes: ['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', attributes: ['name,loc']), + 'spatial index on "loc,name"' => Index::spatial(key: 'idx_loc', attributes: ['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 +2509,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(new Collection(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, Attribute::point(key: 'pointAttr', required: $database->getAdapter()->supports(Capability::SpatialIndexNull) ? 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::SpatialIndexNull)) { $this->expectNotToPerformAssertions(); + return; } - if ($database->getAdapter()->getSupportForOptionalSpatialAttributeWithExistingRows()) { + if ($database->getAdapter()->supports(Capability::OptionalSpatial)) { $this->expectNotToPerformAssertions(); + return; } $col = 'spatial_col_existing_data'; try { - $database->createCollection($col); + $database->createCollection(new Collection(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 +2586,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(new Collection(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::SpatialIndexNull) ? false : true)); + $database->createAttribute($collectionName, Attribute::polygon(key: 'area', required: $database->getAdapter()->supports(Capability::SpatialIndexNull) ? 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', attributes: ['location'])); + $database->createIndex($collectionName, Index::spatial(key: 'route_idx', attributes: ['route'])); + $database->createIndex($collectionName, Index::spatial(key: 'area_idx', attributes: ['area'])); // Create initial document with spatial arrays $initialPoint = [10.0, 20.0]; @@ -2890,7 +2618,7 @@ public function testSpatialArrayWKTConversionInUpdateDocument(): void 'location' => $initialPoint, 'route' => $initialLine, 'area' => $initialPolygon, - 'name' => 'Original' + 'name' => 'Original', ])); // Verify initial values @@ -2907,7 +2635,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 +2652,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 +2660,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 +2672,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 ($database->getAdapter()->supports(Capability::MultiDimensionDistance)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'spatial_distance_error_test'; + $database->createCollection(new Collection(id: $collection)); + $this->assertTrue($database->createAttribute($collection, Attribute::point(key: 'loc', required: true))); + $this->assertTrue($database->createAttribute($collection, Attribute::linestring(key: 'line', required: true))); + $this->assertTrue($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) || ! $adapter->supports(Capability::BatchCreateAttributes)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'spatial_batch_definitions'; + $database->createCollection(new Collection(id: $collection)); + + try { + $this->assertTrue($database->createAttribute($collection, Attribute::point(key: 'singlePoint', required: true))); + $this->assertTrue($database->createAttribute($collection, Attribute::linestring(key: 'singleRoute'))); + $this->assertTrue($database->createAttribute($collection, Attribute::polygon(key: 'singleArea', required: true))); + + $this->assertTrue($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::SpatialIndexNull)) { + $this->expectNotToPerformAssertions(); + + return; + } + + $collection = 'spatial_required_populated'; + $database->createCollection(new Collection(id: $collection)); + + try { + $this->assertTrue($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', + ])); + + $this->assertTrue($database->createAttribute($collection, Attribute::point(key: 'location', required: true))); + $this->assertTrue($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(new Collection(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(new Collection(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, 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::SpatialIndexNull) || ! $adapter->hasFeature(Feature\SchemaAttributes::class)) { + continue; + } + + $nullable = []; + foreach ($database->getSchemaAttributes($collection) as $column) { + $nullable[$column->getId()] = $column->getAttribute('isNullable'); + } + foreach (\array_keys($shapes) as $key) { + $this->assertSame('NO', $nullable[$key] ?? null, '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..3f5c82d28b 100644 --- a/tests/e2e/Adapter/Scopes/VectorTests.php +++ b/tests/e2e/Adapter/Scopes/VectorTests.php @@ -2,13 +2,20 @@ namespace Tests\E2E\Adapter\Scopes; +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\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\Query; -use Utopia\Database\Validator\Authorization; +use Utopia\Database\Relationship; +use Utopia\Query\Schema\ColumnType; trait VectorTests { @@ -17,125 +24,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(new Collection(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', size: 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', size: 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') { + if ($attr->getAttribute('key') === 'embedding') { $embeddingAttr = $attr; - } elseif ($attr['key'] === 'large_embedding') { + } elseif ($attr->getAttribute('key') === 'large_embedding') { $largeEmbeddingAttr = $attr; } } - $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(Document::class, $embeddingAttr); + $this->assertInstanceOf(Document::class, $largeEmbeddingAttr); + $this->assertEquals(ColumnType::Vector->value, $embeddingAttr->getAttribute('type')); + $this->assertEquals(3, $embeddingAttr->getAttribute('size')); + $this->assertEquals(ColumnType::Vector->value, $largeEmbeddingAttr->getAttribute('type')); + $this->assertEquals(128, $largeEmbeddingAttr->getAttribute('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(new Collection(id: 'vectorDocuments')); + $database->createAttribute('vectorDocuments', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorDocuments', Attribute::vector(key: 'embedding', size: 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 +122,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(new Collection(id: 'vectorQueries')); + $database->createAttribute('vectorQueries', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorQueries', Attribute::vector(key: 'embedding', size: 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 +164,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 +177,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 +185,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 +193,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 +203,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 +212,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 +224,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 +234,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 +244,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 +254,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 +270,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(new Collection(id: 'vectorIndexes')); + $database->createAttribute('vectorIndexes', Attribute::vector(key: 'embedding', size: 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', attributes: ['embedding'])); // Cosine distance index - $database->createIndex('vectorIndexes', 'embedding_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorIndexes', Index::hnswCosine(key: 'embedding_cosine', attributes: ['embedding'])); // Inner product (dot product) index - $database->createIndex('vectorIndexes', 'embedding_dot', Database::INDEX_HNSW_DOT, ['embedding']); + $database->createIndex('vectorIndexes', Index::hnswDot(key: 'embedding_dot', attributes: ['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 +332,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(new Collection(id: 'vectorDimMismatch')); + $database->createAttribute('vectorDimMismatch', Attribute::vector(key: 'embedding', size: 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 +361,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(new Collection(id: 'vectorNullEmpty')); + $database->createAttribute('vectorNullEmpty', Attribute::vector(key: 'embedding', size: 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 +384,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 +402,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(new Collection(id: 'vectorLarge')); + $database->createAttribute('vectorLarge', Attribute::vector(key: 'embedding', size: 1536, required: true)); // Common embedding size // Create a large vector $largeVector = array_fill(0, 1536, 0.1); @@ -669,20 +418,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 +447,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(new Collection(id: 'vectorUpdates')); + $database->createAttribute('vectorUpdates', Attribute::vector(key: 'embedding', size: 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 +490,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(new Collection(id: 'multiVector')); + $database->createAttribute('multiVector', Attribute::vector(key: 'embedding1', size: 3, required: true)); + $database->createAttribute('multiVector', Attribute::vector(key: 'embedding2', size: 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 +530,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 +545,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(new Collection(id: 'vectorPagination')); + $database->createAttribute('vectorPagination', Attribute::vector(key: 'embedding', size: 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 +587,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 +596,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 +605,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 +619,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 +641,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(new Collection(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', size: 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 +667,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 +677,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 +688,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 +719,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(new Collection(id: 'vectorSpecialFloats')); + $database->createAttribute('vectorSpecialFloats', Attribute::vector(key: 'embedding', size: 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 +784,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(new Collection(id: 'vectorPerf')); + $database->createAttribute('vectorPerf', Attribute::vector(key: 'embedding', size: 128, required: true)); + $database->createAttribute('vectorPerf', Attribute::string(key: 'name', required: true)); // Create documents $numDocs = 100; @@ -1027,10 +804,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 +817,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', attributes: ['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 +846,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(new Collection(id: 'vectorNorm')); + $database->createAttribute('vectorNorm', Attribute::vector(key: 'embedding', size: 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 +895,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(new Collection(id: 'vectorInfinity')); + $database->createAttribute('vectorInfinity', Attribute::vector(key: 'embedding', size: 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 +921,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 +939,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(new Collection(id: 'vectorNaN')); + $database->createAttribute('vectorNaN', Attribute::vector(key: 'embedding', size: 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 +965,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(new Collection(id: 'vectorStringNums')); + $database->createAttribute('vectorStringNums', Attribute::vector(key: 'embedding', size: 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 +1008,65 @@ 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(new Collection(id: 'vectorParent')); + $database->createAttribute('vectorParent', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorParent', Attribute::vector(key: 'embedding', size: 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(new Collection(id: 'vectorChild')); + $database->createAttribute('vectorChild', Attribute::string(key: 'title', required: true)); + $database->createRelationship(Relationship::manyToOne( + collection: 'vectorChild', + 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,59 @@ 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(new Collection(id: 'vectorAuthors')); + $database->createAttribute('vectorAuthors', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorAuthors', Attribute::vector(key: 'embedding', size: 3, required: true)); + + $database->createCollection(new Collection(id: 'vectorBooks')); + $database->createAttribute('vectorBooks', Attribute::string(key: 'title', required: true)); + $database->createAttribute('vectorBooks', Attribute::vector(key: 'embedding', size: 3, required: true)); + $database->createRelationship(Relationship::manyToOne( + collection: 'vectorBooks', + 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 +1159,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 +1171,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(new Collection(id: 'vectorZeros')); + $database->createAttribute('vectorZeros', Attribute::vector(key: 'embedding', size: 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 +1193,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 +1208,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 +1222,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(new Collection(id: 'vectorCosineZero')); + $database->createAttribute('vectorCosineZero', Attribute::vector(key: 'embedding', size: 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,22 +1256,23 @@ 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(new Collection(id: 'vectorDeleteAttr')); + $database->createAttribute('vectorDeleteAttr', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorDeleteAttr', Attribute::vector(key: 'embedding', size: 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')); @@ -1665,9 +1283,9 @@ public function testDeleteVectorAttribute(): void // Verify attribute is gone $collection = $database->getCollection('vectorDeleteAttr'); - $attributes = $collection->getAttribute('attributes'); + $attributes = $collection->attributes; foreach ($attributes as $attr) { - $this->assertNotEquals('embedding', $attr['key']); + $this->assertNotEquals('embedding', $attr->getAttribute('key')); } // Fetch document - should not have embedding anymore @@ -1683,24 +1301,25 @@ 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(new Collection(id: 'vectorDeleteIndexedAttr')); + $database->createAttribute('vectorDeleteIndexedAttr', Attribute::vector(key: 'embedding', size: 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', attributes: ['embedding'])); + $database->createIndex('vectorDeleteIndexedAttr', Index::hnswEuclidean(key: 'idx2', attributes: ['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 @@ -1709,7 +1328,7 @@ public function testDeleteAttributeWithVectorIndexes(): void // Verify indexes are gone $collection = $database->getCollection('vectorDeleteIndexedAttr'); - $indexes = $collection->getAttribute('indexes'); + $indexes = $collection->indexes; $this->assertCount(0, $indexes); // Cleanup @@ -1721,74 +1340,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(new Collection(id: 'vectorPermissions', permissions: [], documentSecurity: true)); + $database->createAttribute('vectorPermissions', Attribute::string(key: 'name', size: 255, required: true)); + $database->createAttribute('vectorPermissions', Attribute::vector(key: 'embedding', size: 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 +1418,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(new Collection(id: 'vectorPermScoring')); + $database->createAttribute('vectorPermScoring', Attribute::integer(key: 'score', required: true)); + $database->createAttribute('vectorPermScoring', Attribute::vector(key: 'embedding', size: 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 +1467,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(new Collection(id: 'vectorCursorBefore')); + $database->createAttribute('vectorCursorBefore', Attribute::integer(key: 'index', required: true)); + $database->createAttribute('vectorCursorBefore', Attribute::vector(key: 'embedding', size: 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 +1501,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 +1518,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(new Collection(id: 'vectorBackward')); + $database->createAttribute('vectorBackward', Attribute::integer(key: 'value', required: true)); + $database->createAttribute('vectorBackward', Attribute::vector(key: 'embedding', size: 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 +1550,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 +1560,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 +1576,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(new Collection(id: 'vectorDimUpdate')); + $database->createAttribute('vectorDimUpdate', Attribute::vector(key: 'embedding', size: 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', ColumnType::Vector->value, 5, 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 +1612,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(new Collection(id: 'vectorRequiredNull')); + $database->createAttribute('vectorRequiredNull', Attribute::vector(key: 'embedding', size: 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 +1655,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(new Collection(id: 'vectorConcurrent')); + $database->createAttribute('vectorConcurrent', Attribute::vector(key: 'embedding', size: 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,28 +1700,29 @@ 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(new Collection(id: 'vectorDeleteIdx')); + $database->createAttribute('vectorDeleteIdx', Attribute::vector(key: 'embedding', size: 3, required: true)); // Create index - $database->createIndex('vectorDeleteIdx', 'idx_cosine', Database::INDEX_HNSW_COSINE, ['embedding']); + $database->createIndex('vectorDeleteIdx', Index::hnswCosine(key: 'idx_cosine', attributes: ['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 @@ -2101,12 +1731,12 @@ public function testDeleteVectorIndexes(): void // 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 +1750,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(new Collection(id: 'vectorMultiIdx')); + $database->createAttribute('vectorMultiIdx', Attribute::vector(key: 'embedding1', size: 3, required: true)); + $database->createAttribute('vectorMultiIdx', Attribute::vector(key: 'embedding2', size: 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', attributes: ['embedding1'])); + $database->createIndex('vectorMultiIdx', Index::hnswEuclidean(key: 'idx2_euclidean', attributes: ['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 +1799,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(new Collection(id: 'vectorIdxFail')); + $database->createAttribute('vectorIdxFail', Attribute::vector(key: 'embedding', size: 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', attributes: ['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())); - } + $this->assertTrue($database->createIndex('vectorIdxFail', Index::hnswCosine(key: 'idx1', attributes: ['embedding']))); - // Cleanup - $database->deleteCollection('vectorIdxFail'); + try { + $database->createIndex('vectorIdxFail', Index::hnswCosine(key: 'idx1', attributes: ['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->getId(), $database->getCollection('vectorIdxFail')->indexes)); + } finally { + $database->deleteCollection('vectorIdxFail'); + } } public function testVectorQueryWithoutIndex(): void @@ -2203,32 +1838,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(new Collection(id: 'vectorNoIndex')); + $database->createAttribute('vectorNoIndex', Attribute::vector(key: 'embedding', size: 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 +1878,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(new Collection(id: 'vectorEmptyQuery')); + $database->createAttribute('vectorEmptyQuery', Attribute::vector(key: 'embedding', size: 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 +1903,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(new Collection(id: 'vectorSingleDim')); + $database->createAttribute('vectorSingleDim', Attribute::vector(key: 'embedding', size: 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 +1932,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 +1946,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(new Collection(id: 'vectorLongResults')); + $database->createAttribute('vectorLongResults', Attribute::vector(key: 'embedding', size: 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 +1986,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(new Collection(id: 'vectorMultiQuery')); + $database->createAttribute('vectorMultiQuery', Attribute::vector(key: 'embedding', size: 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 +2040,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(new Collection(id: 'vectorLargeVals')); + $database->createAttribute('vectorLargeVals', Attribute::vector(key: 'embedding', size: 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 +2080,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(new Collection(id: 'vectorPrecision')); + $database->createAttribute('vectorPrecision', Attribute::vector(key: 'embedding', size: 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 +2116,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(new Collection(id: 'vector16000')); + $database->createAttribute('vector16000', Attribute::vector(key: 'embedding', size: 16000, required: true)); // Create a vector with exactly 16000 dimensions $largeVector = array_fill(0, 16000, 0.1); @@ -2532,12 +2132,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 +2147,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 +2161,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(new Collection(id: 'vectorLargeDataset')); + $database->createAttribute('vectorLargeDataset', Attribute::vector(key: 'embedding', size: 128, required: true)); // Create 200 documents for ($i = 0; $i < 200; $i++) { @@ -2576,20 +2179,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', attributes: ['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 +2206,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(new Collection(id: 'vectorFilterDisabled')); + $database->createAttribute('vectorFilterDisabled', Attribute::string(key: 'status', size: 50, required: true)); + $database->createAttribute('vectorFilterDisabled', Attribute::vector(key: 'embedding', size: 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 +2261,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(new Collection(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', size: 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 +2289,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 +2308,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(new Collection(id: 'vectorMultiFilters')); + $database->createAttribute('vectorMultiFilters', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorMultiFilters', Attribute::vector(key: 'embedding1', size: 3, required: true)); + $database->createAttribute('vectorMultiFilters', Attribute::vector(key: 'embedding2', size: 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 +2349,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(new Collection(id: 'vectorNested')); + $database->createAttribute('vectorNested', Attribute::string(key: 'name', required: true)); + $database->createAttribute('vectorNested', Attribute::vector(key: 'embedding1', size: 3, required: true)); + $database->createAttribute('vectorNested', Attribute::vector(key: 'embedding2', size: 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 +2376,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 +2393,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(new Collection(id: 'vectorCount')); + $database->createAttribute('vectorCount', Attribute::vector(key: 'embedding', size: 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 +2423,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(new Collection(id: 'vectorSum')); + $database->createAttribute('vectorSum', Attribute::vector(key: 'embedding', size: 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 +2481,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(new Collection(id: 'vectorUpsert')); + $database->createAttribute('vectorUpsert', Attribute::vector(key: 'embedding', size: 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 +2508,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 +2520,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(new Collection(id: 'vectorDistance', attributes: [ + Attribute::string(key: 'name', size: 255, required: true), + Attribute::vector(key: 'embedding', size: 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(new Collection( + id: 'vectorAssociative', + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + try { + $database->createAttribute('vectorAssociative', Attribute::vector(key: 'embedding', size: 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..6a0467fef3 100644 --- a/tests/e2e/Adapter/SharedTables/MariaDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MariaDBTest.php @@ -13,26 +13,23 @@ class MariaDBTest extends Base { protected static ?Database $database = null; + protected static ?PDO $pdo = null; + 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 - */ public function getDatabase(bool $fresh = false): Database { - if (!is_null(self::$database) && !$fresh) { + if (! is_null(self::$database) && ! $fresh) { return self::$database; } @@ -44,18 +41,18 @@ public function getDatabase(bool $fresh = false): Database $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); $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()) + ->enableLocks(true); if ($database->exists()) { $database->delete(); @@ -64,14 +61,16 @@ public function getDatabase(bool $fresh = false): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } 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; @@ -79,9 +78,10 @@ protected function deleteColumn(string $collection, string $column): bool 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..b4b3dd70e6 100644 --- a/tests/e2e/Adapter/SharedTables/MongoDBTest.php +++ b/tests/e2e/Adapter/SharedTables/MongoDBTest.php @@ -5,43 +5,55 @@ 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\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; use Utopia\Mongo\Client; class MongoDBTest extends Base { + use MongoReadFilterTests; + public static ?Database $database = null; + 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 */ 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 +64,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(); @@ -74,30 +87,103 @@ public function getDatabase(): Database 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)); + $this->assertTrue($this->getDatabase()->create()); + $this->assertTrue($this->getDatabase()->delete($this->testDatabase)); + $this->assertTrue($this->getDatabase()->create()); + $this->assertSame($this->getDatabase(), $this->getDatabase()->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->getTenantPerDocument(); + $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(new Collection( + 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) + ->skipDuplicates(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(new Collection(id: 'pooledDefinition', permissions: [Permission::read(Role::any())])); + $database->createCollection(new Collection(id: 'pooledAdminDefinition', permissions: [Permission::read(Role::user('admin'))])); + + $database->setTenant(1); + $database->createCollection(new Collection(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); + } } protected function deleteColumn(string $collection, string $column): bool diff --git a/tests/e2e/Adapter/SharedTables/MySQLTest.php b/tests/e2e/Adapter/SharedTables/MySQLTest.php index 697c42c7ea..08aeb31373 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,26 @@ class MySQLTest extends Base { + use MySQLJoinPlanTests; + public static ?Database $database = null; + protected static ?PDO $pdo = null; + 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 - */ public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -45,19 +45,19 @@ public function getDatabase(): Database $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()) + ->enableLocks(true); if ($database->exists()) { $database->delete(); @@ -66,14 +66,16 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } 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; @@ -81,9 +83,10 @@ protected function deleteColumn(string $collection, string $column): bool 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..6536ecc022 100644 --- a/tests/e2e/Adapter/SharedTables/PostgresTest.php +++ b/tests/e2e/Adapter/SharedTables/PostgresTest.php @@ -13,17 +13,17 @@ class PostgresTest extends Base { public static ?Database $database = null; + public static ?PDO $pdo = null; + protected static string $namespace; /** * Return name of adapter - * - * @return string */ public static function getAdapterName(): string { - return "postgres"; + return 'postgres'; } /** @@ -31,7 +31,7 @@ public static function getAdapterName(): string */ public function getDatabase(): Database { - if (!is_null(self::$database)) { + if (! is_null(self::$database)) { return self::$database; } @@ -43,16 +43,17 @@ public function getDatabase(): Database $pdo = new PDO("pgsql:host={$dbHost};port={$dbPort};", $dbUser, $dbPass, Postgres::getPDOAttributes()); $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,14 +62,16 @@ public function getDatabase(): Database $database->create(); self::$pdo = $pdo; + return self::$database = $database; } 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; @@ -76,10 +79,11 @@ protected function deleteColumn(string $collection, string $column): bool 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/SQLiteTest.php b/tests/e2e/Adapter/SharedTables/SQLiteTest.php index 1701bc8dcc..81fbe4ead7 100644 --- a/tests/e2e/Adapter/SharedTables/SQLiteTest.php +++ b/tests/e2e/Adapter/SharedTables/SQLiteTest.php @@ -7,61 +7,65 @@ 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\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; use Utopia\Database\PDO; class SQLiteTest extends Base { public static ?Database $database = null; + public static ?PDO $pdo = null; + 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 - */ 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, SQLite::getPDOAttributes()); $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); $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,14 +74,65 @@ 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(new Collection(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)); + + $this->assertTrue($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; + } + 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; @@ -85,9 +140,10 @@ protected function deleteColumn(string $collection, string $column): bool 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..e25c6f1ffd --- /dev/null +++ b/tests/e2e/Adapter/Support/EventRecorder.php @@ -0,0 +1,92 @@ + */ + private array $events = []; + + /** @var list */ + private array $payloads = []; + + private bool $recording = true; + + public function __construct( + private readonly string $name, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function handle(Event $event, mixed $data): void + { + if ($this->recording) { + $this->events[] = $event; + $this->payloads[] = $data; + } + } + + /** + * Stop recording and return the events recorded so far. + * + * @return list + */ + public function stop(): array + { + $this->recording = false; + + return $this->events; + } + + /** + * The payloads recorded for $event so far, in the order they fired. + * + * @return list + */ + public function getPayloads(Event $event): array + { + $payloads = []; + foreach ($this->events as $index => $recorded) { + if ($recorded === $event) { + $payloads[] = $this->payloads[$index]; + } + } + + return $payloads; + } + + /** + * The documents recorded for $event so far, in the order they fired. + * + * @return list + * + * @throws UnexpectedValueException When a payload recorded for $event is not a document + */ + public function getDocuments(Event $event): array + { + $documents = []; + foreach ($this->getPayloads($event) as $payload) { + if (! $payload instanceof Document) { + throw new UnexpectedValueException($event->value . ' recorded a ' . \get_debug_type($payload) . ', not a document'); + } + $documents[] = $payload; + } + + 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/AddTableColumnTest.php b/tests/unit/Adapter/AddTableColumnTest.php new file mode 100644 index 0000000000..739074909b --- /dev/null +++ b/tests/unit/Adapter/AddTableColumnTest.php @@ -0,0 +1,56 @@ + + */ + private array $statements = []; + + public function testVectorOnMySQLTableThrowsDatabaseException(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Vector columns are only supported on PostgreSQL'); + + $this->adapter(MariaDB::class)->createAttribute('movies', Attribute::vector(key: 'embedding', size: 3)); + } + + public function testVectorOnPostgreSQLTableSetsTypeAndDimensions(): void + { + $this->assertTrue($this->adapter(Postgres::class)->createAttribute('movies', Attribute::vector(key: 'embedding', size: 4))); + + $this->assertSame(['ALTER TABLE "database"."namespace_movies" ADD COLUMN "embedding" VECTOR(4) NULL'], $this->statements); + } + + /** + * @param class-string $adapter + */ + private function adapter(string $adapter): SQL + { + $statement = self::createStub(PDOStatement::class); + $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; + }); + + $sql = new $adapter($pdo); + $sql->setDatabase('database'); + $sql->setNamespace('namespace'); + + return $sql; + } +} diff --git a/tests/unit/Adapter/AttributeSupportToggleTest.php b/tests/unit/Adapter/AttributeSupportToggleTest.php new file mode 100644 index 0000000000..9be0366f57 --- /dev/null +++ b/tests/unit/Adapter/AttributeSupportToggleTest.php @@ -0,0 +1,42 @@ + + */ + 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->assertTrue($adapter->setSupportForAttributes(false), 'An adapter that always enforces its schema must not report that attribute support was turned off'); + $this->assertTrue($adapter->supports(Capability::DefinedAttributes)); + $this->assertTrue($adapter->setSupportForAttributes(true)); + $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..33c417ad50 --- /dev/null +++ b/tests/unit/Adapter/BaseAdapterStateTest.php @@ -0,0 +1,140 @@ +assertSame([], $adapter->getDebug()); + $this->assertSame($adapter, $adapter->setDebug('a', 1)); + $this->assertSame($adapter, $adapter->setDebug('b', ['nested' => true])); + $this->assertSame($adapter, $adapter->setDebug('a', 2)); + $this->assertSame(['a' => 2, 'b' => ['nested' => true]], $adapter->getDebug()); + + $this->assertSame($adapter, $adapter->resetDebug()); + $this->assertSame([], $adapter->getDebug()); + } + + public function testTenantHookIsFoundAmongTheOtherWriteHooks(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $adapter->addWriteHook(new class () extends Interceptor { + }); + + $this->assertFalse($adapter->hasTenantHook()); + $this->assertNull($adapter->getTenantHook()); + + $tenancy = new Tenancy(7); + $adapter->addWriteHook($tenancy); + + $this->assertTrue($adapter->hasTenantHook()); + $this->assertSame($tenancy, $adapter->getTenantHook()); + + $adapter->removeWriteHook(Tenancy::class); + + $this->assertFalse($adapter->hasTenantHook()); + $this->assertNull($adapter->getTenantHook()); + $this->assertCount(1, $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->assertFalse($adapter->hasTenantHook()); + $hook = $adapter->getTenantHook(); + $this->assertNull($hook); + + $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->assertTrue($adapter->hasTenantHook()); + $hook = $adapter->getTenantHook(); + $this->assertNotNull($hook); + $this->assertSame(7, $hook->getTenant()); + + $database->setSharedTables(false)->setTenant(null); + $database->setNamespace(self::NAMESPACE); + $database->createDocument('own', new Document(['$id' => 'third', 'body' => 'three'])); + + $this->assertFalse($adapter->hasTenantHook()); + $this->assertNull($adapter->getTenantHook()); + } + + public function testClearTimeoutsForgetsEveryEvent(): 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->clearTimeouts(); + + $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 new Collection( + id: $id, + attributes: [Attribute::string('body', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ); + } +} diff --git a/tests/unit/Adapter/CastingAdapterStub.php b/tests/unit/Adapter/CastingAdapterStub.php new file mode 100644 index 0000000000..fe8d790f06 --- /dev/null +++ b/tests/unit/Adapter/CastingAdapterStub.php @@ -0,0 +1,11 @@ + */ + 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', Attribute::integer(key: 'age', required: true), 'years')); + + $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', Attribute::integer(key: 'age', required: true), 'years')); + + $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', Attribute::integer(key: 'age', required: true), 'years'); + } + + public function testPostgresReadsNoCatalogWithoutARename(): void + { + $adapter = $this->createPostgres(['_id', 'age']); + + $this->assertTrue($adapter->updateAttribute('users', Attribute::integer(key: 'age', required: true))); + $this->assertTrue($adapter->updateAttribute('users', Attribute::integer(key: 'age', required: true), 'age')); + + $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', Attribute::integer(key: 'age', required: true), 'years')); + + $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', Attribute::integer(key: 'age', required: true), 'years')); + + $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', 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 => [Storage::SEQUENCE => $column], $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/ElsewherePinnedPool.php b/tests/unit/Adapter/ElsewherePinnedPool.php new file mode 100644 index 0000000000..4e1a50426a --- /dev/null +++ b/tests/unit/Adapter/ElsewherePinnedPool.php @@ -0,0 +1,21 @@ +elsewhere = $adapter; + } + + 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..dd99011c6c --- /dev/null +++ b/tests/unit/Adapter/FeatureAdapterStub.php @@ -0,0 +1,18 @@ +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 carries every optional Feature method so it can forward whichever + * adapter the pool hands out, but it must not implement the interfaces: + * 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 + { + $implements = $this->interfaces(Pool::class); + + foreach ([ + Feature\ConnectionId::class, + Feature\InternalCasting::class, + Feature\Relationships::class, + Feature\SchemaAttributes::class, + Feature\SchemaIndexes::class, + Feature\Spatial::class, + Feature\Timeouts::class, + Feature\Upserts::class, + Feature\UTCCasting::class, + Feature\RawQuery::class, + Feature\QueryBuilder::class, + Feature\ColumnTypes::class, + ] as $feature) { + $this->assertArrayNotHasKey($feature, $implements, $feature); + } + + $pool = $this->pool(new Memory()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support upserts'); + $pool->upsertDocuments(new Document(['$id' => 'any']), '', []); + } + + public function testRedisAdvertisesUpsertsConnectionIdAndRelationships(): void + { + $implements = $this->interfaces(Redis::class); + $this->assertArrayHasKey(Feature\Upserts::class, $implements); + $this->assertArrayHasKey(Feature\ConnectionId::class, $implements); + $this->assertArrayHasKey(Feature\Relationships::class, $implements); + $this->assertArrayNotHasKey(Feature\Spatial::class, $implements); + $this->assertArrayNotHasKey(Feature\RawQuery::class, $implements); + } + + public function testSQLiteImplementsSqlFeaturesButNotSpatialTimeoutsOrConnectionId(): void + { + $implements = $this->interfaces(SQLite::class); + $this->assertArrayHasKey(Feature\Upserts::class, $implements); + $this->assertArrayHasKey(Feature\Relationships::class, $implements); + $this->assertArrayHasKey(Feature\SchemaAttributes::class, $implements); + $this->assertArrayHasKey(Feature\SchemaIndexes::class, $implements); + $this->assertArrayHasKey(Feature\RawQuery::class, $implements); + $this->assertArrayHasKey(Feature\QueryBuilder::class, $implements); + $this->assertArrayHasKey(Feature\ColumnTypes::class, $implements); + $this->assertArrayNotHasKey(Feature\Spatial::class, $implements); + $this->assertArrayNotHasKey(Feature\Timeouts::class, $implements); + $this->assertArrayNotHasKey(Feature\ConnectionId::class, $implements); + } + + public function testMariaDBImplementsSpatialTimeoutsConnectionIdAndSchemaIndexes(): void + { + $implements = $this->interfaces(MariaDB::class); + $this->assertArrayHasKey(Feature\Spatial::class, $implements); + $this->assertArrayHasKey(Feature\Timeouts::class, $implements); + $this->assertArrayHasKey(Feature\ConnectionId::class, $implements); + $this->assertArrayHasKey(Feature\SchemaIndexes::class, $implements); + } + + public function testPostgresImplementsSpatialTimeoutsAndConnectionIdWithoutSchemaIntrospection(): void + { + $implements = $this->interfaces(Postgres::class); + $this->assertArrayHasKey(Feature\Spatial::class, $implements); + $this->assertArrayHasKey(Feature\Timeouts::class, $implements); + $this->assertArrayHasKey(Feature\ConnectionId::class, $implements); + $this->assertArrayNotHasKey(Feature\SchemaAttributes::class, $implements); + $this->assertArrayNotHasKey(Feature\SchemaIndexes::class, $implements); + } + + 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 = 'Utopia\\Database\\Adapter\\Feature\\'.$capability->name; + if (\interface_exists($feature) && $adapter->supports($capability) !== $hasFeature($feature)) { + $disagreements[] = "{$name}: Capability::{$capability->name}"; + } + } + } + + $this->assertSame([], $disagreements, 'supports() and hasFeature() disagree'); + } + + /** + * @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/FullOuterJoinRandomOrderTest.php b/tests/unit/Adapter/FullOuterJoinRandomOrderTest.php new file mode 100644 index 0000000000..ca83ed1a91 --- /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(new Collection(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', '$id', 'customerId', '=', 'note'); + $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', '$id', 'customerId', '=', 'note')], + 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/HostnameSQLite.php b/tests/unit/Adapter/HostnameSQLite.php new file mode 100644 index 0000000000..be636af98e --- /dev/null +++ b/tests/unit/Adapter/HostnameSQLite.php @@ -0,0 +1,22 @@ +setHostname($hostname); + } + + #[\Override] + public function capabilities(): array + { + return [...parent::capabilities(), Capability::Hostname]; + } +} diff --git a/tests/unit/Adapter/HostnameTest.php b/tests/unit/Adapter/HostnameTest.php new file mode 100644 index 0000000000..fb1e7ec280 --- /dev/null +++ b/tests/unit/Adapter/HostnameTest.php @@ -0,0 +1,37 @@ +assertSame($adapter, $adapter->setHostname('db-1')); + $this->assertSame('db-1', $adapter->getHostname()); + + $adapter->setHostname('db-2'); + $this->assertSame('db-2', $adapter->getHostname()); + } + + public function testAnSQLAdapterOverAPlainPDOReturnsTheHostnameItWasGiven(): void + { + $adapter = new MariaDB(self::createStub(PDO::class)); + $adapter->setHostname('db-1'); + + $this->assertSame('db-1', $adapter->getHostname()); + } + + public function testAnAdapterWithoutAHostnameReturnsAnEmptyOne(): void + { + $this->assertSame('', (new SQLite(new PDO('sqlite::memory:')))->getHostname()); + $this->assertSame('', (new Memory())->getHostname()); + } +} diff --git a/tests/unit/Adapter/IndexColumnOrderTest.php b/tests/unit/Adapter/IndexColumnOrderTest.php new file mode 100644 index 0000000000..110203242a --- /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, Order::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: [Order::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: [Order::Asc, Order::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: [Order::Asc, Order::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', attributes: ['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/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..0aad9d4747 --- /dev/null +++ b/tests/unit/Adapter/MariaDBCreateDocumentTest.php @@ -0,0 +1,149 @@ + */ + 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) + { + } + + 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..0e469c82df --- /dev/null +++ b/tests/unit/Adapter/MariaDBSchemaTest.php @@ -0,0 +1,219 @@ + */ + 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', RelationType::ManyToMany, twoWay: true, side: RelationSide::Parent), + $this->relationship('cover', RelationType::OneToOne, twoWay: false, side: RelationSide::Child), + $this->relationship('chapters', RelationType::OneToMany, twoWay: true, side: RelationSide::Parent), + $this->relationship('shelf', RelationType::ManyToOne, twoWay: true, side: RelationSide::Child), + $this->relationship('isbn', RelationType::OneToOne, twoWay: false, side: RelationSide::Parent), + $this->relationship('summary', RelationType::OneToOne, twoWay: true, side: RelationSide::Child), + $this->relationship('series', RelationType::OneToMany, twoWay: true, side: RelationSide::Child), + $this->relationship('publisher', RelationType::ManyToOne, twoWay: true, side: RelationSide::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)], [ + new Index('location_index', IndexType::Spatial, ['location'], orders: [Order::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)], [ + new Index('location_index', IndexType::Spatial, ['location'], orders: [Order::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::Index, 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', new Index('happened_index', $type, ['happened'])); + $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', new Index('happened_index', IndexType::Key, ['happened']))); + $this->assertCount(1, $this->statements); + $this->assertStringContainsString('`happened_index`', $this->statements[0]); + } + + private function relationship(string $key, RelationType $type, bool $twoWay, RelationSide $side): Attribute + { + return Attribute::relationship(key: $key, 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); + } + + 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); + } + + 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..636ab065a2 --- /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..55774d4f2f --- /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('varchar', $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..f9ddbf86fc --- /dev/null +++ b/tests/unit/Adapter/MemoryAdapterTest.php @@ -0,0 +1,318 @@ +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(new Collection( + 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(new Collection(id: 'shared', attributes: [Attribute::string(key: 'name', size: 8)], permissions: $permissions)); + $database->setTenant(self::TENANT); + $database->createCollection(new Collection(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(new Collection(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', size: 4, required: true, signed: false)); + $database->createAttribute('documents', Attribute::integer(key: 'bigint_signed', size: 8, required: true)); + $database->createAttribute('documents', Attribute::integer(key: 'bigint_unsigned', size: 9, 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(new Collection( + 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..63855cc30c --- /dev/null +++ b/tests/unit/Adapter/MemorySchemaPathsTest.php @@ -0,0 +1,180 @@ +adapter(); + + $adapter->startTransaction(); + $this->assertTrue($adapter->createCollection('rolled')); + $this->assertTrue($adapter->exists(self::DATABASE, 'rolled')); + $adapter->rollbackTransaction(); + + $this->assertFalse($adapter->exists(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: [Order::Asc, Order::Desc, Order::Asc])); + + $this->assertTrue($adapter->deleteAttribute(self::COLLECTION, 'a')); + + $index = $indexOf(self::COLLECTION, 'by_all'); + $this->assertSame(['b', 'c'], $index['attributes'] ?? null); + $this->assertSame([Order::Desc->value, Order::Asc->value], \array_map( + static fn (mixed $order): mixed => $order instanceof Order ? $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..047d5feb68 --- /dev/null +++ b/tests/unit/Adapter/MemoryWritePathsTest.php @@ -0,0 +1,280 @@ +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(DuplicateException::class); + $this->expectExceptionMessage('Cannot create unique index: existing rows already contain duplicate values'); + $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(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(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 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(self::COLLECTION, $id, 'visits', 1, self::UPDATED_AT, max: 5)); + $this->assertTrue($adapter->increaseDocumentAttribute(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(new Collection( + 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/OperatorLimitExactnessTest.php b/tests/unit/Adapter/OperatorLimitExactnessTest.php new file mode 100644 index 0000000000..f8c7be8d09 --- /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(new Collection( + 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..cf217cac4b --- /dev/null +++ b/tests/unit/Adapter/PingRecordingMemory.php @@ -0,0 +1,38 @@ + + */ + public array $pings = []; + + private ?Closure $pause = null; + + public function pauseNextPing(Closure $pause): void + { + $this->pause = $pause; + } + + 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; + } +} diff --git a/tests/unit/Adapter/PoolAlterLockTest.php b/tests/unit/Adapter/PoolAlterLockTest.php new file mode 100644 index 0000000000..e922bd8ad2 --- /dev/null +++ b/tests/unit/Adapter/PoolAlterLockTest.php @@ -0,0 +1,80 @@ + */ + private array $statements = []; + + public function testEnableLocksReachesTheBorrowedAdapter(): void + { + [$database, $pool] = $this->database(); + + $database->enableLocks(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->enableLocks(true); + $database->enableLocks(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..c8f078807b --- /dev/null +++ b/tests/unit/Adapter/PoolCapabilityTest.php @@ -0,0 +1,216 @@ +connections(new Memory()); + $database = $this->database($connections); + $database->disableValidation(); + $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->disableValidation(); + $database->getDocument('posts', 'first'); + + $this->down = true; + + $this->assertSame('first', $database->getDocument('posts', 'first')->getAttribute('title')); + } + + public function testAWarmValidatedReadChecksOutOnlyForDefinedAttributes(): void + { + $memory = new class () extends Memory { + /** @var list */ + public array $asked = []; + + public function supports(Capability $feature): bool + { + $this->asked[] = $feature->name; + + return parent::supports($feature); + } + }; + $database = $this->database($this->connections($memory)); + $database->getDocument('posts', 'first'); + + $this->checkouts = 0; + $memory->asked = []; + $database->getDocument('posts', 'first'); + + $this->assertSame(['DefinedAttributes', 'DefinedAttributes'], $memory->asked); + $this->assertSame(2, $this->checkouts); + } + + public function testACapabilityQuestionOnAColdPoolChecksOutOnce(): void + { + $pool = $this->pool($this->connections(new Memory())); + + $this->assertTrue($pool->supports(Capability::Casting)); + $this->assertFalse($pool->supports(Capability::Hostname)); + $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::Casting)); + + $this->checkouts = 0; + $this->down = true; + + $handle = $this->pool($connections); + $this->assertTrue($handle->supports(Capability::Casting)); + $this->assertTrue($handle->supports(Capability::Fulltext)); + $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)); + + $mongo->setSupportForAttributes(false); + $this->assertFalse($pool->supports(Capability::DefinedAttributes)); + + $mongo->setSupportForAttributes(true); + $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 testAFailedFirstCheckoutAnswersNothingAndTheNextOneFillsTheAnswers(): void + { + $pool = $this->pool($this->connections(new Memory())); + $this->down = true; + + $caught = null; + try { + $pool->supports(Capability::Casting); + } 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::Casting)); + + $this->down = true; + $this->assertTrue($pool->supports(Capability::Casting)); + } + + /** + * @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(new Collection( + 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/PoolCoroutineTransactionTest.php b/tests/unit/Adapter/PoolCoroutineTransactionTest.php new file mode 100644 index 0000000000..dc9267698d --- /dev/null +++ b/tests/unit/Adapter/PoolCoroutineTransactionTest.php @@ -0,0 +1,164 @@ +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), + ); + } + + /** + * @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..f25dfba343 --- /dev/null +++ b/tests/unit/Adapter/PoolDelegationTest.php @@ -0,0 +1,103 @@ + + */ + 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->getBuilder('books'), 'Adapter does not support query builder'], + 'schema attributes' => [static fn (Pool $pool): mixed => $pool->getSchemaAttributes('books'), 'Adapter does not support schema attributes'], + 'schema indexes' => [static fn (Pool $pool): mixed => $pool->getSchemaIndexes('books'), 'Adapter does not support schema indexes'], + 'column types' => [static fn (Pool $pool): mixed => $pool->getColumnType('string', 32), 'Adapter does not support column types'], + 'spatial' => [static fn (Pool $pool): mixed => $pool->decodePoint(''), 'Adapter does not support spatial'], + 'internal casting' => [static fn (Pool $pool): mixed => $pool->castingBefore(new Document(), new Document()), 'Adapter does not support internal casting'], + 'UTC casting' => [static fn (Pool $pool): mixed => $pool->setUTCDatetime('2026-01-01'), 'Adapter does not support UTC casting'], + 'connection id' => [static fn (Pool $pool): mixed => $pool->getConnectionId(), 'Adapter does not support connection id'], + 'relationships' => [static fn (Pool $pool): mixed => $pool->createRelationship(new Relationship(collection: 'books', relatedCollection: 'authors', type: RelationType::OneToOne, 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 testDebugEntriesReachTheBorrowedAdapter(): void + { + $adapter = new Memory(); + $adapter->setDebug('stale', 'entry'); + $pool = $this->pool($adapter); + $pool->setDebug('request', 'r-1'); + + $this->assertTrue($pool->ping()); + $this->assertSame(['request' => 'r-1'], $adapter->getDebug()); + } + + 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 testQueryBuilderIsServedByTheBorrowedAdapter(): void + { + $this->assertInstanceOf(SQLiteBuilder::class, $this->pool(new SQLite(new PDO('sqlite::memory:')))->getBuilder('books')); + } + + 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/PoolTest.php b/tests/unit/Adapter/PoolTest.php new file mode 100644 index 0000000000..60e197c32f --- /dev/null +++ b/tests/unit/Adapter/PoolTest.php @@ -0,0 +1,330 @@ +addWriteHook(new Tenancy('old')); + $pool = $this->createPool($adapter); + + $hook = new Tenancy('new'); + $pool->addWriteHook($hook); + + $this->assertTrue($pool->ping()); + $this->assertSame([$hook], $adapter->getWriteHooks()); + $this->assertSame([Storage::TENANT => 'new'], $adapter->getTenantHook()?->decorateRow([])); + } + + public function testTransactionReplacesStatefulWriteHookOnReusedAdapter(): void + { + $adapter = new Memory(); + $adapter->addWriteHook(new Permissions()); + $adapter->addWriteHook(new Tenancy('old')); + $pool = $this->createPool($adapter); + + $hook = new Tenancy('new'); + $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->assertTrue($pool->ping()); + $this->assertSame([$hook], $adapter->getWriteHooks()); + + $pool->removeWriteHook(Permissions::class); + $this->assertTrue($pool->ping()); + $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->assertTrue($pool->ping()); + $this->assertSame('new_db', $adapter->getDatabase()); + $this->assertSame('new_ns', $adapter->getNamespace()); + $this->assertSame(2, $adapter->getTenant()); + }); + } + + public function testMemoryPoolPingDoesNotRequireTimeouts(): void + { + $pool = $this->createPool(new Memory()); + + $this->assertTrue($pool->ping()); + } + + 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->setSupportForAttributes(false); + + $this->assertFalse($pool->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 QueryProfiler(); + $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 QueryProfiler(); + $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->enableProfiling(); + $database->create(); + $database->createCollection(new Collection(id: 'posts')); + + $profiler = $database->getProfiler(); + $this->assertInstanceOf(QueryProfiler::class, $profiler); + $captured = $profiler->getQueryCount(); + $this->assertGreaterThan(0, $captured, 'The pooled SQLite connection must profile its statements'); + + $database->disableProfiling(); + + $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..2cfc25c789 --- /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: [Order::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..85178ca9bb --- /dev/null +++ b/tests/unit/Adapter/PostgresColumnRewriteTest.php @@ -0,0 +1,56 @@ + */ + private array $statements = []; + + public function testDatetimeRewriteCastsTheColumnItself(): void + { + $this->createAdapter()->updateAttribute('events', 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', Attribute::datetime(key: 'at'), '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..1ea3d9f24e --- /dev/null +++ b/tests/unit/Adapter/PostgresCreateCollectionCleanupTest.php @@ -0,0 +1,166 @@ + */ + 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)], [ + new Index('title_index', IndexType::Key, ['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)], [ + new Index('title_index', IndexType::Key, ['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)], [ + new Index('title_index', IndexType::Key, ['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)], [ + new Index('location_index', IndexType::Spatial, ['location'], orders: [Order::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..900915285f --- /dev/null +++ b/tests/unit/Adapter/PostgresRenameIndexTest.php @@ -0,0 +1,132 @@ + */ + private array $indexes = []; + + /** @var list */ + 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); + } + + 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 \str_contains($query, '_metadata') ? $this->tenants : []; + }); + + 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..f9e2af562d --- /dev/null +++ b/tests/unit/Adapter/PostgresSchemaTest.php @@ -0,0 +1,143 @@ + */ + private array $statements = []; + + public function testCreateCollectionAddsColumnsOnlyForRelationshipSidesThatStoreAKey(): void + { + $this->adapter()->createCollection('books', [ + Attribute::string('title', size: 64), + $this->relationship('tags', RelationType::ManyToMany, twoWay: true, side: RelationSide::Parent), + $this->relationship('cover', RelationType::OneToOne, twoWay: false, side: RelationSide::Child), + $this->relationship('chapters', RelationType::OneToMany, twoWay: true, side: RelationSide::Parent), + $this->relationship('shelf', RelationType::ManyToOne, twoWay: true, side: RelationSide::Child), + $this->relationship('isbn', RelationType::OneToOne, twoWay: false, side: RelationSide::Parent), + $this->relationship('summary', RelationType::OneToOne, twoWay: true, side: RelationSide::Child), + $this->relationship('series', RelationType::OneToMany, twoWay: true, side: RelationSide::Child), + $this->relationship('publisher', RelationType::ManyToOne, twoWay: true, side: RelationSide::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]; + yield 'index' => [IndexType::Index]; + } + + #[DataProvider('unknownIndexTypes')] + public function testCreateIndexRefusesATypeTheEngineDoesNotCreate(IndexType $type): void + { + try { + $this->adapter()->createIndex('events', new Index('happened_index', $type, ['happened'])); + $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', new Index('meta_index', IndexType::Object, [$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', new Index('meta_index', IndexType::Object, ['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', 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, RelationType $type, bool $twoWay, RelationSide $side): Attribute + { + return Attribute::relationship(key: $key, 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..84b0f9a051 --- /dev/null +++ b/tests/unit/Adapter/PostgresStatementTest.php @@ -0,0 +1,199 @@ + */ + 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); + } + + public function testPowerWithANumericExponentIsSentAsPower(): void + { + $this->adapter()->updateDocuments( + new Document(['$id' => 'scores', 'attributes' => []]), + new Document(['value' => Operator::power(3)]), + [new Document(['$id' => 'first', '$sequence' => '1'])], + ); + + $this->assertCount(1, $this->statements); + $this->assertStringContainsString('POWER(', $this->statements[0]); + } + + 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..e2c80cf93e --- /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(new Collection( + 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/ProfilerProbeAdapter.php b/tests/unit/Adapter/ProfilerProbeAdapter.php new file mode 100644 index 0000000000..4a98e69283 --- /dev/null +++ b/tests/unit/Adapter/ProfilerProbeAdapter.php @@ -0,0 +1,18 @@ +profiled = $this->getProfiler(); + + return true; + } +} diff --git a/tests/unit/Adapter/QueryCommentsTest.php b/tests/unit/Adapter/QueryCommentsTest.php new file mode 100644 index 0000000000..4b7dcf98eb --- /dev/null +++ b/tests/unit/Adapter/QueryCommentsTest.php @@ -0,0 +1,330 @@ + + */ + private array $statements = []; + + private SQLite $adapter; + + private Database $database; + + protected function setUp(): void + { + $this->adapter = new SQLite($this->recordingConnection()); + $this->database = $this->open($this->adapter); + $this->database->create(); + $this->database->createCollection(new Collection( + 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 = []; + + 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 */\nSELECT ", + $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->assertNotEmpty($this->statements); + foreach ($this->statements as $statement) { + $this->assertStringStartsNotWith('/*', $statement); + } + } + + public function testStatementsPreparedWithoutAnEventCarryTheComments(): void + { + $this->database->setMetadata('user', 'user-1'); + + $this->assertTrue($this->database->ping()); + $rows = $this->database->getAuthorization()->skip(fn (): array => $this->database->rawQuery('SELECT ? AS answer', [42])); + + $this->assertSame(42, $rows[0]->getAttribute('answer')); + $this->assertSame([ + "/* user: user-1 */\nSELECT 1", + "/* user: user-1 */\nSELECT ? AS answer", + ], $this->statements); + } + + /** + * @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->getConnectionId()); + $this->assertSame(["/* user: user-1 */\nSELECT {$function}()"], $this->statements); + } + + 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->assertTrue($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 { + 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->assertTrue($this->database->ping()); + $this->assertSame([ + "/* 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\"} */\n" + .'SELECT 1', + ], $this->statements); + } + + public function testPooledConnectionCarriesOnlyTheCurrentHandlesMetadata(): void + { + $first = $this->open($this->pool()); + $second = $this->open($this->pool()); + + $first->setMetadata('user', 'user-1'); + + $this->assertTrue($first->ping()); + $this->assertTrue($second->ping()); + $this->assertSame(["/* user: user-1 */\nSELECT 1", 'SELECT 1'], $this->statements); + } + + /** + * @param non-empty-string $prefix + */ + private function assertEveryStatementStartsWith(string $prefix): void + { + $this->assertNotEmpty($this->statements); + foreach ($this->statements as $statement) { + $this->assertStringStartsWith($prefix, $statement); + } + } + + 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..57fe7d503d --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolMetadataTest.php @@ -0,0 +1,331 @@ +}> + */ + public static function metadataCalls(): iterable + { + $calls = [ + 'supports' => [Capability::Index], + 'capabilities' => [], + 'hasFeature' => [Feature\Spatial::class], + 'setSupportForAttributes' => [true], + 'getSupportNonUtfCharacters' => [], + 'getLimitForString' => [], + 'getLimitForInt' => [], + 'getLimitForBigInt' => [], + 'getLimitForAttributes' => [], + 'getLimitForIndexes' => [], + 'getMaxIndexLength' => [], + 'getMaxVarcharLength' => [], + 'getMaxUIDLength' => [], + 'getMinDateTime' => [], + 'getIdAttributeType' => [], + 'getDocumentSizeLimit' => [], + 'getAttributeWidth' => [new Document()], + 'getCountOfAttributes' => [new Document()], + 'getCountOfIndexes' => [new Document()], + 'getCountOfDefaultAttributes' => [], + 'getCountOfDefaultIndexes' => [], + 'getKeywords' => [], + 'getInternalIndexesKeys' => [], + 'getBuilder' => ['posts'], + 'getSchema' => [], + 'getColumnType' => ['string', 255, true, false, false], + 'decodePoint' => ['POINT(1 2)'], + 'decodeLinestring' => ['LINESTRING(1 2, 3 4)'], + 'decodePolygon' => ['POLYGON((1 2, 3 4, 5 6, 1 2))'], + 'castingBefore' => [new Document(), new Document()], + 'castingAfter' => [new Document(), new Document()], + 'setUTCDatetime' => ['2026-09-23 00:00:00'], + 'quote' => ['posts'], + ]; + + foreach ($calls as $method => $args) { + yield $method => [$method, $args]; + } + } + + /** + * @return iterable}> + */ + public static function callsThatNeedThePrimary(): iterable + { + $calls = [ + 'getDriver' => [], + 'getSequences' => ['posts', []], + 'analyzeCollection' => ['posts'], + 'startTransaction' => [], + 'commitTransaction' => [], + 'rollbackTransaction' => [], + 'execute' => [null], + ]; + + 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); + + $replica->expects($this->once())->method($method); + $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"); + } + + /** + * @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('getHostname')->willReturn('primary'); + $replica->expects($this->never())->method('getHostname'); + $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->getHostname()); + $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->getHostname(), '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->getHostname())); + } + + public function testHostnameIsLookedUpOncePerHandle(): void + { + $primary = $this->createMock(CastingAdapterStub::class); + $replica = $this->createMock(CastingAdapterStub::class); + $pool = $this->createPool($primary, $replica); + + $primary->expects($this->once())->method('getHostname')->willReturn('primary'); + $replica->expects($this->never())->method('getHostname'); + + $this->assertSame('primary', $pool->getHostname()); + $this->assertSame('primary', $pool->getHostname()); + } + + public function testReadsReachTheReplicaAfterTheStickyWindowWhileCacheKeysNameTheHost(): void + { + $database = $this->createReplicatedDatabase(new HostnameSQLite('primary'), new HostnameSQLite('replica')); + $database->setQueryCache(new QueryCache(new Cache(new MemoryCache()))); + + $this->assertTrue( + $database->getAdapter()->supports(Capability::Hostname), + 'The cache keys must name the host, as they do on MariaDB, MySQL, PostgreSQL and MongoDB', + ); + $this->assertSame('primary', $database->getAdapter()->getHostname()); + + $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 testConfiguringAttributeSupportKeepsReadsOnTheReplica(): void + { + $database = $this->createReplicatedDatabase( + new SQLite(new PDO('sqlite::memory:')), + new SQLite(new PDO('sqlite::memory:')), + ); + + $database->getAdapter()->setSupportForAttributes(true); + + $this->assertSame( + 'replica', + $database->getDocument('posts', 'post')->getAttribute('server'), + 'setSupportForAttributes() sent the next read to the primary', + ); + } + + public function testInternalCastingKeepsReadsOnTheReplica(): void + { + $database = $this->createReplicatedDatabase(new CastingMemory(), new CastingMemory()); + + $post = $database->getDocument('posts', 'post'); + + $this->assertSame('replica', $post->getAttribute('server'), 'castingAfter() 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')])), + 'setUTCDatetime() sent the read to the primary', + ); + $this->assertSame( + ['replica'], + $this->servers($database->find('posts', [Query::cursorAfter($post)])), + 'castingBefore() 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(new Collection( + 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/ReadWritePoolTest.php b/tests/unit/Adapter/ReadWritePoolTest.php new file mode 100644 index 0000000000..eee99b9597 --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolTest.php @@ -0,0 +1,755 @@ +&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; + + 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', + 'list', + 'getSchemaAttributes', + 'getSchemaIndexes', + 'getBuilder', + 'getSchema', + 'getColumnType', + 'decodePoint', + 'decodeLinestring', + 'decodePolygon', + 'getSizeOfCollection', + 'getSizeOfCollectionOnDisk', + 'ping', + 'getConnectionId', + 'getDocumentSizeLimit', + 'getAttributeWidth', + 'getCountOfAttributes', + 'getCountOfIndexes', + 'getCountOfDefaultAttributes', + 'getCountOfDefaultIndexes', + 'getLimitForString', + 'getLimitForInt', + 'getLimitForBigInt', + 'getLimitForAttributes', + 'getLimitForIndexes', + 'getMaxIndexLength', + 'getMaxVarcharLength', + 'getMaxUIDLength', + 'getMinDateTime', + 'getIdAttributeType', + 'getKeywords', + 'getInternalIndexesKeys', + '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', ['collection', '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', ['collection', '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); + + $this->assertTrue($pool->ping()); + $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->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 = new \Utopia\Database\Attribute(key: 'test', type: \Utopia\Query\Schema\ColumnType::String, size: 128); + $this->pool->delegate('createAttribute', ['collection', $attr]); + } + + public function testCreateIndexRoutesToWritePool(): void + { + $writeAdapter = $this->mockWriteAdapter(); + $writeAdapter->expects($this->once()) + ->method('createIndex') + ->willReturn(true); + + $index = new \Utopia\Database\Index(key: 'idx', type: \Utopia\Query\Schema\IndexType::Key, 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 QueryProfiler(); + $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(new Collection( + 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', + 'getDocumentSizeLimit', 'getAttributeWidth', 'getCountOfAttributes', + 'getCountOfIndexes', 'getCountOfDefaultAttributes', 'getCountOfDefaultIndexes', + 'getLimitForString', 'getLimitForInt', 'getLimitForBigInt', + 'getLimitForAttributes', 'getLimitForIndexes', 'getMaxIndexLength', + 'getMaxVarcharLength', 'getMaxUIDLength' => 0, + 'exists', 'ping', 'supports', 'hasFeature' => true, + 'getConnectionId', 'getIdAttributeType' => 'string', + 'getMinDateTime' => new \DateTime(), + 'getSchemaAttributes', 'getSchemaIndexes', 'getKeywords', + 'getInternalIndexesKeys', 'capabilities', 'decodePoint', + 'decodeLinestring', 'decodePolygon' => [], + 'getBuilder' => $this->createStub(\Utopia\Query\Builder::class), + 'getSchema' => $this->createStub(\Utopia\Query\Schema::class), + 'getColumnType' => 'VARCHAR', + 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', null], + 'list' => [], + 'getSizeOfCollection', 'getSizeOfCollectionOnDisk' => ['collection'], + 'ping' => [], + 'getConnectionId' => [], + 'getDocumentSizeLimit' => [], + 'getAttributeWidth' => [new Document()], + 'getCountOfAttributes' => [new Document()], + 'getCountOfIndexes' => [new Document()], + 'getLimitForString', 'getLimitForInt', 'getLimitForBigInt', + 'getLimitForAttributes', 'getLimitForIndexes', + 'getMaxIndexLength', 'getMaxVarcharLength', + 'getMaxUIDLength' => [], + 'getMinDateTime' => [], + 'getIdAttributeType' => [], + 'supports' => [\Utopia\Database\Capability::Index], + 'hasFeature' => [Feature\Spatial::class], + 'getSchemaAttributes', 'getSchemaIndexes', 'getBuilder' => ['collection'], + 'getColumnType' => ['string', 255, true, false, false], + 'decodePoint', 'decodeLinestring', 'decodePolygon' => ['wkb'], + 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 QueryCache(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(new Collection( + 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..85c478a1db --- /dev/null +++ b/tests/unit/Adapter/ReadWritePoolWriteDecisionTest.php @@ -0,0 +1,125 @@ +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(new Collection( + 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..6aef9dd1c7 --- /dev/null +++ b/tests/unit/Adapter/RedisAdapterPathsTest.php @@ -0,0 +1,679 @@ + */ + 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 = []; + + 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(RelationType::OneToMany, key: 'pets', twoWayKey: 'owner'); + $database->createDocument('owners', new Document(['$id' => 'alice', 'name' => 'Alice', 'pets' => [ + new Document(['$id' => 'rex', 'name' => 'Rex']), + ]])); + + $this->assertTrue($database->updateRelationship('pets', 'owner', newKey: '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(RelationType::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'])); + + $this->assertTrue($database->updateRelationship('owners', 'pets', newTwoWayKey: '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 (DuplicateException $exception) { + $this->assertSame('Cannot create unique index: existing rows already contain duplicate values', $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(new Collection( + 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(new Collection( + 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(new Collection( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + $database->createRelationship(new Relationship( + collection: 'books', + relatedCollection: 'authors', + type: RelationType::ManyToMany, + 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( + new Relationship(collection: 'books', relatedCollection: 'authors', type: RelationType::ManyToMany, twoWay: true, key: 'authors', twoWayKey: 'books', side: RelationSide::Parent), + '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(new Collection( + 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(new Relationship(collection: 'missing', relatedCollection: 'gone', type: RelationType::OneToOne, 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(self::NOTES, [])); + + $documents = $adapter->getSequences(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(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(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(self::NOTES, $id, 'count', 1, '2026-01-01 00:00:00.000', max: 5)); + $this->assertTrue($adapter->increaseDocumentAttribute(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(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(new Collection( + 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')); + } + + private function petsDatabase(RelationType $type, string $key, string $twoWayKey, string $from = 'owners', string $to = 'pets'): Database + { + $database = $this->database(); + $database->create(); + foreach (['owners', 'pets'] as $collection) { + $database->createCollection(new Collection( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + )); + } + $database->createRelationship(new Relationship( + collection: $from, + relatedCollection: $to, + type: $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($database)); + + 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/RedisUniqueIndexTest.php b/tests/unit/Adapter/RedisUniqueIndexTest.php new file mode 100644 index 0000000000..4ecf02b5dc --- /dev/null +++ b/tests/unit/Adapter/RedisUniqueIndexTest.php @@ -0,0 +1,636 @@ + */ + 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 = []; + + 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(new Collection(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'])); + + $this->assertTrue($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); + } + + $this->assertTrue($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); + } + + $this->assertTrue($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(new Collection(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(new Collection( + 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(new Collection( + 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(new Collection( + 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/SQLArrayWritesTest.php b/tests/unit/Adapter/SQLArrayWritesTest.php new file mode 100644 index 0000000000..8bd841d1d5 --- /dev/null +++ b/tests/unit/Adapter/SQLArrayWritesTest.php @@ -0,0 +1,152 @@ +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(new Collection( + 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->disableValidation(); + + $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..b2b7dba5b4 --- /dev/null +++ b/tests/unit/Adapter/SQLChildSideRelationshipRenameTest.php @@ -0,0 +1,106 @@ + */ + private array $statements = []; + + /** + * @return iterable, RelationType, RelationSide, 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, RelationType::OneToMany, RelationSide::Child, 'writer', null, $stored]; + yield $engine . ' one-to-many two-way key from the parent' => [$class, RelationType::OneToMany, RelationSide::Parent, null, 'writer', $stored]; + yield $engine . ' many-to-one two-way key from the child' => [$class, RelationType::ManyToOne, RelationSide::Child, null, 'writer', $stored]; + yield $engine . ' many-to-one key from the parent' => [$class, RelationType::ManyToOne, RelationSide::Parent, 'writer', null, $stored]; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('renames')] + public function testARenameTouchesTheColumnTheSideStores(string $class, RelationType $type, RelationSide $side, ?string $newKey, ?string $newTwoWayKey, string $expected): void + { + $this->assertTrue($this->adapter($class)->updateRelationship($this->relationship($type, $side), $newKey, $newTwoWayKey)); + + $this->assertSame([$expected], $this->statements); + } + + /** + * @return iterable, RelationType, RelationSide, 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, RelationType::OneToMany, RelationSide::Child, null, 'writer']; + yield $engine . ' one-to-many key from the parent' => [$class, RelationType::OneToMany, RelationSide::Parent, 'writer', null]; + yield $engine . ' many-to-one key from the child' => [$class, RelationType::ManyToOne, RelationSide::Child, 'writer', null]; + yield $engine . ' many-to-one two-way key from the parent' => [$class, RelationType::ManyToOne, RelationSide::Parent, null, 'writer']; + yield $engine . ' unchanged key from the child' => [$class, RelationType::OneToMany, RelationSide::Child, 'author', null]; + yield $engine . ' unchanged two-way key from the parent' => [$class, RelationType::OneToMany, RelationSide::Parent, null, 'author']; + } + } + + /** + * @param class-string $class + */ + #[DataProvider('renamesOfColumnsTheSideDoesNotStore')] + public function testARenameOfAColumnTheSideDoesNotStoreSendsNothing(string $class, RelationType $type, RelationSide $side, ?string $newKey, ?string $newTwoWayKey): void + { + $this->assertTrue($this->adapter($class)->updateRelationship($this->relationship($type, $side), $newKey, $newTwoWayKey)); + + $this->assertSame([], $this->statements); + } + + private function relationship(RelationType $type, RelationSide $side): Relationship + { + $booksStoreTheKey = ($type === RelationType::OneToMany) === ($side === RelationSide::Child); + + return $booksStoreTheKey + ? new Relationship(collection: 'books', relatedCollection: 'authors', type: $type, twoWay: true, key: 'author', twoWayKey: 'books', side: $side) + : new Relationship(collection: 'authors', relatedCollection: 'books', type: $type, twoWay: true, key: 'books', twoWayKey: 'author', side: $side); + } + + /** + * @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..2d5456402c --- /dev/null +++ b/tests/unit/Adapter/SQLDeleteFailureTest.php @@ -0,0 +1,162 @@ +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(new Collection( + 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('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('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) + { + } + + 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..8aec80ab73 --- /dev/null +++ b/tests/unit/Adapter/SQLJoinWithoutValidationTest.php @@ -0,0 +1,99 @@ +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->disableValidation(); + } + + 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', '$id', 'customerId', '=', 'note')]); + + $this->assertSame($sequences['c1'] * 2 + $sequences['c2'], $sum); + } + + public function testAJoinWithANonStringColumnIsAQueryError(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join columns must be strings'); + + $this->database->find('customers', [new Query(Method::LeftJoin, 'notes', ['$id', '=', 5, 'note'])]); + } + + 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); + } + + /** + * @param list $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(new Collection( + 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..8956456fd0 --- /dev/null +++ b/tests/unit/Adapter/SQLJoinedReadsTest.php @@ -0,0 +1,148 @@ +database(new SQLite(new PDO('sqlite::memory:'))); + $join = Query::join('notes', '$id', 'customerId', '=', 'note'); + + $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', '$id', 'customerId', '=', 'note'), + 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', '$id', 'customerId', '=', 'note'), + 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', '$id', 'customerId', '=', 'note'); + + 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(new Collection( + 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..353db7913f --- /dev/null +++ b/tests/unit/Adapter/SQLRawStatementTest.php @@ -0,0 +1,96 @@ +assertSame('', $adapter->getHostname()); + } + + 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->getHostname()); + } + + 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 testAnUnknownColumnTypeSpellingIsRefused(SQL $adapter): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unknown column type: not-a-type'); + + $adapter->getColumnType('not-a-type', 0); + } + + #[DataProvider('adapters')] + public function testAKnownColumnTypeSpellingIsMapped(SQL $adapter): void + { + $this->assertNotSame('', $adapter->getColumnType('integer', 0)); + } +} diff --git a/tests/unit/Adapter/SQLSchemaBuilderTest.php b/tests/unit/Adapter/SQLSchemaBuilderTest.php new file mode 100644 index 0000000000..a6f3633b3a --- /dev/null +++ b/tests/unit/Adapter/SQLSchemaBuilderTest.php @@ -0,0 +1,59 @@ +setDatabase(self::NAMESPACE)->setNamespace(self::NAMESPACE)->setAuthorization(new Authorization()); + $database->create(); + + $rows = $database->getAuthorization()->skip(function () use ($database): mixed { + $table = $database->schema()->table(self::NAMESPACE . '_raw_items'); + $table->integer('value'); + $table->string('label', 16); + $table->create()->execute(); + + $database->execute($database->from('raw_items')->set(['value' => 7, 'label' => 'seven'])->insert()); + $database->execute($database->from('raw_items')->set(['value' => 8, 'label' => 'eight'])->insert()); + + return $database->execute($database->from('raw_items')->select(['value', 'label'])->filter([BaseQuery::equal('value', [7])])); + }); + + $this->assertIsArray($rows); + $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..c2ed555ee1 --- /dev/null +++ b/tests/unit/Adapter/SQLiteChildSideRelationshipTest.php @@ -0,0 +1,140 @@ +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)); + $this->database->create(); + + foreach (['authors', 'books'] as $collection) { + $this->database->createCollection(new Collection( + 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(new Relationship( + collection: 'authors', + relatedCollection: 'books', + type: RelationType::OneToMany, + twoWay: true, + key: 'books', + twoWayKey: 'author', + )); + + $this->assertTrue($this->database->updateRelationship('books', 'author', newKey: '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(new Relationship( + collection: 'books', + relatedCollection: 'authors', + type: RelationType::ManyToOne, + twoWay: true, + key: 'author', + twoWayKey: 'books', + )); + + $this->assertTrue($this->database->updateRelationship('authors', 'books', newTwoWayKey: '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..24a2fab504 --- /dev/null +++ b/tests/unit/Adapter/SQLiteCreateCollectionCleanupTest.php @@ -0,0 +1,87 @@ +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/SQLitePowerOperatorTest.php b/tests/unit/Adapter/SQLitePowerOperatorTest.php new file mode 100644 index 0000000000..d333b3f6f3 --- /dev/null +++ b/tests/unit/Adapter/SQLitePowerOperatorTest.php @@ -0,0 +1,81 @@ +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(new Collection( + 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..9f6983af5e --- /dev/null +++ b/tests/unit/Adapter/SQLiteSchemaTest.php @@ -0,0 +1,169 @@ +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(new Collection( + 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(new Collection( + 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->exists(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')); + } + + /** + * @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..ba2af4a0c4 --- /dev/null +++ b/tests/unit/Adapter/SQLiteSharedFulltextTest.php @@ -0,0 +1,136 @@ +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(new Collection( + 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))); + } + + /** + * @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..b21103c0c6 --- /dev/null +++ b/tests/unit/Adapter/SQLiteStatementPreparationTest.php @@ -0,0 +1,154 @@ +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->exists('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; + } + + 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..a3114864bf --- /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(new Collection( + 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..537fd8581f --- /dev/null +++ b/tests/unit/Adapter/SQLiteUpsertArrayRemoveTest.php @@ -0,0 +1,73 @@ +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(new Collection( + 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..6d716ba186 --- /dev/null +++ b/tests/unit/Adapter/SQLiteUpsertIntegerOverflowTest.php @@ -0,0 +1,83 @@ +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(new Collection( + 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..ac44b141ce --- /dev/null +++ b/tests/unit/Adapter/SQLiteVectorQueryTest.php @@ -0,0 +1,64 @@ +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(new Collection( + 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->disableValidation(); + + $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/SkipDuplicatesStatementTest.php b/tests/unit/Adapter/SkipDuplicatesStatementTest.php new file mode 100644 index 0000000000..81c9e51bca --- /dev/null +++ b/tests/unit/Adapter/SkipDuplicatesStatementTest.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->skipDuplicates(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/SpatialBindingTest.php b/tests/unit/Adapter/SpatialBindingTest.php new file mode 100644 index 0000000000..d57e9eb7eb --- /dev/null +++ b/tests/unit/Adapter/SpatialBindingTest.php @@ -0,0 +1,174 @@ +, 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/SpatialColumnDefinitionTest.php b/tests/unit/Adapter/SpatialColumnDefinitionTest.php new file mode 100644 index 0000000000..c5aa0c8b40 --- /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); + } + + $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); + } + + $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/TimeoutRecordingAdapter.php b/tests/unit/Adapter/TimeoutRecordingAdapter.php new file mode 100644 index 0000000000..aef4ac251f --- /dev/null +++ b/tests/unit/Adapter/TimeoutRecordingAdapter.php @@ -0,0 +1,20 @@ +setTimeoutState($milliseconds, $event); + } + + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + } +} diff --git a/tests/unit/Adapter/UpsertNewDocumentOperatorTest.php b/tests/unit/Adapter/UpsertNewDocumentOperatorTest.php new file mode 100644 index 0000000000..7a402b71fc --- /dev/null +++ b/tests/unit/Adapter/UpsertNewDocumentOperatorTest.php @@ -0,0 +1,87 @@ +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(new Collection( + id: 'tasks', + attributes: [ + Attribute::datetime('due', default: '2026-01-31T12:30:00.000+00:00', filters: ['datetime']), + Attribute::datetime('reminder', filters: ['datetime']), + 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')); + } +} diff --git a/tests/unit/Adapter/UpsertNewDocumentParityTest.php b/tests/unit/Adapter/UpsertNewDocumentParityTest.php new file mode 100644 index 0000000000..6d7f93074c --- /dev/null +++ b/tests/unit/Adapter/UpsertNewDocumentParityTest.php @@ -0,0 +1,88 @@ +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(new Collection( + 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..a2a96d6961 --- /dev/null +++ b/tests/unit/AggregateEngineErrorsTest.php @@ -0,0 +1,298 @@ +setDatabase('aggregate_engine_errors') + ->setNamespace('aggregate_engine_errors_'.\uniqid()) + ->setAuthorization(new Authorization()); + $database->addHook(new Permissions()); + $database->create(); + + $database->createCollection(new Collection( + 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()->find(self::COLLECTION, $queries), + ); + } + + public function testSQLiteStillAnswersTheAggregatesItHasFunctionsFor(): void + { + $results = $this->database()->find(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]->getAttribute('rows')); + $this->assertSame(2, $results[0]->getAttribute('sensors')); + $this->assertSame(14, $results[0]->getAttribute('total')); + $this->assertEqualsWithDelta(14 / 3, $results[0]->getAttribute('mean'), 1e-9); + $this->assertSame(3, $results[0]->getAttribute('least')); + $this->assertSame(6, $results[0]->getAttribute('most')); + } + + /** + * @return array + */ + public static function adapterProvider(): array + { + return [ + 'MariaDB' => [new MariaDB(new stdClass()), true], + 'MySQL' => [new MySQL(new stdClass()), true], + 'Postgres' => [new Postgres(new stdClass()), true], + 'SQLite' => [new SQLite(new PDO('sqlite::memory:')), false], + 'SQLite emulating MySQL' => [(new SQLite(new PDO('sqlite::memory:')))->setEmulateMySQL(true), false], + ]; + } + + #[DataProvider('adapterProvider')] + public function testOnlySQLiteLacksTheStatisticalAndBitwiseAggregates(SQL $adapter, bool $supported): void + { + $this->assertTrue($adapter->supports(Capability::Aggregations)); + $this->assertSame($supported, $adapter->supports(Capability::StatisticalAggregates)); + $this->assertSame($supported, $adapter->supports(Capability::BitwiseAggregates)); + } + + /** + * @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); + + $results = $this->database()->find(self::COLLECTION, [Query::sum('value', $alias)]); + + $this->assertCount(1, $results); + $this->assertSame([$alias => 14], $results[0]->getArrayCopy()); + } + + /** + * @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 testFindRejectsAnInvalidAggregateAlias(array $queries, string $message): void + { + $this->assertFailsWith( + QueryException::class, + $message, + fn () => $this->database()->find(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/AttributeModelTest.php b/tests/unit/AttributeModelTest.php new file mode 100644 index 0000000000..6710e43102 --- /dev/null +++ b/tests/unit/AttributeModelTest.php @@ -0,0 +1,511 @@ +assertSame('', $attr->key); + $this->assertSame(ColumnType::String, $attr->type); + $this->assertSame(0, $attr->size); + $this->assertFalse($attr->required); + $this->assertNull($attr->default); + $this->assertTrue($attr->signed); + $this->assertFalse($attr->array); + $this->assertNull($attr->format); + $this->assertSame([], $attr->formatOptions); + $this->assertSame([], $attr->filters); + $this->assertNull($attr->status); + $this->assertNull($attr->options); + } + + public function testConstructorWithAllValues(): void + { + $attr = Attribute::double( + key: 'score', + required: true, + default: 0.0, + format: 'number', + formatOptions: ['min' => 0, 'max' => 100], + filters: ['range'], + status: 'available', + options: ['precision' => 2], + ); + + $this->assertSame('score', $attr->key); + $this->assertSame(ColumnType::Double, $attr->type); + $this->assertSame(0, $attr->size); + $this->assertTrue($attr->required); + $this->assertSame(0.0, $attr->default); + $this->assertTrue($attr->signed); + $this->assertFalse($attr->array); + $this->assertSame('number', $attr->format); + $this->assertSame(['min' => 0, 'max' => 100], $attr->formatOptions); + $this->assertSame(['range'], $attr->filters); + $this->assertSame('available', $attr->status); + $this->assertSame(['precision' => 2], $attr->options); + } + + public function testToDocumentProducesCorrectStructure(): void + { + $attr = Attribute::string( + key: 'email', + size: 256, + required: true, + format: 'email', + formatOptions: ['allowPlus' => true], + filters: ['lowercase'], + ); + + $doc = $attr->toDocument(); + + $this->assertSame('email', $doc->getId()); + $this->assertSame('email', $doc->getAttribute('key')); + $this->assertSame('string', $doc->getAttribute('type')); + $this->assertSame(256, $doc->getAttribute('size')); + $this->assertTrue($doc->getAttribute('required')); + $this->assertNull($doc->getAttribute('default')); + $this->assertTrue($doc->getAttribute('signed')); + $this->assertFalse($doc->getAttribute('array')); + $this->assertSame('email', $doc->getAttribute('format')); + $this->assertSame(['allowPlus' => true], $doc->getAttribute('formatOptions')); + $this->assertSame(['lowercase'], $doc->getAttribute('filters')); + } + + public function testToDocumentIncludesStatusWhenSet(): void + { + $attr = Attribute::string(key: 'name', size: 0, status: 'processing'); + + $doc = $attr->toDocument(); + $this->assertSame('processing', $doc->getAttribute('status')); + } + + public function testToDocumentExcludesStatusWhenNull(): void + { + $attr = Attribute::string(key: 'name', size: 0); + + $doc = $attr->toDocument(); + $this->assertNull($doc->getAttribute('status')); + } + + public function testToDocumentIncludesOptionsWhenSet(): void + { + $options = [ + 'relatedCollection' => 'users', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'posts', + ]; + $attr = Attribute::relationship(key: 'author', options: $options); + + $doc = $attr->toDocument(); + $this->assertSame($options, $doc->getAttribute('options')); + } + + public function testToDocumentExcludesOptionsWhenNull(): void + { + $attr = Attribute::string(key: 'name', size: 0); + + $doc = $attr->toDocument(); + $this->assertNull($doc->getAttribute('options')); + } + + public function testArrayAccessMatchesDocumentShape(): void + { + $attr = Attribute::string(key: 'title', size: 128, required: true, status: 'available'); + + $this->assertSame('title', $attr['$id']); + $this->assertSame('title', $attr['key']); + $this->assertSame(ColumnType::String->value, $attr['type']); + $this->assertSame(128, $attr['size']); + $this->assertTrue($attr['required']); + $this->assertSame('available', $attr['status']); + $this->assertSame('title', $attr->getId()); + $this->assertSame(ColumnType::String->value, $attr->getAttribute('type')); + } + + public function testFromDocumentRoundtrip(): void + { + $original = Attribute::string(key: 'tags', size: 64, array: true, filters: ['json']); + + $doc = $original->toDocument(); + $restored = Attribute::fromDocument($doc); + + $this->assertSame($original->key, $restored->key); + $this->assertSame($original->type, $restored->type); + $this->assertSame($original->size, $restored->size); + $this->assertSame($original->required, $restored->required); + $this->assertSame($original->default, $restored->default); + $this->assertSame($original->signed, $restored->signed); + $this->assertSame($original->array, $restored->array); + $this->assertSame($original->format, $restored->format); + $this->assertSame($original->formatOptions, $restored->formatOptions); + $this->assertSame($original->filters, $restored->filters); + } + + public function testFromDocumentWithMinimalDocument(): void + { + $doc = new Document(['$id' => 'name']); + $attr = Attribute::fromDocument($doc); + + $this->assertSame('name', $attr->key); + $this->assertSame(ColumnType::String, $attr->type); + $this->assertSame(0, $attr->size); + $this->assertFalse($attr->required); + $this->assertTrue($attr->signed); + $this->assertFalse($attr->array); + } + + public function testFromDocumentUsesKeyOverId(): void + { + $doc = new Document(['$id' => 'id_val', 'key' => 'key_val', 'type' => 'string']); + $attr = Attribute::fromDocument($doc); + + $this->assertSame('key_val', $attr->key); + } + + public function testFromDocumentFallsBackToId(): void + { + $doc = new Document(['$id' => 'my_attr', 'type' => 'integer']); + $attr = Attribute::fromDocument($doc); + + $this->assertSame('my_attr', $attr->key); + } + + public function testFromArray(): void + { + $data = [ + 'key' => 'amount', + 'type' => 'double', + 'size' => 0, + 'required' => true, + 'default' => 0.0, + 'signed' => true, + 'array' => false, + 'format' => null, + 'formatOptions' => [], + 'filters' => [], + ]; + + $attr = Attribute::fromArray($data); + + $this->assertSame('amount', $attr->key); + $this->assertSame(ColumnType::Double, $attr->type); + $this->assertTrue($attr->required); + $this->assertSame(0.0, $attr->default); + } + + public function testFromArrayWithIdFallback(): void + { + $data = ['$id' => 'my_field', 'type' => 'boolean']; + $attr = Attribute::fromArray($data); + + $this->assertSame('my_field', $attr->key); + $this->assertSame(ColumnType::Boolean, $attr->type); + } + + public function testFromArrayDefaults(): void + { + $data = ['type' => 'integer']; + $attr = Attribute::fromArray($data); + + $this->assertSame('', $attr->key); + $this->assertSame(ColumnType::Integer, $attr->type); + $this->assertSame(0, $attr->size); + $this->assertFalse($attr->required); + $this->assertNull($attr->default); + $this->assertTrue($attr->signed); + $this->assertFalse($attr->array); + $this->assertNull($attr->format); + $this->assertSame([], $attr->formatOptions); + $this->assertSame([], $attr->filters); + } + + public function testAllColumnTypeValues(): void + { + $typesToTest = [ + ColumnType::String, + ColumnType::Varchar, + ColumnType::Text, + ColumnType::MediumText, + ColumnType::LongText, + ColumnType::Integer, + ColumnType::Double, + ColumnType::Boolean, + ColumnType::Datetime, + ColumnType::Relationship, + ColumnType::Point, + ColumnType::Linestring, + ColumnType::Polygon, + ColumnType::Vector, + ColumnType::Object, + ]; + + foreach ($typesToTest as $type) { + $attr = new Attribute(key: 'test_' . $type->value, type: $type); + $doc = $attr->toDocument(); + $restored = Attribute::fromDocument($doc); + + $this->assertSame($type, $restored->type, "Roundtrip failed for type: {$type->value}"); + } + } + + public function testWithFormatAndFormatOptions(): void + { + $attr = Attribute::string(key: 'url', size: 2048, format: 'url', formatOptions: ['allowedSchemes' => ['http', 'https']]); + + $doc = $attr->toDocument(); + $this->assertSame('url', $doc->getAttribute('format')); + $this->assertSame(['allowedSchemes' => ['http', 'https']], $doc->getAttribute('formatOptions')); + + $restored = Attribute::fromDocument($doc); + $this->assertSame('url', $restored->format); + $this->assertSame(['allowedSchemes' => ['http', 'https']], $restored->formatOptions); + } + + public function testWithFilters(): void + { + $attr = Attribute::string(key: 'content', size: 65535, filters: ['json', 'encrypt']); + + $doc = $attr->toDocument(); + $this->assertSame(['json', 'encrypt'], $doc->getAttribute('filters')); + + $restored = Attribute::fromDocument($doc); + $this->assertSame(['json', 'encrypt'], $restored->filters); + } + + public function testWithRelationshipOptions(): void + { + $options = [ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => 'cascade', + 'side' => 'parent', + ]; + + $attr = Attribute::relationship(key: 'comments', options: $options); + + $doc = $attr->toDocument(); + $restored = Attribute::fromDocument($doc); + + $this->assertSame($options, $restored->options); + } + + public function testWithDefaultValueTypes(): void + { + $stringAttr = Attribute::string(key: 's', size: 32, default: 'hello'); + $this->assertSame('hello', $stringAttr->default); + + $intAttr = Attribute::integer(key: 'i', default: 42); + $this->assertSame(42, $intAttr->default); + + $boolAttr = Attribute::boolean(key: 'b', default: true); + $this->assertTrue($boolAttr->default); + + $doubleAttr = Attribute::double(key: 'd', default: 3.14); + $this->assertSame(3.14, $doubleAttr->default); + + $nullAttr = Attribute::string(key: 'n', size: 32); + $this->assertNull($nullAttr->default); + } + + public function testFromArrayWithColumnTypeInstance(): void + { + $data = [ + 'key' => 'test', + 'type' => ColumnType::Integer, + 'size' => 0, + ]; + + $attr = Attribute::fromArray($data); + $this->assertSame(ColumnType::Integer, $attr->type); + } + + public function testFromDocumentWithColumnTypeInstance(): void + { + $doc = new Document([ + '$id' => 'test', + 'key' => 'test', + 'type' => ColumnType::Boolean, + ]); + + $attr = Attribute::fromDocument($doc); + $this->assertSame(ColumnType::Boolean, $attr->type); + } + + public function testFromDocumentReturnsStringType(): void + { + $doc = new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => 'string', + ]); + + $attr = Attribute::fromDocument($doc); + + $this->assertInstanceOf(StringType::class, $attr); + } + + public function testFromArrayReturnsInteger(): void + { + $attr = Attribute::fromArray(['type' => 'integer']); + + $this->assertInstanceOf(Integer::class, $attr); + } + + public function testSubclassDefaultValues(): void + { + $stringAttr = Attribute::string(key: 's', size: 32, default: 'hello'); + $this->assertSame('hello', $stringAttr->default); + + $intAttr = Attribute::integer(key: 'i', default: 42); + $this->assertSame(42, $intAttr->default); + + $boolAttr = Attribute::boolean(key: 'b', default: true); + $this->assertSame(true, $boolAttr->default); + + $doubleAttr = Attribute::double(key: 'd', default: 3.14); + $this->assertSame(3.14, $doubleAttr->default); + + $nullAttr = Attribute::string(key: 'n', size: 32, default: null); + $this->assertSame(null, $nullAttr->default); + } + + public function testLegacyEmptyFormatHydratesLikeTheDefinition(): void + { + $stored = Attribute::fromArray($this->legacyRow('resourceInternalId')); + $definition = Attribute::string(key: 'resourceInternalId', size: Database::LENGTH_KEY); + + $this->assertSame($definition->type, $stored->type); + foreach (['size', 'required', 'default', 'signed', 'array', 'format', 'formatOptions', 'filters'] as $key) { + $this->assertSame($definition->getAttribute($key), $stored->getAttribute($key), "Stored '{$key}' differs from the definition"); + } + $this->assertSame($definition->toDocument()->getArrayCopy(), $stored->toDocument()->getArrayCopy()); + } + + public function testEmptyFormatMeansNoFormatOnEveryConstructionPath(): void + { + $assigned = Attribute::string(key: 'title', format: 'email'); + $assigned->format = ''; + + $attributes = [ + 'constructor' => new Attribute(key: 'title', format: ''), + 'factory' => Attribute::string(key: 'title', format: ''), + 'fromDocument' => Attribute::fromDocument(new Document($this->legacyRow('title'))), + 'magic setter' => $assigned, + ]; + + foreach ($attributes as $path => $attribute) { + $this->assertNull($attribute->getAttribute('format'), $path); + $this->assertNull($attribute->format, $path); + $this->assertNull($attribute->toDocument()->getAttribute('format'), $path); + } + } + + public function testEmptyFormatWrittenDirectlyReadsAsNoFormat(): void + { + $attribute = Attribute::string(key: 'title', format: 'email'); + $attribute->setAttribute('format', ''); + + $this->assertNull($attribute->format); + $this->assertNull($attribute->toDocument()->getAttribute('format')); + } + + /** + * @return array + */ + private function legacyRow(string $key): array + { + return [ + Document::ID => $key, + 'key' => $key, + 'type' => ColumnType::String->value, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'format' => '', + 'formatOptions' => [], + 'filters' => [], + ]; + } + + public function testMagicGetFallsBackToTheStoredValue(): void + { + $attribute = Attribute::string(key: 'title'); + $attribute->setAttribute('twoWayKey', 'x'); + + $this->assertSame('x', $attribute->__get('twoWayKey')); + $this->assertNull($attribute->__get('missing')); + } + + public function testNonArrayFormatOptionsAndFiltersReadAsEmptyArrays(): void + { + $attribute = Attribute::string(key: 'title'); + $attribute->setAttribute('formatOptions', 'x'); + $attribute->setAttribute('filters', 'x'); + + $this->assertSame([], $attribute->formatOptions); + $this->assertSame([], $attribute->filters); + } + + #[DataProvider('assignableProperties')] + public function testPropertyAssignmentWritesTheStoredAttribute(string $name, mixed $value): void + { + $attribute = Attribute::string(key: 'title'); + + $attribute->{$name} = $value; + + $this->assertSame($value, $attribute->getAttribute($name)); + $this->assertSame($value, $attribute->{$name}); + } + + /** + * @return iterable + */ + public static function assignableProperties(): iterable + { + yield 'key' => ['key', 'renamed']; + yield 'size' => ['size', 128]; + yield 'required' => ['required', true]; + yield 'default' => ['default', 'fallback']; + yield 'signed' => ['signed', false]; + yield 'array' => ['array', true]; + yield 'formatOptions' => ['formatOptions', ['min' => 1]]; + yield 'filters' => ['filters', ['json']]; + yield 'status' => ['status', 'available']; + yield 'options' => ['options', ['relationType' => 'oneToOne']]; + yield 'a custom name' => ['twoWayKey', 'reverse']; + } + + public function testAssigningTheKeyAlsoRenamesTheId(): void + { + $attribute = Attribute::string(key: 'title'); + + $attribute->key = 'renamed'; + + $this->assertSame('renamed', $attribute->getId()); + $this->assertSame('renamed', $attribute->toDocument()->getId()); + } + + public function testIsRelationshipReadsAnEnumTypedDocument(): void + { + $this->assertTrue(Attribute::isRelationship(new Document(['type' => ColumnType::Relationship]))); + $this->assertFalse(Attribute::isRelationship(new Document(['type' => ColumnType::String]))); + } +} diff --git a/tests/unit/AttributeSubclassTest.php b/tests/unit/AttributeSubclassTest.php new file mode 100644 index 0000000000..54a71d43ea --- /dev/null +++ b/tests/unit/AttributeSubclassTest.php @@ -0,0 +1,382 @@ +, string, ColumnType, int}> + */ + public static function types(): array + { + return [ + 'string' => [StringType::class, 'string', ColumnType::String, Database::LENGTH_KEY], + 'varchar' => [Varchar::class, 'varchar', ColumnType::Varchar, Database::LENGTH_KEY], + 'text' => [Text::class, 'text', ColumnType::Text, 0], + 'mediumText' => [MediumText::class, 'mediumText', ColumnType::MediumText, 0], + 'longText' => [LongText::class, 'longText', ColumnType::LongText, 0], + 'integer' => [Integer::class, 'integer', ColumnType::Integer, 0], + 'bigInteger' => [BigInteger::class, 'bigInteger', ColumnType::BigInteger, 0], + 'float' => [FloatType::class, 'float', ColumnType::Float, 0], + 'double' => [Double::class, 'double', ColumnType::Double, 0], + 'boolean' => [Boolean::class, 'boolean', ColumnType::Boolean, 0], + 'datetime' => [Datetime::class, 'datetime', ColumnType::Datetime, 0], + 'point' => [Point::class, 'point', ColumnType::Point, 0], + 'linestring' => [Linestring::class, 'linestring', ColumnType::Linestring, 0], + 'polygon' => [Polygon::class, 'polygon', ColumnType::Polygon, 0], + 'vector' => [Vector::class, 'vector', ColumnType::Vector, 0], + 'id' => [Id::class, 'id', ColumnType::Id, 0], + 'object' => [ObjectType::class, 'object', ColumnType::Object, 0], + 'relationship' => [Relationship::class, 'relationship', ColumnType::Relationship, 0], + ]; + } + + /** + * @return array + */ + public static function removedTypes(): array + { + return [ + 'tinyinteger' => [ColumnType::TinyInteger], + 'smallinteger' => [ColumnType::SmallInteger], + 'decimal' => [ColumnType::Decimal], + 'timestamp' => [ColumnType::Timestamp], + 'json' => [ColumnType::Json], + 'binary' => [ColumnType::Binary], + 'enum' => [ColumnType::Enum], + 'uuid' => [ColumnType::Uuid], + 'uuid7' => [ColumnType::Uuid7], + 'serial' => [ColumnType::Serial], + 'bigserial' => [ColumnType::BigSerial], + 'smallserial' => [ColumnType::SmallSerial], + 'array' => [ColumnType::Array], + 'tuple' => [ColumnType::Tuple], + ]; + } + + /** + * @param class-string $class + */ + #[DataProvider('types')] + public function testFactoryAndSubclassDefaults( + string $class, + string $factory, + ColumnType $type, + int $defaultSize, + ): void { + $fromFactory = Attribute::{$factory}(key: 'x'); + $this->assertInstanceOf($class, $fromFactory); + $this->assertSame($type, $fromFactory->type); + $this->assertSame($defaultSize, $fromFactory->size); + $this->assertSame('x', $fromFactory->key); + + $fromConstructor = new $class(key: 'x'); + $this->assertInstanceOf($class, $fromConstructor); + $this->assertSame($type, $fromConstructor->type); + $this->assertSame($defaultSize, $fromConstructor->size); + $this->assertSame('x', $fromConstructor->key); + } + + public function testStringFactoryOmitsTypeAndUsesLengthKey(): void + { + $attribute = Attribute::string(key: 'name'); + + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(Database::LENGTH_KEY, $attribute->size); + $this->assertSame('name', $attribute->key); + } + + public function testStringTypeConstructorUsesLengthKey(): void + { + $attribute = new StringType(key: 'name'); + + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(Database::LENGTH_KEY, $attribute->size); + $this->assertSame('name', $attribute->key); + } + + public function testIntegerConstructorOmitsType(): void + { + $attribute = new Integer(key: 'age', default: 0); + + $this->assertSame(ColumnType::Integer, $attribute->type); + $this->assertSame(0, $attribute->size); + $this->assertSame(0, $attribute->default); + $this->assertSame('age', $attribute->key); + } + + public function testIntegerFactory(): void + { + $attribute = Attribute::integer(key: 'age', default: 0); + + $this->assertSame(ColumnType::Integer, $attribute->type); + $this->assertSame(0, $attribute->size); + $this->assertSame(0, $attribute->default); + $this->assertSame('age', $attribute->key); + } + + public function testVectorKeepsExplicitSize(): void + { + $fromFactory = Attribute::vector(key: 'embedding', size: 3); + $this->assertSame(ColumnType::Vector, $fromFactory->type); + $this->assertSame(3, $fromFactory->size); + $this->assertSame('embedding', $fromFactory->key); + + $fromConstructor = new Vector(key: 'embedding', size: 3); + $this->assertSame(ColumnType::Vector, $fromConstructor->type); + $this->assertSame(3, $fromConstructor->size); + $this->assertSame('embedding', $fromConstructor->key); + } + + public function testRelationshipStoresOptions(): void + { + $options = [ + 'relatedCollection' => 'users', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'posts', + ]; + + $attribute = Attribute::relationship(key: 'author', options: $options); + + $this->assertSame(ColumnType::Relationship, $attribute->type); + $this->assertSame($options, $attribute->options); + $this->assertSame('author', $attribute->key); + $this->assertSame(0, $attribute->size); + } + + /** + * @param class-string $class + */ + #[DataProvider('types')] + public function testFromDocumentReturnsSubclass( + string $class, + string $_factory, + ColumnType $type, + int $_defaultSize, + ): void { + $attribute = Attribute::fromDocument(new Document([ + 'key' => 'x', + 'type' => $type->value, + ])); + + $this->assertInstanceOf($class, $attribute); + $this->assertSame($type, $attribute->type); + $this->assertSame('x', $attribute->key); + } + + /** + * @param class-string $class + */ + #[DataProvider('types')] + public function testFromArrayReturnsSubclass( + string $class, + string $_factory, + ColumnType $type, + int $_defaultSize, + ): void { + $attribute = Attribute::fromArray([ + 'key' => 'x', + 'type' => $type->value, + ]); + + $this->assertInstanceOf($class, $attribute); + $this->assertSame($type, $attribute->type); + $this->assertSame('x', $attribute->key); + } + + public function testFromDocumentPreservesStoredSizeZero(): void + { + $attribute = Attribute::fromDocument(new Document([ + 'key' => 'name', + 'type' => ColumnType::String->value, + 'size' => 0, + ])); + + $this->assertInstanceOf(StringType::class, $attribute); + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(0, $attribute->size); + $this->assertSame('name', $attribute->key); + } + + public function testFromArrayPreservesStoredSizeZero(): void + { + $attribute = Attribute::fromArray([ + 'key' => 'name', + 'type' => ColumnType::String->value, + 'size' => 0, + ]); + + $this->assertInstanceOf(StringType::class, $attribute); + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(0, $attribute->size); + $this->assertSame('name', $attribute->key); + } + + /** + * @param class-string $class + */ + #[DataProvider('types')] + public function testReadingBackHydratesTheMappedSubclass( + string $class, + string $_factory, + ColumnType $type, + int $_defaultSize, + ): void { + $document = $this->store($class, $type); + + $this->assertInstanceOf($class, $document); + $this->assertSame($type, $document->type); + $this->assertSame('x', $document->key); + } + + /** + * @param class-string $class + */ + #[DataProvider('types')] + public function testReadingBackHydratesTheSubclassThatTheStoredTypeNames( + string $class, + string $_factory, + ColumnType $type, + int $_defaultSize, + ): void { + $document = $this->store(Attribute::class, $type); + + $this->assertInstanceOf($class, $document); + $this->assertSame($type, $document->type); + $this->assertSame('x', $document->key); + } + + public function testStoredTypeWinsOverTheMappedClass(): void + { + $this->assertInstanceOf(Integer::class, $this->store(StringType::class, ColumnType::Integer)); + } + + public function testFromDocumentMissingSizeIsZero(): void + { + $attribute = Attribute::fromDocument(new Document([ + 'key' => 'name', + 'type' => ColumnType::String->value, + ])); + + $this->assertInstanceOf(StringType::class, $attribute); + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(0, $attribute->size); + $this->assertSame('name', $attribute->key); + } + + public function testLegacyBigintHydratesToBigInteger(): void + { + $attribute = Attribute::fromDocument(new Document([ + 'key' => 'count', + 'type' => 'bigint', + ])); + + $this->assertInstanceOf(BigInteger::class, $attribute); + $this->assertSame(ColumnType::BigInteger, $attribute->type); + $this->assertSame('count', $attribute->key); + } + + public function testBaseConstructorIsNotASubclass(): void + { + $attribute = new Attribute(key: 'name', type: ColumnType::String, size: Database::LENGTH_KEY); + + $this->assertSame(Attribute::class, $attribute::class); + $this->assertSame(ColumnType::String, $attribute->type); + $this->assertSame(Database::LENGTH_KEY, $attribute->size); + $this->assertSame('name', $attribute->key); + } + + public function testStringRoundTrip(): void + { + $original = Attribute::string(key: 'email', size: 256, required: true); + $restored = Attribute::fromDocument($original->toDocument()); + + $this->assertInstanceOf(StringType::class, $restored); + $this->assertSame($original->key, $restored->key); + $this->assertSame($original->size, $restored->size); + $this->assertSame($original->required, $restored->required); + $this->assertSame($original->type, $restored->type); + $this->assertSame(256, $restored->size); + $this->assertSame(true, $restored->required); + } + + #[DataProvider('removedTypes')] + public function testMetadataOfARemovedTypeStillHydratesAsThePlainAttribute(ColumnType $type): void + { + $fromDocument = Attribute::fromDocument(new Document(['key' => 'x', 'type' => $type->value])); + $fromArray = Attribute::fromArray(['key' => 'x', 'type' => $type->value]); + + foreach ([$fromDocument, $fromArray] as $attribute) { + $this->assertSame(Attribute::class, $attribute::class); + $this->assertSame($type, $attribute->type); + $this->assertSame('x', $attribute->key); + } + } + + /** + * Write one attribute row and read it back through the public API, under + * the given document type mapping. + * + * @param class-string $documentType + */ + private function store(string $documentType, ColumnType $type): Document + { + $database = new Database(new Memory(), new Cache(new NoneAdapter())); + $database->setAuthorization(new Authorization()) + ->setDatabase('attribute_subclass') + ->setNamespace('subclass_'.\uniqid()); + $database->create(); + + $database->createCollection(new Collection(id: 'schema', attributes: [ + Attribute::string(key: 'key', size: 255), + Attribute::string(key: 'type', size: 64), + ], permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ])); + + $database->setDocumentType('schema', $documentType); + + $database->createDocument('schema', new Document([ + '$id' => 'x', + '$permissions' => [Permission::read(Role::any())], + 'key' => 'x', + 'type' => $type->value, + ])); + + return $database->getDocument('schema', 'x'); + } +} diff --git a/tests/unit/Attributes/AttributeValidationTest.php b/tests/unit/Attributes/AttributeValidationTest.php new file mode 100644 index 0000000000..3368ae9fb7 --- /dev/null +++ b/tests/unit/Attributes/AttributeValidationTest.php @@ -0,0 +1,383 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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')->willReturnArgument(2); + } + + 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->getLimitForString() + 1; + $this->database->createAttribute('testCol', Attribute::string(key: 'bigstr', size: $tooBig)); + } + + public function testCreateAttributeSucceedsWithValidString(): void + { + $this->setupCollection('testCol'); + + $result = $this->database->createAttribute('testCol', Attribute::string(key: 'name', size: 128)); + $this->assertTrue($result); + } + + public function testCreateAttributeSucceedsWithInteger(): void + { + $this->setupCollection('testCol'); + + $result = $this->database->createAttribute('testCol', Attribute::integer(key: 'age')); + $this->assertTrue($result); + } + + public function testCreateAttributeSucceedsWithBoolean(): void + { + $this->setupCollection('testCol'); + + $result = $this->database->createAttribute('testCol', Attribute::boolean(key: 'active')); + $this->assertTrue($result); + } + + public function testCreateAttributeSucceedsWithDouble(): void + { + $this->setupCollection('testCol'); + + $result = $this->database->createAttribute('testCol', Attribute::double(key: 'score')); + $this->assertTrue($result); + } + + public function testCreateAttributeEnforcesAttributeCountLimit(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(2); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(100); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [Capability::Index, Capability::IndexArray, Capability::UniqueIndex, 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 new Document(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('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(100); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(200); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [Capability::Index, Capability::IndexArray, Capability::UniqueIndex, 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 new Document(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); + + $result = $this->database->deleteAttribute('testCol', 'name'); + $this->assertTrue($result); + } + + 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/CreateAttributesRollbackTest.php b/tests/unit/Attributes/CreateAttributesRollbackTest.php new file mode 100644 index 0000000000..6490a1ddb4 --- /dev/null +++ b/tests/unit/Attributes/CreateAttributesRollbackTest.php @@ -0,0 +1,205 @@ + + */ + 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; + $this->assertTrue($database->createAttributes(self::COLLECTION, $this->attributes('title', 'nick'))); + $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_values(\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(new Collection(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/FilterColumnTypesTest.php b/tests/unit/Attributes/FilterColumnTypesTest.php new file mode 100644 index 0000000000..4ba1b4797b --- /dev/null +++ b/tests/unit/Attributes/FilterColumnTypesTest.php @@ -0,0 +1,15 @@ +assertFalse(\defined(Database::class.'::ATTRIBUTE_FILTER_TYPES')); + $this->assertTrue(\defined(Database::class.'::ATTRIBUTE_FILTER_COLUMN_TYPES')); + } +} diff --git a/tests/unit/Attributes/OrphanColumnTypeTest.php b/tests/unit/Attributes/OrphanColumnTypeTest.php new file mode 100644 index 0000000000..3362f983c1 --- /dev/null +++ b/tests/unit/Attributes/OrphanColumnTypeTest.php @@ -0,0 +1,357 @@ + + */ + 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($type->value, 0)); + } + + #[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, new Attribute(key: self::KEY, type: $type))); + + $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, [new Attribute(key: self::KEY, type: $type)])); + + $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, new Attribute(key: self::KEY, type: $type))); + $this->assertSame($orphan, $this->schemaColumnType($database)); + + $this->assertRefused(fn () => $database->createAttributes(self::COLLECTION, [new Attribute(key: self::KEY, type: $type)])); + $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->assertTrue($database->createAttribute(self::COLLECTION, Attribute::string(key: self::KEY, size: 64))); + + $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->assertTrue($database->createAttribute(self::COLLECTION, Attribute::string(key: self::KEY, size: 64))); + + $this->assertNotSame($orphan, $this->schemaColumnType($database)); + $this->assertSame(\strtolower($adapter->getColumnType(ColumnType::String->value, 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); + + $this->assertTrue($database->createAttributes(self::COLLECTION, [ + Attribute::string(key: self::KEY, size: 64), + Attribute::integer(key: 'count'), + ])); + + $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); + + $this->assertTrue($database->createAttributes(self::COLLECTION, [ + Attribute::string(key: self::KEY, size: 64), + Attribute::integer(key: 'count'), + ])); + + $this->assertNotSame($orphan, $this->schemaColumnType($database)); + $this->assertSame(\strtolower($adapter->getColumnType(ColumnType::String->value, 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'); + + $this->assertTrue($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 { + 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 = new Collection(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 (Document $column): string => $column->getId(), + $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, + \array_values($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(new Collection(id: self::COLLECTION)); + + return [$database, $adapter]; + } + + private function schemaColumnType(Database $database): string + { + foreach ($database->getSchemaAttributes(self::COLLECTION) as $column) { + if ($column->getId() === self::KEY) { + $columnType = $column->getAttribute('columnType'); + $this->assertIsString($columnType); + + return $columnType; + } + } + + $this->fail('The orphan column is no longer in the schema'); + } + + private function assertRefused(callable $operation): void + { + try { + $operation(); + } catch (DatabaseException $error) { + $this->assertSame(DatabaseException::class, $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..b62f7d79bc --- /dev/null +++ b/tests/unit/Attributes/RelaxRequiredTest.php @@ -0,0 +1,117 @@ +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', 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', required: false); + $this->fail('Expected the unconfirmed relax to surface'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to update attribute', $error->getMessage()); + } + + $this->assertTrue($this->storedAttribute($database, 'name')->required); + } + + public function testRequiredOnlyChangeRelaxesWithoutRewritingTheColumn(): void + { + $adapter = new class () extends Memory { + /** + * @var list + */ + public array $calls = []; + + #[\Override] + public function relaxAttributeRequired(string $collection, string $id): bool + { + $this->calls[] = 'relax '.$id; + + return parent::relaxAttributeRequired($collection, $id); + } + + #[\Override] + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool + { + $this->calls[] = 'update '.$attribute->key; + + return parent::updateAttribute($collection, $attribute, $newKey); + } + }; + $database = $this->database($adapter); + + $updated = $database->updateAttribute('items', 'name', required: false); + + $this->assertFalse($updated->getAttribute('required')); + $this->assertFalse($this->storedAttribute($database, 'name')->required); + $this->assertSame(['relax name'], $adapter->calls); + } + + 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(new Collection( + 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/SharedColumnTest.php b/tests/unit/Attributes/SharedColumnTest.php new file mode 100644 index 0000000000..c799ae89cd --- /dev/null +++ b/tests/unit/Attributes/SharedColumnTest.php @@ -0,0 +1,281 @@ +assertIsString($path); + $this->path = $path; + $this->namespace = 'shared_column_'.\uniqid(); + } + + 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 (): bool => $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->assertTrue($second->createAttribute(self::COLLECTION, Attribute::integer(key: self::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 (): bool => $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); + + $this->assertTrue($second->createAttributes(self::COLLECTION, [ + Attribute::integer(key: self::KEY), + Attribute::string(key: 'nick', size: 16), + ])); + + $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->assertTrue($second->createAttribute(self::COLLECTION, $attribute)); + + $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 (): bool => $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(): bool $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 = new Collection( + 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); + } + + public function getSchemaAttributes(string $collection): array + { + return \array_map( + fn (Document $column): Document => $column->getId() === SharedColumnTest::KEY + ? $column->setAttribute('columnType', $this->columnType) + : $column, + parent::getSchemaAttributes($collection), + ); + } + }; + } + + /** + * @return list + */ + private function keys(Database $database): array + { + return \array_map( + static fn (Attribute $attribute): string => $attribute->key, + \array_values($database->getCollection(self::COLLECTION)->attributes), + ); + } + + /** + * @return array + */ + private function columns(Database $database): array + { + return \array_map( + static fn (Document $column): string => $column->getId(), + $database->getSchemaAttributes(self::COLLECTION), + ); + } +} diff --git a/tests/unit/Attributes/SharedColumnTypeTest.php b/tests/unit/Attributes/SharedColumnTypeTest.php new file mode 100644 index 0000000000..964d3d501e --- /dev/null +++ b/tests/unit/Attributes/SharedColumnTypeTest.php @@ -0,0 +1,212 @@ + + */ + 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', size: 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', size: 8)], + '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', size: 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 { + public function createAttribute(string $collection, Attribute $attribute): bool + { + throw new MismatchException('Attribute exists in the shared table with another type'); + } + + /** + * @param array $attributes + */ + 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(new Collection(id: 'items')); + + return $database; + } +} diff --git a/tests/unit/Attributes/SharedRenameTest.php b/tests/unit/Attributes/SharedRenameTest.php new file mode 100644 index 0000000000..f9cafea3c3 --- /dev/null +++ b/tests/unit/Attributes/SharedRenameTest.php @@ -0,0 +1,164 @@ +createSharedDatabase(); + + foreach (self::TENANTS as $tenant) { + $database->setTenant($tenant); + $this->assertTrue($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', required: true, newKey: 'years')->getId()); + } + + $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', newKey: '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'); + + $this->assertTrue($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 new Collection( + 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, + \array_values($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; + } + + public function testAvailableTypesFollowTheTableAndTheCapabilities(): void + { + $everywhere = [ + 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, + ]; + + $this->assertSame(self::STORABLE, Attribute::availableTypes(objects: true, spatial: true, vectors: true)); + $this->assertSame($everywhere, Attribute::availableTypes(objects: false, spatial: false, vectors: false)); + $this->assertSame([...$everywhere, ColumnType::Object], Attribute::availableTypes(objects: true, spatial: false, vectors: false)); + $this->assertSame( + [...$everywhere, ColumnType::Point, ColumnType::Linestring, ColumnType::Polygon], + Attribute::availableTypes(objects: false, spatial: true, vectors: false), + ); + $this->assertSame([...$everywhere, ColumnType::Vector], Attribute::availableTypes(objects: false, spatial: false, vectors: true)); + } + + #[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 + && \is_subclass_of($returnType->getName(), Attribute::class) + ) { + $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 + { + $validator = new AttributeValidator( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 16381, + maxIntLength: 4294967295, + supportForVectors: true, + supportForSpatialAttributes: true, + supportForObject: true, + ); + $attribute = new Attribute(key: 'value', type: $type, size: $this->validSize($type)); + + if (\in_array($type, self::STORABLE, true)) { + $this->assertTrue($validator->checkType($attribute)); + + return; + } + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unknown attribute type: '.$type->value.'.'); + $validator->checkType($attribute); + } + + #[DataProvider('columnTypes')] + public function testStructureFollowsTheTable(ColumnType $type): void + { + $attribute = new Attribute(key: 'value', type: $type, size: $this->validSize($type)); + $structure = new Structure( + new Document([ + Document::ID => 'items', + Document::COLLECTION => Database::METADATA, + 'attributes' => [$attribute->toDocument()], + ]), + ColumnType::Integer->value, + ); + $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->assertTrue($structure->isValid($document), $structure->getDescription()); + + return; + } + + $this->assertFalse($structure->isValid($document)); + $this->assertStringContainsString('Unknown attribute type "'.$type->value.'"', $structure->getDescription()); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('unstorableTypes')] + public function testUnstorableTypesAreRejectedUpFront(ColumnType $type, string $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(new Collection(id: 'items', permissions: $this->permissions())); + + $message = 'Unknown attribute type: '.$type->value.'.'; + $this->assertRejected($message, fn () => $database->createAttribute('items', new Attribute(key: 'value', type: $type))); + $this->assertRejected($message, fn () => $database->createAttributes('items', [new Attribute(key: 'value', type: $type)])); + $this->assertRejected($message, fn () => $database->createCollection(new Collection( + id: 'inline', + attributes: [new Attribute(key: 'value', type: $type)], + permissions: $this->permissions(), + ))); + + $this->assertTrue($database->getCollection('inline')->isEmpty()); + $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(new Collection(id: 'items', permissions: $this->permissions())); + + $this->assertRejected($message, fn () => $database->createAttribute('items', $this->attribute($factory))); + $this->assertRejected($message, fn () => $database->createCollection(new Collection( + id: 'inline', + attributes: [$this->attribute($factory)], + permissions: $this->permissions(), + ))); + + $this->assertTrue($database->getCollection('inline')->isEmpty()); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('availableFactories')] + public function testTypeRoundTripsThroughCreateAttribute(string $factory, string $adapter): void + { + $sample = self::samples()[$factory]; + $database = $this->database($adapter); + $database->createCollection(new Collection(id: 'items', permissions: $this->permissions())); + + $this->assertTrue($database->createAttribute('items', $this->attribute($factory))); + $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(new Collection( + 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(new Collection(id: 'items', permissions: $this->permissions())); + $database->createAttribute('items', $this->attribute($factory)); + $this->write($database, ['value' => $sample->value]); + + $updated = $database->updateAttribute('items', 'value', newKey: 'renamed'); + + $this->assertSame('renamed', $updated->getAttribute('key')); + $this->assertSame(Attribute::normalizeType($this->attribute($factory)->type), Attribute::normalizeType($this->storedType($updated))); + $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(new Collection(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->updateAttributeDefault('items', 'value', $sample->value); + $this->assertSame($sample->value, $updated->getAttribute('default')); + } + + /** + * @param class-string $adapter + */ + #[DataProvider('adapters')] + public function testDefaultOnATypeWithoutScalarDefaultsIsAMismatch(string $adapter): void + { + $database = $this->database($adapter); + $database->createCollection(new Collection(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->updateAttributeDefault('items', 'value', '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(new Collection(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 + { + $sample = self::samples()[$factory]; + $attribute = Attribute::{$factory}(key: 'value', size: $sample->size, 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 storedType(Document $attribute): ColumnType|string + { + $type = $attribute->getAttribute('type'); + $this->assertTrue($type instanceof ColumnType || \is_string($type)); + + return $type; + } + + 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/UpdateAttributeValidationTest.php b/tests/unit/Attributes/UpdateAttributeValidationTest.php new file mode 100644 index 0000000000..c7603cac31 --- /dev/null +++ b/tests/unit/Attributes/UpdateAttributeValidationTest.php @@ -0,0 +1,557 @@ + + */ + 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->updateAttributeRequired($collection, $id, false), + 'format' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttributeFormat($collection, $id, 'text'), + 'format options' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttributeFormatOptions($collection, $id, ['maximum' => 1]), + 'filters' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttributeFilters($collection, $id, []), + 'default' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttributeDefault($collection, $id, 'x'), + 'structure' => static fn (Database $database, string $collection, string $id): mixed => $database->updateAttribute($collection, $id, 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 attribute type "string"', + fn (): mixed => $database->updateAttributeFormat(self::COLLECTION, 'label', self::FORMAT), + ); + $this->assertRefused( + 'Format ("'.self::FORMAT.'") not available for this attribute type ("string")', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', format: self::FORMAT), + ); + $this->assertSame($before, $this->definitions($database)); + + $this->assertSame(self::FORMAT, $database->updateAttributeFormat(self::COLLECTION, 'count', self::FORMAT)->getAttribute('format')); + } 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); + + foreach (['x', null] as $default) { + $this->assertRefused( + 'Cannot set a default value on a required attribute', + fn (): mixed => $database->updateAttributeDefault(self::COLLECTION, 'name', $default), + ); + } + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @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->updateAttributeDefault(self::COLLECTION, $attribute, $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->updateAttributeDefault(self::COLLECTION, 'tags', ['a', 'b'])->getAttribute('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', size: 3)); + $before = $this->definitions($database); + + $this->assertRefused( + 'Vector components must be numeric values (float or integer)', + fn (): mixed => $database->updateAttributeDefault(self::COLLECTION, 'embedding', ['a', 'b', 'c']), + ); + + $this->assertSame($before, $this->definitions($database)); + $this->assertSame([1, 2.5, 3], $database->updateAttributeDefault(self::COLLECTION, 'embedding', [1, 2.5, 3])->getAttribute('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); + + $message = $this->refusal(fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', type: $type)); + + $this->assertStringStartsWith("Unknown attribute type: {$type->value}. Must be one of ", $message); + $listed = \explode(', ', \substr($message, \strlen("Unknown attribute type: {$type->value}. Must be one of "))); + $this->assertContains(ColumnType::String->value, $listed); + $this->assertContains(ColumnType::Relationship->value, $listed); + $this->assertSame( + $database->getAdapter()->supports(Capability::Objects), + \in_array(ColumnType::Object->value, $listed, true), + 'object is listed exactly when the adapter stores objects', + ); + $this->assertNotContains(ColumnType::Point->value, $listed); + $this->assertNotContains(ColumnType::Vector->value, $listed); + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testARelationshipCannotBeUpdatedAsAnAttribute(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $this->assertRefused( + 'Cannot update relationship as an attribute', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', type: ColumnType::Relationship), + ); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testTheSizeRulesApplyOnUpdate(\Closure $adapter): void + { + $database = $this->database($adapter()); + $limits = $database->getAdapter(); + $before = $this->definitions($database); + + $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', size: 0)); + $this->assertRefused( + 'Max size allowed for string is: '.\number_format($limits->getLimitForString()), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', size: $limits->getLimitForString() + 1), + ); + $this->assertRefused('Size length is required', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', type: ColumnType::Varchar, size: 0)); + $this->assertRefused( + 'Max size allowed for varchar is: '.\number_format($limits->getMaxVarcharLength()), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', type: ColumnType::Varchar, size: $limits->getMaxVarcharLength() + 1), + ); + $signedLimit = $limits->getLimitForInt() / 2; + $this->assertRefused( + 'Max size allowed for int is: '.\number_format($signedLimit), + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'count', size: (int) $signedLimit + 1), + ); + $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'ratio', size: 8)); + $this->assertRefused('Size must be empty', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'flag', 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', 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', type: ColumnType::Object)); + $this->assertRefused('Object attributes cannot be arrays', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', 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', type: $spatial, size: 0)); + } + $this->assertRefused( + 'Vector types are not supported by the current database', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', 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', size: 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', + size: $size, + default: $change['default'] ?? null, + array: $array, + )); + + $this->assertSame($before, $this->definitions($database)); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testADatetimeAttributeKeepsItsRequiredFilterOnUpdate(\Closure $adapter): void + { + $database = $this->database($adapter()); + $before = $this->definitions($database); + + $this->assertRefused( + 'Attribute of type: datetime requires the following filters: datetime', + fn (): mixed => $database->updateAttribute(self::COLLECTION, 'occurredAt', filters: []), + ); + $this->assertSame($before, $this->definitions($database)); + } + + public function testAnUpdatePastTheRowWidthLimitIsRefused(): void + { + $database = $this->database(new class () extends Memory { + public function getDocumentSizeLimit(): int + { + return 1_000; + } + + public function getAttributeWidth(Document $collection): int + { + return 1_000; + } + }); + $before = $this->definitions($database); + + try { + $database->updateAttribute(self::COLLECTION, 'label', 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 { + public function updateAttribute(string $collection, Attribute $attribute, ?string $newKey = null): bool + { + return false; + } + }); + $before = $this->definitions($database); + + $this->assertRefused('Failed to update attribute', fn (): mixed => $database->updateAttribute(self::COLLECTION, 'label', size: 128)); + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameTheAdapterDoesNotApplyIsReportedWithBothNames(): void + { + $database = $this->database(new class () extends Memory { + public function renameAttribute(string $collection, string $old, string $new): bool + { + return false; + } + }); + $before = $this->definitions($database); + + $this->assertRefused( + "Failed to rename attribute 'label' to 'caption': Failed to rename attribute", + fn (): bool => $database->renameAttribute(self::COLLECTION, 'label', 'caption'), + ); + $this->assertSame($before, $this->definitions($database)); + } + + public function testARenameFailureOnASchemaIntrospectingAdapterIsWrappedWithTheCause(): void + { + $cause = new RuntimeException('the engine refused the rename'); + $database = $this->database(new class (new PDO('sqlite::memory:'), $cause) extends SQLite { + public function __construct(object $pdo, private readonly RuntimeException $cause) + { + parent::__construct($pdo); + } + + public function renameAttribute(string $collection, string $old, string $new): bool + { + throw $this->cause; + } + }); + $before = $this->definitions($database); + + try { + $database->renameAttribute(self::COLLECTION, 'label', 'caption'); + $this->fail('a failed rename must be reported'); + } catch (DatabaseException $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)); + } + + private function vectorMemory(): Memory + { + return new class () extends Memory { + 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(new Collection( + 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..b968a5eddc --- /dev/null +++ b/tests/unit/Authorization/AuthorizationTest.php @@ -0,0 +1,382 @@ +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 testSetDefaultStatus(): void + { + $this->auth->setDefaultStatus(false); + $this->assertFalse($this->auth->getStatus()); + + $this->auth->reset(); + $this->assertFalse($this->auth->getStatus()); + } + + public function testResetRestoresDefaultStatus(): void + { + $this->auth->setDefaultStatus(true); + $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..b7aa245d95 --- /dev/null +++ b/tests/unit/Authorization/CoroutineRolesTest.php @@ -0,0 +1,245 @@ +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 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..30745500da --- /dev/null +++ b/tests/unit/Authorization/CoroutineStatusTest.php @@ -0,0 +1,267 @@ +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 testEnableInACoroutineStartedInsideWithStatusStaysInThatCoroutine(): void + { + $seen = []; + + $this->inCoroutine(function () use (&$seen): void { + $this->authorization->disable(); + $this->authorization->withStatus(false, 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/PermissionCheckTest.php b/tests/unit/Authorization/PermissionCheckTest.php new file mode 100644 index 0000000000..05465caf5b --- /dev/null +++ b/tests/unit/Authorization/PermissionCheckTest.php @@ -0,0 +1,892 @@ +adapter = self::createStub(Adapter::class); + + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('1970-01-01 00:00:00')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('2999-12-31 23:59:59')); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $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->disableValidation(); + $this->database->disableFilters(); + + $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..abd1bd9808 --- /dev/null +++ b/tests/unit/BigIntegerSpellingTest.php @@ -0,0 +1,370 @@ +value; + + $this->assertSame($expected, Attribute::persistedType($type), $type->name); + $this->assertSame($type, Attribute::normalizeType(Attribute::persistedType($type)), $type->name); + } + } + + public function testBothSpellingsNormalizeToBigInteger(): void + { + foreach ([self::PERSISTED, ColumnType::BigInteger->value] as $spelling) { + $this->assertSame(ColumnType::BigInteger, Attribute::normalizeType($spelling), $spelling); + $this->assertSame(ColumnType::BigInteger, Attribute::tryNormalizeType($spelling), $spelling); + } + + $this->assertNull(Attribute::tryNormalizeType('huge')); + + $this->expectException(ValueError::class); + Attribute::normalizeType('huge'); + } + + public function testAttributeModelsHoldThePersistedSpelling(): void + { + $attribute = Attribute::bigInteger(key: 'total'); + + $this->assertSame(ColumnType::BigInteger, $attribute->type); + $this->assertSame(self::PERSISTED, $attribute->getAttribute('type')); + $this->assertSame(self::PERSISTED, $attribute->toDocument()->getAttribute('type')); + $this->assertSame(self::PERSISTED, (new Attribute(key: 'total', type: ColumnType::BigInteger))->getAttribute('type')); + $this->assertSame(self::PERSISTED, Attribute::fromArray([ + '$id' => 'total', + 'type' => ColumnType::BigInteger->value, + ])->getAttribute('type')); + $this->assertSame(self::PERSISTED, Attribute::fromDocument(new Document([ + '$id' => 'total', + 'type' => self::PERSISTED, + ]))->getAttribute('type')); + + $changed = Attribute::integer(key: 'count'); + + $changed->type = ColumnType::BigInteger; + $this->assertSame(self::PERSISTED, $changed->getAttribute('type')); + + $changed->setAttribute('type', ColumnType::Integer); + $this->assertSame(ColumnType::Integer->value, $changed->getAttribute('type')); + + $changed->setAttribute('type', ColumnType::BigInteger->value); + $this->assertSame(self::PERSISTED, $changed->getAttribute('type')); + $this->assertSame(ColumnType::BigInteger, $changed->type); + + $changed['type'] = ColumnType::BigInteger; + $this->assertSame(self::PERSISTED, $changed->getAttribute('type')); + + $changed->setAttribute('type', 'huge'); + $this->assertSame('huge', $changed->getAttribute('type'), 'An unknown type is stored as given so validation can report it'); + } + + /** + * @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(new Collection( + 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', type: ColumnType::BigInteger); + $database->updateAttribute('ledger', 'single', 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(new Collection( + 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->updateAttributeRequired('ledger', 'total', 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(new Collection( + id: 'ledger', + attributes: [Attribute::bigInteger(key: 'total')], + permissions: $this->permissions(), + )); + $this->storeType($database, 'ledger', 'total', $spelling); + + $this->assertTrue($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; + } + + $this->assertSame('BIGINT', $sqlite->getColumnType($spelling, 0)); + + $columnTypes = []; + foreach ($database->getSchemaAttributes('ledger') as $column) { + $columnTypes[$column->getId()] = $column->getAttribute('columnType'); + } + $this->assertSame(\strtolower($sqlite->getColumnType($spelling, 0)), $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', 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..bb05e6c4c7 --- /dev/null +++ b/tests/unit/BigIntegerTest.php @@ -0,0 +1,390 @@ +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()); + + $this->assertSame('BIGINT', $mariaDB->getColumnType(ColumnType::BigInteger->value, 0)); + $this->assertSame('BIGINT', $mariaDB->getColumnType('bigint', 9999)); + $this->assertSame('BIGINT UNSIGNED', $mariaDB->getColumnType(ColumnType::BigInteger->value, 0, false)); + $this->assertSame('BIGINT', $postgres->getColumnType(ColumnType::BigInteger->value, 0)); + $this->assertSame('BIGINT', $postgres->getColumnType('bigint', 9999)); + } + + public function testCastingNormalizesLegacyBigIntegerWithoutPrecisionLoss(): void + { + /** @var Adapter&Stub $adapter */ + $adapter = self::createStub(Adapter::class); + $adapter->method('supports')->willReturnCallback( + static fn (Capability $capability): bool => $capability === Capability::Casting + ); + $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 { + /** + * @return array{expression: string, bindings: list} + */ + public function expression(Operator $operator): array + { + return $this->getOperatorBuilderExpression('value', $operator); + } + }; + + $result = $adapter->expression(Operator::increment(1, BigInt::UNSIGNED_MAX)); + + $this->assertStringContainsString('CASE', $result['expression']); + $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('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/Cache/CountingCache.php b/tests/unit/Cache/CountingCache.php new file mode 100644 index 0000000000..714ef9bf87 --- /dev/null +++ b/tests/unit/Cache/CountingCache.php @@ -0,0 +1,105 @@ +operations++; + + return $this->cache->load($key, $ttl, $hash); + } + + 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); + } + + public function touch(string $key, string $hash = ''): bool + { + $this->operations++; + + return $this->cache->touch($key, $hash); + } + + /** @return array */ + public function list(string $key): array + { + $this->operations++; + + return $this->cache->list($key); + } + + public function purge(string $key, string $hash = ''): bool + { + $this->operations++; + + return $this->cache->purge($key, $hash); + } + + public function flush(): bool + { + $this->operations++; + + return $this->cache->flush(); + } + + public function ping(): bool + { + $this->operations++; + + return $this->cache->ping(); + } + + public function getSize(): int + { + $this->operations++; + + return $this->cache->getSize(); + } + + public function getName(?string $key = null): string + { + return $this->cache->getName($key); + } + + public function getGeneration(string $key): string + { + $this->operations++; + + return $this->cache->getGeneration($key); + } + + 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; + } + + public function resetOperations(): void + { + $this->operations = 0; + } +} diff --git a/tests/unit/Cache/DatabaseQueryCacheTest.php b/tests/unit/Cache/DatabaseQueryCacheTest.php new file mode 100644 index 0000000000..aada88752b --- /dev/null +++ b/tests/unit/Cache/DatabaseQueryCacheTest.php @@ -0,0 +1,791 @@ +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 QueryCache($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 testSetQueryCacheInstallsOneInvalidatorAndRemovesIt(): void + { + [$database, $queryAdapter] = $this->createDatabase(queryCache: false); + $database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false)); + + $queryCache = new QueryCache(new Cache($queryAdapter)); + $database->setQueryCache($queryCache); + $database->setQueryCache($queryCache); + + $queryAdapter->resetPurges(); + $database->createDocument('users', new Document(['$id' => 'a'])); + $this->assertSame(2, $queryAdapter->getWrites($this->collectionKey($database, 'users').'#epoch')); + + $database->setQueryCache(null); + $queryAdapter->resetPurges(); + $database->createDocument('users', new Document(['$id' => 'b'])); + $this->assertSame(0, $queryAdapter->getWrites($this->collectionKey($database, 'users').'#epoch')); + } + + public function testSchemaAndCollectionMutationsInvalidateQueries(): void + { + [$database, $queryAdapter] = $this->createDatabase(); + $database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false)); + $started = $this->collectionKey($database, 'users').'#started'; + + $queryAdapter->resetPurges(); + $database->updateCollection('users', $this->permissions(), false); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + + $queryAdapter->resetPurges(); + $database->createAttribute('users', Attribute::string(key: 'name')); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + + $queryAdapter->resetPurges(); + $database->updateAttribute('users', 'name', size: 128); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + + $queryAdapter->resetPurges(); + $database->createIndex('users', Index::key(key: 'name', attributes: ['name'])); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + + $queryAdapter->resetPurges(); + $database->renameIndex('users', 'name', 'renamed'); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + + $queryAdapter->resetPurges(); + $database->deleteIndex('users', 'renamed'); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + + $queryAdapter->resetPurges(); + $database->deleteAttribute('users', 'name'); + $this->assertGreaterThan(0, $queryAdapter->getPurges($started)); + } + + public function testDeleteAndRecreateCannotReuseOldCollectionResults(): void + { + [$database] = $this->createDatabase(); + $database->createCollection(new Collection(id: 'users', permissions: $this->permissions(), documentSecurity: false)); + $database->createDocument('users', new Document(['$id' => 'old'])); + $this->assertSame(['old'], $this->ids($database->find('users'))); + + $database->deleteCollection('users'); + $database->createCollection(new Collection(id: 'users', permissions: $this->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(new Collection(id: 'users', permissions: $this->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(new Collection(id: 'users', permissions: $this->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 + { + $adapter = new ObservedMemory(); + [$database] = $this->createDatabase($adapter, queryCache: false); + $database->createCollection(new Collection(id: 'users', attributes: [ + Attribute::string(key: 'name'), + ], permissions: $this->permissions(), documentSecurity: false)); + $database->getCollection('users'); + + $adapter->observeValidators( + fn () => $database->createAttribute('users', Attribute::integer(key: 'age')), + ); + $database->find('users', [Query::equal('name', ['first'])]); + $straddled = $adapter->getObservedValidators(); + + $database->find('users', [Query::equal('name', ['second'])]); + $this->assertSame($straddled + 1, $adapter->getObservedValidators(), 'The build that straddled the purge must not be published'); + + $database->find('users', [Query::equal('name', ['third'])]); + $this->assertSame($straddled + 1, $adapter->getObservedValidators(), 'The build after the purge must be published and reused'); + } + + public function testMemoryCacheSeparatesRolesAndExecutionShapes(): void + { + [$database] = $this->createDatabase(queryCache: false); + $database->setQueryCache(new QueryCache(new Cache(new MemoryCache()))); + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection(new Collection(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(new Collection(id: 'users', attributes: [ + Attribute::string(key: 'name'), + Attribute::string(key: 'email'), + ], permissions: $this->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(new Collection(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', [ + Permission::create(Role::user('user-1')), + Permission::update(Role::user('user-1')), + ], false); + + $this->expectException(AuthorizationException::class); + $reader->find('users'); + } + + public function testSilentPermissionRevocationStillInvalidatesQueryCache(): void + { + [$database] = $this->createDatabase(queryCache: false); + $database->setQueryCache(new QueryCache(new Cache(new MemoryCache()))); + $database->getAuthorization()->skip(function () use ($database): void { + $database->createCollection(new Collection(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(new Collection(id: 'parents', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'children', permissions: $this->permissions(), documentSecurity: false)); + + $queries = [Query::join('children', '$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 QueryCache(new Cache($cache))); + $database->createCollection(new Collection(id: 'users', permissions: $this->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, $cache, $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, $cache, &$duringCommit): void { + $epoch = $cache->load($this->collectionKey($reader, 'users').'#epoch', 3600); + $this->assertIsString($epoch); + $this->assertStringStartsWith('blocked:', $epoch); + $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()); + } + + $epoch = $cache->load($this->collectionKey($reader, 'users').'#epoch', 3600); + $this->assertIsString($epoch); + $this->assertStringStartsWith('blocked:', $epoch); + + $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 QueryCache(new Cache(new MemoryCache()))); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->createCollection(new Collection(id: 'users', attributes: [ + Attribute::string(key: 'name'), + ], permissions: $this->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, + ): array { + $dataAdapter ??= new LeasableHashCache(); + $queryAdapter = new LeasableHashCache(); + $database = 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 QueryCache(new Cache($queryAdapter))); + } + + return [$database, $queryAdapter]; + } + + private function createUsers(Database $database): void + { + $database->createCollection(new Collection(id: 'users', permissions: $this->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(new Collection(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: $this->permissions(), documentSecurity: false)); + $database->createDocument('users', new Document([ + '$id' => 'existing', + 'name' => 'original', + ])); + + $cache = new FailingMemory(); + $database->setQueryCache(new QueryCache(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 = SQLite::getPDOAttributes(); + $attributes[\PDO::ATTR_PERSISTENT] = false; + $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(new Collection(id: 'users', attributes: [ + Attribute::string(key: 'name', required: true), + ], permissions: $this->permissions(), documentSecurity: false)); + $writer->createDocument('users', new Document([ + '$id' => 'existing', + 'name' => 'original', + ])); + + $cache = new LeasableHashCache(); + $writer->setQueryCache(new QueryCache(new Cache($cache))); + $reader->setQueryCache(new QueryCache(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 collectionKey(Database $database, string $collection): string + { + $adapter = $database->getAdapter(); + + return (new QueryCache(new Cache(new None())))->getCollectionKey(new Scope( + hostname: $adapter->supports(Capability::Hostname) ? $adapter->getHostname() : '', + database: $adapter->getDatabase(), + namespace: $adapter->getNamespace(), + tenant: $adapter->getTenant(), + ), $collection); + } + + /** @return array */ + 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, + ); + } + + /** + * @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..97d11b7f1d --- /dev/null +++ b/tests/unit/Cache/InvalidationCache.php @@ -0,0 +1,76 @@ + */ + public array $values = []; + + /** @var array */ + public array $generations = []; + + /** @var array */ + public array $purges = []; + + /** @var array */ + public array $failures = []; + + public function __construct() + { + parent::__construct(new Memory()); + } + + #[\Override] + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->values[$key] ?? false; + } + + #[\Override] + public function save(string $key, mixed $data, string $hash = '', int $ttl = 0): bool|string|array + { + if (isset($this->failures[$key])) { + return false; + } + + 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->purges[$key] = ($this->purges[$key] ?? 0) + 1; + if (isset($this->failures[$key])) { + return false; + } + + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + unset($this->values[$key]); + + return true; + } + + public function fail(string $key): void + { + $this->failures[$key] = true; + } + + public function getPurges(string $key): int + { + return $this->purges[$key] ?? 0; + } +} 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..3ad28fe21f --- /dev/null +++ b/tests/unit/Cache/LeasableHashCache.php @@ -0,0 +1,143 @@ +|string}>> */ + private array $store = []; + + /** @var array */ + private array $generations = []; + + /** @var array */ + private array $purges = []; + + /** @var array */ + private array $writes = []; + + private bool $failActivations = false; + + 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; + } + + 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->writes[$key] = ($this->writes[$key] ?? 0) + 1; + $this->store[$key][$hash] = ['time' => \time(), 'data' => $data]; + + return $data; + } + + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + 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); + } + + 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 */ + public function list(string $key): array + { + return \array_keys($this->store[$key] ?? []); + } + + public function purge(string $key, string $hash = ''): bool + { + $this->purges[$key] = ($this->purges[$key] ?? 0) + 1; + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + + if ($hash === '') { + unset($this->store[$key]); + } else { + unset($this->store[$key][$hash]); + } + + return true; + } + + public function flush(): bool + { + $this->store = []; + $this->generations = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'leasable-hash'; + } + + public function resetPurges(): void + { + $this->purges = []; + $this->writes = []; + } + + public function getPurges(string $key): int + { + return $this->purges[$key] ?? 0; + } + + public function getWrites(string $key): int + { + return $this->writes[$key] ?? 0; + } + + public function failActivations(): void + { + $this->failActivations = true; + } +} diff --git a/tests/unit/Cache/ObservedMemory.php b/tests/unit/Cache/ObservedMemory.php new file mode 100644 index 0000000000..286852caad --- /dev/null +++ b/tests/unit/Cache/ObservedMemory.php @@ -0,0 +1,137 @@ +metadataCollection = $collection; + $this->metadataCallback = $callback; + $this->metadataReads = 0; + } + + public function observeValidators(Closure $callback): void + { + $this->validatorCallback = $callback; + $this->validators = 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 getObservedValidators(): int + { + return $this->validators; + } + + 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 getIdAttributeType(): string + { + if ($this->validatorCallback !== null) { + $this->validators++; + $callback = $this->validatorCallback; + $this->validatorCallback = null; + $callback(); + } elseif ($this->validators > 0) { + $this->validators++; + } + + return parent::getIdAttributeType(); + } + + #[\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..c3fd6a79e0 --- /dev/null +++ b/tests/unit/Cache/OwnershipCache.php @@ -0,0 +1,142 @@ +|string}> */ + private array $store = []; + + /** @var array */ + private array $generations = []; + + private ?Closure $activation = null; + + private bool $flushDuringActivation = false; + + private bool $failDuringActivation = false; + + 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; + } + + 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; + } + + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + 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); + } + + public function touch(string $key, string $hash = ''): bool + { + if (! isset($this->store[$key])) { + return false; + } + + $this->store[$key]['time'] = \time(); + + return true; + } + + /** @return array */ + public function list(string $key): array + { + return []; + } + + 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; + } + + public function flush(): bool + { + $this->store = []; + $this->generations = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->store); + } + + public function getName(?string $key = null): string + { + return 'ownership'; + } + + public function has(string $key): bool + { + return isset($this->store[$key]); + } + + 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/QueryCacheMetadataTest.php b/tests/unit/Cache/QueryCacheMetadataTest.php new file mode 100644 index 0000000000..fcf9afa47b --- /dev/null +++ b/tests/unit/Cache/QueryCacheMetadataTest.php @@ -0,0 +1,86 @@ +setDatabase('metadata') + ->setNamespace('metadata_'.\uniqid()); + $database->create(); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->setQueryCache(new QueryCache(new Cache(new LeasableHashCache()))); + $database->createCollection(new Collection(id: 'first', permissions: $this->permissions())); + + $this->assertSame(['first'], $this->listCollectionIds($database)); + + $database->createCollection(new Collection(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 QueryCache(new Cache(new LeasableHashCache()))); + $database->createCollection(new Collection(id: 'owned', permissions: $this->permissions())); + + $this->assertSame(['owned'], $this->listCollectionIds($database)); + + $database->withTenant(null, fn (): Document => $database->createCollection(new Collection(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 array */ + 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/QueryCachePurgeTest.php b/tests/unit/Cache/QueryCachePurgeTest.php new file mode 100644 index 0000000000..28265fe085 --- /dev/null +++ b/tests/unit/Cache/QueryCachePurgeTest.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 QueryCache(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 QueryCache(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'])); + + $this->assertTrue($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(new Collection(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, ?QueryCache $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 QueryCache(new Cache(new LeasableHashCache()))); + $reader->createCollection(new Collection(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/QueryCacheRoundTripTest.php b/tests/unit/Cache/QueryCacheRoundTripTest.php new file mode 100644 index 0000000000..e6efb6c343 --- /dev/null +++ b/tests/unit/Cache/QueryCacheRoundTripTest.php @@ -0,0 +1,77 @@ +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->assertSame(3, $cache->getOperations(), 'A hit reads the epoch, the started generation it was published under and the entry'); + } + + public function testAnUncachedFindCostsFiveCacheRoundTrips(): 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->assertSame(5, $cache->getOperations(), 'A miss reads the epoch, its started generation, the entry and the entry\'s lease, then stores the result'); + } + + /** + * @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 QueryCache(new Cache($cache))); + $database->createCollection(new Collection(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/QueryCacheScopeTest.php b/tests/unit/Cache/QueryCacheScopeTest.php new file mode 100644 index 0000000000..f44e16d9a1 --- /dev/null +++ b/tests/unit/Cache/QueryCacheScopeTest.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 QueryCache(new Cache($cache))); + $database->createCollection(new Collection(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 QueryCache(new Cache(new LeasableHashCache()))); + + foreach ([1, 2] as $tenant) { + $database->withTenant($tenant, function () use ($database, $tenant): void { + $database->createCollection(new Collection(id: 'posts', permissions: $this->permissions(), documentSecurity: false)); + $database->createDocument('posts', new Document(['$id' => 'seed-'.$tenant])); + }); + } + + return [$database, $adapter]; + } + + /** @return array */ + 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/QueryCacheTenantPerDocumentTest.php b/tests/unit/Cache/QueryCacheTenantPerDocumentTest.php new file mode 100644 index 0000000000..d6aa84d3bb --- /dev/null +++ b/tests/unit/Cache/QueryCacheTenantPerDocumentTest.php @@ -0,0 +1,336 @@ +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 QueryCache(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 QueryCache(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 QueryCache(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 QueryCache(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 new Collection( + 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/QueryCacheTest.php b/tests/unit/Cache/QueryCacheTest.php new file mode 100644 index 0000000000..a43738ea79 --- /dev/null +++ b/tests/unit/Cache/QueryCacheTest.php @@ -0,0 +1,1025 @@ +cache = self::createCache(); + $this->queryCache = new QueryCache($this->cache); + } + + public function testConstructorWithDefaults(): void + { + $queryCache = new QueryCache(self::createCache()); + + $this->assertNotNull($queryCache->getEntry(new Scope(), 'any_collection', [])); + } + + public function testConstructorWithCustomName(): void + { + $queryCache = new QueryCache(self::createCache(), 'custom'); + + $entry = $queryCache->getEntry(new Scope(), 'users', []); + + $this->assertNotNull($entry); + $this->assertStringStartsWith('custom:', $entry->key); + } + + 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 testGetReturnsDocumentArrayForCacheHit(): void + { + $this->cache->method('load')->willReturn([ + 'version' => 2, + 'epoch' => '', + 'field' => '', + 'documents' => [ + ['$id' => 'doc1', 'name' => 'Alice'], + ['$id' => 'doc2', 'name' => 'Bob'], + ], + ]); + + $result = $this->queryCache->get(new Entry('some-key', 'users')); + + $this->assertNotNull($result); + $this->assertCount(2, $result); + $this->assertSame('doc1', $result[0]->getId()); + } + + 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 + { + $cache = $this->createMock(Cache::class); + $queryCache = new QueryCache($cache); + $queryCache->setRegion('users', new Region(ttl: 120)); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + + $cache->expects($this->exactly(2)) + ->method('load') + ->willReturnCallback(function (string $cacheKey, int $ttl) use ($key): string|false { + return match (true) { + $cacheKey === $key.'#epoch' && $ttl === \PHP_INT_MAX => 'active:epoch@0', + $cacheKey === $key && $ttl === 120 => false, + default => throw new \LogicException("Unexpected load of '{$cacheKey}' for {$ttl} seconds"), + }; + }); + $cache->method('getGeneration')->willReturn('0'); + + $entry = $queryCache->getEntry($scope, 'users', [Query::limit(10)]); + + $this->assertNotNull($entry); + $this->assertSame('active:epoch', $entry->epoch); + $this->assertNull($queryCache->get($entry)); + } + + public function testGetPurgesMalformedPayload(): void + { + $cache = $this->createMock(Cache::class); + $queryCache = new QueryCache($cache); + + $cache->method('load')->willReturn(['version' => 2, 'epoch' => '', 'field' => '', 'documents' => ['invalid']]); + $cache->expects($this->once()) + ->method('purge') + ->with('entry-key') + ->willReturn(true); + + $this->assertNull($queryCache->get(new Entry('entry-key', 'users'))); + } + + public function testSetSerializesDocuments(): void + { + $cache = $this->createMock(Cache::class); + $queryCache = new QueryCache($cache); + + $cache->expects($this->once()) + ->method('saveWithLease') + ->with( + 'entry-key', + $this->callback(function (array $data): bool { + $documents = $data['documents'] ?? null; + + return ($data['version'] ?? null) === 2 + && ($data['field'] ?? null) === 'field' + && ($data['epoch'] ?? null) === 'epoch' + && \is_array($documents) + && \is_array($documents[0] ?? null) + && ($documents[0]['$id'] ?? null) === 'doc1'; + }), + 'slot', + '7', + ) + ->willReturnArgument(1); + + $this->assertTrue($queryCache->set( + new Entry('entry-key', 'users', 'field', 'epoch', 'slot'), + [new Document(['$id' => 'doc1', 'name' => 'Alice'])], + '7', + )); + } + + public function testInvalidateCollectionBlocksThenPublishesAFreshEpoch(): void + { + $cache = new InvalidationCache(); + $queryCache = new QueryCache($cache); + + $queryCache->invalidateCollection(new Scope(), 'users'); + + $this->assertInvalidated($cache, $queryCache->getCollectionKey(new Scope(), 'users')); + } + + public function testAnInvalidEpochIsAMiss(): void + { + $cache = new Cache(new Memory()); + $queryCache = new QueryCache($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 testFlushDelegatesToCacheFlush(): void + { + $cache = $this->createMock(Cache::class); + $queryCache = new QueryCache($cache); + + $cache->expects($this->once()) + ->method('flush') + ->willReturn(true); + + $queryCache->flush(); + } + + 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 + { + $cache = $this->createMock(Cache::class); + $invalidator = new Invalidator(new QueryCache($cache)); + + $cache->expects($this->never())->method('purge'); + $invalidator->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 + { + $cache = $this->createMock(Cache::class); + $invalidator = new Invalidator(new QueryCache($cache)); + + $cache->expects($this->never())->method('purge'); + $invalidator->handle(Event::DocumentCreate, new Document(['$id' => 'doc1'])); + } + + public function testInvalidatorInvalidatesBothRelationshipCollections(): void + { + $this->assertInvalidatorInvalidates(Event::AttributeCreate, new Document([ + '$collection' => 'posts', + 'options' => [ + 'relatedCollection' => 'authors', + ], + ]), ['posts', 'authors']); + } + + public function testInvalidatorUsesCollectionIdentityForCollectionMutations(): void + { + $this->assertInvalidatorInvalidates(Event::CollectionUpdate, new Document([ + '$id' => 'users', + '$collection' => Database::METADATA, + ]), ['users']); + } + + public function testInvalidatorInvalidatesTheScopeItIsGiven(): void + { + $cache = new InvalidationCache(); + $queryCache = new QueryCache($cache); + $scope = new Scope('host', 'database', 'namespace', 7); + + (new Invalidator($queryCache))->invalidate(Event::DocumentCreate, 'users', $scope); + + $this->assertInvalidated($cache, $queryCache->getCollectionKey($scope, 'users')); + $this->assertSame(0, $cache->getPurges($queryCache->getCollectionKey(new Scope(), 'users').'#started')); + } + + public function testInvalidatorHandlesEventsInTheScopeItWasGiven(): void + { + $cache = new InvalidationCache(); + $queryCache = new QueryCache($cache); + $scope = new Scope(namespace: 'namespace', tenant: 'tenant'); + + (new Invalidator($queryCache, $scope))->handle(Event::DocumentCreate, 'users'); + + $this->assertInvalidated($cache, $queryCache->getCollectionKey($scope, 'users')); + } + + public function testInvalidatorKeysTokensByTheScopedCollection(): void + { + $queryCache = new QueryCache(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 QueryCache(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 QueryCache(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 QueryCache(new Cache($adapter)); + $second = new QueryCache(new Cache($adapter)); + $reader = new QueryCache(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 QueryCache(new Cache($adapter)))->get($entry) ?? [])); + + $first->blockCollection($key, $firstToken); + $adapter->pauseNextActivation(function () use ($adapter, $reader, $second, $scope, $key, $secondToken): void { + $second->blockCollection($key, $secondToken); + + $this->assertNull($reader->getEntry($scope, 'users', [])); + $this->assertTrue($adapter->has($key.'#owner:'.$secondToken)); + }); + + $first->activateCollection($key, $firstToken); + + $this->assertFalse($adapter->has($key.'#owner:'.$firstToken)); + $this->assertTrue($adapter->has($key.'#owner:'.$secondToken)); + $this->assertNull($reader->getEntry($scope, 'users', [])); + + $second->activateCollection($key, $secondToken); + + $this->assertFalse($adapter->has($key.'#owner:'.$secondToken)); + $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 QueryCache(new Cache($adapter)))->get($fresh) ?? [])); + } + } + + public function testAnEpochPublishedAfterALaterFinishStaysUsable(): void + { + $adapter = new OwnershipCache(); + $queryCache = new QueryCache(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'); + }); + $queryCache->activateCollection($key, 'first'); + + $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 + { + $adapter = new OwnershipCache(); + $queryCache = new QueryCache(new Cache($adapter)); + $queryCache->setRegion('users', new Region(ttl: 0)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + + $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'); + + $this->assertStringStartsWith('active:', $this->epochOf($adapter, $key), 'The writer must still own its tombstone and publish a fresh epoch'); + } + + public function testAKilledWriterDoesNotDisableTheQueryCacheForever(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new QueryCache(new Cache($adapter), writerTimeout: 0); + $scope = new Scope(namespace: 'ns'); + $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 QueryCache(new Cache($adapter)); + $writer = new QueryCache(new Cache($adapter), writerTimeout: 0); + $reader = new QueryCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $killed->blockCollection($killed->getCollectionKey($scope, 'users'), $killed->createToken()); + $this->assertNull($reader->getEntry($scope, 'users', [])); + + $writer->invalidateCollection($scope, '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($scope, '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 QueryCache(new Cache($adapter)); + $writer = new QueryCache(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); + + $this->assertNotNull($writer->getEntry($scope, 'users', [])); + } + + public function testAWriterWhoseTokenHasNoCreationTimeCountsAsLive(): void + { + $adapter = new RedisLeasableCache(); + $live = new QueryCache(new Cache($adapter)); + $writer = new QueryCache(new Cache($adapter), writerTimeout: 0); + $reader = new QueryCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $live->blockCollection($live->getCollectionKey($scope, 'users'), 'token-without-a-time'); + + $writer->invalidateCollection($scope, '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 QueryCache(new Cache($adapter), writerTimeout: 0); + $scope = new Scope(namespace: 'ns'); + $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); + + $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 QueryCache(new Cache($adapter)); + $writer = new QueryCache(new Cache($adapter), writerTimeout: 0); + $scope = new Scope(namespace: 'ns'); + $key = $slow->getCollectionKey($scope, 'users'); + $token = $slow->createToken(); + $slow->blockCollection($key, $token); + $writer->invalidateCollection($scope, '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); + + $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 QueryCache(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 QueryCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $adapter->flushDuringActivation(); + + $queryCache->activateCollection($key, 'owner'); + + $this->assertNotNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testCacheFlushBeforeActivationDoesNotFailInvalidation(): void + { + $adapter = new OwnershipCache(); + $queryCache = new QueryCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $this->assertTrue($adapter->flush()); + + $queryCache->activateCollection($key, 'owner'); + + $this->assertNotNull($queryCache->getEntry(new Scope(), 'users', [])); + } + + public function testActivationPurgeFailureStillPropagates(): void + { + $adapter = new OwnershipCache(); + $queryCache = new QueryCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $adapter->failDuringActivation(); + + try { + $queryCache->activateCollection($key, 'owner'); + $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 testInvalidationsAndFillsKeepTheKeyCountBounded(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new QueryCache(new Cache($adapter), slots: 4); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + $keys = []; + + for ($cycle = 1; $cycle <= 20; $cycle++) { + $entry = $queryCache->getEntry($scope, 'users', [Query::limit($cycle)]); + $this->assertNotNull($entry); + $this->assertNull($queryCache->get($entry)); + $this->assertTrue($queryCache->set($entry, [new Document(['$id' => 'cycle-'.$cycle])], $queryCache->getGeneration($entry))); + $this->assertSame(['cycle-'.$cycle], $this->ids($queryCache->get($entry) ?? [])); + $queryCache->invalidateCollection($scope, 'users'); + $keys[$cycle] = \count($adapter->keys()); + $this->assertLessThanOrEqual(4, \count($adapter->list($key)), 'Redis keeps no expiry on the hash, so its slot count must bound what fills leave behind'); + } + + $this->assertSame($keys[1], $keys[20], 'A purged key stays behind in Redis, so fills and invalidations must reuse the same keys (7.3.12 has no query cache; the per-epoch entry keys added one key per cycle)'); + $this->assertNotNull($queryCache->getEntry($scope, 'users', [Query::limit(1)])); + } + + public function testAnInvalidationRetiresCachedResultsWithoutDeletingThem(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new QueryCache(new Cache($adapter)); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + 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))); + } + $fields = \count($adapter->list($key)); + + $queryCache->invalidateCollection($scope, 'users'); + + $this->assertSame($fields, \count($adapter->list($key)), 'A write must not delete the scope\'s cached results: on Redis that is one command blocking in proportion to them, inside the write\'s transaction'); + for ($query = 1; $query <= 50; $query++) { + $entry = $queryCache->getEntry($scope, 'users', [Query::limit($query)]); + $this->assertNotNull($entry); + $this->assertNull($queryCache->get($entry), 'The new epoch must retire every result the previous one filled'); + } + } + + public function testQueriesSharingASlotNeverServeEachOther(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new QueryCache(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 QueryCache(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 QueryCache(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 testQueriesShareTheCollectionSlotWithoutSharingResultsOnACacheWithFields(): void + { + $adapter = new RedisLeasableCache(); + $queryCache = new QueryCache(new Cache($adapter)); + $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); + $keys = \count($adapter->keys()); + + $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) ?? [])); + $this->assertSame($keys + 1, \count($adapter->keys()), 'Every result of a collection scope lives in one hash'); + } + + public function testOverlappingInvalidationsSucceedOnACacheWithoutFields(): void + { + $queryCache = new QueryCache(new Cache(new Memory())); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + + $queryCache->blockCollection($key, 'first'); + $queryCache->blockCollection($key, 'second'); + $queryCache->activateCollection($key, 'first'); + $this->assertNull($queryCache->getEntry(new Scope(), 'users', []), 'The second writer is still in flight'); + $queryCache->activateCollection($key, 'second'); + + $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 QueryCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $adapter->corruptFieldWrites(); + $queryCache->blockCollection($key, 'owner'); + + try { + $queryCache->activateCollection($key, 'owner'); + $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 QueryCache(new Cache($adapter)); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $queryCache->blockCollection($key, 'owner'); + $adapter->failFieldPurges(); + + try { + $queryCache->activateCollection($key, 'owner'); + $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 + { + $cache = new InvalidationCache(); + $queryCache = new QueryCache($cache); + $cache->fail($queryCache->getCollectionKey(new Scope(), 'users').'#epoch'); + + $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 QueryCache(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'); + + $this->assertNull($queryCache->getEntry($scope, 'users', []), 'A writer whose registration was flushed away must not enable the cache while another writer is in flight'); + $this->assertStringStartsWith('blocked:second@', $this->epochOf($adapter, $key)); + + $queryCache->activateCollection($key, 'second'); + + $this->assertNotNull($queryCache->getEntry($scope, 'users', [])); + } + + public function testAnOwnerReleasedByAConcurrentFlushIsNotReported(): void + { + $adapter = new RedisLeasableCache(); + $cache = new class ($adapter) extends Cache { + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '' && \str_ends_with($key, '#owners')) { + $this->flush(); + } + + return parent::purge($key, $hash); + } + }; + $queryCache = new QueryCache($cache); + $scope = new Scope(namespace: 'ns'); + $key = $queryCache->getCollectionKey($scope, 'users'); + $queryCache->blockCollection($key, 'owner'); + + $queryCache->activateCollection($key, 'owner'); + + $epoch = $adapter->load($key.'#epoch', \PHP_INT_MAX); + $this->assertIsString($epoch); + $this->assertStringStartsWith('active:', $epoch, 'An owner that a flush removed before its release must still publish a fresh epoch'); + $this->assertNotNull($queryCache->getEntry($scope, 'users', [])); + } + + public function testGetPurgesAnEntryOfAnotherVersionAndMisses(): void + { + $cache = $this->createMock(Cache::class); + $queryCache = new QueryCache($cache); + + $cache->method('load')->willReturn(['version' => 1, 'epoch' => 'epoch', 'field' => 'field', 'documents' => []]); + $cache->expects($this->once()) + ->method('purge') + ->with('entry-key', 'slot') + ->willReturn(true); + + $this->assertNull($queryCache->get(new Entry('entry-key', 'users', 'field', 'epoch', 'slot'))); + } + + public function testInvalidationPropagatesAnOwnerRegistrationFailure(): void + { + $cache = new InvalidationCache(); + $queryCache = new QueryCache($cache); + $key = $queryCache->getCollectionKey(new Scope(), 'users'); + $cache->fail($key.'#owners'); + + 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()); + } + + $this->assertArrayNotHasKey($key.'#epoch', $cache->values, 'A write whose owner was not registered must not block the epoch'); + } + + public function testFlushFailureIsReported(): void + { + $cache = $this->createMock(Cache::class); + $queryCache = new QueryCache($cache); + + $cache->expects($this->once()) + ->method('flush') + ->willReturn(false); + + $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 + { + $cache = new InvalidationCache(); + $queryCache = new QueryCache($cache); + + (new Invalidator($queryCache))->handle($event, $data); + + foreach ($collections as $collection) { + $this->assertInvalidated($cache, $queryCache->getCollectionKey(new Scope(), $collection)); + } + } + + private function assertInvalidated(InvalidationCache $cache, string $key): void + { + $this->assertSame(1, $cache->getPurges($key.'#started'), 'The invalidation must block the collection once'); + $this->assertSame(1, $cache->getPurges($key.'#finished'), 'The invalidation must finish what it blocked'); + $this->assertStringStartsWith('active:', $cache->values[$key.'#epoch'] ?? '', 'The invalidation must publish a fresh epoch'); + } + + private static function createCache(): Cache&Stub + { + $cache = self::createStub(Cache::class); + $cache->method('getGeneration')->willReturn('0'); + + return $cache; + } + + private function epochOf(OwnershipCache $adapter, string $key): string + { + $epoch = $adapter->load($key.'#epoch', \PHP_INT_MAX); + $this->assertIsString($epoch); + + return $epoch; + } + + /** + * @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/RedisLeasableCache.php b/tests/unit/Cache/RedisLeasableCache.php new file mode 100644 index 0000000000..45f0aaaa44 --- /dev/null +++ b/tests/unit/Cache/RedisLeasableCache.php @@ -0,0 +1,168 @@ +|string}>> */ + private array $fields = []; + + /** @var array */ + private array $generations = []; + + private bool $failingFieldPurges = false; + + private bool $corruptingFieldWrites = false; + + 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; + } + + 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; + } + + public function getGeneration(string $key): string + { + return (string) ($this->generations[$key] ?? 0); + } + + 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); + } + + 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 */ + public function list(string $key): array + { + return \array_map(\strval(...), \array_keys($this->fields[$key] ?? [])); + } + + public function purge(string $key, string $hash = ''): bool + { + if ($hash !== '' && $this->failingFieldPurges) { + return false; + } + + $this->generations[$key] = ($this->generations[$key] ?? 0) + 1; + + 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; + } + + public function flush(): bool + { + $this->fields = []; + $this->generations = []; + + return true; + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return \count($this->keys()); + } + + 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; + } + + /** + * 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(): void + { + $this->corruptingFieldWrites = true; + } + + /** + * 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]); + } + + 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..23487c3c84 100644 --- a/tests/unit/CacheKeyTest.php +++ b/tests/unit/CacheKeyTest.php @@ -6,10 +6,14 @@ use Utopia\Cache\Adapter\None; use Utopia\Cache\Cache; use Utopia\Database\Adapter; +use Utopia\Database\Capability; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; +use Utopia\Database\Hook\Relationships; +use Utopia\Database\PermissionType; use Utopia\Database\Query; +use Utopia\Query\Schema\ColumnType; class CacheKeyTest extends TestCase { @@ -18,8 +22,13 @@ class CacheKeyTest extends TestCase */ private function createDatabase(array $instanceFilters = [], string $database = 'test'): Database { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(false); + $adapter = self::createStub(Adapter::class); + $adapter->method('supports')->willReturnCallback(function (Capability $capability) { + return match ($capability) { + Capability::Hostname => false, + default => false, + }; + }); $adapter->method('getTenant')->willReturn(null); $adapter->method('getNamespace')->willReturn('test'); $adapter->method('getDatabase')->willReturn($database); @@ -35,8 +44,10 @@ 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 = self::createStub(Adapter::class); + $adapter->method('supports')->willReturnCallback( + fn (Capability $capability): bool => $capability === Capability::Hostname + ); $adapter->method('getHostname')->willReturn('mysql-project'); $adapter->method('getNamespace')->willReturn('project'); $adapter->method('getTenant')->willReturn(42); @@ -54,6 +65,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(); @@ -158,32 +184,38 @@ public function testFiltersDisabledEntirelyProducesDifferentCacheKey(): void public function testQueryCacheKeyUsesQueryCacheShape(): void { - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); + $adapter = self::createStub(Adapter::class); + $adapter->method('supports')->willReturnCallback( + fn (Capability $capability): bool => $capability === Capability::Hostname + ); $adapter->method('getHostname')->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 = self::createStub(Adapter::class); + $adapter->method('supports')->willReturnCallback( + fn (Capability $capability): bool => $capability === Capability::Hostname + ); $adapter->method('getHostname')->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 +226,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']]), @@ -208,12 +240,13 @@ public function testQueryCacheFieldUsesQueryCacheShape(): void ]; $schemaHash = \md5( - (\json_encode($collection->getAttribute('attributes', [])) ?: '') - . (\json_encode($collection->getAttribute('indexes', [])) ?: '') + (\json_encode($collection->getArray('attributes')) ?: '') + . (\json_encode($collection->getArray('indexes')) ?: '') . (\json_encode($collection->getAttribute('$permissions', [])) ?: '') . (\json_encode($collection->getAttribute('documentSecurity', false)) ?: '') ); $field = $db->getQueryCacheField($collection, $queries); + $this->assertNotNull($field); $this->assertStringStartsWith("{$schemaHash}:", $field); $this->assertStringEndsWith(':documents', $field); @@ -226,24 +259,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 +305,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 +333,7 @@ public function testQueryCacheFieldIncludesCursorDocumentPayload(): void public function testQueryCacheFieldIncludesAmbientState(): void { $db = $this->createDatabase(); + $db->addHook(new Relationships($db)); $field = $db->getQueryCacheField(null, [Query::limit(10)]); @@ -326,9 +363,15 @@ public function testParseHostname(): void { $hostname = 'database_db_nyc3_self_hosted_0_0'; - $adapter = $this->createMock(Adapter::class); - $adapter->method('getSupportForHostname')->willReturn(true); + $adapter = self::createStub(Adapter::class); + $adapter->method('supports')->willReturnCallback(function (Capability $capability) { + return match ($capability) { + Capability::Hostname => true, + default => false, + }; + }); $adapter->method('getHostname')->willReturn($hostname); + $adapter->method('getDatabase')->willReturn('appwrite'); $adapter->method('getTenant')->willReturn(999); $adapter->method('getSharedTables')->willReturn(true); $adapter->method('getNamespace')->willReturn('_ns'); @@ -339,7 +382,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 +393,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..a236f435fb --- /dev/null +++ b/tests/unit/ChangeTest.php @@ -0,0 +1,73 @@ + 'doc1', 'name' => 'Old Name']); + $new = new Document(['$id' => 'doc1', 'name' => 'New Name']); + + $change = new Change($old, $new); + + $this->assertSame($old, $change->getOld()); + $this->assertSame($new, $change->getNew()); + } + + public function testGetOldAndGetNew(): void + { + $old = new Document(['$id' => 'test', 'status' => 'draft']); + $new = new Document(['$id' => 'test', 'status' => 'published']); + + $change = new Change($old, $new); + + $this->assertSame('draft', $change->getOld()->getAttribute('status')); + $this->assertSame('published', $change->getNew()->getAttribute('status')); + $this->assertSame('test', $change->getOld()->getId()); + $this->assertSame('test', $change->getNew()->getId()); + } + + public function testSetOld(): void + { + $old = new Document(['$id' => 'doc', 'val' => 1]); + $new = new Document(['$id' => 'doc', 'val' => 2]); + $change = new Change($old, $new); + + $replacement = new Document(['$id' => 'doc', 'val' => 0]); + $change->setOld($replacement); + + $this->assertSame($replacement, $change->getOld()); + $this->assertSame(0, $change->getOld()->getAttribute('val')); + $this->assertSame($new, $change->getNew()); + } + + public function testSetNew(): void + { + $old = new Document(['$id' => 'doc', 'val' => 1]); + $new = new Document(['$id' => 'doc', 'val' => 2]); + $change = new Change($old, $new); + + $replacement = new Document(['$id' => 'doc', 'val' => 99]); + $change->setNew($replacement); + + $this->assertSame($old, $change->getOld()); + $this->assertSame($replacement, $change->getNew()); + $this->assertSame(99, $change->getNew()->getAttribute('val')); + } + + public function testWithEmptyDocuments(): void + { + $old = new Document(); + $new = new Document(); + + $change = new Change($old, $new); + + $this->assertTrue($change->getOld()->isEmpty()); + $this->assertTrue($change->getNew()->isEmpty()); + } +} diff --git a/tests/unit/CollectionDefinitionTest.php b/tests/unit/CollectionDefinitionTest.php new file mode 100644 index 0000000000..4ab23873ad --- /dev/null +++ b/tests/unit/CollectionDefinitionTest.php @@ -0,0 +1,154 @@ +resolve($def); + + $keys = []; + foreach ($attributes as $attribute) { + $keys[] = $this->fields($attribute)['key']; + } + + $this->assertNotContains('externalId', $keys); + $this->assertSame(false, \array_key_exists('externalId', $definition)); + } + + public function testCollectionDefinitionKeys(): void + { + $def = Database::collectionDefinition(); + [$definition, $attributes] = $this->resolve($def); + + $keys = []; + foreach ($attributes as $attribute) { + $keys[] = $this->fields($attribute)['key']; + } + + $this->assertSame(Database::METADATA, $definition[Document::ID]); + $this->assertSame(Database::METADATA, $definition[Document::COLLECTION]); + $this->assertSame('collections', $definition['name']); + $this->assertSame(['name', 'attributes', 'indexes', 'documentSecurity'], $keys); + } + + public function testCollectionDefinitionAttributeTypes(): void + { + $def = Database::collectionDefinition(); + [, $attributes] = $this->resolve($def); + + $byKey = []; + foreach ($attributes as $attribute) { + $fields = $this->fields($attribute); + $byKey[$fields['key']] = $fields; + } + + $this->assertSame('string', $byKey['name']['type']); + $this->assertSame(256, $byKey['name']['size']); + $this->assertSame(true, $byKey['name']['required']); + + $this->assertSame('string', $byKey['attributes']['type']); + $this->assertSame(1000000, $byKey['attributes']['size']); + $this->assertSame(true, \in_array('json', $byKey['attributes']['filters'], true)); + + $this->assertSame('string', $byKey['indexes']['type']); + $this->assertSame(1000000, $byKey['indexes']['size']); + $this->assertSame(true, \in_array('json', $byKey['indexes']['filters'], true)); + + $this->assertSame('boolean', $byKey['documentSecurity']['type']); + $this->assertSame(true, $byKey['documentSecurity']['required']); + } + + public function testMetadataSchemaHasNoExternalId(): void + { + $db = new Database(new Memory(), new Cache(new None())); + $db->setDatabase('testing')->setNamespace('collections'); + $db->create(); + + $meta = $db->getCollection(Database::METADATA); + $attributes = $meta->attributes; + + $keys = []; + foreach ($attributes as $attribute) { + $keys[] = $this->fields($attribute)['key']; + } + + $this->assertNotContains('externalId', $keys); + } + + /** + * @param array $definition + * @return array{0: array, 1: list} + */ + private function resolve(array $definition): array + { + $attributes = $definition['attributes'] ?? []; + if (! \is_array($attributes)) { + $attributes = []; + } + + return [$definition, \array_values($attributes)]; + } + + /** + * @return array{key: string, type: string, size: int, required: bool, filters: array} + */ + private function fields(mixed $attribute): array + { + if ($attribute instanceof Attribute) { + $key = $attribute->key; + $type = $attribute->type->value; + $size = $attribute->size; + $required = $attribute->required; + $filters = $attribute->filters; + } elseif ($attribute instanceof Document) { + $key = $attribute->getAttribute('key', $attribute->getId()); + $type = $attribute->getAttribute('type', ''); + $size = $attribute->getAttribute('size', 0); + $required = $attribute->getAttribute('required', false); + $filters = $attribute->getAttribute('filters', []); + } elseif (\is_array($attribute)) { + $key = $attribute['key'] ?? $attribute[Document::ID] ?? ''; + $type = $attribute['type'] ?? ''; + $size = $attribute['size'] ?? 0; + $required = $attribute['required'] ?? false; + $filters = $attribute['filters'] ?? []; + } else { + $this->fail('Attribute must be a Document or array'); + } + + if ($type instanceof \BackedEnum) { + $type = (string) $type->value; + } + + $this->assertIsString($key); + $this->assertIsString($type); + $this->assertIsInt($size); + $this->assertIsBool($required); + $this->assertIsArray($filters); + + $typedFilters = []; + foreach ($filters as $filter) { + $this->assertIsString($filter); + $typedFilters[] = $filter; + } + + return [ + 'key' => $key, + 'type' => $type, + 'size' => $size, + 'required' => $required, + 'filters' => $typedFilters, + ]; + } +} diff --git a/tests/unit/CollectionModelTest.php b/tests/unit/CollectionModelTest.php new file mode 100644 index 0000000000..d94c17fedb --- /dev/null +++ b/tests/unit/CollectionModelTest.php @@ -0,0 +1,413 @@ +assertSame('', $collection->id); + $this->assertSame('', $collection->name); + $this->assertTrue($collection->isEmpty()); + $this->assertSame([], $collection->attributes); + $this->assertSame([], $collection->indexes); + $this->assertNull($collection->permissions); + $this->assertTrue($collection->documentSecurity); + $this->assertSame([], $collection->metadata); + } + + public function testConstructorRequiresAttributeAndIndexModels(): void + { + $collection = new Collection( + id: 'nested', + attributes: [ + Attribute::string(key: 'name', size: 64, required: true), + ], + indexes: [ + Index::key(key: 'idx_name', attributes: ['name'], lengths: [64], orders: [Order::Asc]), + ], + ); + + $this->assertSame('name', $collection->attributes[0]->key); + $this->assertSame('idx_name', $collection->indexes[0]->key); + } + + public function testConstructorWithValues(): void + { + $attr = Attribute::string(key: 'title', size: 128); + $idx = Index::key(key: 'idx_title', attributes: ['title']); + + $collection = new Collection( + id: 'users', + name: 'Users', + attributes: [$attr], + indexes: [$idx], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + ); + + $this->assertSame('users', $collection->id); + $this->assertFalse($collection->isEmpty()); + $this->assertSame('Users', $collection->name); + $this->assertCount(1, $collection->attributes); + $this->assertCount(1, $collection->indexes); + $this->assertNotNull($collection->permissions); + $this->assertCount(1, $collection->permissions); + $this->assertFalse($collection->documentSecurity); + } + + public function testToDocumentProducesCorrectStructure(): void + { + $attr = Attribute::string(key: 'email', size: 256, required: true); + $idx = Index::unique(key: 'idx_email', attributes: ['email']); + + $collection = new Collection( + id: 'accounts', + name: 'Accounts', + attributes: [$attr], + indexes: [$idx], + permissions: [Permission::read(Role::any()), Permission::create(Role::user('admin'))], + documentSecurity: true, + ); + + $doc = $collection; + + $this->assertSame('accounts', $doc->getId()); + $this->assertSame('Accounts', $doc->getAttribute('name')); + $this->assertTrue($doc->getAttribute('documentSecurity')); + $this->assertCount(1, $doc->attributes); + $this->assertCount(1, $doc->indexes); + $this->assertCount(2, $doc->getPermissions()); + } + + public function testToDocumentUsesIdWhenNameEmpty(): void + { + $collection = new Collection(id: 'myCol', name: ''); + + $this->assertSame('myCol', $collection->getAttribute('name')); + } + + public function testToDocumentPreservesNameWhenSet(): void + { + $collection = new Collection(id: 'myCol', name: 'My Collection'); + + $this->assertSame('My Collection', $collection->getAttribute('name')); + } + + public function testFromDocumentRoundtrip(): void + { + $attr = Attribute::string(key: 'status', size: 32, required: false, default: 'active'); + $idx = Index::key(key: 'idx_status', attributes: ['status']); + + $original = new Collection( + id: 'projects', + name: 'Projects', + attributes: [$attr], + indexes: [$idx], + permissions: [Permission::read(Role::any())], + documentSecurity: false, + ); + + $restored = Collection::fromArray($original->getArrayCopy()); + + $this->assertSame($original->id, $restored->id); + $this->assertSame($original->name, $restored->name); + $this->assertSame($original->documentSecurity, $restored->documentSecurity); + $this->assertCount(count($original->attributes), $restored->attributes); + $this->assertCount(count($original->indexes), $restored->indexes); + $this->assertSame($original->attributes[0]->key, $restored->attributes[0]->key); + $this->assertSame($original->indexes[0]->key, $restored->indexes[0]->key); + $this->assertInstanceOf(StringType::class, $restored->attributes[0]); + } + + public function testFromArrayWithEmptyPayload(): void + { + $collection = Collection::fromArray([]); + + $this->assertSame('', $collection->id); + $this->assertSame('', $collection->name); + $this->assertSame([], $collection->attributes); + $this->assertSame([], $collection->indexes); + $this->assertNull($collection->permissions); + $this->assertTrue($collection->documentSecurity); + } + + public function testWithMultipleAttributes(): void + { + $attrs = [ + Attribute::string(key: 'name', size: 128, required: true), + Attribute::string(key: 'email', size: 256, required: true), + new Integer(key: 'age', required: false, default: 0), + new Boolean(key: 'active'), + ]; + + $collection = new Collection(id: 'users', attributes: $attrs); + + $this->assertCount(4, $collection->attributes); + + $restored = Collection::fromArray($collection->getArrayCopy()); + $this->assertCount(4, $restored->attributes); + $this->assertSame('name', $restored->attributes[0]->key); + $this->assertSame('active', $restored->attributes[3]->key); + $this->assertInstanceOf(StringType::class, $restored->attributes[0]); + $this->assertInstanceOf(StringType::class, $restored->attributes[1]); + $this->assertInstanceOf(Integer::class, $restored->attributes[2]); + $this->assertInstanceOf(Boolean::class, $restored->attributes[3]); + } + + public function testWithMultipleIndexes(): void + { + $indexes = [ + Index::key(key: 'idx_name', attributes: ['name']), + Index::unique(key: 'idx_email', attributes: ['email']), + Index::key(key: 'idx_compound', attributes: ['name', 'email']), + ]; + + $collection = new Collection(id: 'users', indexes: $indexes); + + $this->assertCount(3, $collection->indexes); + + $restored = Collection::fromArray($collection->getArrayCopy()); + $this->assertCount(3, $restored->indexes); + $this->assertSame('idx_compound', $restored->indexes[2]->key); + } + + public function testWithPermissions(): void + { + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::user('admin')), + Permission::update(Role::team('editors')), + Permission::delete(Role::user('owner')), + ]; + + $collection = new Collection(id: 'posts', permissions: $permissions); + + $this->assertCount(4, $collection->getPermissions()); + $this->assertContains(Permission::read(Role::any()), $collection->getPermissions()); + } + + public function testDocumentSecurityTrue(): void + { + $collection = new Collection(id: 'secure', documentSecurity: true); + + $this->assertTrue($collection->getAttribute('documentSecurity')); + } + + public function testDocumentSecurityFalse(): void + { + $collection = new Collection(id: 'insecure', documentSecurity: false); + + $this->assertFalse($collection->getAttribute('documentSecurity')); + } + + public function testFromDocumentPreservesPermissions(): void + { + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ]; + + $collection = Collection::fromArray([ + '$id' => 'test', + '$permissions' => $permissions, + 'name' => 'test', + 'attributes' => [], + 'indexes' => [], + 'documentSecurity' => true, + ]); + $this->assertNotNull($collection->permissions); + $this->assertCount(2, $collection->permissions); + } + + public function testConstructorHydratesDocumentAttributesWithoutKey(): void + { + $collection = new Collection( + id: 'bucket_1', + attributes: [ + new Document([ + '$id' => 'name', + 'type' => 'string', + 'size' => 128, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ]), + ], + indexes: [ + new Document([ + '$id' => 'idx_name', + 'type' => 'key', + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => [], + ]), + ], + ); + + $this->assertSame('name', $collection->attributes[0]->key); + $this->assertSame('idx_name', $collection->indexes[0]->key); + } + + public function testAttributeModelsStayModels(): void + { + $attr = Attribute::string(key: 'title', size: 64); + $collection = new Collection(id: 'articles', attributes: [$attr]); + + $attributes = $collection->attributes; + + $this->assertSame('title', $attributes[0]->key); + $this->assertSame('string', $attributes[0]->getAttribute('type')); + } + + public function testIndexModelsStayModels(): void + { + $idx = Index::fullText(key: 'idx_test', attributes: ['body']); + $collection = new Collection(id: 'articles', indexes: [$idx]); + + $indexes = $collection->indexes; + + $this->assertSame('idx_test', $indexes[0]->key); + $this->assertSame('fulltext', $indexes[0]->getAttribute('type')); + } + + public function testLegacyEmptyFormatHydratesAsNoFormat(): void + { + $row = [ + Document::ID => 'resourceInternalId', + 'key' => 'resourceInternalId', + 'type' => ColumnType::String->value, + 'size' => Database::LENGTH_KEY, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'format' => '', + 'formatOptions' => [], + 'filters' => [], + ]; + $definition = Attribute::string(key: 'resourceInternalId', size: Database::LENGTH_KEY)->toDocument()->getArrayCopy(); + + $collections = [ + 'fromArray' => Collection::fromArray([Document::ID => 'migrations', 'attributes' => [$row]]), + 'constructor' => new Collection(id: 'migrations', attributes: [new Document($row)]), + ]; + + foreach ($collections as $path => $collection) { + $this->assertNull($collection->attributes[0]->getAttribute('format'), $path); + $this->assertSame($definition, $collection->attributes[0]->toDocument()->getArrayCopy(), $path); + } + } + + public function testPropertyWritesStoreTheirAttributes(): void + { + $attributes = [Attribute::string(key: 'title', size: 64)]; + $indexes = [Index::key(key: 'titleIndex', attributes: ['title'])]; + $permissions = [Permission::read(Role::any())]; + + $collection = new Collection(id: 'before'); + $collection->id = 'after'; + $collection->name = 'Renamed'; + $collection->attributes = $attributes; + $collection->indexes = $indexes; + $collection->permissions = $permissions; + $collection->documentSecurity = false; + $collection->__set('search', 'title'); + + $this->assertSame('after', $collection->getId()); + $this->assertSame('Renamed', $collection->getAttribute('name')); + $this->assertSame($attributes, $collection->getAttribute('attributes')); + $this->assertSame($indexes, $collection->getAttribute('indexes')); + $this->assertSame($permissions, $collection->getPermissions()); + $this->assertFalse($collection->getAttribute('documentSecurity')); + $this->assertSame('title', $collection->getAttribute('search')); + + $this->assertSame('after', $collection->id); + $this->assertSame('Renamed', $collection->name); + $this->assertSame($attributes, $collection->attributes); + $this->assertSame($indexes, $collection->indexes); + $this->assertSame($permissions, $collection->permissions); + $this->assertFalse($collection->documentSecurity); + $this->assertSame('title', $collection->__get('search')); + } + + public function testAssigningNullPermissions(): void + { + $collection = new Collection(id: 'posts', permissions: [Permission::read(Role::any())]); + $collection->permissions = null; + + $this->assertSame([], $collection->getAttribute(Document::PERMISSIONS)); + $this->assertSame([], $collection->permissions); + } + + public function testFromArrayCoercesNonStringIdAndName(): void + { + $withoutId = Collection::fromArray([Document::ID => 5]); + + $this->assertSame('', $withoutId->id); + $this->assertSame('', $withoutId->name); + + $withoutName = Collection::fromArray([Document::ID => 'posts', 'name' => ['Posts']]); + + $this->assertSame('posts', $withoutName->id); + $this->assertSame('posts', $withoutName->name); + } + + public function testUnknownPropertyReadsTheAttribute(): void + { + $collection = new Collection(id: 'posts', metadata: ['search' => 'title']); + + $this->assertSame('title', $collection->__get('search')); + $this->assertNull($collection->__get('missing')); + } + + public function testConstructorRejectsAnAttributeThatIsNotAModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Collection attributes must be Attribute models'); + + new Collection(id: 'posts', attributes: ['title']); // @phpstan-ignore argument.type + } + + public function testFromArrayRejectsAnAttributeThatIsNotAModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Collection attributes must be Attribute models'); + + Collection::fromArray([Document::ID => 'posts', 'attributes' => ['title']]); + } + + public function testConstructorRejectsAnIndexThatIsNotAModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Collection indexes must be Index models'); + + new Collection(id: 'posts', indexes: ['titleIndex']); // @phpstan-ignore argument.type + } + + public function testFromArrayRejectsAnIndexThatIsNotAModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Collection indexes must be Index models'); + + Collection::fromArray([Document::ID => 'posts', 'indexes' => ['titleIndex']]); + } +} diff --git a/tests/unit/Collections/AnalyzeCollectionTest.php b/tests/unit/Collections/AnalyzeCollectionTest.php new file mode 100644 index 0000000000..29d057182c --- /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(new Collection( + 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/CollectionGuardsTest.php b/tests/unit/Collections/CollectionGuardsTest.php new file mode 100644 index 0000000000..c3df3916c5 --- /dev/null +++ b/tests/unit/Collections/CollectionGuardsTest.php @@ -0,0 +1,296 @@ +database($adapter); + + $this->assertTrue($adapter->exists('guards', Database::METADATA)); + $this->assertTrue($database->deleteCollection(Database::METADATA)); + $this->assertFalse($adapter->exists('guards', Database::METADATA)); + } + + public function testDeletingTheMetadataCollectionDropsItsTableOnSQLite(): void + { + $adapter = new SQLite(new PDO('sqlite::memory:')); + $database = $this->database($adapter); + + $this->assertTrue($database->deleteCollection(Database::METADATA)); + $this->assertFalse($adapter->exists('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; + + 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); + } + + 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(new Collection(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, [Permission::read(Role::user('intruder'))], 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->assertFalse($database->getCollection(self::COLLECTION)->isEmpty()); + } + + 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); + } + + 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->exists('guards', self::COLLECTION), 'the table is created again'); + $this->assertFalse($database->getCollection(self::COLLECTION)->isEmpty()); + $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); + } + + public function createDocument(string $collection, Document $document): Document + { + if ($collection === self::METADATA && $document->getId() === 'raced') { + throw $this->winner; + } + + return parent::createDocument($collection, $document); + } + + public function purgeCachedDocument(string $collectionId, ?string $id): bool + { + if ($id === 'raced') { + throw new RuntimeException('the cache is down'); + } + + return parent::purgeCachedDocument($collectionId, $id); + } + }; + $database->setDatabase('guards')->setNamespace('guards_'.\uniqid()); + $database->create(); + + $error = null; + $log = StderrCapture::during(function () use ($database, &$error): void { + try { + $database->createCollection(new Collection(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->exists('guards', 'raced'), 'the table the winner described is kept'); + } + + 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(new Collection( + 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(new Collection( + 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..9a807ff39b --- /dev/null +++ b/tests/unit/Collections/CollectionValidationTest.php @@ -0,0 +1,517 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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(new Collection(id: 'existing')); + } + + public function testCreateCollectionValidatesPermissionsFormat(): void + { + $this->setupEmptyMetadata(); + $this->database->enableValidation(); + + $this->expectException(DatabaseException::class); + $this->database->createCollection(new Collection(id: 'newCol', permissions: ['bad-format'])); + } + + public function testCreateCollectionWithAttributeLimits(): void + { + $adapter = self::createStub(Adapter::class); + $adapter->method('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(1); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(100); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 = new \Utopia\Database\Attribute( + key: 'name', + type: \Utopia\Query\Schema\ColumnType::String, + size: 128, + required: false, + ); + + $this->expectException(LimitException::class); + $this->expectExceptionMessage('Attribute limit'); + $db->createCollection(new Collection(id: 'newCol', attributes: [$attr])); + } + + public function testCreateCollectionRejectsPointAttributeOnMemoryWhenValidateIsOn(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('testing') + ->setNamespace('collections') + ->enableValidation(); + $database->create(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Spatial attributes are not supported'); + + $database->createCollection(new Collection(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') + ->enableValidation(); + $database->create(); + + $collection = $database->createCollection(new Collection(id: 'users', attributes: [ + Attribute::string(key: 'prefs', size: 65535, default: new \stdClass(), filters: ['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') + ->enableValidation(); + $database->create(); + + $collection = $database->createCollection(new Collection( + id: 'users', + name: 'Users', + attributes: [Attribute::string(key: 'name', required: true)], + )); + + $this->assertSame('users', $collection->getId()); + $this->assertSame('Users', $collection->getAttribute('name')); + + /** @var array $attributes */ + $attributes = $collection->attributes; + $this->assertSame(1, \count($attributes)); + $this->assertSame('name', $attributes[0]->getAttribute('key')); + $this->assertSame('string', $attributes[0]->getAttribute('type')); + } + + public function testCreateCollectionEmptyPermissionsUsesDefault(): void + { + $database = new Database(new Memory(), new Cache(new None())); + $database + ->setDatabase('testing') + ->setNamespace('collections') + ->enableValidation(); + $database->create(); + + $anon = $database->createCollection(new Collection(id: 'anon')); + $control = $database->createCollection(new Collection(id: 'control')); + $locked = $database->createCollection(new Collection(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('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(0); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(100); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 = new \Utopia\Database\Attribute( + key: 'name', + type: \Utopia\Query\Schema\ColumnType::String, + size: 128, + required: false, + ); + $index = new \Utopia\Database\Index( + key: 'idx_name', + type: \Utopia\Query\Schema\IndexType::Key, + attributes: ['name'], + ); + + $this->expectException(LimitException::class); + $this->expectExceptionMessage('Index limit'); + $db->createCollection(new Collection(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', $newPermissions, 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', [Permission::read(Role::any())], 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', [Permission::read(Role::any())], 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->getCollection('myCol'); + $this->assertFalse($result->isEmpty()); + $this->assertSame('myCol', $result->getId()); + } + + public function testExistsDelegatesToAdapter(): void + { + $this->adapter->method('getDatabase')->willReturn('testdb'); + $this->adapter->method('exists')->willReturn(true); + + $result = $this->database->exists('testdb', 'testCol'); + $this->assertTrue($result); + } +} diff --git a/tests/unit/Collections/MetadataWriteRecorder.php b/tests/unit/Collections/MetadataWriteRecorder.php new file mode 100644 index 0000000000..8ef35a0424 --- /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->isValidationEnabled(); + } + + 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..25af45026d --- /dev/null +++ b/tests/unit/Collections/MetadataWriteValidationTest.php @@ -0,0 +1,181 @@ + + */ + 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(new Collection(id: 'related')); + + try { + $database->createRelationship($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(new Collection(id: 'related')); + $database->skipValidation(fn (): bool => $database->createRelationship($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(new Collection(id: 'posts', attributes: [Attribute::string(key: 'owner', size: 64)])); + $database->createCollection(new Collection(id: 'users')); + $database->createIndex('posts', new Index(key: '_index_author', type: IndexType::Key, attributes: ['owner'])); + $setupWrites = \count($database->getValidations()); + + try { + $database->createRelationship(new Relationship( + collection: 'posts', + relatedCollection: 'users', + type: RelationType::ManyToOne, + 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 = new Collection(id: 'unvalidated', name: \str_repeat('n', self::OVERSIZED_NAME_LENGTH)); + $database->skipValidation(fn (): Collection => $database->createCollection($collection)); + } + + private function relationship(): Relationship + { + return new Relationship( + collection: 'unvalidated', + relatedCollection: 'related', + type: RelationType::OneToMany, + 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, + \array_values($database->getCollection($collection)->attributes), + ); + } +} diff --git a/tests/unit/Collections/UpdateCollectionValidationTest.php b/tests/unit/Collections/UpdateCollectionValidationTest.php new file mode 100644 index 0000000000..8c7cd5cc46 --- /dev/null +++ b/tests/unit/Collections/UpdateCollectionValidationTest.php @@ -0,0 +1,110 @@ + + */ + 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(new Collection(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(new Collection(id: 'unvalidated', name: $name))); + + $this->expectException(StructureException::class); + + $database->updateCollection('unvalidated', [Permission::read(Role::any())], true); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testUpdateCollectionPersistsHydratedAttributesIndexesAndRelationships(Closure $adapter): void + { + $database = $this->database($adapter()); + $database->createCollection(new Collection( + 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: [new Index(key: 'title_index', type: IndexType::Key, attributes: ['title'])], + permissions: [Permission::create(Role::any())], + )); + $database->createCollection(new Collection(id: 'authors', attributes: [Attribute::string('name', size: 64, required: false)])); + $database->createRelationship(new Relationship( + collection: 'books', + relatedCollection: 'authors', + type: RelationType::ManyToOne, + twoWay: true, + key: 'author', + twoWayKey: 'books', + )); + + $permissions = [Permission::read(Role::any()), Permission::update(Role::any())]; + $database->updateCollection('books', $permissions, 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..7f6d506e64 --- /dev/null +++ b/tests/unit/CoreMinorsTest.php @@ -0,0 +1,820 @@ + + */ + 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')], + ]; + } + + #[DataProvider('deterministicFailures')] + public function testDeterministicFailuresAreNotRetried(Throwable $failure): void + { + $writes = 0; + $database = $this->metadataFailing($failure, $writes); + + $error = $this->attempt(fn (): bool => $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 (): bool => $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'); + } + + 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(new Collection(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $failing = true; + + $error = $this->attempt(fn (): bool => $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(new Collection( + id: 'logs', + attributes: [Attribute::integer(key: 'count')], + indexes: [Index::key(key: 'by_count', attributes: ['count'])], + )); + $failing = true; + + $error = $this->attempt(fn (): bool => $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(new Collection(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $failing = true; + + $error = $this->attempt(fn (): bool => $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(new Collection(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $armed = true; + + $error = $this->attempt(fn (): bool => $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(new Collection(id: 'logs', attributes: [Attribute::integer(key: 'count')])); + $failing = true; + + $error = $this->attempt(fn (): bool => $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(new Collection(id: 'profiles')); + $database->createCollection(new Collection(id: 'accounts')); + $armed = true; + + $error = $this->attempt(fn (): bool => $database->createRelationship(new Relationship( + collection: 'profiles', + relatedCollection: 'accounts', + type: RelationType::OneToOne, + 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(new Collection(id: 'profiles')); + $database->createCollection(new Collection(id: 'accounts')); + $armed = true; + + $error = $this->attempt(fn (): bool => $database->createRelationship(new Relationship( + collection: 'profiles', + relatedCollection: 'accounts', + type: RelationType::OneToOne, + 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 AttributeValidator(attributes: []); + $error = $this->attempt(fn (): bool => $validator->isValid(new Attribute(key: 'total', type: ColumnType::BigInteger, 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(new Collection(id: 'logs', attributes: [Attribute::bigInteger(key: 'total')])); + + $error = $this->attempt(fn (): Document => $database->updateAttributeDefault('logs', 'total', '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(new Attribute(key: 'value', type: ColumnType::Timestamp))); + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertStringContainsString(', bigint, ', $error->getMessage(), 'The listed types must use the stored spelling'); + } + + /** + * @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(new Collection( + 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 AttributeValidator(attributes: [], supportForSpatialAttributes: true); + $created = $this->attempt(fn (): bool => $validator->isValid(new Attribute(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->value, $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 AttributeValidator(attributes: [], supportForSpatialAttributes: true); + $database = new class ($this->adapter(), new Cache(new None())) extends Database { + public function checkDefault(ColumnType $type, mixed $default): void + { + $this->validateDefaultTypes($type->value, $default); + } + }; + + foreach ($defaults as [$type, $default]) { + $this->assertTrue($validator->isValid(new Attribute(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(new Collection( + 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(new Collection( + id: 'embeddings', + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + $database->createAttribute('embeddings', Attribute::vector(key: 'embedding', size: 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. + */ + private function metadataFailing(Throwable $failure, int &$writes): Database + { + /** @var bool $failing */ + $failing = false; + $database = $this->interceptingMetadataWrites(function () use (&$failing, &$writes, $failure): void { + if (! $failing) { + return; + } + + $writes++; + + throw $failure; + }); + $this->configure($database); + $database->createCollection(new Collection(id: 'logs')); + $failing = true; + + return $database; + } + + /** + * 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 and after each outermost commit. + * + * @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, + ): SQLite { + return new class (new PDO('sqlite::memory:'), $beforeCreateIndex, $beforeDeleteIndex, $beforeTransaction, $afterCommit) extends SQLite { + /** + * @param (Closure(): void)|null $beforeCreateIndex + * @param (Closure(): void)|null $beforeDeleteIndex + * @param (Closure(): void)|null $beforeTransaction + * @param (Closure(): void)|null $afterCommit + */ + public function __construct( + PDO $pdo, + private readonly ?Closure $beforeCreateIndex, + private readonly ?Closure $beforeDeleteIndex, + private readonly ?Closure $beforeTransaction, + private readonly ?Closure $afterCommit, + ) { + parent::__construct($pdo); + } + + #[\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, + \array_values($database->getCollection($collection)->attributes), + ); + } + + /** + * @return list + */ + private function indexKeys(Database $database, string $collection): array + { + return \array_map( + static fn (Index $index): string => $index->key, + \array_values($database->getCollection($collection)->indexes), + ); + } + + private function hasSchemaAttribute(Database $database, string $collection, string $key): bool + { + foreach ($database->getSchemaAttributes($collection) as $attribute) { + if ($attribute->getId() === $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->getId(), $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..8269220212 --- /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(new Collection( + 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->exists('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(new Collection( + id: 'logs', + attributes: [Attribute::string(key: 'message', size: 64)], + )); + } catch (Throwable $thrown) { + $error = $thrown; + } + + $this->assertInstanceOf(DatabaseException::class, $error); + $this->assertFalse($adapter->exists('cleanup', 'logs'), 'A table without a stored definition must be dropped'); + $this->assertTrue($database->getCollection('logs')->isEmpty()); + } + + 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..538c9edc7a 100644 --- a/tests/unit/CreateCollectionRaceTest.php +++ b/tests/unit/CreateCollectionRaceTest.php @@ -6,6 +6,8 @@ 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; @@ -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' => [ @@ -57,10 +54,10 @@ public function testCreateCollectionDoesNotDropUncommittedPeerTable(): void ])); try { - $database->createCollection($collection, [$name], permissions: [ + $database->createCollection(new Collection(id: $collection, attributes: [$name], permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ])); $this->fail('Expected DuplicateException for an existing physical collection'); } catch (DuplicateException) { } @@ -96,22 +93,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(new Collection(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..7e1960a87f --- /dev/null +++ b/tests/unit/CustomDocumentTypeTest.php @@ -0,0 +1,320 @@ +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; + + protected function setUp(): void + { + $this->adapter = self::createStub(Adapter::class); + + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('1970-01-01 00:00:00')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('2999-12-31 23:59:59')); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $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->disableValidation(); + $this->database->disableFilters(); + } + + 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->clearAllDocumentTypes(); + + $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->clearAllDocumentTypes(); + $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..72a3fe57e0 --- /dev/null +++ b/tests/unit/DatabaseGuardsTest.php @@ -0,0 +1,167 @@ + + */ + 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 testExecuteIsRefusedWithoutRawQueries(): void + { + $database = $this->database(new Memory()); + $statement = new Statement('SELECT 1', [], readOnly: true); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Raw queries are not supported by this adapter'); + + $database->getAuthorization()->skip(fn () => $database->execute($statement)); + } + + /** + * @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(); + } + + /** + * @param \Closure(): Adapter $adapter + */ + #[DataProvider('adaptersWithoutTimeouts')] + public function testGetConnectionIdIsRefusedWithoutConnectionIds(\Closure $adapter): void + { + $database = $this->database($adapter()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Adapter does not support connection ids'); + + $database->getConnectionId(); + } + + public function testSchemaIntrospectionWithoutTheFeatureListsNothing(): void + { + $database = $this->database(new Memory()); + $database->create(); + $database->createCollection(new Collection( + id: 'introspected', + attributes: [Attribute::string(key: 'name', size: 32)], + indexes: [new Index(key: 'byName', type: IndexType::Key, attributes: ['name'])], + )); + + $this->assertSame([], $database->getSchemaAttributes('introspected')); + $this->assertSame([], $database->getSchemaIndexes('introspected')); + } + + public function testDropUnknownAttributesGetterFollowsTheSetter(): void + { + $database = $this->database(new Memory()); + + $this->assertFalse($database->getDropUnknownAttributes()); + $this->assertTrue($database->setDropUnknownAttributes(true)->getDropUnknownAttributes()); + $this->assertFalse($database->setDropUnknownAttributes(false)->getDropUnknownAttributes()); + } + + 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..ffdd873763 --- /dev/null +++ b/tests/unit/DefaultFilterDecodeTest.php @@ -0,0 +1,119 @@ + + */ + 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(new Collection( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'payload', size: 64, filters: ['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..91eedbdbc7 --- /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(new Collection(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(new Collection(id: 'databases', attributes: $attributes, indexes: $indexes)); + $fresh = $this->database(new Memory()) + ->createCollection(new Collection(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([Order::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(new Collection(id: 'events')); + $attribute = Attribute::datetime(key: 'startsAt'); + $definition = $attribute->getArrayCopy(); + + $database->createAttribute('events', $attribute); + + $this->assertSame($definition, $attribute->getArrayCopy()); + $this->assertSame(['datetime'], $database->getCollection('events')->attributes[0]->filters); + } + + public function testCreateAttributesLeavesCallerAttributesUntouched(): void + { + $database = $this->database(new Memory()); + $database->createCollection(new Collection(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(new Collection(id: 'events', attributes: [Attribute::string(key: 'tags', size: 64, array: true)])); + $index = Index::key(key: '_key_tags', attributes: ['tags'], lengths: [64], orders: [Order::Desc]); + $definition = $index->getArrayCopy(); + + $database->createIndex('events', $index); + + $this->assertSame($definition, $index->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: [Order::Asc]), + Index::key(key: '_key_tags', attributes: ['tags'], lengths: [64], orders: [Order::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->getArrayCopy(), $attributes)), + 'indexes' => \array_values(\array_map(static fn (Index $index): array => $index->getArrayCopy(), $indexes)), + ]; + } +} diff --git a/tests/unit/DocumentAdvancedTest.php b/tests/unit/DocumentAdvancedTest.php new file mode 100644 index 0000000000..893f8def2a --- /dev/null +++ b/tests/unit/DocumentAdvancedTest.php @@ -0,0 +1,448 @@ + '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 testFindWithSubjectKey(): void + { + $doc = new Document([ + '$id' => 'root', + 'items' => [ + new Document(['$id' => 'item1', 'name' => 'first']), + new Document(['$id' => 'item2', 'name' => 'second']), + ], + ]); + + $found = $doc->find('name', 'second', 'items'); + $this->assertInstanceOf(Document::class, $found); + $this->assertSame('item2', $found->getId()); + } + + public function testFindReturnsDocumentOnDirectMatch(): void + { + $doc = new Document(['$id' => 'test', 'status' => 'active']); + + $result = $doc->find('status', 'active'); + $this->assertInstanceOf(Document::class, $result); + $this->assertSame('test', $result->getId()); + } + + public function testFindReturnsFalseWhenNotFound(): void + { + $doc = new Document([ + '$id' => 'test', + 'items' => [ + new Document(['$id' => 'a', 'name' => 'alpha']), + ], + ]); + + $this->assertFalse($doc->find('name', 'nonexistent', 'items')); + } + + public function testFindReturnsFalseForDirectMismatch(): void + { + $doc = new Document(['$id' => 'test', 'status' => 'active']); + $this->assertFalse($doc->find('status', 'inactive')); + } + + public function testFindAndReplaceWithSubject(): void + { + $doc = new Document([ + '$id' => 'root', + 'items' => [ + new Document(['$id' => 'a', 'name' => 'alpha']), + new Document(['$id' => 'b', 'name' => 'beta']), + ], + ]); + + $result = $doc->findAndReplace('name', 'alpha', new Document(['$id' => 'a', 'name' => 'replaced']), 'items'); + $this->assertTrue($result); + + /** @var array $items */ + $items = $doc->getAttribute('items'); + $this->assertSame('replaced', $items[0]->getAttribute('name')); + } + + public function testFindAndReplaceReturnsFalseForMissing(): void + { + $doc = new Document([ + '$id' => 'root', + 'items' => [ + new Document(['$id' => 'a', 'name' => 'alpha']), + ], + ]); + + $this->assertFalse($doc->findAndReplace('name', 'nonexistent', 'new', 'items')); + } + + public function testFindAndRemoveWithSubject(): void + { + $doc = new Document([ + '$id' => 'root', + 'items' => [ + new Document(['$id' => 'a', 'name' => 'alpha']), + new Document(['$id' => 'b', 'name' => 'beta']), + new Document(['$id' => 'c', 'name' => 'gamma']), + ], + ]); + + $result = $doc->findAndRemove('name', 'beta', 'items'); + $this->assertTrue($result); + + /** @var array $items */ + $items = $doc->getAttribute('items'); + $this->assertCount(2, $items); + } + + public function testFindAndRemoveReturnsFalseForMissing(): void + { + $doc = new Document([ + '$id' => 'root', + 'items' => [ + new Document(['$id' => 'a', 'name' => 'alpha']), + ], + ]); + + $this->assertFalse($doc->findAndRemove('name', 'nonexistent', 'items')); + } + + public function testGetArrayCopyWithAllowFilter(): void + { + $doc = new Document([ + '$id' => 'test', + 'name' => 'John', + 'email' => 'john@example.com', + 'age' => 30, + ]); + + $copy = $doc->getArrayCopy(['name', 'email']); + + $this->assertArrayHasKey('name', $copy); + $this->assertArrayHasKey('email', $copy); + $this->assertArrayNotHasKey('$id', $copy); + $this->assertArrayNotHasKey('age', $copy); + } + + public function testGetArrayCopyWithDisallowFilter(): void + { + $doc = new Document([ + '$id' => 'test', + 'name' => 'John', + 'secret' => 'hidden', + 'password' => '12345', + ]); + + $copy = $doc->getArrayCopy([], ['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 testFindWithArrayValues(): void + { + $doc = new Document([ + '$id' => 'root', + 'items' => [ + ['name' => 'alpha', 'score' => 1], + ['name' => 'beta', 'score' => 2], + ], + ]); + + $found = $doc->find('name', 'beta', 'items'); + $this->assertIsArray($found); + $this->assertSame('beta', $found['name']); + $this->assertSame(2, $found['score']); + } + + 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..ac34a94df1 --- /dev/null +++ b/tests/unit/DocumentFromStorageTest.php @@ -0,0 +1,305 @@ + '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->getRead()); + $this->assertSame(['users'], $document->getUpdate()); + } + + 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 = new Collection(id: 'users', attributes: [ + Attribute::string(key: 'prefs', size: 1024, filters: ['json']), + Attribute::string(key: 'settings', size: 1024, filters: ['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 QueryCache(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(new Collection( + id: 'users', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::string(key: 'prefs', size: 1024, filters: ['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->createDocumentInstance($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..02cf119f43 100644 --- a/tests/unit/DocumentTest.php +++ b/tests/unit/DocumentTest.php @@ -2,45 +2,37 @@ 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\Exception\Structure as StructureException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\PermissionType; +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 + 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' => [ + 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()), @@ -52,23 +44,23 @@ public function setUp(): void ], '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 + protected function tearDown(): void { } - public function testDocumentNulls(): void + public function test_document_nulls(): void { $data = [ 'cat' => null, @@ -86,58 +78,106 @@ 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()); } - public function testGetRead(): void + public function test_get_read(): void { $this->assertEquals(['user:123', 'team:123'], $this->document->getRead()); $this->assertEquals([], $this->empty->getRead()); } - public function testGetUpdate(): void + public function test_get_update(): void { $this->assertEquals(['any', 'user:updater'], $this->document->getUpdate()); $this->assertEquals([], $this->empty->getUpdate()); } - public function testGetDelete(): void + public function test_get_delete(): void { $this->assertEquals(['any', 'user:deleter'], $this->document->getDelete()); $this->assertEquals([], $this->empty->getDelete()); } - 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(Database::PERMISSION_DELETE)); - $this->assertEquals([], $this->empty->getPermissionsByType(Database::PERMISSION_DELETE)); + $this->assertEquals(['any', 'user:deleter'], $this->document->getPermissionsByType(PermissionType::Delete)); + $this->assertEquals([], $this->empty->getPermissionsByType(PermissionType::Delete)); } - public function testGetPermissions(): void + public function test_get_permissions(): void { $this->assertEquals([ Permission::read(Role::user(ID::custom('123'))), @@ -151,28 +191,28 @@ public function testGetPermissions(): void ], $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,17 +223,17 @@ 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']); @@ -217,13 +257,13 @@ 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 + public function test_find(): void { $this->assertEquals(null, $this->document->find('find', 'one')); @@ -234,16 +274,21 @@ public function testFind(): void $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')); + /** @var array $children */ + $children = $this->document->getAttribute('children'); + $this->assertEquals($children[0], $this->document->find('name', 'x', 'children')); + $this->assertEquals($children[2], $this->document->find('name', 'z', 'children')); $this->assertEquals(null, $this->document->find('name', 'v', 'children')); } - public function testFindAndReplace(): void + public function test_find_and_replace(): void { + $id = $this->id; + $collection = $this->collection; + $document = new Document([ - '$id' => ID::custom($this->id), - '$collection' => ID::custom($this->collection), + '$id' => ID::custom($id), + '$collection' => ID::custom($collection), '$permissions' => [ Permission::read(Role::user(ID::custom('123'))), Permission::read(Role::team(ID::custom('123'))), @@ -253,18 +298,20 @@ public function testFindAndReplace(): void ], 'title' => 'This is a test.', 'list' => [ - 'one' + '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']); + /** @var array> $children */ + $children = $document->getAttribute('children'); + $this->assertEquals('1', $children[0]['name']); + $this->assertEquals(true, $children[0]['test']); // Array with wrong value $this->assertEquals(false, $document->findAndReplace('name', 'xy', new Document(['name' => '1', 'test' => true]), 'children')); @@ -283,11 +330,14 @@ public function testFindAndReplace(): void $this->assertEquals(false, $document->findAndReplace('titlex', 'This is a test.', 'new')); } - public function testFindAndRemove(): void + public function test_find_and_remove(): void { + $id = $this->id; + $collection = $this->collection; + $document = new Document([ - '$id' => ID::custom($this->id), - '$collection' => ID::custom($this->collection), + '$id' => ID::custom($id), + '$collection' => ID::custom($collection), '$permissions' => [ Permission::read(Role::user(ID::custom('123'))), Permission::read(Role::team(ID::custom('123'))), @@ -297,17 +347,19 @@ public function testFindAndRemove(): void ], 'title' => 'This is a test.', 'list' => [ - 'one' + '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')); + /** @var array> $childrenAfterRemove */ + $childrenAfterRemove = $document->getAttribute('children'); + $this->assertEquals('y', $childrenAfterRemove[1]['name']); + $this->assertCount(2, $childrenAfterRemove); // Array with wrong value $this->assertEquals(false, $document->findAndRemove('name', 'xy', 'children')); @@ -326,20 +378,20 @@ public function testFindAndRemove(): void $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,31 +410,47 @@ 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), @@ -399,24 +467,25 @@ public function testGetArrayCopy(): void ], '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,12 +497,17 @@ 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 @@ -453,8 +527,12 @@ public function testArrayCopyPreservesKeysAndFiltersNestedDocuments(): void ], $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 +544,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 +575,368 @@ 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(); - $before = memory_get_usage(); + $mark = memory_get_usage(); + $plain = $values; + $plain[0] = 0; + $plainCost = memory_get_peak_usage() - $mark; + unset($plain); + + memory_reset_peak_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->getRead()); + + $document[Document::PERMISSIONS] = [Permission::read(Role::any())]; + + $this->assertSame(['any'], $document->getRead()); + $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->getRead()); + + unset($document[Document::PERMISSIONS]); + + $this->assertSame([], $document->getRead()); + $this->assertSame([], $document->getPermissions()); + } + + public function testExchangeArrayRefreshesRoles(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::any())]]); + $this->assertSame(['any'], $document->getRead()); + + $document->exchangeArray([Document::PERMISSIONS => [Permission::read(Role::user('new'))]]); + + $this->assertSame(['user:new'], $document->getRead()); + } + + public function testReferenceWriteToPermissionsRefreshesRoles(): void + { + $document = new Document([Document::PERMISSIONS => [Permission::read(Role::user('old'))]]); + $this->assertSame(['user:old'], $document->getRead()); + + $permissions = &$document[Document::PERMISSIONS]; + $this->assertIsArray($permissions); + $permissions[] = Permission::update(Role::any()); + unset($permissions); + + $this->assertSame(['user:old'], $document->getRead()); + $this->assertSame(['any'], $document->getUpdate()); + } + + 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->getUpdate()); + } + + 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->getRead()); + } + + 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->getRead()); + } + + public function testFindAndReplaceInPlainArrayChildren(): void + { + $document = new Document([ + 'children' => [ + ['name' => 'x'], + ['name' => 'y'], + ], + ]); + + $this->assertTrue($document->findAndReplace('name', 'x', ['name' => '1'], 'children')); + $this->assertSame([['name' => '1'], ['name' => 'y']], $document->getAttribute('children')); + + $this->assertFalse($document->findAndReplace('name', 'missing', ['name' => '2'], 'children')); + $this->assertSame([['name' => '1'], ['name' => 'y']], $document->getAttribute('children')); + } + + /** + * @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')); + } + + public function testFindAndRemoveRemovesAPlainArrayItemFromASubject(): void + { + $document = new Document([ + 'items' => [ + ['name' => 'x'], + ['name' => 'y'], + ], + ]); + + $this->assertTrue($document->findAndRemove('name', 'x', 'items')); + $this->assertSame([1 => ['name' => 'y']], $document->getAttribute('items')); + + $this->assertFalse($document->findAndRemove('name', 'missing', 'items')); + $this->assertSame([1 => ['name' => 'y']], $document->getAttribute('items')); + } + + public function testFindAndReplaceWithoutSubjectPrefersTheTopLevelKey(): void + { + $document = new Document([ + 'meta' => ['title' => 'x'], + 'title' => 'x', + ]); + + $this->assertTrue($document->findAndReplace('title', 'x', 'y')); + $this->assertSame('y', $document->getAttribute('title')); + $this->assertSame(['title' => 'x'], $document->getAttribute('meta')); + } + + public function testFindAndReplaceWithoutSubjectIgnoresNestedMatches(): void + { + $document = new Document(['meta' => ['title' => 'x']]); + + $this->assertFalse($document->findAndReplace('title', 'x', 'y')); + $this->assertSame(['title' => 'x'], $document->getAttribute('meta')); + } + + public function testFindAndReplaceWithADocumentSubjectReplacesInsideIt(): void + { + $document = new Document(['child' => new Document(['$id' => 'c', 'name' => 'x'])]); + + $this->assertTrue($document->findAndReplace('name', 'x', 'y', 'child')); + $this->assertSame('y', $document->getDocument('child')->getAttribute('name')); + } + + public function testFindAndRemoveWithoutSubjectPrefersTheTopLevelKey(): void + { + $document = new Document([ + 'meta' => ['title' => 'x'], + 'title' => 'x', + ]); + + $this->assertTrue($document->findAndRemove('title', 'x')); + $this->assertFalse($document->isSet('title')); + $this->assertSame(['title' => 'x'], $document->getAttribute('meta')); + } + + public function testFindAndRemoveWithADocumentSubjectRemovesInsideIt(): void + { + $document = new Document(['child' => new Document(['$id' => 'c', 'name' => 'x'])]); + + $this->assertTrue($document->findAndRemove('name', 'x', 'child')); + $this->assertFalse($document->getDocument('child')->isSet('name')); + $this->assertSame('c', $document->getDocument('child')->getId()); + } + + public function testFindAndRemoveWithoutSubjectIgnoresNestedMatches(): void + { + $document = new Document(['meta' => ['title' => 'x']]); + + $this->assertFalse($document->findAndRemove('title', 'x')); + $this->assertSame(['title' => 'x'], $document->getAttribute('meta')); + } } diff --git a/tests/unit/Documents/AggregateOrderTest.php b/tests/unit/Documents/AggregateOrderTest.php new file mode 100644 index 0000000000..cc331df972 --- /dev/null +++ b/tests/unit/Documents/AggregateOrderTest.php @@ -0,0 +1,224 @@ +database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * @return iterable, string}> + */ + public static function ungroupedOrders(): iterable + { + $note = Query::join('notes', '$id', 'customerId', '=', 'note'); + + 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->find('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', '$id', 'customerId', '=', 'note'); + + 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->find('customers', $queries); + + $this->assertSame($expected, \array_map(static fn (Document $group): mixed => $group->getAttribute($attribute), $groups)); + } + + public function testRandomOrderNextToAnAggregateIsAccepted(): void + { + $groups = $this->database->find('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 array $attributes + */ + private function createCollection(Database $database, string $id, array $attributes): void + { + $database->createCollection(new Collection( + 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..7bb5b79009 --- /dev/null +++ b/tests/unit/Documents/AggregateSelectTest.php @@ -0,0 +1,441 @@ +database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + } + + /** + * @return iterable, string}> + */ + public static function ungroupedSelects(): iterable + { + $note = Query::join('notes', '$id', 'customerId', '=', 'note'); + + 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->find('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', '$id', 'customerId', '=', 'note'), Query::count('*', 'rows'), Query::select(['*'])], [['rows' => 3]]]; + yield 'a count over a left join' => [[Query::leftJoin('notes', '$id', 'customerId', '=', 'note'), 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->rows($this->database->find('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->rows($this->database->find('customers', [Query::count('*', 'rows'), Query::select($selects)]))); + $this->assertSame( + [['rows' => 2, 'status' => 'a'], ['rows' => 1, 'status' => 'b']], + $this->rows($this->database->find('customers', [Query::count('*', 'rows'), Query::groupBy(['status']), Query::select($selects), Query::orderAsc('status')])), + ); + $this->assertSame( + [['rows' => 2, 'name' => 'first'], ['rows' => 1, 'name' => 'second']], + $this->rows($this->database->find('customers', [Query::join('notes', '$id', 'customerId', '=', 'note'), 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', '$id', 'customerId', '=', 'note'); + + $this->assertSame([['rows' => 5]], $this->rows($this->database->find('customers', [$note, Query::count('*', 'rows'), Query::select(['*'])]))); + $this->assertSame([['rows' => 5]], $this->rows($this->database->find('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->rows($this->database->find('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->find('customers', [$note, Query::count('*', 'rows'), Query::select(['name'])])); + } + + /** + * @return iterable, list>}> + */ + public static function groupedSelects(): iterable + { + $note = Query::join('notes', '$id', 'customerId', '=', 'note'); + + 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->rows($this->database->find('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->rows($this->database->find('customers', [ + Query::fullOuterJoin('notes', '$id', 'customerId', '=', 'note'), + 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->find('customers', [Query::count('*', 'rows'), Query::select(['$tenant'])])); + $this->assertSame( + [['rows' => 3, Storage::TENANT => 1]], + $this->rows($this->database->find('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->rows($this->database->skipValidation(fn (): array => $this->database->find('customers', [Query::count('*', 'rows'), Query::select(['name'])]))), + ); + $this->assertSame( + [['rows' => 3]], + $this->rows($this->database->skipValidation(fn (): array => $this->database->find('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', '$id', 'customerId', '=', 'note'), 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, [], ColumnType::String->value); + $this->assertFalse($documents->isValid($queries)); + $this->assertSame('Invalid query method: count', $documents->getDescription()); + + $document = new DocumentValidator($attributes); + $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]), + ]); + + $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); + } + + /** + * @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(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)); + $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(Relationship::oneToOne(collection: 'customers', relatedCollection: 'accounts', key: 'account', twoWayKey: 'customer')); + $database->createRelationship(Relationship::manyToOne(collection: 'accounts', 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 array $attributes + */ + private function createCollection(Database $database, string $id, array $attributes): void + { + $database->createCollection(new Collection( + 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/AggregationErrorTest.php b/tests/unit/Documents/AggregationErrorTest.php new file mode 100644 index 0000000000..2347a63c6f --- /dev/null +++ b/tests/unit/Documents/AggregationErrorTest.php @@ -0,0 +1,190 @@ + $capabilities + */ + private function buildDatabase(array $capabilities): Database + { + $adapter = self::createStub(Adapter::class); + $adapter->method('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $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 testFindWithAggregationOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Aggregation queries are not supported'); + $db->skipValidation(fn () => $db->find('testCol', [Query::count('*', 'cnt')])); + } + + public function testFindWithAggregationSkipsRelationshipPopulation(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + Capability::Aggregations, + ]); + + $results = $db->skipValidation(fn () => $db->find('testCol', [Query::count('*', 'cnt')])); + $this->assertSame([], $results); + } + + public function testFindWithCursorAndAggregationThrows(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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->find('testCol', [ + Query::count('*', 'cnt'), + Query::cursorAfter($cursorDoc), + ])); + } + + public function testFindWithJoinOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $db->skipValidation(fn () => $db->find('testCol', [Query::join('other', 'fk', '$id')])); + } + + public function testCountWithJoinOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $db->skipValidation(fn () => $db->count('testCol', [Query::join('other', 'fk', '$id')])); + } + + public function testSumWithJoinOnUnsupportedAdapterThrows(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + ]); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Join queries are not supported'); + $db->skipValidation(fn () => $db->sum('testCol', 'amount', [Query::join('other', 'fk', '$id')])); + } + + public function testSumValidatesQueriesWhenEnabled(): void + { + $db = $this->buildDatabase([ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + ]); + $db->enableValidation(); + + $this->expectException(QueryException::class); + $db->sum('testCol', 'amount', [Query::equal('nonexistent', ['val'])]); + } +} diff --git a/tests/unit/Documents/BulkWriteGuardsTest.php b/tests/unit/Documents/BulkWriteGuardsTest.php new file mode 100644 index 0000000000..f92e85561c --- /dev/null +++ b/tests/unit/Documents/BulkWriteGuardsTest.php @@ -0,0 +1,286 @@ +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( + 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->upsertDocumentsWithIncrease(self::COLLECTION, '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 testAnUpsertCallbackFailureGoesToTheErrorHandlerAndTheUpsertContinues(): void + { + $database = $this->database(new SQLite(new PDO('sqlite::memory:'))); + $errors = []; + + $count = $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'); + } + }, + onError: static function (Throwable $error) use (&$errors): void { + $errors[] = $error->getMessage(); + }, + ); + + $this->assertSame(2, $count); + $this->assertSame(['the consumer refused a'], $errors); + $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 testIteratingWithoutALimitPagesTwentyFiveDocumentsAtATime(): 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); + } + + 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->iterate(self::COLLECTION, [Query::orderAsc('rank')]) 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(new Collection( + 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..886f169ecc --- /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', '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', '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', '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', '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', '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', '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(new Collection( + id: 'customers', + attributes: [Attribute::string(key: 'name')], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(new Collection( + 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..e515a89276 --- /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(new Collection( + 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/CreateDocumentLogicTest.php b/tests/unit/Documents/CreateDocumentLogicTest.php new file mode 100644 index 0000000000..3b935c6bbb --- /dev/null +++ b/tests/unit/Documents/CreateDocumentLogicTest.php @@ -0,0 +1,312 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 new Document(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->enableValidation(); + + $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->enableValidation(); + + $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 testCreateDocumentsCallsOnErrorCallbackOnFailure(): void + { + $this->setupCollection('testCol'); + + $docs = [ + new Document(['$permissions' => [Permission::read(Role::any())], '$collection' => 'testCol']), + ]; + + $errorCaught = false; + $this->database->createDocuments('testCol', $docs, 100, function () { + throw new \RuntimeException('onNext error'); + }, function (\Throwable $e) use (&$errorCaught) { + $errorCaught = true; + $this->assertSame('onNext error', $e->getMessage()); + }); + + $this->assertTrue($errorCaught); + } + + 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/DistinctCapabilityTest.php b/tests/unit/Documents/DistinctCapabilityTest.php new file mode 100644 index 0000000000..11efa9c16b --- /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(new Collection( + 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..b551d7e318 --- /dev/null +++ b/tests/unit/Documents/DocumentCacheActivationTest.php @@ -0,0 +1,92 @@ + + */ + 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(); + [$collectionKey] = $first->getCacheKeys(self::COLLECTION); + + $second->withTransaction(function () use ($first, $second, $firstAdapter, $cache, $collectionKey, $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'])); + }); + + $epoch = $cache->load($collectionKey.'#epoch', Database::TTL); + $this->assertIsString($epoch); + $this->assertStringStartsWith('blocked:', $epoch, 'the second writer is still in flight'); + + $firstAdapter->reset(); + $first->getDocument(self::COLLECTION, 'hook'); + $first->getDocument(self::COLLECTION, 'hook'); + $this->assertSame(2, $firstAdapter->documentReads, 'no read is cached while the barrier stands'); + }); + + $epoch = $cache->load($collectionKey.'#epoch', Database::TTL); + $this->assertIsString($epoch); + $this->assertStringStartsWith('active:', $epoch, 'the last writer to finish publishes the epoch'); + + $firstAdapter->reset(); + $first->getDocument(self::COLLECTION, 'hook'); + $first->getDocument(self::COLLECTION, 'hook'); + $this->assertSame(1, $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(new Collection( + 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..7fdd271c39 --- /dev/null +++ b/tests/unit/Documents/DocumentCacheEpochTest.php @@ -0,0 +1,984 @@ +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 testPurgeCachedCollectionDoesNotThrowWhenEpochPurgeReturnsFalse(): void + { + [$database, $adapter] = $this->createDatabase(); + $this->assertTrue($database->purgeCachedCollection('webhooks')); + + [$collectionKey] = $database->getCacheKeys('webhooks'); + $epochKey = $collectionKey.'#epoch'; + $before = $database->getCache()->load($epochKey, Database::TTL); + $this->assertIsString($before); + $this->assertNotSame('', $before); + + $adapter->failPurges(); + + $this->assertTrue($database->purgeCachedCollection('webhooks')); + $this->assertTrue($database->purgeCachedDocument('webhooks', 'hook')); + + $after = $database->getCache()->load($epochKey, Database::TTL); + $this->assertIsString($after); + $this->assertNotSame($before, $after); + } + + public function testCreateDocumentsDoesNotFailWhenEpochPurgeReturnsFalse(): void + { + [$database, $adapter] = $this->createDatabase(); + $this->assertTrue($database->purgeCachedCollection('webhooks')); + $adapter->failPurges(); + + $created = $database->createDocuments('webhooks', [new Document([ + '$id' => 'hook', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ])]); + + $this->assertSame(1, $created); + $this->assertSame('hook', $database->getDocument('webhooks', 'hook')->getId()); + } + + 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()); + } + + [$collectionKey] = $database->getCacheKeys('webhooks', 'hook'); + $epoch = $database->getCache()->load($collectionKey.'#epoch', Database::TTL); + $this->assertIsString($epoch); + $this->assertStringStartsWith('blocked:', $epoch); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + public function testActivationFailureDoesNotStrandOtherCollectionEpochs(): void + { + $cache = new FailDocumentEpochMemory(); + $database = $this->createDatabaseWithCache($cache); + $database->createCollection(new Collection(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()); + } + + [$webhooksKey] = $database->getCacheKeys('webhooks', 'hook'); + [$logsKey] = $database->getCacheKeys('logs', 'log'); + $webhooksEpoch = $database->getCache()->load($webhooksKey.'#epoch', Database::TTL); + $logsEpoch = $database->getCache()->load($logsKey.'#epoch', Database::TTL); + $this->assertIsString($webhooksEpoch); + $this->assertStringStartsWith('blocked:', $webhooksEpoch); + $this->assertIsString($logsEpoch); + $this->assertStringNotContainsString('blocked:', $logsEpoch); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame('updated', $database->getDocument('logs', 'log')->getAttribute('name')); + } + + 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()); + $this->assertTrue($database->purgeCachedCollection('webhooks')); + $cache->flushAfterReading('collection:webhooks#finished'); + + $this->assertTrue($database->deleteCollection('webhooks')); + $this->assertTrue($database->getCollection('webhooks')->isEmpty()); + } + + 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 QueryCache(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()); + } + + [$collectionKey] = $database->getCacheKeys('webhooks', 'hook'); + $this->assertDocumentCacheEpochBlocked($database, $collectionKey); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + 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()); + } + + [$collectionKey] = $database->getCacheKeys('webhooks', 'hook'); + $this->assertDocumentCacheEpochBlocked($database, $collectionKey); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + + 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 assertDocumentCacheEpochBlocked(Database $database, string $collectionKey): void + { + $epoch = $database->getCache()->load($collectionKey.'#epoch', Database::TTL); + $this->assertIsString($epoch); + $this->assertStringStartsWith('blocked:', $epoch); + } + + /** + * @return array{Database, FailPurgeMemory} + */ + private function createDatabase(): array + { + $adapter = new FailPurgeMemory(); + $database = $this->createDatabaseWithCache($adapter); + + return [$database, $adapter]; + } + + private function createDatabaseWithCache(CacheAdapter $cache, ?string $namespace = null): Database + { + $database = new Database(new DatabaseMemory(), new Cache($cache)); + $database + ->setDatabase('utopiaTests') + ->setNamespace($namespace ?? 'epoch_'.\uniqid()); + $database->create(); + $database->createCollection(new Collection(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 = SQLite::getPDOAttributes(); + $attributes[\PDO::ATTR_PERSISTENT] = false; + $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(new Collection(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()); + } + + $adapter->reset(); + for ($read = 0; $read < 3; $read++) { + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + } + $this->assertSame(3, $adapter->documentReads, 'A collection whose write has not activated stays uncached while the write is younger than the writer timeout'); + + $database->setCacheWriterTimeout(0); + $this->assertSame('updated', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $adapter->reset(); + for ($read = 0; $read < 3; $read++) { + $this->assertSame('updated', $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'); + $adapter->reset(); + $this->assertSame('second', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame('second', $database->getDocument('webhooks', 'hook')->getAttribute('name')); + $this->assertSame(2, $adapter->documentReads, 'A write younger than the writer timeout counts as in flight, so the next write leaves the collection blocked'); + + $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'); + $this->assertTrue($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'); + $this->assertTrue($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); + $this->assertIsArray($definition); + + $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'); + } + + 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(new Collection(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 = SQLite::getPDOAttributes(); + $attributes[\PDO::ATTR_PERSISTENT] = false; + $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(new Collection(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 FailPurgeMemory extends MemoryCache +{ + private bool $failing = false; + + public function failPurges(): void + { + $this->failing = true; + } + + #[\Override] + public function purge(string $key, string $hash = ''): bool + { + if ($this->failing && \str_ends_with($key, '#epoch')) { + return false; + } + + return parent::purge($key, $hash); + } +} + +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; + } + + 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; + } + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $this->cache->save($key, $data, $hash); + } + + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + public function list(string $key): array + { + return $this->cache->list($key); + } + + 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); + } + + public function flush(): bool + { + return $this->cache->flush(); + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return $this->cache->getSize(); + } + + public function getName(?string $key = null): string + { + return 'abandoning'; + } + + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + 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..9f8cdd3c80 --- /dev/null +++ b/tests/unit/Documents/DocumentCacheInvalidationTest.php @@ -0,0 +1,452 @@ +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 testAnUpdateInvalidatesTheCacheOnceLikeADelete(): void + { + $cache = new CountingCache(new RedisLeasableCache()); + $database = $this->createDatabase(new CountingMemory(), $cache); + $database->createDocument('webhooks', $this->hook('updated')); + $database->createDocument('webhooks', $this->hook('deleted')); + $database->getDocument('webhooks', 'updated'); + $database->getDocument('webhooks', 'deleted'); + + $cache->resetOperations(); + $database->updateDocument('webhooks', 'updated', new Document(['name' => 'renamed'])); + $update = $cache->getOperations(); + + $cache->resetOperations(); + $database->deleteDocument('webhooks', 'deleted'); + $delete = $cache->getOperations(); + + $this->assertSame($delete, $update, 'updateDocument() must invalidate its document once, as deleteDocument() does'); + } + + 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', + ])), + 3, + 3, + ], + 'updateDocument' => [ + static fn (Database $database): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])), + 4, + 6, + ], + 'increaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->increaseDocumentAttribute('webhooks', 'hook', 'count'), + 4, + 4, + ], + 'decreaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->decreaseDocumentAttribute('webhooks', 'hook', 'count'), + 4, + 4, + ], + 'deleteDocument' => [ + static fn (Database $database): bool => $database->deleteDocument('webhooks', 'hook'), + 4, + 6, + ], + ]; + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('singleDocumentWrites')] + public function testSingleDocumentWritesDoNotBlockTheCollection(Closure $write, int $expected, int $baseline): void + { + $cache = new CountingCache(new RedisLeasableCache()); + $database = $this->createDatabase(new CountingMemory(), $cache); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getDocument('webhooks', 'hook'); + + $cache->resetOperations(); + $write($database); + + $this->assertSame( + $expected, + $cache->getOperations(), + "Cache round trips of the write on a warm cache: one collection lookup, one more for the locking read inside the transaction of writes that read the document first, and one purge of the document inside the transaction and one after it (7.3.12: {$baseline})", + ); + } + + public function testACollectionDefinitionWriteKeepsTheOtherDefinitionsCached(): void + { + $adapter = new CountingMemory(); + $database = $this->createDatabase($adapter, new RedisLeasableCache()); + $database->createCollection(new Collection(id: 'logs', permissions: [Permission::read(Role::any())])); + $database->getCollection('webhooks'); + $database->getCollection('logs'); + + $database->updateCollection('logs', [Permission::read(Role::any()), Permission::create(Role::any())], true); + $adapter->reset(); + + $this->assertFalse($database->getCollection('webhooks')->isEmpty()); + $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'); + $this->assertSame(1, $adapter->metadataReads); + } + + public function testAFailedPurgeInsideTheTransactionRollsTheWriteBack(): void + { + /** @var bool $refusing */ + $refusing = false; + $database = $this->createDatabase(new CountingMemory(), $this->purgeRefusingCache( + static function (string $key) use (&$refusing): bool { + return $refusing && \str_ends_with($key, ':hook'); + } + )); + $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 = SQLite::getPDOAttributes(); + $attributes[\PDO::ATTR_PERSISTENT] = false; + $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 (string $key) use (&$refusing): bool { + return $refusing && \str_ends_with($key, ':hook'); + }); + $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()); + + $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()); + $this->assertTrue($database->purgeCachedDocument('webhooks', $id)); + } + + $this->assertSame($keys + $churned, \count($cache->keys()), 'A purge keeps one generation-only key per document id ever written'); + for ($index = 0; $index < $churned; $index++) { + $documentKey = \strtolower($database->getCacheBaseKeys('webhooks', 'churn'.$index)[1]); + $this->assertSame([], $cache->list($documentKey), '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(string): bool $refuses Whether a purge of the key throws + */ + private function purgeRefusingCache(Closure $refuses): CacheAdapter&Leasable + { + return new class ($refuses) implements CacheAdapter, Leasable { + private RedisLeasableCache $cache; + + /** + * @param Closure(string): bool $refuses + */ + public function __construct(private readonly Closure $refuses) + { + $this->cache = new RedisLeasableCache(); + } + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return $this->cache->save($key, $data, $hash); + } + + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + public function list(string $key): array + { + return $this->cache->list($key); + } + + public function purge(string $key, string $hash = ''): bool + { + if (($this->refuses)($key)) { + throw new RuntimeException(DocumentCacheInvalidationTest::PURGE_FAILURE); + } + + return $this->cache->purge($key, $hash); + } + + public function flush(): bool + { + return $this->cache->flush(); + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return $this->cache->getSize(); + } + + public function getName(?string $key = null): string + { + return 'purge-failing'; + } + + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + 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(new Collection(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..6ef5a8aceb --- /dev/null +++ b/tests/unit/Documents/DocumentCacheRoundTripTest.php @@ -0,0 +1,338 @@ +createDatabase(); + $database->getCollection('webhooks'); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertFalse($database->getCollection('webhooks')->isEmpty()); + + $this->assertSame(1, $cache->getOperations(), 'getCollection() on a warm cache (7.3.12: 1 round trip)'); + $this->assertSame(0, $adapter->metadataReads); + } + + public function testAGetDocumentHitCostsTwoRoundTrips(): 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->assertSame(2, $cache->getOperations(), 'getDocument() on a warm cache: the collection lookup and the document (7.3.12: 2 round trips)'); + $this->assertSame(0, $adapter->documentReads + $adapter->metadataReads); + } + + public function testACachedMissCostsTwoRoundTrips(): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->getDocument('webhooks', 'missing'); + + $adapter->reset(); + $cache->resetOperations(); + $this->assertTrue($database->getDocument('webhooks', 'missing')->isEmpty()); + + $this->assertSame(2, $cache->getOperations(), 'getDocument() of a missing document on a warm cache (7.3.12: 2 round trips)'); + $this->assertSame(0, $adapter->documentReads); + } + + public function testAnUncachedGetDocumentCostsFourRoundTrips(): 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->assertSame(4, $cache->getOperations(), 'getDocument() of an uncached document: the collection lookup, the document, its lease and the fill (7.3.12: 5 round trips)'); + $this->assertSame(1, $adapter->documentReads); + } + + /** + * @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 testCollectionReadsCostOneRoundTripBeforeTheirStatement(Closure $read): void + { + [$database, $adapter, $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $read($database); + + $adapter->reset(); + $cache->resetOperations(); + $read($database); + + $this->assertSame(1, $cache->getOperations(), 'find(), count() and sum() without a query cache look up their collection only (7.3.12: 1 round trip)'); + $this->assertSame(0, $adapter->metadataReads); + } + + public function testASiblingReadAfterAWriteCostsTwoRoundTripsAndNoRead(): 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->assertSame(2, $cache->getOperations(), 'A read of a sibling after a write (7.3.12: 2 round trips)'); + $this->assertSame(0, $adapter->documentReads + $adapter->metadataReads, 'A read of a sibling after a write (7.3.12: 0 statements)'); + } + + /** + * @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, + 3, + ], + 'updateDocument' => [ + static fn (Database $database): Document => $database->updateDocument('webhooks', 'hook', new Document(['name' => 'renamed'])), + 4, + 6, + ], + 'increaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->increaseDocumentAttribute('webhooks', 'hook', 'count'), + 4, + 4, + ], + 'decreaseDocumentAttribute' => [ + static fn (Database $database): Document => $database->decreaseDocumentAttribute('webhooks', 'hook', 'count'), + 4, + 4, + ], + 'deleteDocument' => [ + static fn (Database $database): bool => $database->deleteDocument('webhooks', 'hook'), + 4, + 6, + ], + ]; + } + + /** + * @param Closure(Database): mixed $write + */ + #[DataProvider('singleDocumentWrites')] + public function testSingleDocumentWritesStayWithinSevenThreeRoundTrips(Closure $write, int $expected, int $baseline): void + { + [$database, , $cache] = $this->createDatabase(); + $database->createDocument('webhooks', $this->hook('hook')); + $database->getDocument('webhooks', 'hook'); + + $cache->resetOperations(); + $write($database); + + $this->assertSame($expected, $cache->getOperations(), "Cache round trips of the write on a warm cache: one collection lookup, one more for the locking read of writes that read the document first, and one purge inside the transaction and one after it (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->assertSame(6, $cache->getOperations(), 'withTransaction(update + get of a sibling) on a warm cache (7.3.12: 11 round trips)'); + $this->assertSame(0, $adapter->metadataReads, 'withTransaction(update + get of a sibling) reads no collection definition (7.3.12: 0)'); + $this->assertSame(1, $adapter->documentReads, 'withTransaction(update + get of a sibling) reads only the written document, with its lock (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->assertSame(80, $cache->getOperations(), 'Ten updateDocument() + getDocument() pairs (cache.keys_after_1000_writes; 7.3.12: 110 round trips)'); + $this->assertSame(20, $adapter->documentReads, 'Each pair reads the document once with its lock and once to refill the cache (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(new Collection(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 testPurgingTheMetadataCollectionRereadsEveryDefinition(): void + { + [$database, $adapter] = $this->createDatabase(); + $database->createCollection(new Collection(id: 'logs', permissions: [Permission::read(Role::any())])); + $database->getCollection('webhooks'); + $database->getCollection('logs'); + + $database->purgeCachedCollection(Database::METADATA); + $adapter->reset(); + $this->assertFalse($database->getCollection('webhooks')->isEmpty()); + $this->assertFalse($database->getCollection('logs')->isEmpty()); + + $this->assertSame(2, $adapter->metadataReads, 'purgeCachedCollection(\'_metadata\') must retire every cached definition'); + } + + public function testPurgingTheMetadataCollectionRetiresACachedMissingCollection(): void + { + [$database, $adapter] = $this->createDatabase(); + $this->assertTrue($database->getCollection('logs')->isEmpty()); + + $uncached = new Database($adapter, new Cache(new None())); + $uncached + ->setDatabase('utopiaTests') + ->setNamespace($database->getNamespace()); + $uncached->createCollection(new Collection(id: 'logs', permissions: [Permission::read(Role::any())])); + $this->assertTrue($database->getCollection('logs')->isEmpty(), 'A definition written without this cache leaves the cached miss in place'); + + $database->purgeCachedCollection(Database::METADATA); + + $this->assertFalse($database->getCollection('logs')->isEmpty(), '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(new Collection(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..cc0f39dc71 --- /dev/null +++ b/tests/unit/Documents/DocumentMinorsTest.php @@ -0,0 +1,311 @@ +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 testATimeToLiveIndexWithoutAPeriodNeverExpires(): void + { + $database = $this->database(new class () extends Memory { + public function capabilities(): array + { + return [...parent::capabilities(), Capability::TTLIndexes]; + } + }); + $database->skipValidation(fn (): bool => $database->createIndex(self::COLLECTION, Index::ttl(key: 'expiry', attributes: ['recordedAt'], ttl: 0))); + $database->createDocument(self::COLLECTION, new Document([Document::ID => 'entry', 'recordedAt' => DateTime::format(new \DateTime('2000-01-01'))])); + + $this->assertFalse($database->getDocument(self::COLLECTION, 'entry')->isEmpty()); + } + + public function testAnUnchangedListOfRelatedIdsIsNotAChange(): void + { + $authorization = new Authorization(); + $authorization->addRole(Role::any()->toString()); + $database = new class (new Memory(), new Cache(new None())) extends Database { + 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($database)); + $readOnly = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(new Collection(id: 'parents', permissions: $readOnly)); + $database->createCollection(new Collection(id: 'children', permissions: [...$readOnly, Permission::update(Role::any())])); + $database->createRelationship(Relationship::oneToMany(collection: 'parents', 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 testPurgingANullIdTouchesNothing(): void + { + $cache = new CountingCache(new LeasableHashCache()); + $database = $this->database(new Memory(), new Cache($cache)); + $before = $cache->getOperations(); + + $this->assertTrue($database->purgeCachedDocument(self::COLLECTION, null)); + $this->assertSame($before, $cache->getOperations()); + } + + public function testAWriteWhoseCacheOwnerCannotBeRegisteredIsRolledBack(): void + { + $cache = new class () extends MemoryCache { + public bool $refuseOwners = false; + + /** + * @param array|string $data + * @return bool|string|array + */ + 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($database)); + $permissions = [Permission::create(Role::any()), Permission::read(Role::any())]; + $database->createCollection(new Collection(id: 'books', attributes: [Attribute::integer(key: 'pages')], permissions: $permissions)); + $database->createCollection(new Collection(id: 'authors', attributes: [Attribute::string(key: 'name', size: 32)], permissions: $permissions)); + $database->createRelationship(Relationship::manyToOne(collection: 'books', 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', 'count', 'count', alias: 'gone'); + + 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(); + } + + 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(new Collection( + 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..e53b4d8ccd --- /dev/null +++ b/tests/unit/Documents/DocumentWriteMinorsTest.php @@ -0,0 +1,268 @@ +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; + + public function commitTransaction(): bool + { + if ($this->commitFailures > 0) { + $this->commitFailures--; + + throw new RuntimeException('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()), [ + self::WRAPPED => [ + 'encode' => static fn (mixed $value): ?string => $value === null ? null : \json_encode(['value' => $value], JSON_THROW_ON_ERROR), + 'decode' => 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(new Collection( + 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: ['json']), + Attribute::datetime('seen', required: false, filters: ['datetime']), + ], + 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..f15368b1c3 --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorCacheTest.php @@ -0,0 +1,63 @@ +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..bc5cd4f82b --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorDatabase.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/DocumentsValidatorGrammarTest.php b/tests/unit/Documents/DocumentsValidatorGrammarTest.php new file mode 100644 index 0000000000..29b41e951d --- /dev/null +++ b/tests/unit/Documents/DocumentsValidatorGrammarTest.php @@ -0,0 +1,83 @@ +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', '$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', '$id', 'customerId')]), $validator->getDescription()); + $this->assertTrue($validator->isValid([Query::sum('amount', 'total')]), $validator->getDescription()); + } + + public function testCapabilitiesArePartOfTheCacheKey(): void + { + $adapter = new class () extends Memory { + /** + * @var array + */ + public array $enabled = []; + + public function supports(Capability $feature): bool + { + return isset($this->enabled[$feature->name]) || parent::supports($feature); + } + }; + $database = new DocumentsValidatorDatabase($adapter, new Cache(new None())); + $queries = [Query::join('customers', '$id', 'customerId'), Query::sum('amount', 'total')]; + + $this->assertFalse($database->documentsValidator($this->orders)->isValid($queries)); + + $adapter->enabled[Capability::Joins->name] = true; + $adapter->enabled[Capability::Aggregations->name] = 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..d302e01296 --- /dev/null +++ b/tests/unit/Documents/FindLogicTest.php @@ -0,0 +1,1217 @@ +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('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 new Document(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->enableValidation(); + $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 testFindWithAggregationOnUnsupportedAdapterThrows(): void + { + $this->setupCollectionLookup('testCol'); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Aggregation queries are not supported'); + $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', 'fk', '$id'), + ])); + } + + public function testFindAggregationWithCursorThrows(): void + { + $db = $this->buildDbWithCapabilities(self::createStub(Adapter::class), [ + Capability::Index, Capability::IndexArray, Capability::UniqueIndex, + 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->find('testCol', [ + Query::count('*', 'cnt'), + Query::cursorAfter($cursorDoc), + ])); + } + + public function testFindWithGroupBy(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::Index, Capability::IndexArray, Capability::UniqueIndex, + 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->find('testCol', [ + Query::groupBy(['status']), + Query::count('*', 'cnt'), + ])); + $this->assertCount(1, $results); + } + + public function testFindWithDistinct(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::Index, Capability::IndexArray, Capability::UniqueIndex, + 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::Index, Capability::IndexArray, Capability::UniqueIndex, + 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::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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', 'fk', '$id'), + ])); + + $this->assertTrue($authOnFind); + } + + public function testCountKeepsAuthorizationEnabledOnJoins(): void + { + $authOnCount = null; + $captured = null; + $db = null; + $db = $this->buildDbWithCapabilities(self::createStub(Adapter::class), [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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', '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::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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', '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::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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', '$id', 'mainId', '=', 'rev'), + ])); + + $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::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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', '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 testAggregateDelegatesToFind(): void + { + $db = $this->buildDbWithCapabilities($this->createMock(Adapter::class), [ + Capability::Index, Capability::IndexArray, Capability::UniqueIndex, + Capability::DefinedAttributes, Capability::Aggregations, + ], function (Adapter&MockObject $adapter): void { + $aggResult = new Document(['cnt' => 10]); + $adapter->expects($this->once()) + ->method('find') + ->willReturn([$aggResult]); + }); + + /** @var list $results */ + $results = $db->skipValidation(fn () => $db->aggregate('testCol', [Query::count('*', 'cnt')])); + $this->assertCount(1, $results); + $this->assertSame(10, $results[0]->getAttribute('cnt')); + } + + 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->enableValidation(); + + $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('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $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; + } +} diff --git a/tests/unit/Documents/FractionalBoundTest.php b/tests/unit/Documents/FractionalBoundTest.php new file mode 100644 index 0000000000..dc128200f8 --- /dev/null +++ b/tests/unit/Documents/FractionalBoundTest.php @@ -0,0 +1,199 @@ + + */ + 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 { + 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(new Collection( + 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..085f0ff4c3 --- /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(new Collection( + 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..6381c854aa --- /dev/null +++ b/tests/unit/Documents/GetDocumentFallbackTest.php @@ -0,0 +1,168 @@ +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, 'owner', 'owner', alias: 'twin')]); + } + + 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', attributes: ['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 { + public function capabilities(): array + { + return [...parent::capabilities(), Capability::TTLIndexes]; + } + }; + } + + /** + * @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) + { + } + + 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 + */ + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array + { + return parent::save($key, $data, $hash); + } + + public function getGeneration(string $key): string + { + $this->refuse('getGeneration', $key); + + return '0'; + } + + /** + * @param array|string $data + * @return bool|string|array + */ + 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(new Collection( + 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/IncreaseDecreaseTest.php b/tests/unit/Documents/IncreaseDecreaseTest.php new file mode 100644 index 0000000000..8f9f71112b --- /dev/null +++ b/tests/unit/Documents/IncreaseDecreaseTest.php @@ -0,0 +1,527 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $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::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 new Document(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(TypeException::class); + $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(TypeException::class); + $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(InvalidArgumentException::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(InvalidArgumentException::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(InvalidArgumentException::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); + } +} diff --git a/tests/unit/Documents/InvalidationRestoreFailureTest.php b/tests/unit/Documents/InvalidationRestoreFailureTest.php new file mode 100644 index 0000000000..716c0ff589 --- /dev/null +++ b/tests/unit/Documents/InvalidationRestoreFailureTest.php @@ -0,0 +1,113 @@ +failingEpochCache(); + $queryCache = new QueryCache(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(new Collection( + 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; + } + + [$collectionKey] = $database->getCacheKeys(self::COLLECTION); + $epoch = $database->getCache()->load($collectionKey.'#epoch', Database::TTL); + $this->assertIsString($epoch); + $this->assertStringStartsWith('blocked:', $epoch, 'a restore that failed leaves the document cache fail-closed'); + $this->assertSame('kept', $database->getDocument(self::COLLECTION, 'kept')->getAttribute('entry')); + } + + 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(QueryCache $queryCache): Invalidator + { + $failing = fn (): bool => $this->failing; + + return new class ($queryCache, $failing) extends Invalidator { + public function __construct(QueryCache $queryCache, private readonly \Closure $failing) + { + parent::__construct($queryCache); + } + + /** + * @param array $tokens + */ + #[\Override] + public function activate(array $tokens): void + { + if (($this->failing)()) { + throw new RuntimeException('the query cache refused the activation'); + } + + parent::activate($tokens); + } + }; + } +} diff --git a/tests/unit/Documents/LiteralUniqueIdTest.php b/tests/unit/Documents/LiteralUniqueIdTest.php new file mode 100644 index 0000000000..abc4a11619 --- /dev/null +++ b/tests/unit/Documents/LiteralUniqueIdTest.php @@ -0,0 +1,226 @@ + + */ + 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(new Relationship( + collection: self::ALBUMS, + relatedCollection: self::ARTISTS, + type: RelationType::ManyToOne, + 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($database)) + ->addHook(new Permissions()); + $database->create(); + + foreach ($collections as $collection) { + $database->createCollection(new Collection( + 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..4250724f1c --- /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)); + + $this->database->create(); + + $this->database->createCollection(new Collection(id: 'authors')); + $this->database->createAttribute('authors', Attribute::string(key: 'name')); + + $this->database->createCollection(new Collection(id: 'books')); + $this->database->createAttribute('books', Attribute::string(key: 'title')); + $this->database->createRelationship(Relationship::oneToOne( + collection: 'books', + 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..a3331400bd --- /dev/null +++ b/tests/unit/Documents/MetadataTenantInvalidationTest.php @@ -0,0 +1,205 @@ +databases(); + + $this->assertTrue( + $reader->getCollection('targets')->isEmpty(), + 'The collection does not exist yet, so the read must miss', + ); + + $writer->createCollection(new Collection(id: 'targets', attributes: [ + Attribute::string(key: 'name', size: 64), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $this->assertFalse( + $reader->getCollection('targets')->isEmpty(), + 'A miss cached before provisioning must not outlive the collection being created', + ); + } + + public function testACollectionIsReadableByItsOwnWriterAfterCreation(): void + { + [$writer] = $this->databases(); + + $writer->createCollection(new Collection(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->assertTrue( + $reader->getCollection('targets')->isEmpty(), + 'The collection does not exist yet, so the read must miss', + ); + + $writer->createCollection(new Collection(id: 'targets', attributes: [ + Attribute::string(key: 'name', size: 64), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + + $this->assertFalse( + $reader->getCollection('targets')->isEmpty(), + '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 = []; + + 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..d5f42c62cd --- /dev/null +++ b/tests/unit/Documents/MissingCollectionAggregateTest.php @@ -0,0 +1,96 @@ + + */ + 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('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $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..770723f486 --- /dev/null +++ b/tests/unit/Documents/NegativeCacheEpochTest.php @@ -0,0 +1,181 @@ +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(new Collection(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 = []; + + 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..930eabf2dd --- /dev/null +++ b/tests/unit/Documents/NullNumericIncrementTest.php @@ -0,0 +1,267 @@ +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(new Collection( + 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( + 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(new Collection( + 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..70770677b5 --- /dev/null +++ b/tests/unit/Documents/NumericUpdateGuardsTest.php @@ -0,0 +1,242 @@ +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->without(Capability::Casting)); + $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(InvalidArgumentException::class, 'Value must be numeric and greater than 0', fn (): Document => $database->increaseDocumentAttribute(self::COLLECTION, 'ledger', 'count', $change)); + $this->assertRefused(InvalidArgumentException::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 without(Capability $missing): Memory + { + return new class ($missing) extends Memory { + public function __construct(private readonly Capability $missing) + { + parent::__construct(); + } + + 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(new Collection( + 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/SkipDuplicatesPermissionTest.php b/tests/unit/Documents/SkipDuplicatesPermissionTest.php new file mode 100644 index 0000000000..26b47b94f0 --- /dev/null +++ b/tests/unit/Documents/SkipDuplicatesPermissionTest.php @@ -0,0 +1,361 @@ + + */ + 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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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(new Collection( + 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->skipDuplicates(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(new Collection( + 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 = []; + + 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->getTenantPerDocument()) { + $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->getTenantPerDocument() ? $this->database->withTenant($tenant, $read) : $read(); + } + + /** + * @return list + */ + private function grants(string $document): array + { + $statement = $this->pdo->prepare( + 'SELECT "'.Storage::PERM_TYPE.'", "'.Storage::PERM_PERMISSION.'" FROM "'.self::NAMESPACE.'_'.self::COLLECTION.'_perms"' + .' WHERE "'.Storage::PERM_DOCUMENT.'" = ? ORDER BY "'.Storage::PERM_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..e4bb2efc80 --- /dev/null +++ b/tests/unit/Documents/SkipPermissionsTest.php @@ -0,0 +1,171 @@ +method('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 new Document(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 new Document(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/TenantSequencesTest.php b/tests/unit/Documents/TenantSequencesTest.php new file mode 100644 index 0000000000..57fbb4a878 --- /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(new Collection( + 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..ddd43af150 --- /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(new Collection( + 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..62c428ce64 --- /dev/null +++ b/tests/unit/Documents/TransactionCacheReadTest.php @@ -0,0 +1,465 @@ + + */ + 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', [Permission::read(Role::any()), Permission::update(Role::any())], 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->assertTrue($database->getCollection('absent')->isEmpty()); + }); + + $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)); + $database->createCollection(new Collection(id: 'libraries', attributes: [ + Attribute::string(key: 'name'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createCollection(new Collection(id: 'books', attributes: [ + Attribute::string(key: 'title'), + ], permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ])); + $database->createRelationship(Relationship::oneToMany( + collection: 'libraries', + 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->assertTrue($database->getCollection('missing')->isEmpty()); + $this->assertSame(1, $adapter->metadataReads, 'A missing collection must cost one read of its definition (7.3.12: 1 read)'); + + $this->assertTrue($database->getCollection('missing')->isEmpty()); + $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(new Collection(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->assertTrue($database->getCollection('logs')->isEmpty()); + $database->createCollection(new Collection(id: 'audits', permissions: [Permission::read(Role::any())])); + + $adapter->reset(); + $database->createCollection(new Collection(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(); + } + + public function load(string $key, int $ttl, string $hash = ''): mixed + { + return $this->cache->load($key, $ttl, $hash); + } + + 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); + } + + public function touch(string $key, string $hash = ''): bool + { + return $this->cache->touch($key, $hash); + } + + /** @return array */ + public function list(string $key): array + { + return $this->cache->list($key); + } + + public function purge(string $key, string $hash = ''): bool + { + return $this->cache->purge($key, $hash); + } + + public function flush(): bool + { + return $this->cache->flush(); + } + + public function ping(): bool + { + return true; + } + + public function getSize(): int + { + return $this->cache->getSize(); + } + + public function getName(?string $key = null): string + { + return 'fill-counting'; + } + + public function getGeneration(string $key): string + { + return $this->cache->getGeneration($key); + } + + 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 = SQLite::getPDOAttributes(); + $attributes[\PDO::ATTR_PERSISTENT] = false; + $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(new Collection(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(new Collection(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/UpdateDocumentLogicTest.php b/tests/unit/Documents/UpdateDocumentLogicTest.php new file mode 100644 index 0000000000..1b3c5211a3 --- /dev/null +++ b/tests/unit/Documents/UpdateDocumentLogicTest.php @@ -0,0 +1,337 @@ +getAuthorization()->addRole(Role::any()->toString()); + + return $db; + } + + private function makeAdapter(): Adapter&Stub + { + $adapter = self::createStub(Adapter::class); + $adapter->method('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 new Document(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->enableValidation(); + + $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..617c8cb274 --- /dev/null +++ b/tests/unit/Documents/UpdateDocumentsCastingTest.php @@ -0,0 +1,98 @@ + */ + private array $casted = []; + + /** @var array */ + public array $receivedCastedUpdates = []; + + /** @var array */ + public array $receivedUpdatedAtTypes = []; + + /** @var array */ + public array $receivedUpdatedAtValues = []; + + /** @var array */ + public array $receivedUpdateIds = []; + + public function castingBefore(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; + } + + public function castingAfter(Document $collection, Document $document): Document + { + return $document; + } + + #[\Override] + public function updateDocuments(Document $collection, Document $updates, array $documents): 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); + } + }; + $database = new Database($adapter, new Cache(new None())); + $database->getAuthorization()->disable(); + $database->setNamespace('casting'); + $this->assertTrue($database->create()); + $database->createCollection(new Collection(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..62aa9b99b4 --- /dev/null +++ b/tests/unit/Documents/UpdateDocumentsOperatorDecodeTest.php @@ -0,0 +1,132 @@ +database = new Database(new SQLite(new PDO('sqlite::memory:')), new Cache(new None()), [ + 'wrapped' => [ + 'encode' => static fn (mixed $value): ?string => $value === null ? null : \json_encode(['value' => $value], JSON_THROW_ON_ERROR), + 'decode' => 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(new Collection( + 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..ff706f2f94 --- /dev/null +++ b/tests/unit/Documents/WithCacheTest.php @@ -0,0 +1,241 @@ +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 { + public function capabilities(): array + { + return [...parent::capabilities(), Capability::TTLIndexes]; + } + }; + $database = $this->database($adapter, new Cache(new MemoryCache())); + $database->createIndex(self::COLLECTION, Index::ttl(key: 'expiry', attributes: ['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) + { + } + + 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 + */ + 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); + } + + 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); + } + + 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 + */ + 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(new Collection( + 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/EmptyDocumentTypeTest.php b/tests/unit/EmptyDocumentTypeTest.php index 787c0981f5..0f097f6c94 100644 --- a/tests/unit/EmptyDocumentTypeTest.php +++ b/tests/unit/EmptyDocumentTypeTest.php @@ -6,6 +6,7 @@ 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 +29,7 @@ public function testEmptyIdReturnsTheMappedDocumentType(): void ->setDatabase('utopiaTests') ->setNamespace('empty_type_' . \uniqid()); $database->create(); - $database->createCollection('users'); + $database->createCollection(new Collection(id: 'users')); $database->setDocumentType('users', TypedUser::class); $empty = $database->getDocument('users', ''); diff --git a/tests/unit/EmptySetAggregateContractTest.php b/tests/unit/EmptySetAggregateContractTest.php new file mode 100644 index 0000000000..934692cb43 --- /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', '$id', 'collectionId', '=', 'joined'), + 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..c906260bad --- /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..a44c69def7 --- /dev/null +++ b/tests/unit/EngineErrorMappingTest.php @@ -0,0 +1,463 @@ +, 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'); + } + + 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(new Collection( + 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 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/AttributeCreateTest.php b/tests/unit/Event/AttributeCreateTest.php new file mode 100644 index 0000000000..bb0ea4fea0 --- /dev/null +++ b/tests/unit/Event/AttributeCreateTest.php @@ -0,0 +1,57 @@ +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->getPayloads(Event::AttributeCreate); + $this->assertSame(['posts/summary', 'posts/likes'], \array_map($this->describe(...), $created)); + + $batches = $recorder->getPayloads(Event::AttributesCreate); + $this->assertCount(1, $batches); + $this->assertIsArray($batches[0]); + $this->assertSame(['posts/summary', 'posts/likes'], \array_map($this->describe(...), $batches[0])); + } + + 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->getPayloads(Event::AttributeCreate))); + } + + private function describe(mixed $attribute): string + { + $this->assertInstanceOf(Document::class, $attribute); + + return $attribute->getCollection().'/'.$attribute->getId(); + } +} diff --git a/tests/unit/Event/DispatcherHookRecording.php b/tests/unit/Event/DispatcherHookRecording.php new file mode 100644 index 0000000000..574660fce1 --- /dev/null +++ b/tests/unit/Event/DispatcherHookRecording.php @@ -0,0 +1,27 @@ + */ + public array $events = []; + + public function add(Domain $event): void + { + $this->events[] = $event; + } + + /** + * @return list, string}> + */ + public function summary(): array + { + return \array_map( + static fn (Domain $event): array => [$event::class, $event->collection], + $this->events, + ); + } +} diff --git a/tests/unit/Event/DispatcherHookTest.php b/tests/unit/Event/DispatcherHookTest.php new file mode 100644 index 0000000000..044ad09f24 --- /dev/null +++ b/tests/unit/Event/DispatcherHookTest.php @@ -0,0 +1,405 @@ +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::DocumentRead), 'An event with no domain event is never handled'); + + $dispatching = new DispatcherHook(new class () { + public function dispatch(object $event): object + { + return $event; + } + }); + $this->assertTrue($dispatching->handles(Event::DocumentDelete)); + $this->assertTrue($dispatching->handles(Event::CollectionCreate)); + $this->assertFalse($dispatching->handles(Event::DocumentRead)); + } + + public function testDocumentCreatedEvent(): void + { + $received = null; + $this->hook->on(DocumentCreated::class, function (DocumentCreated $event) use (&$received) { + $received = $event; + }); + + $doc = new Document([ + '$id' => 'doc-1', + '$collection' => 'users', + ]); + + $this->hook->handle(Event::DocumentCreate, $doc); + + $this->assertInstanceOf(DocumentCreated::class, $received); + $this->assertEquals('users', $received->collection); + $this->assertSame($doc, $received->document); + } + + public function testDocumentUpdatedEvent(): void + { + $received = null; + $this->hook->on(DocumentUpdated::class, function (DocumentUpdated $event) use (&$received) { + $received = $event; + }); + + $doc = new Document([ + '$id' => 'doc-2', + '$collection' => 'posts', + ]); + + $this->hook->handle(Event::DocumentUpdate, $doc); + + $this->assertInstanceOf(DocumentUpdated::class, $received); + $this->assertEquals('posts', $received->collection); + } + + public function testDocumentDeletedEvent(): void + { + $received = null; + $this->hook->on(DocumentDeleted::class, function (DocumentDeleted $event) use (&$received) { + $received = $event; + }); + + $doc = new Document([ + '$id' => 'doc-3', + '$collection' => 'users', + ]); + + $this->hook->handle(Event::DocumentDelete, $doc); + + $this->assertInstanceOf(DocumentDeleted::class, $received); + $this->assertEquals('doc-3', $received->documentId); + } + + public function testDocumentDeletedEventFromAnObjectPayload(): void + { + $received = $this->record(); + + $payload = new stdClass(); + $payload->collection = 'users'; + $payload->id = 'doc-6'; + $this->hook->handle(Event::DocumentDelete, $payload); + + $incomplete = new stdClass(); + $incomplete->collection = 'users'; + $this->hook->handle(Event::DocumentDelete, $incomplete); + + $this->assertCount(1, $received->events); + $this->assertInstanceOf(DocumentDeleted::class, $received->events[0]); + $this->assertSame('users', $received->events[0]->collection); + $this->assertSame('doc-6', $received->events[0]->documentId); + } + + public function testCollectionEventsFromTheirPayloads(): void + { + $received = $this->record(); + $collection = new Document(['$id' => 'posts']); + + $this->hook->handle(Event::CollectionCreate, $collection); + $this->hook->handle(Event::CollectionDelete, $collection); + $this->hook->handle(Event::CollectionDelete, 'comments'); + $this->hook->handle(Event::CollectionCreate, 'ignored'); + + $this->assertSame( + [ + [CollectionCreated::class, 'posts'], + [CollectionDeleted::class, 'posts'], + [CollectionDeleted::class, 'comments'], + ], + $received->summary(), + ); + $this->assertInstanceOf(CollectionCreated::class, $received->events[0]); + $this->assertSame($collection, $received->events[0]->document); + } + + public function testUnhandledEventDoesNothing(): void + { + $called = false; + $this->hook->on(DocumentCreated::class, function () use (&$called) { + $called = true; + }); + + $this->hook->handle(Event::DatabaseCreate, 'test'); + + $this->assertFalse($called); + } + + public function testMultipleListeners(): void + { + $count = 0; + $this->hook->on(DocumentCreated::class, function () use (&$count) { + $count++; + }); + $this->hook->on(DocumentCreated::class, function () use (&$count) { + $count++; + }); + + $doc = new Document([ + '$id' => 'doc-4', + '$collection' => 'test', + ]); + + $this->hook->handle(Event::DocumentCreate, $doc); + + $this->assertEquals(2, $count); + } + + 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(Event::DocumentCreate, new Document(['$id' => 'doc-5', '$collection' => 'test'])); + } 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, HookFixture::COLLECTION]], $received->summary()); + } + + public function testBulkWritesDeliverNoSingleDocumentEventWithAnEmptyId(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $received = $this->record(); + + $this->bulkWrites($database); + + foreach ($received->events as $event) { + $this->assertNotInstanceOf(DocumentCreated::class, $event); + $this->assertNotInstanceOf(DocumentUpdated::class, $event); + $this->assertNotInstanceOf(DocumentDeleted::class, $event); + } + } + + public function testBulkWritesDeliverBulkEventsWithTheirCounts(): void + { + $database = HookFixture::memory(); + $database->addHook($this->hook); + $received = $this->record(); + + $this->bulkWrites($database); + + $this->assertSame( + [ + [DocumentsCreated::class, HookFixture::COLLECTION], + [DocumentsUpdated::class, HookFixture::COLLECTION], + [DocumentsDeleted::class, HookFixture::COLLECTION], + ], + $received->summary(), + ); + $this->assertInstanceOf(DocumentsCreated::class, $received->events[0]); + $this->assertSame(3, $received->events[0]->count); + $this->assertSame(Event::DocumentsCreate, $received->events[0]->event); + $this->assertInstanceOf(DocumentsUpdated::class, $received->events[1]); + $this->assertSame(2, $received->events[1]->count); + $this->assertSame(Event::DocumentsUpdate, $received->events[1]->event); + $this->assertInstanceOf(DocumentsDeleted::class, $received->events[2]); + $this->assertSame(3, $received->events[2]->count); + $this->assertSame(Event::DocumentsDelete, $received->events[2]->event); + } + + public function testABulkPayloadWithoutACountDeliversNothing(): void + { + $received = $this->record(); + + $this->hook->handle(Event::DocumentsCreate, new Document(['$collection' => 'posts'])); + $this->hook->handle(Event::DocumentsDelete, 'posts'); + + $this->assertSame([], $received->events); + } + + 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(new Collection(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(new Collection(id: 'comments')); + + $this->assertTrue($ran); + $this->assertFalse($database->getCollection('comments')->isEmpty()); + } + + 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(new Collection(id: 'comments')); + $this->assertInstanceOf(CollectionCreated::class, $dispatcher->events[0]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('dispatcher failure'); + + HookFixture::seed($database, ['first']); + } + + private function bulkWrites(Database $database): void + { + $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); + } + + 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..d48de63a1d --- /dev/null +++ b/tests/unit/Event/DocumentPurgeTest.php @@ -0,0 +1,683 @@ + + */ + 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 fn (Database $database): mixed => $database->purgeCachedDocument(HookFixture::COLLECTION, 'first')]; + } + + /** + * @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']); + + $this->assertTrue($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; + + public function commitTransaction(): bool + { + if ($this->commitFailures > 0) { + $this->commitFailures--; + + throw new RuntimeException('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->getPayloads(Event::DocumentPurge) as $payload) { + $this->assertInstanceOf(Document::class, $payload); + $purged[] = $payload->getCollection().'/'.$payload->getId(); + } + + 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::NestedTransactions, + )); + } + + #[\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 RuntimeException('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(new Collection( + 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->getPayloads(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)); + } + + /** + * @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, + ) { + } + + public function handle(Event $event, mixed $data): void + { + if ($event === Event::DocumentPurge) { + $this->observed->append(($this->observe)()); + } + } + }); + + return $observed; + } +} diff --git a/tests/unit/Event/DomainTest.php b/tests/unit/Event/DomainTest.php new file mode 100644 index 0000000000..bf72dcfd0f --- /dev/null +++ b/tests/unit/Event/DomainTest.php @@ -0,0 +1,133 @@ +assertEquals('users', $event->collection); + $this->assertEquals(Event::DocumentCreate, $event->event); + } + + public function testDomainEventOccurredAtAutoSetToNow(): void + { + $before = new \DateTimeImmutable(); + $event = new Domain('users', Event::DocumentCreate); + $after = new \DateTimeImmutable(); + + $this->assertGreaterThanOrEqual($before, $event->occurredAt); + $this->assertLessThanOrEqual($after, $event->occurredAt); + } + + public function testDomainEventCustomOccurredAt(): void + { + $custom = new \DateTimeImmutable('2025-01-01 12:00:00'); + $event = new Domain('users', Event::DocumentCreate, $custom); + $this->assertSame($custom, $event->occurredAt); + } + + public function testDocumentCreatedCarriesDocument(): void + { + $doc = new Document(['$id' => 'doc1', 'name' => 'Alice']); + $event = new DocumentCreated('users', $doc); + + $this->assertSame($doc, $event->document); + $this->assertEquals('users', $event->collection); + } + + public function testDocumentCreatedHasCorrectEventType(): void + { + $doc = new Document(['$id' => 'doc1']); + $event = new DocumentCreated('users', $doc); + $this->assertEquals(Event::DocumentCreate, $event->event); + } + + public function testDocumentUpdatedCarriesDocument(): void + { + $doc = new Document(['$id' => 'doc1', 'name' => 'Bob']); + $event = new DocumentUpdated('users', $doc); + + $this->assertSame($doc, $event->document); + $this->assertEquals(Event::DocumentUpdate, $event->event); + } + + public function testBulkEventsCarryTheirCollectionCountAndEvent(): void + { + $created = new DocumentsCreated('users', 3); + $updated = new DocumentsUpdated('users', 2); + $deleted = new DocumentsDeleted('users', 0); + + $this->assertSame(['users', 3, Event::DocumentsCreate], [$created->collection, $created->count, $created->event]); + $this->assertSame(['users', 2, Event::DocumentsUpdate], [$updated->collection, $updated->count, $updated->event]); + $this->assertSame(['users', 0, Event::DocumentsDelete], [$deleted->collection, $deleted->count, $deleted->event]); + } + + public function testDocumentDeletedCarriesDocumentId(): void + { + $event = new DocumentDeleted('users', 'doc-42'); + + $this->assertEquals('doc-42', $event->documentId); + $this->assertEquals('users', $event->collection); + $this->assertEquals(Event::DocumentDelete, $event->event); + } + + public function testCollectionCreatedCarriesDocument(): void + { + $doc = new Document(['$id' => 'col1', 'name' => 'users']); + $event = new CollectionCreated('users', $doc); + + $this->assertSame($doc, $event->document); + $this->assertEquals(Event::CollectionCreate, $event->event); + } + + public function testCollectionDeletedHasCorrectEventType(): void + { + $event = new CollectionDeleted('users'); + $this->assertEquals(Event::CollectionDelete, $event->event); + $this->assertEquals('users', $event->collection); + } + + public function testDomainEventIsReadonly(): void + { + $event = new Domain('users', Event::DocumentCreate); + + $this->assertLessThanOrEqual(new \DateTimeImmutable(), $event->occurredAt); + $this->assertEquals('users', $event->collection); + $this->assertEquals(Event::DocumentCreate, $event->event); + } + + public function testDocumentCreatedOccurredAtIsAutoPopulated(): void + { + $doc = new Document(['$id' => 'doc1']); + $event = new DocumentCreated('users', $doc); + + $this->assertLessThanOrEqual(new \DateTimeImmutable(), $event->occurredAt); + } + + public function testDocumentDeletedOccurredAtIsAutoPopulated(): void + { + $event = new DocumentDeleted('users', 'doc1'); + $this->assertLessThanOrEqual(new \DateTimeImmutable(), $event->occurredAt); + } + + public function testCollectionDeletedOccurredAtIsAutoPopulated(): void + { + $event = new CollectionDeleted('users'); + $this->assertLessThanOrEqual(new \DateTimeImmutable(), $event->occurredAt); + } +} diff --git a/tests/unit/Event/FailingLifecycle.php b/tests/unit/Event/FailingLifecycle.php new file mode 100644 index 0000000000..fc5298eb4c --- /dev/null +++ b/tests/unit/Event/FailingLifecycle.php @@ -0,0 +1,23 @@ +event) { + throw $this->failure; + } + } +} diff --git a/tests/unit/Event/HookErrorCleanupTest.php b/tests/unit/Event/HookErrorCleanupTest.php new file mode 100644 index 0000000000..dcdd96d835 --- /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->assertTrue( + $database->getCollection(self::COLLECTION)->isEmpty(), + '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(new Collection(id: $id, permissions: $this->permissions(), documentSecurity: false)); + } + $database->createRelationship(Relationship::oneToMany( + collection: 'parent', + relatedCollection: 'child', + twoWay: true, + key: 'children', + twoWayKey: 'parent', + onDelete: ForeignKeyAction::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', newTwoWayKey: '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(new Collection( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: $this->permissions(), + documentSecurity: false, + )); + } + + /** + * @return array + */ + 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..d3c9cc59ad --- /dev/null +++ b/tests/unit/Event/HookFailureTest.php @@ -0,0 +1,158 @@ + + */ + 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(new Collection(id: 'comments'))]; + yield 'collection update' => [Event::CollectionUpdate, static fn (Database $database): mixed => $database->updateCollection(HookFixture::COLLECTION, [Permission::read(Role::any())], 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->updateAttributeRequired(HookFixture::COLLECTION, 'title', true)]; + yield 'attribute delete' => [Event::AttributeDelete, static fn (Database $database): mixed => $database->deleteAttribute(HookFixture::COLLECTION, 'views')]; + yield 'index rename' => [Event::IndexRename, static function (Database $database): mixed { + $database->createIndex(HookFixture::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + + return $database->renameIndex(HookFixture::COLLECTION, 'by_title', 'by_heading'); + }]; + yield 'index delete' => [Event::IndexDelete, static function (Database $database): mixed { + $database->createIndex(HookFixture::COLLECTION, Index::key(key: 'by_title', attributes: ['title'])); + + return $database->deleteIndex(HookFixture::COLLECTION, 'by_title'); + }]; + yield 'collection delete' => [Event::CollectionDelete, static fn (Database $database): mixed => $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..c103524956 --- /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(new Collection( + 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..6bc9d20a8a --- /dev/null +++ b/tests/unit/Event/NamedRecordingLifecycle.php @@ -0,0 +1,18 @@ +name; + } +} diff --git a/tests/unit/Event/RecordingLifecycle.php b/tests/unit/Event/RecordingLifecycle.php new file mode 100644 index 0000000000..59a654346a --- /dev/null +++ b/tests/unit/Event/RecordingLifecycle.php @@ -0,0 +1,44 @@ + */ + private array $events = []; + + /** @var list */ + private array $payloads = []; + + public function handle(Event $event, mixed $data): void + { + $this->events[] = $event; + $this->payloads[] = $data; + } + + /** + * @return list + */ + public function getEvents(): array + { + return $this->events; + } + + /** + * @return list + */ + public function getPayloads(Event $event): array + { + $payloads = []; + foreach ($this->events as $index => $recorded) { + if ($recorded === $event) { + $payloads[] = $this->payloads[$index]; + } + } + + return $payloads; + } +} 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/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..63dde428d9 100644 --- a/tests/unit/FilterRegistryTest.php +++ b/tests/unit/FilterRegistryTest.php @@ -2,13 +2,20 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; 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 as DatabaseException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Type\Custom; +use Utopia\Database\Type\TypeRegistry; +use Utopia\Query\Schema\ColumnType; class FilterRegistryTest extends TestCase { @@ -23,7 +30,7 @@ class FilterRegistryTest extends TestCase /** * @var array */ - private array $registry; + private array $registry = []; protected function setUp(): void { @@ -36,11 +43,11 @@ protected function setUp(): void // 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(new Collection(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())], @@ -52,8 +59,7 @@ 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 @@ -79,7 +85,7 @@ private function writeBehindTheCache(string $value): void $this->adapter->updateDocument($collection, 'project', $document, true); } - private function read(?Database $database = null): string + private function read(?Database $database = null): mixed { return ($database ?? $this->database) ->getDocument('projects', 'project') @@ -135,8 +141,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 +152,7 @@ public function testOverridingABuiltInFilterBeforeTheFirstInstanceStillWins(): v 'attributes' => [ new Document([ '$id' => 'occurredAt', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'array' => false, 'filters' => ['datetime'], ]), @@ -176,4 +181,120 @@ 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 testCustomTypeEncodeFailureIsADatabaseExceptionWithTheOriginalAsPrevious(): void + { + $failure = new \DomainException('cannot encode the custom probe', 11); + $registry = new TypeRegistry(); + $registry->register(new class ($failure) implements Custom { + public function __construct(private readonly \DomainException $failure) + { + } + + public function name(): string + { + return 'failingType'; + } + + public function encode(mixed $value): mixed + { + throw $this->failure; + } + + public function decode(mixed $value): mixed + { + return $value; + } + }); + + $this->assertEncodeFailureWrapped($this->createDatabase()->setTypeRegistry($registry), 'failingType', $failure); + } + + 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()); + } + } + + /** + * @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; + } } diff --git a/tests/unit/FlatAggregateTest.php b/tests/unit/FlatAggregateTest.php new file mode 100644 index 0000000000..25835a62dd --- /dev/null +++ b/tests/unit/FlatAggregateTest.php @@ -0,0 +1,286 @@ +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(new Collection( + 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', 'category', 'category', '=', 'label'); + + [$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', 'category', 'category', '=', 'label')], 1)); + $this->assertSame(3, $this->database->count('items', [Query::fullOuterJoin('labels', 'category', 'category', '=', 'label')])); + $this->assertSame(2, $this->database->count('items', [Query::fullOuterJoin('labels', 'category', 'category', '=', 'label')], 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 = Query::join('labels', 'label', [Query::on('category', 'category'), Query::limit(1)]); + $this->database->disableValidation(); + + 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->enableProfiling()->getProfiler(); + $this->assertNotNull($profiler); + + try { + $profiler->reset(); + $result = $read(); + } finally { + $this->database->disableProfiling(); + } + + $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..45140432d3 100644 --- a/tests/unit/ForUpdateCacheTest.php +++ b/tests/unit/ForUpdateCacheTest.php @@ -6,6 +6,8 @@ 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; @@ -26,9 +28,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(new Collection(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..ded6c0bfea 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,8 +15,6 @@ class Format extends Text * Get Description * * Returns validator description - * - * @return string */ public function getDescription(): string { @@ -30,12 +26,11 @@ public function getDescription(): string * * Validation will pass when $value is valid email address. * - * @param mixed $value - * @return bool + * @param mixed $value */ 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..9633c240f2 --- /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', '$id', 'mainId', '=', 'b'), + Query::rightJoin('c', '$id', 'mainId', '=', 'c'), + ]]; + yield 'right join on the full outer joined collection' => [[ + Query::fullOuterJoin('b', '$id', 'mainId', '=', 'b'), + Query::rightJoin('c', 'b.mainId', 'mainId', '=', 'c'), + ]]; + } + + /** + * 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', self::LINK, self::LINK, '=', 'a'), + Query::fullOuterJoin('b', 'a.'.self::LINK, self::LINK, '=', 'b'), + ]; + + 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', self::LINK, self::LINK, '=', 'a'), + Query::fullOuterJoin('b', 'a.'.self::LINK, self::LINK, '=', 'b'), + ]); + + $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', self::LINK, self::LINK, '=', 'a'), + Query::crossJoin('b', 'b'), + Query::rightJoin('c', 'b.'.self::LINK, self::LINK, '=', 'c'), + ]); + } + + /** + * @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, $left, self::LINK, '=', $collection), + Method::LeftJoin => Query::leftJoin($collection, $left, self::LINK, '=', $collection), + Method::RightJoin => Query::rightJoin($collection, $left, self::LINK, '=', $collection), + default => Query::fullOuterJoin($collection, $left, self::LINK, '=', $collection), + }; + $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->getJoinAlias(), $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(new Collection( + 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(new Collection( + id: 'main', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + foreach (['b', 'c'] as $collection) { + $database->createCollection(new Collection( + 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..6508dae3f5 100644 --- a/tests/unit/HashAwareMemoryCache.php +++ b/tests/unit/HashAwareMemoryCache.php @@ -21,7 +21,7 @@ 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 + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { return parent::save($this->field($key, $hash), $data); } diff --git a/tests/unit/Hook/ColumnNameTest.php b/tests/unit/Hook/ColumnNameTest.php new file mode 100644 index 0000000000..fac765449b --- /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); + + AllowNullColumn::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): PermissionFilter $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): PermissionFilter => new PermissionFilter(['any'], $permissionsTable, documentColumn: $column), + 'permDocumentColumn' => static fn (string $column): PermissionFilter => new PermissionFilter(['any'], $permissionsTable, permDocumentColumn: $column), + 'permRoleColumn' => static fn (string $column): PermissionFilter => new PermissionFilter(['any'], $permissionsTable, permRoleColumn: $column), + 'permTypeColumn' => static fn (string $column): PermissionFilter => new PermissionFilter(['any'], $permissionsTable, permTypeColumn: $column), + 'permColumnColumn' => static fn (string $column): PermissionFilter => new PermissionFilter(['any'], $permissionsTable, permColumnColumn: $column), + ]; + foreach ($constructors as $parameter => $construct) { + foreach (self::invalidColumns() as $label => [$column]) { + yield $parameter.' with '.$label => [$construct, $column]; + } + } + } + + public function testPermissionFilterWithoutRolesMatchesNothing(): void + { + $filter = new PermissionFilter([], 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 PermissionFilter(['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 PermissionFilter(['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 PermissionFilter(['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)', AllowNullColumn::wrap(new Condition('x = 1'), '1db._uid')->expression); + $this->assertSame('(x = 1 OR `-ns`.`_uid` IS NULL)', AllowNullColumn::wrap(new Condition('x = 1'), '-ns._uid')->expression); + } + + public function testPermissionFilterAcceptsADigitLeadingPermissionsTableAndQuotesIt(): void + { + $filter = new PermissionFilter(['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 PermissionFilter(['any'], static fn (string $table): string => $table.'_perms', permRoleColumn: '1role'); + } +} diff --git a/tests/unit/Hook/DecoratorBatchTest.php b/tests/unit/Hook/DecoratorBatchTest.php new file mode 100644 index 0000000000..60e4670a74 --- /dev/null +++ b/tests/unit/Hook/DecoratorBatchTest.php @@ -0,0 +1,120 @@ +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 { + 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..3ada17fff7 --- /dev/null +++ b/tests/unit/Hook/JoinAliasQuotingTest.php @@ -0,0 +1,209 @@ + + */ + 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 TenantFilter(7, Database::METADATA, 'authors', quoteChar: $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 TenantFilter(7, Database::METADATA, Database::METADATA, quoteChar: $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 TenantFilter(7, Database::METADATA, 'authors', quoteChar: $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 TenantFilter(7, allowNullColumn: self::SOURCE.'.'.Storage::UID, quoteChar: $quote); + + $result = $filter->filterJoin(self::ALIAS, $joinType); + + $this->assertNotNull($result); + $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 TenantFilter(7, Database::METADATA, 'authors', self::SOURCE.'.'.Storage::UID, $quote); + + $result = (new OuterJoinTenantFilter($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 JoinChain([self::EARLIER => JoinType::Cross, self::ALIAS => JoinType::Right]); + $tenants = new TenantFilter(7, quoteChar: $quote); + $permission = $this->permission(self::EARLIER, $quote); + + $tenant = (new OuterJoinChainFilter($chain, [self::EARLIER => $tenants->joined(self::EARLIER)], $quote)) + ->filterJoin(self::ALIAS, JoinType::Right); + $permitted = (new OuterJoinChainFilter($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 PermissionJoinFilter($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 PermissionAllowNullUid($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 OuterJoinPermissionFilter(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): PermissionFilter + { + return new PermissionFilter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'database.namespace_books_perms', + documentColumn: $alias.'.'.Storage::UID, + permDocumentColumn: Storage::PERM_DOCUMENT, + permRoleColumn: Storage::PERM_PERMISSION, + permTypeColumn: Storage::PERM_TYPE, + quoteChar: $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( + '/(?assertSame(['extra', 'reviews'], $chain->preceding('note'), 'A join without an alias is keyed by its table'); + $this->assertTrue($chain->has(JoinType::Cross)); + $this->assertFalse($chain->has(JoinType::Left)); + } + + public function testOnlyTablesWhoseConditionsSitInWherePrecedeALaterJoin(): void + { + $chain = new JoinChain([ + '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 JoinChain(['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 JoinChain())->hasPreservingOuterJoin()); + $this->assertFalse((new JoinChain(['a' => JoinType::Inner, 'b' => JoinType::Left, 'c' => JoinType::Cross]))->hasPreservingOuterJoin()); + $this->assertTrue((new JoinChain(['a' => JoinType::Inner, 'b' => JoinType::Right]))->hasPreservingOuterJoin()); + $this->assertTrue((new JoinChain(['a' => JoinType::FullOuter]))->hasPreservingOuterJoin()); + } +} diff --git a/tests/unit/Hook/NamedTest.php b/tests/unit/Hook/NamedTest.php new file mode 100644 index 0000000000..614997c112 --- /dev/null +++ b/tests/unit/Hook/NamedTest.php @@ -0,0 +1,322 @@ +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 { + 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 { + 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, + ) { + } + + public function handle(Event $event, mixed $data): 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, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function handle(Event $event, mixed $data): void + { + $this->hook->handle($event, $data); + } + }; + } + + 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, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function handle(Event $event, mixed $data): void + { + $this->failing->handle($event, $data); + } + }; + } +} diff --git a/tests/unit/Hook/OuterJoinChainFilterTest.php b/tests/unit/Hook/OuterJoinChainFilterTest.php new file mode 100644 index 0000000000..8ed62a6b13 --- /dev/null +++ b/tests/unit/Hook/OuterJoinChainFilterTest.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 JoinChain([ + 'b' => JoinType::Inner, + 'c' => JoinType::Right, + 'x' => JoinType::Cross, + 'd' => $joinType, + 'e' => JoinType::Right, + ]); + $tenants = new TenantFilter(7); + $conditions = []; + foreach (['b', 'c', 'x', 'd', 'e'] as $alias) { + $conditions[$alias] = $tenants->joined($alias); + } + + $result = (new OuterJoinChainFilter($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 JoinChain(['c' => JoinType::Right, 'd' => $joinType]); + + $this->assertNull((new OuterJoinChainFilter($chain, ['c' => new Condition('c.ok')]))->filterJoin('d', $joinType)); + } + + public function testTheFirstOuterJoinAndTablesWithoutConditionsAddNothing(): void + { + $chain = new JoinChain(['c' => JoinType::Right, 'd' => JoinType::Right]); + + $this->assertNull((new OuterJoinChainFilter($chain, ['c' => new Condition('c.ok')]))->filterJoin('c', JoinType::Right)); + $this->assertNull((new OuterJoinChainFilter($chain, ['d' => new Condition('d.ok')]))->filterJoin('d', JoinType::Right)); + } + + public function testBindingsFollowTheConditionsInOrder(): void + { + $chain = new JoinChain(['c' => JoinType::FullOuter, 'x' => JoinType::Cross, 'd' => JoinType::Right]); + + $result = (new OuterJoinChainFilter($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/Hook/OuterJoinPermissionFilterTest.php b/tests/unit/Hook/OuterJoinPermissionFilterTest.php new file mode 100644 index 0000000000..4029af8682 --- /dev/null +++ b/tests/unit/Hook/OuterJoinPermissionFilterTest.php @@ -0,0 +1,130 @@ + + */ + 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 OuterJoinPermissionFilter(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 OuterJoinPermissionFilter(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 OuterJoinPermissionFilter(self::SOURCE, [self::SOURCE => new Condition('main.ok = ?', [1])], '"')) + ->filterJoin(self::ALIAS, JoinType::Right); + $joinedOnly = (new OuterJoinPermissionFilter(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 OuterJoinPermissionFilter(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 PermissionJoinFilter($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->assertNotNull($tenant); + $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): PermissionFilter + { + return new PermissionFilter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'perms_'.$table, + documentColumn: $alias.'.'.Storage::UID, + ); + } +} diff --git a/tests/unit/Hook/OuterJoinTenantFilterTest.php b/tests/unit/Hook/OuterJoinTenantFilterTest.php new file mode 100644 index 0000000000..9e787d3d66 --- /dev/null +++ b/tests/unit/Hook/OuterJoinTenantFilterTest.php @@ -0,0 +1,101 @@ + + */ + 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 TenantFilter(7, Database::METADATA, 'orders', self::SOURCE.'.'.Storage::UID); + + $result = (new OuterJoinTenantFilter($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 TenantFilter(7, Database::METADATA, 'orders'); + + $this->assertNull((new OuterJoinTenantFilter($filter, self::SOURCE))->filterJoin(self::ALIAS, $joinType)); + } + + public function testATenantlessMetadataRowStaysMatchable(): void + { + $filter = new TenantFilter(7, Database::METADATA, Database::METADATA); + + $result = (new OuterJoinTenantFilter($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 TenantFilter([1, 2], Database::METADATA, 'orders'); + + $result = (new OuterJoinTenantFilter($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 TenantFilter(7))->filterJoin(self::ALIAS, JoinType::FullOuter); + + $this->assertNotNull($result); + $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/Hook/PermissionAllowNullUidTest.php b/tests/unit/Hook/PermissionAllowNullUidTest.php new file mode 100644 index 0000000000..a94fad4cf3 --- /dev/null +++ b/tests/unit/Hook/PermissionAllowNullUidTest.php @@ -0,0 +1,61 @@ + 'perms_'.$table, + documentColumn: 'table_main.'.Storage::UID, + ); + $hook = new PermissionAllowNullUid($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 { + public function filter(string $table): Condition + { + return new Condition('inner_expr', ['role']); + } + }; + $hook = new PermissionAllowNullUid($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 PermissionAllowNullUid( + new PermissionFilter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'perms_'.$table, + ), + 'table_main._uid; DROP TABLE', + ); + } +} diff --git a/tests/unit/Hook/PermissionJoinFilterTest.php b/tests/unit/Hook/PermissionJoinFilterTest.php new file mode 100644 index 0000000000..cc30ab8ce9 --- /dev/null +++ b/tests/unit/Hook/PermissionJoinFilterTest.php @@ -0,0 +1,77 @@ +permissionFilter(); + $hook = new PermissionJoinFilter($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 PermissionJoinFilter($this->permissionFilter(), 'j0'); + $result = $hook->filterJoin('j0', JoinType::Inner); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement); + } + + public function testRightJoinPlacesPermissionInWhereClause(): void + { + $hook = new PermissionJoinFilter($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 PermissionJoinFilter($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 PermissionJoinFilter($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(): PermissionFilter + { + return new PermissionFilter( + roles: ['any'], + permissionsTable: static fn (string $table): string => 'perms_'.$table, + documentColumn: 'j0.'.Storage::UID, + ); + } +} diff --git a/tests/unit/Hook/PermissionsBatchSkipTest.php b/tests/unit/Hook/PermissionsBatchSkipTest.php new file mode 100644 index 0000000000..c7391e9f2b --- /dev/null +++ b/tests/unit/Hook/PermissionsBatchSkipTest.php @@ -0,0 +1,174 @@ + + */ + private array $statements = []; + + private Database $database; + + 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 + */ + 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(new Collection( + 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 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()]), + ]); + foreach ($documents as $document) { + $document->setAttribute(Document::SKIP_PERMISSIONS_UPDATE, true); + } + $updates = new class ([Document::PERMISSIONS => self::stored()]) extends Document { + public int $calls = 0; + + #[\Override] + public function getPermissionsByType(PermissionType $type): array + { + $this->calls++; + + return parent::getPermissionsByType($type); + } + }; + + $before = \count($this->statements); + $adapter->updateDocuments($collection, $updates, $documents); + + $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..3adb8fe804 --- /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 $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 $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->getCreate()); + } + + 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->getCreate()); + $this->assertSame(['any', 'guests'], $document->getRead()); + } + + 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 $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/RawOuterJoinTenantFilterTest.php b/tests/unit/Hook/RawOuterJoinTenantFilterTest.php new file mode 100644 index 0000000000..a8538824e0 --- /dev/null +++ b/tests/unit/Hook/RawOuterJoinTenantFilterTest.php @@ -0,0 +1,38 @@ +filterJoin('Book', $type); + $this->assertNull($hook->filterJoin('Book', $type), "A {$type->value} meets its condition where RawTenantFilter 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/Hook/RawTenantFilterTest.php b/tests/unit/Hook/RawTenantFilterTest.php new file mode 100644 index 0000000000..d9634dba8b --- /dev/null +++ b/tests/unit/Hook/RawTenantFilterTest.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 RawTenantFilter(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 RawTenantFilter(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 RawTenantFilter(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(): RawTenantFilter + { + return new RawTenantFilter(self::TENANT, self::TABLE, false, '`'); + } +} diff --git a/tests/unit/Hook/RelationshipCoroutineTest.php b/tests/unit/Hook/RelationshipCoroutineTest.php new file mode 100644 index 0000000000..5f7e1bdcb7 --- /dev/null +++ b/tests/unit/Hook/RelationshipCoroutineTest.php @@ -0,0 +1,227 @@ +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', RelationType::OneToMany, 'children', 'parent'); + $this->relate($database, 'levelTwo', 'levelThree', RelationType::OneToMany, 'children', 'parent'); + $this->relate($database, 'shelves', 'books', RelationType::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', RelationType::OneToMany, 'pets', 'owner'); + $this->relate($database, 'people', 'passports', RelationType::OneToOne, 'passport', 'person', ForeignKeyAction::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()), + [self::FILTER => ['encode' => $pause, 'decode' => static fn (mixed $value): mixed => $value]], + ); + $database + ->setAuthorization(new Authorization()) + ->setDatabase('relationships') + ->setNamespace('relationships_'.\uniqid()); + $database->addHook(new Relationships($database)); + $database->create(); + + return $database; + } + + private function collection(string $id): Collection + { + return new Collection( + 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, + RelationType $type, + string $key, + string $twoWayKey, + ForeignKeyAction $onDelete = ForeignKeyAction::SetNull, + ): void { + $database->createRelationship(new Relationship( + collection: $collection, + relatedCollection: $relatedCollection, + type: $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/TenancyHookTest.php b/tests/unit/Hook/TenancyHookTest.php new file mode 100644 index 0000000000..f267cc7bbe --- /dev/null +++ b/tests/unit/Hook/TenancyHookTest.php @@ -0,0 +1,38 @@ + + */ + public static function tenants(): array + { + return [ + 'integer' => [5], + 'string' => ['tenant-a'], + 'per document' => [null], + ]; + } + + #[DataProvider('tenants')] + public function testGetTenantReturnsTheAmbientTenant(int|string|null $tenant): void + { + $this->assertSame($tenant, (new Tenancy($tenant))->getTenant()); + } + + public function testDecorateRowPrefersTheMetadataTenantOverTheAmbientOne(): void + { + $hook = new Tenancy(5); + + $this->assertSame(['title' => 'x', Storage::TENANT => 7], $hook->decorateRow(['title' => 'x'], ['tenant' => 7])); + $this->assertSame(['title' => 'x', Storage::TENANT => 5], $hook->decorateRow(['title' => 'x'])); + $this->assertSame(['title' => 'x', 'owner' => 5], (new Tenancy(5, 'owner'))->decorateRow(['title' => 'x'])); + } +} diff --git a/tests/unit/Hook/TenantFilterTest.php b/tests/unit/Hook/TenantFilterTest.php new file mode 100644 index 0000000000..cc75b7e76f --- /dev/null +++ b/tests/unit/Hook/TenantFilterTest.php @@ -0,0 +1,113 @@ +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 TenantFilter(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 TenantFilter(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 TenantFilter(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 TenantFilter(7); + $result = $hook->filterJoin('j0', JoinType::Left); + + $this->assertNotNull($result); + $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 TenantFilter(7); + $result = $hook->filterJoin('j0', JoinType::Right); + + $this->assertNotNull($result); + $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 TenantFilter(7); + $result = $hook->filterJoin('j0', JoinType::FullOuter); + + $this->assertNotNull($result); + $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 TenantFilter(7); + $result = $hook->filterJoin('j0', JoinType::Inner); + + $this->assertNotNull($result); + $this->assertSame(Placement::On, $result->placement); + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + } + + public function testFilterJoinCrossPlacesTenantInWhereClause(): void + { + $hook = new TenantFilter(7); + $result = $hook->filterJoin('j0', JoinType::Cross); + + $this->assertNotNull($result); + $this->assertSame(Placement::Where, $result->placement); + $this->assertSame('`j0`.'.Storage::TENANT.' IN (?)', $result->condition->expression); + } +} diff --git a/tests/unit/IDTest.php b/tests/unit/IDTest.php index 8953097560..68b30f5d3b 100644 --- a/tests/unit/IDTest.php +++ b/tests/unit/IDTest.php @@ -7,16 +7,16 @@ class IDTest extends TestCase { - public function testCustomID(): void + public function test_custom_id(): void { $id = ID::custom('test'); $this->assertEquals('test', $id); } - public function testUniqueID(): void + public function test_unique_id(): void { $id = ID::unique(); $this->assertNotEmpty($id); - $this->assertIsString($id); + $this->assertIsString($id); // @phpstan-ignore method.alreadyNarrowedType } } diff --git a/tests/unit/IndexModelTest.php b/tests/unit/IndexModelTest.php new file mode 100644 index 0000000000..e064b8db14 --- /dev/null +++ b/tests/unit/IndexModelTest.php @@ -0,0 +1,349 @@ +assertSame('idx_test', $index->key); + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame([], $index->attributes); + $this->assertSame([], $index->lengths); + $this->assertSame([], $index->orders); + $this->assertSame(1, $index->ttl); + } + + public function testConstructorWithAllValues(): void + { + $index = Index::unique( + key: 'idx_compound', + attributes: ['name', 'email'], + lengths: [128, 256], + orders: [Order::Asc, Order::Desc], + ttl: 3600, + ); + + $this->assertSame('idx_compound', $index->key); + $this->assertSame(IndexType::Unique, $index->type); + $this->assertSame(['name', 'email'], $index->attributes); + $this->assertSame([128, 256], $index->lengths); + $this->assertSame([Order::Asc, Order::Desc], $index->orders); + $this->assertSame(3600, $index->ttl); + } + + public function testConstructorRejectsStringOrders(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Index order must be Order or null'); + + // @phpstan-ignore-next-line + Index::key(key: 'idx_enabled', attributes: ['enabled'], orders: ['ASC']); + } + + public function testConstructorAcceptsOrderEnum(): void + { + $index = Index::key( + key: 'idx_enabled', + attributes: ['enabled'], + orders: [Order::Asc, Order::Desc], + ); + + $this->assertSame([Order::Asc, Order::Desc], $index->orders); + $this->assertSame(['ASC', 'DESC'], $index->toDocument()->getAttribute('orders')); + } + + public function testToDocumentProducesCorrectStructure(): void + { + $index = Index::unique(key: 'idx_email', attributes: ['email'], lengths: [256], orders: [Order::Asc]); + + $doc = $index->toDocument(); + + $this->assertSame('idx_email', $doc->getId()); + $this->assertSame('idx_email', $doc->getAttribute('key')); + $this->assertSame('unique', $doc->getAttribute('type')); + $this->assertSame(['email'], $doc->getArray('attributes')); + $this->assertSame([256], $doc->getAttribute('lengths')); + $this->assertSame(['ASC'], $doc->getAttribute('orders')); + $this->assertSame(1, $doc->getAttribute('ttl')); + } + + public function testArrayAccessMatchesDocumentShape(): void + { + $index = Index::unique(key: 'idx_email', attributes: ['email'], lengths: [128], orders: [Order::Asc]); + + $this->assertSame('idx_email', $index['$id']); + $this->assertSame('idx_email', $index['key']); + $this->assertSame(IndexType::Unique->value, $index['type']); + $this->assertSame(['email'], $index['attributes']); + $this->assertSame('idx_email', $index->getId()); + } + + public function testFromDocumentRoundtrip(): void + { + $original = Index::key( + key: 'idx_status_name', + attributes: ['status', 'name'], + lengths: [32, 128], + orders: [Order::Asc, Order::Asc], + ttl: 7200, + ); + + $doc = $original->toDocument(); + $restored = Index::fromDocument($doc); + + $this->assertSame($original->key, $restored->key); + $this->assertSame($original->type, $restored->type); + $this->assertSame($original->attributes, $restored->attributes); + $this->assertSame($original->lengths, $restored->lengths); + $this->assertSame($original->orders, $restored->orders); + $this->assertSame($original->ttl, $restored->ttl); + } + + public function testFromDocumentWithMinimalDocument(): void + { + $doc = new Document([ + '$id' => 'idx_min', + 'type' => 'key', + ]); + + $index = Index::fromDocument($doc); + + $this->assertSame('idx_min', $index->key); + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame([], $index->attributes); + $this->assertSame([], $index->lengths); + $this->assertSame([], $index->orders); + $this->assertSame(1, $index->ttl); + } + + public function testFromDocumentNormalizesLegacyOrderCase(): void + { + $index = Index::fromDocument(new Document([ + '$id' => 'idx_legacy', + 'type' => 'key', + 'attributes' => ['createdAt', 'name'], + 'orders' => ['asc', 'desc'], + ])); + + $this->assertSame([Order::Asc, Order::Desc], $index->orders); + $this->assertSame(['ASC', 'DESC'], $index->toDocument()->getAttribute('orders')); + } + + public function testFromDocumentUsesKeyOverId(): void + { + $doc = new Document([ + '$id' => 'id_value', + 'key' => 'key_value', + 'type' => 'index', + ]); + + $index = Index::fromDocument($doc); + $this->assertSame('key_value', $index->key); + } + + public function testAllIndexTypeValues(): void + { + $types = [ + IndexType::Key, + IndexType::Index, + IndexType::Unique, + IndexType::Fulltext, + IndexType::Spatial, + IndexType::HnswEuclidean, + IndexType::HnswCosine, + IndexType::HnswDot, + IndexType::Trigram, + IndexType::Ttl, + ]; + + foreach ($types as $type) { + $index = new Index(key: 'idx_' . $type->value, type: $type, attributes: ['col']); + $doc = $index->toDocument(); + $restored = Index::fromDocument($doc); + + $this->assertSame($type, $restored->type, "Roundtrip failed for type: {$type->value}"); + } + } + + public function testWithTTL(): void + { + $index = Index::ttl(key: 'idx_ttl', attributes: ['expiresAt'], ttl: 86400); + + $doc = $index->toDocument(); + $this->assertSame(86400, $doc->getAttribute('ttl')); + + $restored = Index::fromDocument($doc); + $this->assertSame(86400, $restored->ttl); + } + + public function testWithNullLengthsAndOrders(): void + { + $index = Index::key(key: 'idx_mixed', attributes: ['a', 'b'], lengths: [128, null], orders: [Order::Asc, null]); + + $doc = $index->toDocument(); + $this->assertSame([128, null], $doc->getAttribute('lengths')); + $this->assertSame(['ASC', null], $doc->getAttribute('orders')); + + $restored = Index::fromDocument($doc); + $this->assertSame([128, null], $restored->lengths); + $this->assertSame([Order::Asc, null], $restored->orders); + } + + public function testMultipleAttributeIndex(): void + { + $index = Index::key(key: 'idx_multi', attributes: ['firstName', 'lastName', 'email'], lengths: [64, 64, 256], orders: [Order::Asc, Order::Asc, Order::Desc]); + + $doc = $index->toDocument(); + $restored = Index::fromDocument($doc); + + $this->assertCount(3, $restored->attributes); + $this->assertCount(3, $restored->lengths); + $this->assertCount(3, $restored->orders); + } + + /** + * @return array + */ + public static function factories(): array + { + return [ + 'key' => ['key', IndexType::Key], + 'index' => ['index', IndexType::Index], + 'unique' => ['unique', IndexType::Unique], + 'fullText' => ['fullText', IndexType::Fulltext], + 'spatial' => ['spatial', IndexType::Spatial], + 'object' => ['object', IndexType::Object], + 'hnswEuclidean' => ['hnswEuclidean', IndexType::HnswEuclidean], + 'hnswCosine' => ['hnswCosine', IndexType::HnswCosine], + 'hnswDot' => ['hnswDot', IndexType::HnswDot], + 'trigram' => ['trigram', IndexType::Trigram], + 'ttl' => ['ttl', IndexType::Ttl], + ]; + } + + #[DataProvider('factories')] + public function testFactorySetsTypeAndDefaults(string $factory, IndexType $type): void + { + $index = Index::{$factory}(key: 'idx'); + + $this->assertInstanceOf(Index::class, $index); + $this->assertSame('idx', $index->key); + $this->assertSame($type, $index->type); + $this->assertSame([], $index->attributes); + $this->assertSame([], $index->lengths); + $this->assertSame([], $index->orders); + $this->assertSame(1, $index->ttl); + } + + public function testFactoryForwardsOptionalArguments(): void + { + $index = Index::key( + key: 'idx', + attributes: ['name', 'email'], + lengths: [128, 256], + orders: [Order::Asc, Order::Desc], + ttl: 3600, + ); + + $this->assertSame('idx', $index->key); + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame(['name', 'email'], $index->attributes); + $this->assertSame([128, 256], $index->lengths); + $this->assertSame([Order::Asc, Order::Desc], $index->orders); + $this->assertSame(3600, $index->ttl); + } + + public function testKeyFactory(): void + { + $index = Index::key('idx_name', ['name']); + + $this->assertSame('idx_name', $index->key); + $this->assertSame(IndexType::Key, $index->type); + $this->assertSame(['name'], $index->attributes); + } + + public function testFullTextFactory(): void + { + $index = Index::fullText('idx_body', ['body']); + + $this->assertSame('idx_body', $index->key); + $this->assertSame(IndexType::Fulltext, $index->type); + $this->assertSame(['body'], $index->attributes); + } + + public function testTypeStoredAsAnEnumReadsBack(): void + { + $index = Index::key(key: 'titleIndex', attributes: ['title']); + $index->setAttribute('type', IndexType::Unique); + + $this->assertSame(IndexType::Unique, $index->type); + } + + public function testMagicGetFallsBackToTheStoredValue(): void + { + $index = Index::key(key: 'titleIndex', attributes: ['title']); + $index->setAttribute('status', 'available'); + + $this->assertSame('available', $index->__get('status')); + $this->assertNull($index->__get('missing')); + } + + public function testNonArrayAttributesAndLengthsReadAsEmptyArrays(): void + { + $index = Index::key(key: 'titleIndex', attributes: ['title'], lengths: [16]); + $index->setAttribute('attributes', 'title'); + $index->setAttribute('lengths', 16); + + $this->assertSame([], $index->attributes); + $this->assertSame([], $index->lengths); + } + + public function testPropertyAssignmentWritesTheStoredIndex(): void + { + $index = Index::key(key: 'titleIndex', attributes: ['title']); + + $index->key = 'renamed'; + $index->type = IndexType::Unique; + $index->attributes = ['title', 'author']; + $index->ttl = 3600; + $index->__set('status', 'available'); + + $this->assertSame('renamed', $index->getId()); + $this->assertSame('renamed', $index->getAttribute('key')); + $this->assertSame('renamed', $index->toDocument()->getId()); + $this->assertSame(IndexType::Unique->value, $index['type']); + $this->assertSame(IndexType::Unique, $index->type); + $this->assertSame(['title', 'author'], $index->getAttribute('attributes')); + $this->assertSame(['title', 'author'], $index->attributes); + $this->assertSame(3600, $index->getAttribute('ttl')); + $this->assertSame(3600, $index->ttl); + $this->assertSame('available', $index->getAttribute('status')); + } + + public function testFromArrayRejectsAnOrderThatIsNeitherAStringNorAnOrder(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Index order must be Order or null'); + + Index::fromArray(['key' => 'titleIndex', 'type' => IndexType::Key->value, 'attributes' => ['title'], 'orders' => [1]]); + } + + public function testFromDocumentRejectsAnOrderThatIsNeitherAStringNorAnOrder(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Index order must be Order or null'); + + Index::fromDocument(new Document(['key' => 'titleIndex', 'type' => IndexType::Key->value, 'attributes' => ['title'], 'orders' => [1]])); + } +} diff --git a/tests/unit/Indexes/IndexSchemaReconciliationTest.php b/tests/unit/Indexes/IndexSchemaReconciliationTest.php new file mode 100644 index 0000000000..d3ef985e8e --- /dev/null +++ b/tests/unit/Indexes/IndexSchemaReconciliationTest.php @@ -0,0 +1,236 @@ +database(new class () extends Memory { + public function renameIndex(string $collection, string $old, string $new): bool + { + return false; + } + }); + + try { + $database->renameIndex(self::COLLECTION, 'existing', 'renamed'); + $this->fail('an adapter that renames nothing must fail the rename'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to rename index 'existing' to 'renamed': Failed to rename index", $error->getMessage()); + } + + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testAnAdapterThatDoesNotCreateTheIndexFailsTheCreate(): void + { + $database = $this->database(new class () extends Memory { + 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 (): bool => $database->createIndex(self::COLLECTION, $this->byName())); + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + public function testAnIndexOnlyInTheSchemaIsAdopted(): void + { + $database = $this->database(new class () extends Memory { + 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->assertTrue($database->createIndex(self::COLLECTION, $this->byName())); + $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 (DatabaseException $error) { + $this->assertSame("Failed to rename index 'existing' to 'renamed': Failed to rename index", $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')); + + $this->assertTrue($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 = []; + + 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); + + $this->assertTrue($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(); + } + + 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->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 testDeletingAnIndexTheSchemaNoLongerHasSucceeds(): void + { + $database = $this->database(new class () extends Memory { + public function deleteIndex(string $collection, string $id): bool + { + throw new NotFoundException('Index not found in the schema'); + } + }); + + $this->assertTrue($database->deleteIndex(self::COLLECTION, 'existing')); + $this->assertSame([], $this->indexKeys($database)); + } + + public function testAnAdapterThatDoesNotDeleteTheIndexFailsTheDelete(): void + { + $database = $this->database(new class () extends Memory { + public function deleteIndex(string $collection, string $id): bool + { + return false; + } + }); + + $this->assertRefused('Failed to delete index', fn (): bool => $database->deleteIndex(self::COLLECTION, 'existing')); + $this->assertSame(['existing'], $this->indexKeys($database)); + } + + /** + * @return list + */ + private function indexKeys(Database $database): array + { + /** @var array $indexes */ + $indexes = $database->getCollection(self::COLLECTION)->getAttribute('indexes', []); + + return \array_values(\array_map(static fn (Index $index): string => $index->key, $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(new Collection( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 32), Attribute::string(key: 'sku', size: 32)], + indexes: [new Index(key: 'existing', type: IndexType::Key, attributes: ['sku'])], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + return $database; + } + + private function byName(): Index + { + return new Index(key: 'byName', type: IndexType::Key, attributes: ['name']); + } + + /** + * @param callable(): mixed $operation + */ + private function assertRefused(string $message, callable $operation): void + { + try { + $operation(); + $this->fail('the operation must be refused'); + } catch (DatabaseException $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..bf494aa6c8 --- /dev/null +++ b/tests/unit/Indexes/IndexValidationTest.php @@ -0,0 +1,286 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + Capability::DefinedAttributes, + Capability::TTLIndexes, + ]); + }); + $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')->willReturnArgument(2); + } + + 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('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn('string'); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(1); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(5); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [Capability::Index, Capability::IndexArray, Capability::UniqueIndex, 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 new Document(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::key(key: 'idx_empty')); + } + + 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); + + $result = $this->database->createIndex('testCol', Index::key(key: 'idx_name', attributes: ['name'])); + $this->assertTrue($result); + } + + 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); + + $result = $this->database->renameIndex('testCol', 'idx_name', 'idx_new_name'); + $this->assertTrue($result); + } +} diff --git a/tests/unit/Indexes/OrphanIndexTest.php b/tests/unit/Indexes/OrphanIndexTest.php new file mode 100644 index 0000000000..9a8460e578 --- /dev/null +++ b/tests/unit/Indexes/OrphanIndexTest.php @@ -0,0 +1,178 @@ +assertIsString($path); + $this->path = $path; + $this->namespace = 'orphan_index_'.\uniqid(); + } + + 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->assertTrue($database->createIndex(self::COLLECTION, Index::unique(key: self::INDEX, attributes: ['email']))); + + $this->assertSame([['email'], 0], $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->assertTrue($database->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name']))); + + $this->assertSame([['name'], 1], $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'], 1], $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->assertTrue($second->createIndex(self::COLLECTION, Index::key(key: self::INDEX, attributes: ['name']))); + + $this->assertSame([['_tenant', 'name'], 1], $this->schemaIndex($first)); + $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(new Collection( + 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 { + public function getSchemaIndexes(string $collection): array + { + $prefix = '/^'.\preg_quote($this->getNamespace(), '/').'_[^_]*_'.\preg_quote($this->filter($collection), '/').'_/'; + + return \array_map( + fn (Document $index): Document => $index->setAttribute(Document::ID, \preg_replace($prefix, '', $index->getId()) ?? $index->getId()), + parent::getSchemaIndexes($collection), + ); + } + }; + } + + /** + * @return array{list, int} + */ + private function schemaIndex(Database $database): array + { + foreach ($database->getSchemaIndexes(self::COLLECTION) as $index) { + if ($index->getId() === self::INDEX) { + /** @var list $columns */ + $columns = $index->getAttribute('columns'); + /** @var int $nonUnique */ + $nonUnique = $index->getAttribute('nonUnique'); + + return [$columns, $nonUnique]; + } + } + + $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, + \array_values($database->getCollection(self::COLLECTION)->indexes), + ); + } +} diff --git a/tests/unit/JoinAliasSpellingTest.php b/tests/unit/JoinAliasSpellingTest.php new file mode 100644 index 0000000000..bff4f95247 --- /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(new Collection( + 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(new Collection( + 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, $left, 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collection, $left, 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collection, $left, 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $left, 'authorId', '=', $alias), + 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..44ee616ece --- /dev/null +++ b/tests/unit/JoinAliasTest.php @@ -0,0 +1,176 @@ +database(); + + $rows = $database->find('main', [ + Query::join('b', '$id', 'mainId', '=', 'j1'), + Query::join('c', '$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 testGeneratedAliasSkipsAnAliasDeclaredAfterIt(): void + { + $database = $this->database(); + + $rows = $database->find('main', [ + Query::join('b', '$id', 'mainId'), + Query::join('c', '$id', 'mainId', '=', 'j0'), + 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', '$id', 'mainId'), + Query::join('c', '$id', 'mainId', '=', 'j0'), + ])); + } + + /** + * @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', '$id', 'mainId', '=', 'x'), + Query::join('c', '$id', 'mainId', '=', 'x'), + ]]; + yield 'aliases that differ only in case' => [[ + Query::join('b', '$id', 'mainId', '=', 'x'), + Query::leftJoin('c', '$id', 'mainId', '=', 'X'), + ]]; + yield 'the main collection alias' => [[ + Query::join('b', '$id', 'mainId', '=', Query::DEFAULT_ALIAS), + ]]; + yield 'the main collection alias in another case' => [[ + Query::join('b', '$id', 'mainId', '=', \strtoupper(Query::DEFAULT_ALIAS)), + ]]; + yield 'a cross join alias that repeats an earlier one' => [[ + Query::join('b', '$id', 'mainId', '=', 'x'), + Query::crossJoin('c', 'x'), + ]]; + yield 'an alias that is not an identifier' => [[ + Query::join('b', '$id', 'mainId', '=', 'my-alias'), + ]]; + yield 'a nested join alias that repeats an earlier one' => [[ + Query::join('b', '$id', 'mainId', '=', 'x'), + 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(new Collection( + id: 'main', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + foreach (['b', 'c'] as $collection) { + $database->createCollection(new Collection( + 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..2b74f956c3 --- /dev/null +++ b/tests/unit/JoinImplicitProjectionTest.php @@ -0,0 +1,228 @@ +database(sharedTables: false); + + $rows = $database->find('customers', [ + Query::join('orders', '$id', 'customerId', '=', 'ord'), + ]); + + $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', '$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', '$id', 'customerId', '=', 'first'), + Query::leftJoin('orders', '$id', 'customerId', '=', 'second'), + ]); + + $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', '$id', 'customerId', '=', 'ord'), + ]); + + $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', '$id', 'customerId', '=', 'ord'), + 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', '$id', 'customerId', '=', 'ord'), + ]); + + $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', '$id', 'customerId', '=', 'ord'), + 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(new Collection( + id: 'customers', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(new Collection( + 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/JoinTenancyTest.php b/tests/unit/JoinTenancyTest.php new file mode 100644 index 0000000000..a1fb334241 --- /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(new Collection( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: $documentSecurity, + )); + $database->createCollection(new Collection( + id: self::BOOKS, + attributes: [ + Attribute::string(key: 'authorId', size: 64, required: true), + Attribute::integer(key: 'pages', required: true), + ], + permissions: $permissions, + documentSecurity: $documentSecurity, + )); + $database->createCollection(new Collection( + 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, '$id', 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collection, '$id', 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collection, '$id', 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collection, '$id', 'authorId', '=', $alias), + 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(new Collection( + 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, $on, 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collection, $on, 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collection, $on, 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $on, 'authorId', '=', $alias), + 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->getJoinAlias().'.'.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..c2a589190d --- /dev/null +++ b/tests/unit/JoinVectorCursorTest.php @@ -0,0 +1,121 @@ +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); + } + + /** + * @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', '$id', 'mainId', '=', 'meta'), + ], + 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/Joins/AggregateMinorsTest.php b/tests/unit/Joins/AggregateMinorsTest.php new file mode 100644 index 0000000000..a421b4c440 --- /dev/null +++ b/tests/unit/Joins/AggregateMinorsTest.php @@ -0,0 +1,287 @@ +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', 'item', 'code', '=', 'it'); + + $this->assertSame(40, $database->sum('orders', 'price', [$item]), 'a name only the join declares'); + $this->assertSame( + [['total' => 40]], + $this->rows($database->find('orders', [$item, Query::sum('price', 'total')])), + 'find() 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->enableProfiling(); + + foreach (['validated' => true, 'unvalidated' => false] as $case => $validate) { + $validate ? $database->enableValidation() : $database->disableValidation(); + $database->getProfiler()?->reset(); + + $this->assertSame(40, $database->sum('orders', 'price', [Query::join('items', 'item', 'code', '=', 'it')]), $case); + + $reads = \array_filter( + $database->getProfiler()?->getLogs() ?? [], + static fn (QueryLog $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', 'item', 'code', '=', 'it'); + $extra = Query::join('extras', 'item', 'code', '=', 'ex'); + + 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', 'item', 'code', '=', 'it'); + $extra = Query::join('extras', 'item', 'code', '=', 'ex'); + + $this->assertSame( + [['orders' => 2, 'name' => 'x', 'it.name' => 'apple'], ['orders' => 1, 'name' => 'y', 'it.name' => 'banana']], + $this->rows($database->find('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']], + $this->rows($database->find('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']], + $this->rows($database->find('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']], + $this->rows($database->find('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']], + $this->rows($database->find('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']], + $this->rows($database->find('orders', [ + Query::join('items', 'item', 'code', '=', 'it'), + Query::join('extras', 'item', 'code', '=', 'ex'), + 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]; + } + + /** + * @param array $documents + * @return list> + */ + private function rows(array $documents): array + { + return \array_values(\array_map(static fn (Document $document): array => $document->getArrayCopy(), $documents)); + } + + /** + * 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(new Collection( + id: 'orders', + attributes: [ + Attribute::string(key: 'item', size: 16), + Attribute::integer(key: 'quantity'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(new Collection( + 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(new Collection( + 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..a0c5da7821 --- /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]], $this->rows($database->find('main', [$joined, Query::sum('score', 'total')]))); + $this->assertSame([['score' => $total]], $this->rows($database->find('main', [$joined, Query::sum('score', 'score')]))); + $this->assertSame([['total' => $total, 'score' => $rows]], $this->rows($database->find('main', [$joined, Query::sum('score', 'total'), Query::count('*', 'score')])), 'another aggregate named like the attribute'); + $this->assertSame([['score' => $joinedTotal]], $this->rows($database->find('main', [$joined, Query::sum('a.score', 'score')])), 'a joined attribute keeps its alias'); + } + + #[DataProvider('joins')] + public function testUnaliasedAggregatesKeepTheNamesTheEngineGivesThem(Method $join, bool $native, int $total, int $joinedTotal, int $rows): void + { + $database = $this->database($native); + + $this->assertSame( + [['COUNT(*)' => $rows, 'SUM(`table_main`.`score`)' => $total, 'SUM(`a`.`score`)' => $joinedTotal]], + $this->rows($database->find('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', 'link', 'link', '=', 'a'), 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', 'link', 'link', '=', 'a'), Query::count('*', 'label'), Query::groupBy(['label'])], $grouped('label', 'label')]; + yield 'a grouped joined attribute' => [static fn (): array => [Query::join('a', 'link', 'link', '=', 'a'), 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)->find('main', $queries()); + $this->fail(($native ? 'native' : 'emulated').': the shape was accepted and returned '.\json_encode($this->rows($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']], + $this->rows($database->find('main', [Query::count('*', 'links'), Query::sum('score', 'total'), Query::groupBy(['link']), Query::orderAsc('link')])), + ); + $this->assertSame( + [['score' => 10, 'label' => 'first']], + $this->rows($database->find('main', [Query::join('a', 'link', 'link', '=', 'a'), 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', ['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(new Collection( + 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..b18893ccd3 --- /dev/null +++ b/tests/unit/Joins/BareJoinedOrderCursorTest.php @@ -0,0 +1,240 @@ +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, '$id', 'store', '=', 'of')]; + $cursor = $this->database->find(self::MAIN, [...$joins, Query::orderAsc(self::ALIAS.'.price'), Query::limit(1)])[0]; + + if (! $validate) { + $this->database->disableValidation(); + } + + 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, ['$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(new Collection( + 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/DistinctJoinedSelectTest.php b/tests/unit/Joins/DistinctJoinedSelectTest.php new file mode 100644 index 0000000000..9a53b01676 --- /dev/null +++ b/tests/unit/Joins/DistinctJoinedSelectTest.php @@ -0,0 +1,118 @@ +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', ['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', 'code', 'code', '=', 's'), + 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(new Collection( + 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..f49051881c --- /dev/null +++ b/tests/unit/Joins/FullOuterJoinAggregateTest.php @@ -0,0 +1,465 @@ +> + */ + 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->find('main', [ + Query::fullOuterJoin('b', self::LINK, self::LINK, '=', 'b'), + Query::count('*', 'rows'), + Query::sum('b.score', 'total'), + ]); + + $this->assertSame([['rows' => 6, 'total' => 22]], \array_map(static fn (Document $row): array => $row->getArrayCopy(), $rows)); + } + + public function testDistinctReturnsAValueBothHalvesHoldOnce(): void + { + $database = $this->database(native: false, documentSecurity: false, sharedTables: false); + + $rows = $database->find('main', [ + Query::fullOuterJoin('b', self::LINK, self::LINK, '=', 'b'), + 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->find('main', [ + Query::fullOuterJoin('b', self::LINK, self::LINK, '=', 'b'), + Query::count('*', 'rows'), + Query::sum('b.score', 'total'), + ]); + $this->assertSame([['rows' => 6, 'total' => 22]], \array_map(static fn (Document $row): array => $row->getArrayCopy(), $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 testUnaliasedAggregatesKeepTheNamesTheEngineGivesThem(): void + { + $emulated = $this->database(native: false, documentSecurity: false, sharedTables: false); + $native = $this->database(native: true, documentSecurity: false, sharedTables: false); + $queries = [ + Query::fullOuterJoin('b', self::LINK, self::LINK, '=', 'b'), + 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', self::LINK, self::LINK, '=', 'b'), + 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, $left, self::LINK, '=', $collection), + Method::LeftJoin => Query::leftJoin($collection, $left, self::LINK, '=', $collection), + Method::RightJoin => Query::rightJoin($collection, $left, self::LINK, '=', $collection), + default => Query::fullOuterJoin($collection, $left, self::LINK, '=', $collection), + }; + } + + /** + * @param list $queries + * @return list> + */ + private function rows(Database $database, array $queries, bool $ordered): array + { + $rows = \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(new Collection( + 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..a7ad360d70 --- /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, self::LINK, self::LINK, '=', 'j'); + + $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, self::LINK, self::LINK, '=', $alias)])], $alias), + 'getDocument', + ); + + $this->assertReadsMatch($emulated, $native, $alias, [ + 'joined' => [ + [Query::join(self::JOINED, self::LINK, self::LINK, '=', $alias)], + [['m1', 'first', 'j1', 1]], + ], + 'left joined, selecting its columns' => [ + [Query::leftJoin(self::JOINED, self::LINK, self::LINK, '=', $alias), 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, self::LINK, self::LINK, '=', $alias), 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(new Collection( + 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(new Collection( + 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..320b996ceb --- /dev/null +++ b/tests/unit/Joins/JoinCursorTest.php @@ -0,0 +1,569 @@ +useDatabase(new SQLite(new PDO('sqlite::memory:'))); + } + + public function testCursorWithoutItsJoinedOrderValueIsRefusedByName(): void + { + $queries = [Query::join('notes', '$id', 'author', '=', 'n'), 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', ['$id', '=', 'author', 'n']), ...$order], $rows); + } + + public function testCursorFromAnotherJoinShapeIsRefusedByTheJoinedIdItLacks(): void + { + $read = [Query::join('notes', '$id', 'author', '=', 'n'), Query::orderAsc('rank')]; + $foreign = $this->database->find('authors', [Query::join('notes', '$id', 'author', '=', 'other'), 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', '$id', 'author', '=', 'n'), 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', ['$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', '$id', 'author', '=', 'n'), 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', '$id', 'author', '=', 'n'), 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->iterate('notes', [Query::distinct(), Query::select(['label']), Query::orderAsc('label'), Query::limit(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) { + foreach (['cursor', 'iterate'] as $helper) { + yield "{$joinName}, {$helper}, joined id not selected" => [$join, $helper, [Query::select(['name', 'n.rank'])], 'n.$id']; + yield "{$joinName}, {$helper}, joined order not selected" => [$join, $helper, [Query::select(['name', 'n.$id']), Query::orderAsc('n.rank')], 'n.rank']; + } + } + } + + /** + * @param list $queries + */ + #[DataProvider('unpageableJoinedReads')] + public function testPagingAJoinedReadItCannotPageFailsBeforeYieldingARow(Method $join, string $helper, array $queries, string $missing): void + { + $queries = [new Query($join, 'notes', ['$id', '=', 'author', 'n']), ...$queries]; + $rows = $helper === 'cursor' + ? $this->database->cursor('authors', $queries, 2) + : $this->database->iterate('authors', [...$queries, Query::limit(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', '$id', 'author', '=', 'n'), 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(new Collection( + 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(new Collection( + id: 'drafts', + attributes: [Attribute::string(key: 'author', size: 16), Attribute::string(key: 'label', size: 16)], + indexes: [new Index('author_label', IndexType::Key, ['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', ['$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', 'author', '$id', '=', 'a'), false]; + yield 'left join on the joined $id, qualified' => [Query::leftJoin('authors', 'author', 'a.$id', '=', 'a'), false]; + yield 'join on another joined attribute' => [Query::join('authors', 'author', 'name', '=', 'a'), true]; + yield 'right join on the joined $id' => [Query::rightJoin('authors', 'author', '$id', '=', 'a'), true]; + yield 'full outer join on the joined $id' => [Query::fullOuterJoin('authors', 'author', '$id', '=', 'a'), true]; + yield 'join on the joined $id with another operator' => [Query::join('authors', 'author', '$id', '!=', 'a'), true]; + } + + #[DataProvider('joinTieKeys')] + public function testJoinedIdBreaksTiesOnlyWhenAJoinCanPairSeveralRows(Query $join, bool $ordersByJoinedId): void + { + $this->database->enableProfiling(); + $this->database->getProfiler()?->reset(); + + $rows = $this->database->find('notes', [$join, Query::orderAsc('label')]); + + $selects = \array_values(\array_filter( + $this->database->getProfiler()?->getLogs() ?? [], + static fn (QueryLog $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); + } + + 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(new Collection( + 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(new Collection( + 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..75f2e08aca --- /dev/null +++ b/tests/unit/Joins/JoinInternalColumnsTest.php @@ -0,0 +1,440 @@ +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->find('customers', [ + $this->join($join), + Query::count('*', 'rows'), + Query::groupBy(['note.'.$attribute]), + ]); + + $total = 0; + foreach ($groups as $group) { + $this->assertArrayHasKey(Storage::column($attribute), $group->getArrayCopy(), 'a group comes back under its column, as on the main collection'); + $count = $group->getAttribute('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->find('customers', [ + $this->join($join), + Query::count('*', 'rows'), + Query::groupBy(['note.$id']), + Query::orderAsc('note.$id'), + ]); + + $this->assertSame($expected, \array_map(static fn (Document $group): mixed => $group->getAttribute(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->rows($this->database->find('customers', [Query::count('$tenant', 'total')]))); + $this->assertSame([['rows' => 3, Storage::TENANT => 1]], $this->rows($this->database->find('customers', [Query::count('*', 'rows'), Query::groupBy(['$tenant'])]))); + $this->assertSame([['total' => 3]], $this->rows($this->database->find('customers', [$note, Query::count('note.$tenant', 'total')]))); + $this->assertSame([['rows' => 3, Storage::TENANT => 1]], $this->rows($this->database->find('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', '$id', 'customerId', '=', 'note'); + $notFound = 'Invalid query: Attribute not found in schema: '; + + yield 'an unknown right column' => [[Query::join('notes', '$id', 'nothing', '=', 'note')], $notFound.'nothing']; + yield 'an unknown left column' => [[Query::join('notes', 'nothing', 'customerId', '=', 'note')], $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', 'note.nothing', 'noteId', '=', 'reply')], $notFound.'note.nothing']; + yield 'a main attribute under the join alias' => [[Query::rightJoin('notes', '$id', 'note.name', '=', 'note')], $notFound.'note.name']; + yield 'a join declared after it' => [ + [Query::join('replies', 'note.$id', 'noteId', '=', 'reply'), $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', 'note.$id', 'noteId', '=', 'reply')]), 'a chained join reads the join before it'); + $this->assertSame(1, $this->database->count('customers', [Query::crossJoin('replies', 'reply'), Query::join('notes', 'reply.noteId', '$id', '=', 'note'), 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', '$sequence', '$sequence', '<', 'note'), 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', 'library', '$id', '=', 'lib'), 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->rows($this->database->find('persons', [Query::join('books', '$id', 'owner', '=', 'book'), 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', 'owner', '$id', '=', 'person')])); + + $this->assertInvalidQuery( + 'Invalid query: Cannot join on virtual relationship attribute: books', + fn (): mixed => $this->database->find('persons', [Query::join('books', 'books', '$id', '=', 'book')]), + ); + $this->assertInvalidQuery( + 'Invalid query: Cannot join on virtual relationship attribute: person', + fn (): mixed => $this->database->find('persons', [Query::join('libraries', '$id', 'person', '=', 'lib')]), + ); + } + + /** + * @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', '$id', 'holderId', '=', 'vault'); + + $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->rows($this->database->find('customers', [$note, Query::count('note.$id', 'notes'), Query::sum('note.score', 'total')]))); + $this->assertCount(3, $this->database->find('customers', [$note, Query::count('*', 'rows'), Query::groupBy(['note.body'])])); + } + + private function join(Method $method): Query + { + return new Query($method, 'notes', ['$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); + } + + /** + * @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 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->database)); + + $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(Relationship::oneToOne(collection: 'persons', relatedCollection: 'libraries', key: 'library', twoWayKey: 'person')); + $this->database->createRelationship(Relationship::oneToMany(collection: 'persons', 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 array $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(new Collection( + 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..f2895f59d4 --- /dev/null +++ b/tests/unit/Joins/JoinResolutionTest.php @@ -0,0 +1,396 @@ +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', 'theme', '$id', '=', 'th'); + + $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', 'theme', '$id', '=', 'th'), + 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->find('tickets', [ + Query::join('themes', 'theme', '$id', '=', 'th'), + 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->find('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->find('tickets', [ + Query::join('themes', 'theme', '$id', '=', 'th'), + Query::max('th.when', 'latest'), + Query::groupBy(['name']), + Query::having([Query::greaterThan('latest', self::LATER_THAN_EIGHT_UTC)]), + ]); + + $names = \array_map(static fn (Document $row): mixed => $row->getAttribute('name'), $rows); + \sort($names); + $this->assertSame(['first', 'third'], $names); + } + + public function testAJoinReadResolvesEachJoinedCollectionOnce(): void + { + $cache = new class () extends HashAwareMemoryCache { + /** + * @var list + */ + public array $loads = []; + + 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', 'theme', '$id', '=', 'th'); + $selfJoin = Query::join('themes', 'th.$id', '$id', '=', 'tx'); + $reads = [ + 'find()' => fn (): mixed => $this->database->find('tickets', [$join, Query::containsAny('th.tags', ['a'])]), + 'find() of an aggregate' => fn (): mixed => $this->database->find('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', 'theme', '$id', '=', 'th'.$index), + \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', 'theme', '$id', '=', 'th'); + $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', 'theme', '$id', '=', 'th'), + 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', 'theme', '$id', '=', 'th'); + $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] + SQLite::getPDOAttributes()); + $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(new Collection( + 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(new Collection( + 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..4ef25f9b44 --- /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', '$id', 'customerId', '=', 'purchase'); + + foreach ([true, false] as $validate) { + $mode = $validate ? 'validated' : 'unvalidated'; + + $totals = $this->findCustomers($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]->getAttribute('total'), $mode.': a bare joined attribute sums every order, the one without a customer included'); + $this->assertSame(4, $totals[0]->getAttribute('visits'), $mode.': a bare main attribute stays on the main table, even aggregated under its own name'); + $this->assertSame(3, $totals[0]->getAttribute('customers'), $mode.': $id counts the rows that have a customer'); + $this->assertSame(4, $totals[0]->getAttribute('rows'), $mode); + + $byStatus = []; + foreach ($this->findCustomers($validate, [$purchases, Query::sum('amount', 'total'), Query::groupBy(['status'])]) as $group) { + $status = $group->getAttribute('status'); + $this->assertIsString($status, $mode); + $byStatus[$status] = $group->getAttribute('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', '$id', 'customerId', '=', 'alpha'), + Query::join('refunds', '$id', 'customerId', '=', 'beta'), + Query::sum('amount', 'total'), + ], + 'Attribute not found in schema: also_anything' => [ + $purchases, + Query::sum('also_anything', 'total'), + ], + ] as $message => $queries) { + try { + $this->findCustomers($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 array + */ + private function findCustomers(bool $validate, array $queries): array + { + return $validate + ? $this->database->find('customers', $queries) + : $this->database->skipValidation(fn (): array => $this->database->find('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), + ], [ + new Index(key: 'body_fulltext', type: IndexType::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->find('customers', [ + Query::leftJoin('orders', '$id', 'customerId', '=', 'j'), + 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->find('customers', [ + Query::join('orders', '$id', 'customerId', '=', 'alpha'), + Query::join('refunds', '$id', 'customerId', '=', 'beta'), + 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->find('customers', [ + Query::join('orders', '$id', 'customerId', '=', 'alpha'), + Query::join('refunds', '$id', 'customerId', '=', 'beta'), + Query::count('*', 'rows'), + Query::groupBy(['amount']), + ]); + } + + public function testQualifiedAttributesStillPickTheirJoin(): void + { + $results = $this->database->find('customers', [ + Query::join('orders', '$id', 'customerId', '=', 'alpha'), + Query::join('refunds', '$id', 'customerId', '=', 'beta'), + Query::sum('alpha.amount', 'ordered'), + Query::sum('beta.amount', 'refunded'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $results[0]->getAttribute('ordered')); + $this->assertSame(10, $results[0]->getAttribute('refunded')); + } + + public function testBareAggregateAttributeResolvesToTheOneJoinThatDeclaresIt(): void + { + $results = $this->database->find('customers', [ + Query::join('notes', '$id', 'customerId', '=', 'note'), + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $results[0]->getAttribute('total')); + } + + public function testBareGroupByAttributeResolvesToTheOneJoinThatDeclaresIt(): void + { + $results = $this->database->find('customers', [ + Query::join('notes', '$id', 'customerId', '=', 'note'), + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + Query::groupBy(['status']), + ]); + + $totals = []; + foreach ($results as $result) { + $status = $result->getAttribute('status'); + $this->assertIsString($status); + $totals[$status] = $result->getAttribute('total'); + } + \ksort($totals); + + $this->assertSame(['open' => 50, 'paid' => 100], $totals); + } + + public function testBareAttributeResolvesThroughJoinsWithoutAliases(): void + { + $results = $this->database->find('customers', [ + Query::join('notes', '$id', 'customerId'), + Query::join('orders', '$id', 'customerId'), + Query::sum('amount', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(150, $results[0]->getAttribute('total')); + } + + public function testBareAttributeOfTheMainCollectionIsNotReboundToAJoin(): void + { + $results = $this->database->find('customers', [ + Query::join('profiles', '$id', 'customerId', '=', 'profile'), + Query::sum('visits', 'total'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(1, $results[0]->getAttribute('total')); + } + + public function testBareInternalAttributeResolvesToTheMainCollection(): void + { + $results = $this->database->find('customers', [ + Query::leftJoin('notes', '$id', 'customerId', '=', 'note'), + Query::count('$id', 'customers'), + ]); + + $this->assertCount(1, $results); + $this->assertSame(2, $results[0]->getAttribute('customers')); + } + + public function testAdapterResolvesBareAttributesTheSameWayWithoutValidation(): void + { + $results = $this->database->skipValidation(fn () => $this->database->find('customers', [ + Query::join('notes', '$id', 'customerId', '=', 'note'), + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + ])); + $this->assertSame(150, $results[0]->getAttribute('total')); + + foreach ([ + 'Attribute "amount" is ambiguous across joins; qualify it with a join alias' => [ + Query::join('orders', '$id', 'customerId', '=', 'alpha'), + Query::join('refunds', '$id', 'customerId', '=', 'beta'), + Query::sum('amount', 'total'), + ], + 'Attribute not found in schema: also_anything' => [ + Query::leftJoin('orders', '$id', 'customerId', '=', 'j'), + Query::sum('also_anything', 'total'), + ], + ] as $message => $queries) { + try { + $this->database->skipValidation(fn () => $this->database->find('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', '$id', 'customerId', '=', 'purchase'), + 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', '$id', 'customerId', '=', 'note'), + 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', '$id', 'customerId', '=', 'gone'), + Query::search('gone.body', 'needle'), + ]); + } + + public function testJoinedResolutionDoesNotCarryOverToAFindWithoutJoins(): void + { + $joined = $this->database->find('customers', [ + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + ]); + $this->assertSame(157, $joined[0]->getAttribute('total')); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Invalid query: Attribute not found in schema: amount'); + + $this->database->find('customers', [Query::sum('amount', 'total')]); + } + + /** + * @param array $attributes + * @param array $indexes + */ + private function createCollection(string $id, array $attributes, array $indexes = []): void + { + $this->database->createCollection(new Collection( + 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..9e85e7f56a --- /dev/null +++ b/tests/unit/Joins/JoinedColumnValidationTest.php @@ -0,0 +1,421 @@ +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->find('customers', [self::join(), Query::count('note.nothing', 'total')])], + 'sum aggregate' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::sum('note.nothing', 'total')])], + 'groupBy' => [static fn (Database $database): mixed => $database->find('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->find('customers', [self::join(), Query::count('note.body', 'total')])], + 'known column in a sum' => [static fn (Database $database): mixed => $database->find('customers', [self::join(), Query::sum('note.score', 'total')])], + 'known column in a groupBy' => [static fn (Database $database): mixed => $database->find('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->find('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', 'note.$id', 'noteId', '=', 'reply'), + 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->find('customers', [self::join(), $aggregate]); + } + + public function testArithmeticAggregatesOfNumericJoinedAttributesStayValid(): void + { + $results = $this->database->find('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]->getAttribute('total')); + $this->assertSame(0.5, $results[0]->getAttribute('average')); + $this->assertSame(3, $results[0]->getAttribute('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', '$id', 'customerId', '=', 'note'); + } + + /** + * @param array $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(new Collection( + 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..9cc4e263a9 --- /dev/null +++ b/tests/unit/Joins/JoinedDecodeTest.php @@ -0,0 +1,390 @@ + + */ + private ArrayObject $witnessed; + + protected function setUp(): void + { + $this->witnessed = new ArrayObject(); + } + + public function testImplicitProjectionDecodesLikeADirectRead(): void + { + $database = $this->database(); + + $rows = $database->find('customers', [Query::join('orders', '$id', 'customerId', '=', 'ord')]); + + $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', '$id', 'customerId', '=', 'ord'), + 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', '$id', 'customerId', '=', 'ord'), + 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', '$id', 'customerId', '=', 'ord'); + + $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', '$id', 'customerId', '=', 'ord'), + Query::join('refunds', '$id', 'customerId', '=', 'ref'), + ]); + + $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', '$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', '$id', 'customerId'), + Query::join('refunds', '$id', 'customerId', '=', 'j0'), + ]); + $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', '$id', 'customerId', '=', 'ord'), + 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', '$id', 'customerId', '=', 'ord'); + + $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', '$id', 'customerId', '=', 'ord'), + 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', '$id', 'customerId', '=', 'ord'), + 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] + SQLite::getPDOAttributes()); + $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(new Collection( + id: 'customers', + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(new Collection( + 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: ['json']), + Attribute::string(key: 'secret', size: 1024, filters: ['sealed']), + Attribute::string(key: 'profile', size: 1024, filters: ['json', 'sealed']), + Attribute::string(key: 'note', size: 256, filters: ['witness']), + ], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(new Collection( + 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 array + */ + private function filters(): array + { + return [ + 'sealed' => [ + 'encode' => 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', + ]); + }, + 'decode' => 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'])); + }, + ], + 'witness' => [ + 'encode' => static fn (mixed $value): mixed => \is_string($value) ? self::WITNESS_PREFIX.$value : $value, + 'decode' => 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; + }, + ], + ]; + } +} diff --git a/tests/unit/Joins/JoinedShapesTest.php b/tests/unit/Joins/JoinedShapesTest.php new file mode 100644 index 0000000000..e5a73a1ef7 --- /dev/null +++ b/tests/unit/Joins/JoinedShapesTest.php @@ -0,0 +1,234 @@ +database(); + $item = Query::join('items', 'item', 'code', '=', 'it'); + + $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()); + + $this->assertSame( + $this->arrays($database->find('orders', [$item, Query::orderAsc('$id')])), + $this->arrays($database->find('orders', [$item, Query::select(['*', 'it.*']), Query::orderAsc('$id')])), + 'next to * it adds what the read returns anyway', + ); + $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', 'item', 'code', '=', 'ex'), Query::select(['quantity', 'ex.*'])]); + $this->assertCount(2, $both); + $this->assertSame(['ex.$id', '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', 'item', 'code', '=', 'it'); + + $this->assertRefused( + 'Invalid query: Cannot select "it.*": an aggregation query can only select the attributes it groups by', + fn (): mixed => $database->find('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', 'item', 'code', '=', 'it'); + + $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']], + $this->arrays($database->find('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']], + $this->arrays($database->find('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', 'item', 'code', '=', 'it'); + + $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', 'item', 'code', '=', 'it'), Query::join('extras', 'item', 'code', '=', 'ex')]; + $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->disableValidation(); + $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)); + } + + /** + * 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(new Collection( + id: 'orders', + attributes: [ + Attribute::string(key: 'item', size: 16), + Attribute::integer(key: 'quantity'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(new Collection( + id: 'items', + attributes: [ + Attribute::string(key: 'code', size: 16), + Attribute::integer(key: 'price'), + Attribute::string(key: 'name', size: 16), + ], + permissions: $permissions, + )); + $database->createCollection(new Collection( + 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..56c29508f8 --- /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(new Collection(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]; + $expected = $definition->toDocument(); + + $this->assertSame($definition->type, $stored->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..abb0c85e96 --- /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(new Collection( + 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/MariaDBMinorsTest.php b/tests/unit/MariaDBMinorsTest.php new file mode 100644 index 0000000000..aaf03ddb5f --- /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->getMaxVarcharLength(), $adapter->getMaxVarcharLength()); + $this->assertSame($connection->getMaxVarcharLength(), $adapter->getMaxVarcharLength()); + } + + /** + * 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..8ecb0d8979 --- /dev/null +++ b/tests/unit/MemoryPermissionsTenantPerDocumentTest.php @@ -0,0 +1,230 @@ +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(new Collection( + 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/MirrorReplicationTest.php b/tests/unit/MirrorReplicationTest.php new file mode 100644 index 0000000000..e320f6a955 --- /dev/null +++ b/tests/unit/MirrorReplicationTest.php @@ -0,0 +1,656 @@ + + */ + 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 = []; + + 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 (string $action, Throwable $error): void { + $this->errors[] = [$action, $error->getMessage()]; + }); + + $this->authorization->skip(function (): void { + $this->mirror->createCollection(new Collection( + 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(new Collection( + 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 testWritesToDifferentDocumentsDoNotWaitForEachOther(): void + { + $this->delays = ['slow' => 0.05, 'fast' => 0.01]; + + $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->assertSame([], $this->errors); + $this->assertSame([['second', 'fast'], ['first', 'slow']], $this->titlesWritten()); + } + + public function testFinishedReplicationsAreReleased(): void + { + $mirror = new class ($this->mirror->getSource(), $this->destination) extends Mirror { + public function countPendingReplications(): int + { + $pending = \count($this->collectionReplications); + foreach ($this->documentReplications as $documents) { + $pending += \count($documents); + } + + return $pending; + } + }; + $this->delays = ['v0' => 0.01, 'v1' => 0.01]; + $pending = null; + + $this->inCoroutine(function () use ($mirror, &$pending): void { + $mirror->createDocuments(self::NOTES, [ + new Document([Document::ID => 'first', 'title' => 'v0']), + new Document([Document::ID => 'second', 'title' => 'v0']), + ]); + $mirror->updateDocuments(self::NOTES, new Document(['title' => 'v1'])); + $mirror->deleteDocument(self::NOTES, 'public'); + $pending = $mirror->countPendingReplications(); + }); + + $this->assertSame(2, $pending, 'The bulk update waits on the create, and the delete on the bulk update'); + $this->assertSame(0, $mirror->countPendingReplications()); + } + + 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->skipDuplicates(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->getPreserveDates()); + } + + 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 (string $action, Throwable $error): void { + $this->errors[] = [$action, $error->getMessage()]; + }); + foreach ([1, 2] as $tenant) { + $mirror->setTenant($tenant); + $mirror->createCollection(new Collection( + 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): bool => $mirror->deleteAttribute(self::NOTES, 'views')]; + yield 'renameAttribute' => [static fn (Mirror $mirror): bool => $mirror->renameAttribute(self::NOTES, 'views', 'count')]; + yield 'deleteCollection' => [static fn (Mirror $mirror): bool => $mirror->deleteCollection(self::NOTES)]; + yield 'updateCollection' => [static fn (Mirror $mirror): Document => $mirror->updateCollection(self::NOTES, [Permission::create(Role::any())], false)]; + yield 'createIndex' => [static fn (Mirror $mirror): bool => $mirror->createIndex(self::NOTES, new Index(key: 'views_index', type: IndexType::Key, attributes: ['views']))]; + yield 'updateAttributeRequired' => [static fn (Mirror $mirror): Document => $mirror->updateAttributeRequired(self::NOTES, 'title', false)]; + yield 'createRelationship' => [static fn (Mirror $mirror): bool => $mirror->createRelationship(new Relationship( + collection: self::SECRETS, + relatedCollection: self::NOTES, + type: RelationType::ManyToOne, + 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 testASchemaChangeDoesNotWaitForAnotherCollectionsReplications(): void + { + $this->authorization->skip(fn (): bool => $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([], $writesBeforeTheChangeReturned); + $this->assertSame([], $this->errors); + $this->assertSame([['first', 'slow']], $this->writesOf('first')); + } + + /** + * @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. + */ + 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]; + }; + + return new class (new PDO('sqlite::memory:'), $delay, $record) extends SQLite { + /** + * @param Closure(string): float $delay + * @param Closure(string, string): void $record + */ + public function __construct(PDO $pdo, private readonly Closure $delay, private readonly Closure $record) + { + parent::__construct($pdo); + } + + public function getDocument(Document $collection, string $id, array $queries = [], bool $forUpdate = false): Document + { + if (Coroutine::getCid() > 0) { + Coroutine::sleep(0.001); + } + if ($forUpdate) { + $this->written($id, MirrorReplicationTest::LOCKED); + } + + return parent::getDocument($collection, $id, $queries, $forUpdate); + } + + public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float|string $value, string $updatedAt, int|float|string|null $min = null, int|float|string|null $max = null): bool + { + $increased = parent::increaseDocumentAttribute($collection, $id, $attribute, $value, $updatedAt, $min, $max); + $this->written($id, MirrorReplicationTest::INCREASED); + + return $increased; + } + + public function createDocuments(Document $collection, array $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; + } + + public function createDocument(Document $collection, Document $document): Document + { + $this->wait($document->getAttribute('title', '')); + $created = parent::createDocument($collection, $document); + $this->written($document->getId(), $document->getAttribute('title', '')); + + return $created; + } + + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $this->wait($document->getAttribute('title', '')); + $updated = parent::updateDocument($collection, $id, $document, $skipPermissions); + $this->written($id, $document->getAttribute('title', '')); + + return $updated; + } + + public function updateDocuments(Document $collection, Document $updates, array $documents): int + { + $this->wait($updates->getAttribute('title', '')); + $modified = parent::updateDocuments($collection, $updates, $documents); + foreach ($documents as $document) { + $this->written($document->getId(), $updates->getAttribute('title', '')); + } + + return $modified; + } + + /** + * @param array $changes + * @return array + */ + public function upsertDocuments(Document $collection, string $attribute, array $changes): array + { + $title = $changes[0]->getNew()->getAttribute('title', ''); + $this->wait($title); + if ($title === 'broken') { + throw new RuntimeException('destination rejected broken'); + } + $upserted = parent::upsertDocuments($collection, $attribute, $changes); + foreach ($changes as $change) { + $this->written($change->getNew()->getId(), $change->getNew()->getAttribute('title', '')); + } + + return $upserted; + } + + public function deleteDocument(string $collection, string $id): bool + { + $this->wait(MirrorReplicationTest::DELETED); + $deleted = parent::deleteDocument($collection, $id); + $this->written($id, MirrorReplicationTest::DELETED); + + return $deleted; + } + + 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..16a5f6f1e3 --- /dev/null +++ b/tests/unit/MirrorTest.php @@ -0,0 +1,1761 @@ +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->getSharedTables()); + $this->assertTrue($source->getSharedTables()); + $this->assertTrue($destination->getSharedTables()); + } + + public function testCreateCreatesMetadataOnDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror + ->setDatabase('utopiaTests') + ->setNamespace('myapp') + ->create(); + + $this->assertTrue($source->exists('utopiaTests')); + $this->assertTrue($source->exists('utopiaTests', Database::METADATA)); + $this->assertTrue($destination->exists('utopiaTests')); + $this->assertTrue($destination->exists('utopiaTests', Database::METADATA)); + } + + public function testListCollectionsHidesSourceOnlyUpgrades(): void + { + [$mirror, $source] = $this->pair(); + + $mirror + ->setDatabase('utopiaTests') + ->setNamespace('myapp') + ->create(); + + $mirror->createCollection(new Collection(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->assertFalse($source->getCollection('upgrades')->isEmpty()); + } + + public function testSkipValidationRestoresSourceAndDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $this->assertTrue($mirror->isValidationEnabled()); + $this->assertTrue($source->isValidationEnabled()); + $this->assertTrue($destination->isValidationEnabled()); + + $mirror->skipValidation(function () use ($mirror, $source, $destination) { + $this->assertFalse($mirror->isValidationEnabled()); + $this->assertFalse($source->isValidationEnabled()); + $this->assertFalse($destination->isValidationEnabled()); + }); + + $this->assertTrue($mirror->isValidationEnabled()); + $this->assertTrue($source->isValidationEnabled()); + $this->assertTrue($destination->isValidationEnabled()); + } + + public function testDisableValidationDelegatesToSourceAndDestination(): void + { + [$mirror, $source, $destination] = $this->pair(); + + $mirror->disableValidation(); + + $this->assertFalse($mirror->isValidationEnabled()); + $this->assertFalse($source->isValidationEnabled()); + $this->assertFalse($destination->isValidationEnabled()); + + $mirror->enableValidation(); + + $this->assertTrue($mirror->isValidationEnabled()); + $this->assertTrue($source->isValidationEnabled()); + $this->assertTrue($destination->isValidationEnabled()); + } + + public function testCreateThrowsWhenDestinationCreateFails(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $destination = new Database(new class () extends Memory { + 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 QueryCache(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 testTriggerInvalidatesTheMirrorQueryCacheAndDispatchesOnce(): void + { + $source = new Database(new Memory(), new Cache(new None())); + $mirror = new class ($source) extends Mirror { + public function fire(Event $event, mixed $data): void + { + $this->trigger($event, $data); + } + }; + $this->seed($mirror); + $mirror->setQueryCache(new QueryCache(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(Event::DocumentUpdate, $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), + ]; + yield 'addLifecycleHook' => [ + static fn (Mirror $mirror, Invalidator $invalidator): mixed => $mirror->addLifecycleHook($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 QueryCache(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 QueryCache(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 QueryCache(new Cache(new None())); + $typeRegistry = new TypeRegistry(); + $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->getTenantPerDocument(), + true, + ]; + yield 'setTimeout' => [ + static fn (Mirror $mirror): mixed => $mirror->setTimeout(500), + static fn (Database $database): mixed => $database->getAdapter()->getTimeout(), + 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 => $database->getAdapter()->getTimeout(), + 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 'disableFilters' => [ + static fn (Mirror $mirror): mixed => $mirror->disableFilters(), + $meta, + '{"filtered":true}', + ]; + yield 'enableFilters' => [ + static fn (Mirror $mirror): mixed => self::onEach($mirror, static fn (Database $database): mixed => $database->disableFilters())->enableFilters(), + $meta, + ['filtered' => true], + ]; + yield 'enableLocks' => [ + static fn (Mirror $mirror): mixed => $mirror->enableLocks(true), + static fn (Database $database): mixed => $database->getAdapter()->getAlterLocks(), + true, + ]; + yield 'enableProfiling' => [ + static fn (Mirror $mirror): mixed => $mirror->enableProfiling(), + static fn (Database $database): mixed => $database->getProfiler()?->isEnabled(), + true, + ]; + yield 'disableProfiling' => [ + static fn (Mirror $mirror): mixed => self::onEach($mirror, static fn (Database $database): mixed => $database->enableProfiling())->disableProfiling(), + 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 'setTypeRegistry' => [ + static fn (Mirror $mirror): mixed => $mirror->setTypeRegistry($typeRegistry), + static fn (Database $database): mixed => $database->getTypeRegistry(), + $typeRegistry, + ]; + } + + /** + * @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($callback), + static fn (Database $database): mixed => $database->getPreserveDates(), + true, + false, + true, + ]; + yield 'withPreserveSequence' => [ + static fn (Mirror $mirror, Closure $callback): mixed => $mirror->withPreserveSequence($callback), + static fn (Database $database): mixed => $database->getPreserveSequence(), + 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($mirror)); + $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( + 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->enableProfiling(); + + $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->disableProfiling(); + + $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 => $database->getAdapter()->getTimeout(), + 500, + ]; + yield 'clearTimeout' => [ + 'clearTimeout', + static function (Mirror $mirror): void { + $mirror->clearTimeout(); + }, + static fn (Database $database): mixed => $database->getAdapter()->getTimeout(), + 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 { + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + throw new RuntimeException('destination unreachable'); + } + + 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 (string $failed, Throwable $error) use (&$errors): void { + $errors[] = [$failed, $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 'upsertDocumentsWithIncrease' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocumentsWithIncrease(self::COLLECTION, 'views', [$document]), + ]; + } + + /** + * @param Closure(Mirror, Document): mixed $upsert + */ + #[DataProvider('upserts')] + public function testUpsertThroughMirrorFiresEachEventOnce(Closure $upsert): 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::DocumentsUpsert], $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 (string $action, Throwable $error) use (&$errors): void { + $errors[] = [$action, $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), + 'upsertDocuments', + ]; + yield 'upsertDocuments' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocuments(self::COLLECTION, [$document]), + 'upsertDocuments', + ]; + yield 'upsertDocumentsWithIncrease' => [ + static fn (Mirror $mirror, Document $document): mixed => $mirror->upsertDocumentsWithIncrease(self::COLLECTION, 'views', [$document]), + 'upsertDocumentsWithIncrease', + ]; + } + + /** + * @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 + */ + public function upsertDocuments(Document $collection, string $attribute, array $changes): array + { + throw new RuntimeException('destination unreachable'); + } + }, new Cache(new None())); + $mirror = $this->seed(new Mirror(self::sqlite(), $destination)); + $errors = []; + $mirror->onError(static function (string $failed, Throwable $error) use (&$errors): void { + $errors[] = [$failed, $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 = new Collection(id: self::COLLECTION, attributes: [ + Attribute::string(key: 'meta', size: 64, filters: ['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(new Collection( + 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 { + /** + * @return array + */ + public function capabilities(): array + { + return [...parent::capabilities(), Capability::AlterLock]; + } + + public function setTimeout(int $milliseconds, Event $event = Event::All): void + { + $this->setTimeoutState($milliseconds, $event); + } + + public function clearTimeout(Event $event = Event::All): void + { + $this->clearTimeoutState($event); + } + }; + } + + /** + * @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 testEnableLocksFailureOnTheDestinationReachesOnError(): void + { + $source = new Database(self::configurableAdapter(), new Cache(new None())); + $destination = new Database(new class () extends Memory { + /** + * @return array + */ + public function capabilities(): array + { + return [...parent::capabilities(), Capability::AlterLock]; + } + + public function enableAlterLocks(bool $enable): self + { + throw new RuntimeException('destination unreachable'); + } + }, new Cache(new None())); + $mirror = new Mirror($source, $destination); + $errors = []; + $mirror->onError(static function (string $action, Throwable $error) use (&$errors): void { + $errors[] = [$action, $error->getMessage()]; + }); + + $mirror->enableLocks(true); + + $this->assertTrue($source->getAdapter()->getAlterLocks()); + $this->assertSame([['enableLocks', '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 { + public function decorate(Event $event, Document $collection, Document $document): Document + { + return $document->setAttribute('decoratedFor', $collection->getId()); + } + }); + $errors = []; + $mirror->onError(static function (string $action, Throwable $error) use (&$errors): void { + $errors[] = [$action, $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 $mirror->getSource()->getAdapter()->getTimeout(); + }, + 500, + ]; + yield 'disableValidation' => [ + static function (Mirror $mirror): mixed { + $mirror->disableValidation(); + + return [$mirror->isValidationEnabled(), $mirror->getSource()->isValidationEnabled()]; + }, + [false, false], + ]; + yield 'exists' => [ + static fn (Mirror $mirror): mixed => $mirror->exists('mirror', self::COLLECTION), + 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 (string $action, Throwable $error) use (&$errors): void { + $errors[] = [$action, $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->isValidationEnabled(), $source->isValidationEnabled()]); + + $this->assertSame([false, false], $inside); + $this->assertSame([true, true], [$mirror->isValidationEnabled(), $source->isValidationEnabled()]); + } + + 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(new Collection(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->assertFalse($destination->getCollection('filtered')->isEmpty()); + $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(new Collection(id: 'skipped', attributes: [Attribute::string(key: 'title', size: 64)])); + + $this->assertSame([['beforeCreateCollection', 'skipped', 'skipped']], $calls->getArrayCopy()); + $this->assertSame('skipped', $created->getId()); + $this->assertFalse($source->getCollection('skipped')->isEmpty()); + $this->assertTrue($destination->getCollection('skipped')->isEmpty()); + $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, [Permission::read(Role::users())], false); + + $this->assertSame([['beforeUpdateCollection', self::COLLECTION, self::COLLECTION]], $calls->getArrayCopy()); + $this->assertTrue($updated->getAttribute('filtered'), 'The filtered collection is what the caller receives'); + $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, [Permission::read(Role::users())], 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(new Collection(id: 'sourceOnly', attributes: [Attribute::string(key: 'title', size: 64)])); + + $updated = $mirror->updateCollection('sourceOnly', [Permission::read(Role::any())], 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->getCollection('upgrades')->isEmpty()) { + 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 (string $action, Throwable $error) use ($errors): void { + $errors[] = [$action, $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, + ) { + } + + public function beforeCreateCollection(Database $source, Database $destination, string $collectionId, ?Document $collection = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $collection?->getId(), $collection); + } + + public function beforeUpdateCollection(Database $source, Database $destination, string $collectionId, ?Document $collection = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $collection?->getId(), $collection); + } + + public function beforeDeleteCollection(Database $source, Database $destination, string $collectionId): void + { + $this->run(__FUNCTION__, $collectionId, $collectionId); + } + + public function beforeCreateAttribute(Database $source, Database $destination, string $collectionId, string $attributeId, ?Document $attribute = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $attributeId, $attribute); + } + + public function beforeUpdateAttribute(Database $source, Database $destination, string $collectionId, string $attributeId, ?Document $attribute = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $attributeId, $attribute); + } + + public function beforeDeleteAttribute(Database $source, Database $destination, string $collectionId, string $attributeId): void + { + $this->run(__FUNCTION__, $collectionId, $attributeId); + } + + public function beforeCreateIndex(Database $source, Database $destination, string $collectionId, string $indexId, ?Document $index = null): ?Document + { + return $this->run(__FUNCTION__, $collectionId, $indexId, $index); + } + + public function beforeDeleteIndex(Database $source, Database $destination, string $collectionId, string $indexId): void + { + $this->run(__FUNCTION__, $collectionId, $indexId); + } + + public function beforeCreateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + public function afterCreateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + public function beforeUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + public function afterUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + public function beforeUpdateDocuments(Database $source, Database $destination, string $collectionId, Document $updates, array $queries): Document + { + return $this->run(__FUNCTION__, $collectionId, $updates->getAttribute('title'), $updates) ?? $updates; + } + + public function afterUpdateDocuments(Database $source, Database $destination, string $collectionId, Document $updates, array $queries): void + { + $this->run(__FUNCTION__, $collectionId, $updates->getAttribute('title')); + } + + public function beforeDeleteDocument(Database $source, Database $destination, string $collectionId, string $documentId): void + { + $this->run(__FUNCTION__, $collectionId, $documentId); + } + + public function afterDeleteDocument(Database $source, Database $destination, string $collectionId, string $documentId): void + { + $this->run(__FUNCTION__, $collectionId, $documentId); + } + + public function beforeDeleteDocuments(Database $source, Database $destination, string $collectionId, array $queries): void + { + $this->run(__FUNCTION__, $collectionId, \count($queries)); + } + + public function afterDeleteDocuments(Database $source, Database $destination, string $collectionId, array $queries): void + { + $this->run(__FUNCTION__, $collectionId, \count($queries)); + } + + public function beforeCreateOrUpdateDocument(Database $source, Database $destination, string $collectionId, Document $document): Document + { + return $this->run(__FUNCTION__, $collectionId, $document->getId(), $document) ?? $document; + } + + 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); + + $this->assertTrue($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); + + $this->assertTrue($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', size: 100); + + $this->assertSame([['beforeUpdateAttribute', self::COLLECTION, 'title']], $calls->getArrayCopy()); + $this->assertSame($size ?? 100, $updated->getAttribute('size'), 'The caller receives the filtered definition, or the source one when the filter skips'); + $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); + + $this->assertTrue($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): mixed => $mirror->createAttribute(self::COLLECTION, Attribute::string(key: 'summary', size: 64)), + ]; + yield 'createAttributes' => [ + 'createAttributes', + static fn (Mirror $mirror): mixed => $mirror->createAttributes(self::COLLECTION, [Attribute::string(key: 'summary', size: 64)]), + ]; + yield 'deleteAttribute' => [ + 'deleteAttribute', + static function (Mirror $mirror, Database $source): mixed { + $source->createAttribute(self::COLLECTION, Attribute::string(key: 'summary', size: 64)); + + return $mirror->deleteAttribute(self::COLLECTION, 'summary'); + }, + ]; + yield 'createIndex' => [ + 'createIndex', + static function (Mirror $mirror, Database $source, Database $destination): mixed { + $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): mixed { + $source->createIndex(self::COLLECTION, Index::key(key: 'titles', attributes: ['title'])); + + return $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); + } + + $this->assertTrue($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 'upsertDocumentsWithIncrease' => [ + static fn (Mirror $mirror): mixed => $mirror->upsertDocumentsWithIncrease(self::COLLECTION, 'views', [new Document([Document::ID => 'second', 'title' => 'upserted', 'views' => 1])]), + 'upsertDocumentsWithIncrease', + '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; + + public function createDocument(Document $collection, Document $document): Document + { + $this->reach(); + + return parent::createDocument($collection, $document); + } + + public function createDocuments(Document $collection, array $documents): array + { + $this->reach(); + + return parent::createDocuments($collection, $documents); + } + + public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document + { + $this->reach(); + + return parent::updateDocument($collection, $id, $document, $skipPermissions); + } + + public function updateDocuments(Document $collection, Document $updates, array $documents): int + { + $this->reach(); + + return parent::updateDocuments($collection, $updates, $documents); + } + + /** + * @param array $changes + * @return array + */ + public function upsertDocuments(Document $collection, string $attribute, array $changes): array + { + $this->reach(); + + return parent::upsertDocuments($collection, $attribute, $changes); + } + + public function deleteDocument(string $collection, string $id): bool + { + $this->reach(); + + return parent::deleteDocument($collection, $id); + } + + public function deleteDocuments(string $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->getPreserveDates(), '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->getPreserveDates()); + } + + 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(new Collection( + 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->assertTrue($source->getCollection('upgrades')->isEmpty()); + $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()); + } + + 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/MongoAdapterPathsTest.php b/tests/unit/MongoAdapterPathsTest.php new file mode 100644 index 0000000000..2edadf2c7f --- /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(new Relationship(collection: 'books', relatedCollection: 'authors', type: RelationType::ManyToMany, twoWay: true, key: 'authors', twoWayKey: 'books', side: RelationSide::Parent), 'writers'); + $adapter->updateRelationship(new Relationship(collection: 'authors', relatedCollection: 'books', type: RelationType::ManyToMany, twoWay: true, key: 'books', twoWayKey: 'authors', side: RelationSide::Child), '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('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('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->castingBefore($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->castingBefore($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->castingBefore($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->castingBefore($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->castingAfter($collection, $empty)); + + $document = $adapter->castingAfter($collection, new Document([ + 'tags' => '["a","b"]', + 'labels' => 7, + 'count' => '42', + ])); + + $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->castingAfter($collection, new Document(['tags' => 'not json'])); + } + + public function testSchemalessCastingKeepsStringsThatAreNotDates(): void + { + $adapter = $this->adapter(); + $adapter->setSupportForAttributes(false); + $collection = new Document(['indexes' => [['$id' => 'expiry', 'type' => 'ttl', 'attributes' => ['expiresAt']]]]); + + $document = $adapter->castingBefore($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->castingBefore( + 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->castingAfter( + new Document(['attributes' => [['$id' => 'count', 'type' => ColumnType::BigInteger->value, 'array' => false]]]), + new Document(['count' => new Int64('9007199254740993')]), + ); + $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..e341a922d7 --- /dev/null +++ b/tests/unit/MongoAttributesSupportTest.php @@ -0,0 +1,27 @@ +assertTrue($adapter->supports(Capability::DefinedAttributes)); + + $adapter->setSupportForAttributes(false); + $this->assertFalse($adapter->supports(Capability::DefinedAttributes)); + + $adapter->setSupportForAttributes(true); + $this->assertTrue($adapter->supports(Capability::DefinedAttributes)); + } +} diff --git a/tests/unit/MongoContainsFilterTest.php b/tests/unit/MongoContainsFilterTest.php new file mode 100644 index 0000000000..4fe88e718b --- /dev/null +++ b/tests/unit/MongoContainsFilterTest.php @@ -0,0 +1,167 @@ +> + */ + private array $filters = []; + + public function testMongoDeclaresContainsQueries(): void + { + $this->assertTrue($this->createAdapter()->supports(Capability::QueryContains)); + } + + 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->setSupportForAttributes(false); + + $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..c1cfcfebd3 --- /dev/null +++ b/tests/unit/MongoCreateCollectionTest.php @@ -0,0 +1,111 @@ + + */ + public static function existingCollectionProvider(): array + { + return [ + 'the client finds the collection' => [new MongoException('Collection Exists', 48), 'orders', false], + 'the client finds the collection, shared tables' => [new MongoException('Collection Exists', 48), 'orders', true], + 'an older client finds the collection' => [new MongoException('Collection Exists'), 'orders', false], + '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('existingCollectionProvider')] + public function testCreatingAnExistingCollectionSucceeds(MongoException $error, string $name, bool $sharedTables): void + { + $this->assertTrue($this->adapter($error, $sharedTables)->createCollection($name)); + } + + public function testServerAnsweringThatTheCollectionExistsIsDuplicateOutsideSharedTables(): void + { + $error = new MongoException(self::SERVER_EXISTS, 48); + + $failure = $this->createFailure($this->adapter($error, false), 'orders'); + + $this->assertInstanceOf(DuplicateException::class, $failure); + $this->assertSame('Collection already exists', $failure->getMessage()); + $this->assertSame($error, $failure->getPrevious()); + } + + public function testOtherErrorsAreRethrown(): void + { + $error = new MongoException('not authorized on utopiaTests to execute command', 13); + + $this->assertSame($error, $this->createFailure($this->adapter($error, true), 'orders')); + } + + 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 adapter(MongoException $error, bool $sharedTables): Mongo + { + $client = new class ($error) extends Client { + 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; + } + }; + + $adapter = new Mongo($client); + $adapter->setNamespace('engine'); + $adapter->setSharedTables($sharedTables); + + return $adapter; + } +} 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..a2bda6ca21 --- /dev/null +++ b/tests/unit/MongoFilterScopeTest.php @@ -0,0 +1,345 @@ +>> + */ + 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(self::COLLECTION, 'first'); + $adapter->deleteDocuments(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(self::COLLECTION, 'first', 'count', 5, '2026-01-01 00:00:00.000'); + $adapter->increaseDocumentAttribute(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->assertFalse($adapter->hasPermissionHook()); + + $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..9b0791e3b5 --- /dev/null +++ b/tests/unit/MongoFilteredMissCacheTest.php @@ -0,0 +1,113 @@ +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->getRead(), $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(new Collection( + 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..66eeb6037c --- /dev/null +++ b/tests/unit/MongoIdentifierTest.php @@ -0,0 +1,58 @@ +{Storage::SEQUENCE} = new class () { + 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..4b02a5d32f --- /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/MongoPermissionStringsTest.php b/tests/unit/MongoPermissionStringsTest.php index 25cf69da9a..1190e68ce7 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\PermissionFilter; +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 PermissionFilter($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..26fef1da39 --- /dev/null +++ b/tests/unit/MongoQueryFilterTest.php @@ -0,0 +1,392 @@ +>> + */ + 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 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 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([], [], ColumnType::Integer->value, supportForOrderRandom: false); + + $this->assertFalse($unsupported->isValid([Query::orderRandom()])); + $this->assertStringContainsString('Random order is not supported', $unsupported->getDescription()); + + $supported = new Documents([], [], ColumnType::Integer->value); + + $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); + } + + /** + * @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..1fb621717d --- /dev/null +++ b/tests/unit/MongoResultDecodingTest.php @@ -0,0 +1,178 @@ + $record + * @return array + */ + public function restore(array $record): array + { + return $this->replaceChars('_', '$', $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->replaceChars('$', '_', $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 testCastingAfterCastsCollectionAndInternalAttributes(): 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->castingAfter($collection, new Document([ + '$id' => 'movie1', + '$sequence' => 5, + '$permissions' => ['read("any")'], + 'score' => $stored, + 'price' => 3, + 'active' => 1, + 'tags' => ['a', 'b'], + ])); + + $this->assertSame([ + '$id' => 'movie1', + '$sequence' => '5', + '$permissions' => ['read("any")'], + 'score' => $expected, + 'price' => 3.0, + 'active' => true, + 'tags' => ['a', 'b'], + ], $document->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..9df4ed8e0f --- /dev/null +++ b/tests/unit/MongoSequencesTest.php @@ -0,0 +1,200 @@ + + */ + 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(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(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(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 { + public function __toString(): string + { + return '6553f1c2a4b8e3d2f0c1a9b7'; + } + }; + $this->rows = [ + $this->row('generated', $objectId, tenant: null), + ]; + + [$document] = $adapter->getSequences(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(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/MongoSkipDuplicatesTest.php b/tests/unit/MongoSkipDuplicatesTest.php new file mode 100644 index 0000000000..cf0b93bd13 --- /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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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->skipDuplicates(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/MongoUpsertTenantTest.php b/tests/unit/MongoUpsertTenantTest.php new file mode 100644 index 0000000000..7c8b1710b1 --- /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..13d5e0f468 --- /dev/null +++ b/tests/unit/ObjectAttribute/ObjectAttributeValidationTest.php @@ -0,0 +1,265 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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..a04369ae11 --- /dev/null +++ b/tests/unit/ObjectFilterTest.php @@ -0,0 +1,64 @@ + [new Document([ + '$id' => 'meta', + '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', + '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..678022305f --- /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([ + new Attribute(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..f489aafb62 100644 --- a/tests/unit/PDOTest.php +++ b/tests/unit/PDOTest.php @@ -2,50 +2,43 @@ namespace Tests\Unit; +use Closure; +use PDOException; use PHPUnit\Framework\TestCase; -use ReflectionClass; 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 +46,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 +85,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 +139,262 @@ 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 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)); + } + + 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() + { + } + + public function inTransaction(): bool + { + return true; + } + + public function exec(string $statement): int|false + { + 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/PermissionScope/FullOuterJoinAggregateVisibilityTest.php b/tests/unit/PermissionScope/FullOuterJoinAggregateVisibilityTest.php new file mode 100644 index 0000000000..8f0581a60d --- /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(new Collection( + 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(new Collection( + 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, '$id', 'authorId', '=', self::BOOK); + } + + /** + * 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->find(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->getAttribute('rows')) ?? -1, + 'authors' => $this->integerOrNull($totals->getAttribute('authors')) ?? -1, + 'books' => $this->integerOrNull($totals->getAttribute('books')) ?? -1, + 'pages' => $this->integerOrNull($totals->getAttribute('pages')) ?? -1, + 'ranks' => $this->integerOrNull($totals->getAttribute('ranks')) ?? -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 (Document $group): array => [ + $this->stringOrNull($group->getAttribute($key)), + $this->integerOrNull($group->getAttribute('rows')) ?? -1, + ], + $database->find(self::AUTHORS, [$this->join(), Query::groupBy([$attribute]), Query::count('*', 'rows')]), + ); + + return $this->sorted(\array_values($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..c21d6b19a4 --- /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, $on, 'authorId', '=', $alias), + Method::LeftJoin => Query::leftJoin($collection, $on, 'authorId', '=', $alias), + Method::RightJoin => Query::rightJoin($collection, $on, 'authorId', '=', $alias), + Method::FullOuterJoin => Query::fullOuterJoin($collection, $on, 'authorId', '=', $alias), + 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(new Collection( + 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(new Collection( + 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->getJoinAlias().'.'.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..de7002a4bc --- /dev/null +++ b/tests/unit/PermissionScope/JoinVisibilityTest.php @@ -0,0 +1,253 @@ +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(new Collection( + 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(new Collection( + 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(new Collection( + 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(new Collection( + 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(new Collection( + 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', '$id', 'customerId', '=', 'profile'); + + $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', '$id', 'customerId', '=', 'ord'); + + $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', '$id', 'customerId', '=', 'note'); + + $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', '$id', 'customerId', '=', 'profile')]))); + } + + public function testJoinedCollectionWithoutGrantOrDocumentSecurityIsRejected(): void + { + $join = Query::join('ledger', '$id', 'customerId', '=', 'ledger'); + + $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..6f08516cc3 --- /dev/null +++ b/tests/unit/PermissionScope/MetadataVisibilityTest.php @@ -0,0 +1,137 @@ +database(); + $database->create(); + $database->createCollection(new Collection(id: 'public', permissions: [Permission::read(Role::any())])); + $database->createCollection(new Collection(id: 'private', permissions: [Permission::read(Role::user('admin'))])); + $database->createCollection(new Collection(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(new Collection(id: 'pooled', permissions: [Permission::read(Role::any())])); + + $database->setTenant(1); + $database->createCollection(new Collection(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..6a81f11499 --- /dev/null +++ b/tests/unit/PermissionSubqueryTest.php @@ -0,0 +1,391 @@ +_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; + + 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, '$id', '$id', '=', 'peer'), + 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', '$id', 'customerId')]); + + $this->assertSame(1, \substr_count($sql, self::NO_SEMIJOIN), $sql); + } + + private static function join(JoinType $joinType, string $collection, string $alias): Query + { + return match ($joinType) { + JoinType::Left => Query::leftJoin($collection, '$id', 'customerId', '=', $alias), + JoinType::Right => Query::rightJoin($collection, '$id', 'customerId', '=', $alias), + JoinType::FullOuter => Query::fullOuterJoin($collection, '$id', 'customerId', '=', $alias), + default => Query::join($collection, '$id', 'customerId', '=', $alias), + }; + } + + /** + * @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); + } + + protected function prepareStatement(string $sql, ?Event $event = null): DatabasePDOStatement|PDOStatementProxy|PDOStatement + { + $this->statements->append([$sql, []]); + + return parent::prepareStatement($sql, $event); + } + + 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(new Collection( + 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..7c4ce45bab 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\PermissionType; 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); @@ -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'); @@ -292,13 +292,13 @@ public function testInvalidFormats(): void /** * @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 +307,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..07785d603f --- /dev/null +++ b/tests/unit/PermissionsTenantPerDocumentTest.php @@ -0,0 +1,414 @@ +pdo = new PDO('sqlite::memory:'); + $this->adapter = new SQLite($this->pdo); + $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(new Collection( + 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->upsertDocumentsWithIncrease( + self::COLLECTION, + self::COUNTER, + [$this->note(self::TENANT, [self::ALICE])->setAttribute(self::COUNTER, 1)], + ); + + $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(new Collection( + 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::PERM_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(); + } + + 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::PERM_DOCUMENT, Storage::PERM_TYPE, Storage::PERM_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..5558534165 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,28 @@ 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 + public function setTimeout(int $milliseconds, Event $event = Event::All): void { - $this->timeouts[$event] = $milliseconds; + $this->timeouts[$event->value] = $milliseconds; $this->timeout = $milliseconds; } - public function clearTimeout(string $event): void + public function clearTimeout(Event $event = Event::All): void { - unset($this->timeouts[$event]); + if ($event === Event::All) { + $this->timeouts = []; + $this->timeout = 0; - parent::clearTimeout($event); + return; + } + + unset($this->timeouts[$event->value]); } } diff --git a/tests/unit/PostgresIdentifierTest.php b/tests/unit/PostgresIdentifierTest.php new file mode 100644 index 0000000000..2c49deabeb --- /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->exists('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..9a3e67792a --- /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..45900a1c0e --- /dev/null +++ b/tests/unit/Profiler/ProfiledStatementTest.php @@ -0,0 +1,131 @@ +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(new Collection( + 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 (): QueryLog => $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): QueryLog + { + $profiler = $this->database->enableProfiling()->getProfiler(); + $this->assertNotNull($profiler); + + try { + $profiler->reset(); + $read(); + } finally { + $this->database->disableProfiling(); + } + + $logs = \array_values(\array_filter( + $profiler->getLogs(), + static fn (QueryLog $log): bool => \str_contains($log->query, self::NAMESPACE.'_items`'), + )); + $this->assertCount(1, $logs); + + return $logs[0]; + } +} diff --git a/tests/unit/Profiler/QueryProfilerAdvancedTest.php b/tests/unit/Profiler/QueryProfilerAdvancedTest.php new file mode 100644 index 0000000000..fc0ce0a2c1 --- /dev/null +++ b/tests/unit/Profiler/QueryProfilerAdvancedTest.php @@ -0,0 +1,205 @@ +profiler = new QueryProfiler(); + } + + 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(QueryLog::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 testSlowQueryCallbackReceivesQueryLogEntry(): 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(QueryLog::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/QueryProfilerTest.php b/tests/unit/Profiler/QueryProfilerTest.php new file mode 100644 index 0000000000..eed2ca3af4 --- /dev/null +++ b/tests/unit/Profiler/QueryProfilerTest.php @@ -0,0 +1,226 @@ +profiler = new QueryProfiler(); + } + + 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 <= QueryProfiler::DEFAULT_CAPACITY; $index++) { + $this->profiler->log("Q{$index}", [], 1.0); + } + + $logs = $this->profiler->getLogs(); + $this->assertCount(QueryProfiler::DEFAULT_CAPACITY, $logs); + $this->assertSame('Q1', $logs[0]->query); + $this->assertSame('Q'.QueryProfiler::DEFAULT_CAPACITY, $logs[QueryProfiler::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 (QueryLog $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..2b66c8f596 --- /dev/null +++ b/tests/unit/QueryBuilderAuthorizationTest.php @@ -0,0 +1,154 @@ +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(new Collection( + id: self::POSTS, + attributes: [Attribute::string(key: 'title', size: 64, required: true)], + permissions: [Permission::create(Role::users())], + documentSecurity: true, + )); + $this->database->createCollection(new Collection( + 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::execute()' => fn () => $this->database->execute($read), + 'the builder through Database::execute()' => fn () => $this->database->execute($builder), + 'a write statement' => fn () => $write->execute(), + 'a write statement through Database::execute()' => fn () => $this->database->execute($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..4ddc7c924c --- /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->getBuilder(self::AUTHORS) + ->crossJoin($raw(self::EXTRAS), 'Extra') + ->rightJoin($raw(self::REVIEWS), 'Extra.authorId', 'Review.authorId', '=', 'Review') + ->joinWhere($raw(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->getBuilder(self::AUTHORS)->set(['name' => 'renamed'])->update()->query, + ); + $this->assertSame( + "DELETE FROM {$authors} WHERE {$tenant($raw(self::AUTHORS))}", + $adapter->getBuilder(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->getBuilder(self::AUTHORS) + ->rightJoin($raw(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->getBuilder(self::AUTHORS) + ->rightJoin($this->rawTable($adapter, 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->getBuilder(self::AUTHORS) + ->rightJoin($this->rawTable($adapter, 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(new Collection( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64, required: true)], + permissions: $permissions, + documentSecurity: false, + )); + foreach (self::NUMBERS as $collection => $number) { + $database->createCollection(new Collection( + 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::table(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 = self::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/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index 8103a0dd27..99e0857957 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -6,11 +6,14 @@ 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\Query; +use Utopia\Query\Method; class QueryCacheTest extends TestCase { @@ -25,10 +28,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 +49,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 +80,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame(['value' => 'fresh'], $value); $this->assertSame(1, $callbackCalls); @@ -82,6 +92,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame(['value' => 'fresh'], $value); $this->assertSame(1, $callbackCalls); } @@ -101,6 +112,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame([], $value); $value = $database->withCache( @@ -111,6 +123,7 @@ function () use (&$callbackCalls): array { }, ); + /** @var mixed $value */ $this->assertSame([], $value); $this->assertSame(1, $callbackCalls); } @@ -122,7 +135,8 @@ public function testWithCacheCachesNullValues(): void $callbackCalls = 0; - $value = $database->withCache( + $value = $this->withCache( + $database, 'key', function () use (&$callbackCalls): mixed { $callbackCalls++; @@ -132,9 +146,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 +194,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 +212,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 +232,7 @@ function () use (&$callbackCalls): string { }, ); + /** @var mixed $value */ $this->assertSame('fresh', $value); $this->assertSame(2, $callbackCalls); } @@ -241,6 +261,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 +301,10 @@ public function testWithCacheDoesNotCacheMixedDocumentArrays(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', permissions: [ + $database->createCollection(new Collection(id: 'wafRules', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -315,10 +337,10 @@ public function testWithCacheCachesSingleDocument(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', permissions: [ + $database->createCollection(new Collection(id: 'wafRules', permissions: [ Permission::read(Role::any()), Permission::create(Role::any()), - ]); + ], documentSecurity: false)); $database->createDocument('wafRules', new Document([ '$id' => 'rule-a', @@ -355,9 +377,9 @@ public function testWithCacheCachesStaticQueryValues(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('wafRules', permissions: [ + $database->createCollection(new Collection(id: 'wafRules', permissions: [ Permission::read(Role::any()), - ]); + ], documentSecurity: false)); $callbackCalls = 0; $collection = $database->getCollection('wafRules'); @@ -381,6 +403,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 +414,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(new Collection(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 +459,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(new Collection(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 +497,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(new Collection(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', @@ -526,26 +526,30 @@ 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, + 'encode' => 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; + }, + 'decode' => 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(new Collection(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 +572,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(new Collection(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 +607,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(new Collection(id: 'secureRules', attributes: [ + Attribute::string(key: 'projectId'), ], permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); + ])); $database->createDocument('secureRules', new Document([ '$id' => 'rule-a', @@ -670,9 +658,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(new Collection(id: 'secureRules', permissions: [ Permission::create(Role::any()), - ], documentSecurity: true); + ])); }); $database->getAuthorization()->addRole(Role::user('user-1')->toString()); @@ -680,7 +668,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 +680,7 @@ public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void ], ], ], - ], $hash); + ]); $callbackCalls = 0; $documents = $database->withCache( @@ -707,6 +696,7 @@ public function testQueryCacheFiltersDocumentSecurityPayloadsOnHit(): void hash: $hash, ); + /** @var mixed $documents */ $this->assertSame([], $documents); $this->assertSame(0, $callbackCalls); } @@ -715,17 +705,21 @@ public function testQueryCacheRehydratesNestedDocumentPayloads(): void { $cache = new HashMemoryCache(); $database = $this->createDatabase($cache); - $database->createCollection('parents', permissions: [ + $database->createCollection(new Collection(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 +734,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(new Collection(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 +767,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 +790,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(new Collection(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 +813,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 +830,6 @@ public function testQueryCacheRefreshesInvalidPayloadEntry(): void 'invalid', ], ], - $database->getQueryCacheField($collection, $queries), ); $rules = $this->findWithCache($database, 'wafRules', $queries, '_39'); @@ -855,6 +839,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 @@ -875,7 +867,7 @@ 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 + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if ($key === '' || empty($data)) { return false; @@ -963,7 +955,7 @@ 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 + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if ($key === '' || empty($data)) { return false; diff --git a/tests/unit/QueryConversionTest.php b/tests/unit/QueryConversionTest.php new file mode 100644 index 0000000000..c1abe4eec9 --- /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 testAttributeWithoutAStringTypeConvertsWithAnEmptyType(\Closure $adapter): void + { + $database = $this->database($adapter()); + $collection = new Document([ + Document::ID => self::COLLECTION, + 'attributes' => [new Document([Document::ID => 'untyped', 'type' => 5, 'array' => true])], + ]); + + $query = $database->convertQuery($collection, Query::equal('untyped', ['x'])); + + $this->assertSame('', $query->getAttributeType()); + $this->assertTrue($query->onArray()); + $this->assertSame(['x'], $query->getValues()); + } + + /** + * @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 { + 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(new Collection( + 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..1db55789cc --- /dev/null +++ b/tests/unit/QuerySerializationTest.php @@ -0,0 +1,103 @@ + + */ + 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(); + } + + /** + * @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..f0f14dd65e --- /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(new Collection( + 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->find(self::COLLECTION, $queries); + + $this->assertCount(1, $results); + $this->assertSame(0, $results[0]->getAttribute('rows')); + $this->assertSame(0, $results[0]->getAttribute('statuses')); + foreach (['total', 'mean', 'least', 'most'] as $alias) { + $this->assertTrue($results[0]->offsetExists($alias), $alias.' must be present'); + $this->assertNull($results[0]->getAttribute($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(new Collection( + 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', 'status', 'status', '=', 'refund'), + Query::equal('status', ['nonexistent']), + ]; + + $results = $database->find(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]->getAttribute('rows')); + $this->assertSame(0, $results[0]->getAttribute('statuses')); + foreach (['total', 'mean', 'least', 'most'] as $alias) { + $this->assertTrue($results[0]->offsetExists($alias), $alias.' must be present'); + $this->assertNull($results[0]->getAttribute($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..e69a252194 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -6,46 +6,47 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; +use Utopia\Query\Method; class QueryTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + 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 +54,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 +347,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 +355,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 +390,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 +407,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 +418,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 +468,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 @@ -609,4 +618,32 @@ 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(); + } } diff --git a/tests/unit/RedisLenientReadTest.php b/tests/unit/RedisLenientReadTest.php new file mode 100644 index 0000000000..294578d878 --- /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..dfb8190005 --- /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->skipDuplicates(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->skipDuplicates(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/RelationshipModelTest.php b/tests/unit/RelationshipModelTest.php new file mode 100644 index 0000000000..b381c5c239 --- /dev/null +++ b/tests/unit/RelationshipModelTest.php @@ -0,0 +1,577 @@ +assertSame('posts', $rel->collection); + $this->assertSame('comments', $rel->relatedCollection); + $this->assertSame(RelationType::OneToMany, $rel->type); + $this->assertTrue($rel->twoWay); + $this->assertSame('comments', $rel->key); + $this->assertSame('post', $rel->twoWayKey); + $this->assertSame(ForeignKeyAction::Cascade, $rel->onDelete); + $this->assertSame(RelationSide::Parent, $rel->side); + } + + public function testConstructorDefaults(): void + { + $rel = Relationship::oneToOne( + collection: 'a', + relatedCollection: 'b', + ); + + $this->assertFalse($rel->twoWay); + $this->assertSame('', $rel->key); + $this->assertSame('', $rel->twoWayKey); + $this->assertSame(ForeignKeyAction::Restrict, $rel->onDelete); + $this->assertSame(RelationSide::Parent, $rel->side); + } + + public function testToDocumentProducesCorrectStructure(): void + { + $rel = Relationship::oneToOne( + collection: 'users', + relatedCollection: 'profiles', + twoWay: true, + key: 'profile', + twoWayKey: 'user', + onDelete: ForeignKeyAction::SetNull, + side: RelationSide::Parent, + ); + + $doc = $rel->toDocument(); + + $this->assertSame('profiles', $doc->getAttribute('relatedCollection')); + $this->assertSame('oneToOne', $doc->getAttribute('relationType')); + $this->assertTrue($doc->getAttribute('twoWay')); + $this->assertSame('user', $doc->getAttribute('twoWayKey')); + $this->assertSame('setNull', $doc->getAttribute('onDelete')); + $this->assertSame('parent', $doc->getAttribute('side')); + } + + public function testToDocumentDoesNotIncludeCollectionOrKey(): void + { + $rel = Relationship::manyToMany( + collection: 'posts', + relatedCollection: 'tags', + key: 'tags', + ); + + $doc = $rel->toDocument(); + + $this->assertNull($doc->getAttribute('collection')); + $this->assertNull($doc->getAttribute('key')); + } + + public function testFromDocumentRoundtrip(): void + { + $attrDoc = new Document([ + '$id' => 'comments', + 'key' => 'comments', + 'type' => 'relationship', + 'options' => new Document([ + 'relatedCollection' => 'comments', + 'relationType' => 'oneToMany', + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => 'cascade', + 'side' => 'parent', + ]), + ]); + + $rel = Relationship::fromDocument('posts', $attrDoc); + + $this->assertSame('posts', $rel->collection); + $this->assertSame('comments', $rel->relatedCollection); + $this->assertSame(RelationType::OneToMany, $rel->type); + $this->assertTrue($rel->twoWay); + $this->assertSame('comments', $rel->key); + $this->assertSame('post', $rel->twoWayKey); + $this->assertSame(ForeignKeyAction::Cascade, $rel->onDelete); + $this->assertSame(RelationSide::Parent, $rel->side); + } + + public function testFromDocumentWithArrayOptions(): void + { + $attrDoc = new Document([ + '$id' => 'author', + 'key' => 'author', + 'type' => 'relationship', + 'options' => [ + 'relatedCollection' => 'users', + 'relationType' => 'manyToOne', + 'twoWay' => false, + 'twoWayKey' => 'posts', + 'onDelete' => 'restrict', + 'side' => 'child', + ], + ]); + + $rel = Relationship::fromDocument('posts', $attrDoc); + + $this->assertSame('users', $rel->relatedCollection); + $this->assertSame(RelationType::ManyToOne, $rel->type); + $this->assertFalse($rel->twoWay); + $this->assertSame(RelationSide::Child, $rel->side); + } + + public function testFromDocumentWithMissingOptions(): void + { + $attrDoc = new Document([ + '$id' => 'ref', + 'key' => 'ref', + 'type' => 'relationship', + ]); + + $rel = Relationship::fromDocument('coll', $attrDoc); + + $this->assertSame('coll', $rel->collection); + $this->assertSame('', $rel->relatedCollection); + $this->assertSame(RelationType::OneToOne, $rel->type); + $this->assertFalse($rel->twoWay); + $this->assertSame('', $rel->twoWayKey); + $this->assertSame(ForeignKeyAction::Restrict, $rel->onDelete); + $this->assertSame(RelationSide::Parent, $rel->side); + } + + public function testAllRelationTypeValues(): void + { + $types = [ + RelationType::OneToOne, + RelationType::OneToMany, + RelationType::ManyToOne, + RelationType::ManyToMany, + ]; + + foreach ($types as $type) { + $attrDoc = new Document([ + '$id' => 'rel', + 'key' => 'rel', + 'options' => [ + 'relatedCollection' => 'target', + 'relationType' => $type->value, + ], + ]); + + $rel = Relationship::fromDocument('source', $attrDoc); + $this->assertSame($type, $rel->type, "Failed for type: {$type->value}"); + } + } + + public function testTwoWayFlag(): void + { + $twoWay = new Document([ + '$id' => 'rel', + 'key' => 'rel', + 'options' => [ + 'relatedCollection' => 'b', + 'relationType' => 'oneToOne', + 'twoWay' => true, + 'twoWayKey' => 'back', + ], + ]); + + $rel = Relationship::fromDocument('a', $twoWay); + $this->assertTrue($rel->twoWay); + $this->assertSame('back', $rel->twoWayKey); + + $oneWay = new Document([ + '$id' => 'rel', + 'key' => 'rel', + 'options' => [ + 'relatedCollection' => 'b', + 'relationType' => 'oneToOne', + 'twoWay' => false, + ], + ]); + + $rel2 = Relationship::fromDocument('a', $oneWay); + $this->assertFalse($rel2->twoWay); + } + + public function testAllForeignKeyActionValues(): void + { + $actions = [ + ForeignKeyAction::Cascade, + ForeignKeyAction::SetNull, + ForeignKeyAction::SetDefault, + ForeignKeyAction::Restrict, + ForeignKeyAction::NoAction, + ]; + + foreach ($actions as $action) { + $attrDoc = new Document([ + '$id' => 'rel', + 'key' => 'rel', + 'options' => [ + 'relatedCollection' => 'target', + 'relationType' => 'oneToOne', + 'onDelete' => $action->value, + ], + ]); + + $rel = Relationship::fromDocument('source', $attrDoc); + $this->assertSame($action, $rel->onDelete, "Failed for action: {$action->value}"); + } + } + + public function testFromDocumentWithEnumInstances(): void + { + $attrDoc = new Document([ + '$id' => 'rel', + 'key' => 'rel', + 'options' => [ + 'relatedCollection' => 'target', + 'relationType' => RelationType::ManyToMany, + 'onDelete' => ForeignKeyAction::Cascade, + 'side' => RelationSide::Child, + ], + ]); + + $rel = Relationship::fromDocument('source', $attrDoc); + $this->assertSame(RelationType::ManyToMany, $rel->type); + $this->assertSame(ForeignKeyAction::Cascade, $rel->onDelete); + $this->assertSame(RelationSide::Child, $rel->side); + } + + /** + * @return array + */ + public static function factories(): array + { + return [ + 'oneToOne' => ['oneToOne', RelationType::OneToOne], + 'oneToMany' => ['oneToMany', RelationType::OneToMany], + 'manyToOne' => ['manyToOne', RelationType::ManyToOne], + 'manyToMany' => ['manyToMany', RelationType::ManyToMany], + ]; + } + + #[DataProvider('factories')] + public function testFactorySetsTypeAndDefaults(string $factory, RelationType $type): void + { + $relationship = Relationship::{$factory}( + collection: 'posts', + relatedCollection: 'comments', + ); + + $this->assertInstanceOf(Relationship::class, $relationship); + $this->assertSame('posts', $relationship->collection); + $this->assertSame('comments', $relationship->relatedCollection); + $this->assertSame($type, $relationship->type); + $this->assertFalse($relationship->twoWay); + $this->assertSame('', $relationship->key); + $this->assertSame('', $relationship->twoWayKey); + $this->assertSame(ForeignKeyAction::Restrict, $relationship->onDelete); + $this->assertSame(RelationSide::Parent, $relationship->side); + } + + public function testFactoryOmitsTypeParameter(): void + { + $names = array_map( + static fn (ReflectionParameter $parameter): string => $parameter->getName(), + (new ReflectionMethod(Relationship::class, 'oneToOne'))->getParameters(), + ); + + $this->assertSame(false, in_array('type', $names, true)); + } + + public function testFactoryForwardsOptionalArguments(): void + { + $relationship = Relationship::manyToOne( + collection: 'reviews', + relatedCollection: 'movies', + twoWay: true, + key: 'movie', + twoWayKey: 'reviews', + onDelete: ForeignKeyAction::Cascade, + side: RelationSide::Child, + ); + + $this->assertSame(RelationType::ManyToOne, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('movie', $relationship->key); + $this->assertSame('reviews', $relationship->twoWayKey); + $this->assertSame(ForeignKeyAction::Cascade, $relationship->onDelete); + $this->assertSame(RelationSide::Child, $relationship->side); + } + + public function testBaseConstructorWithDynamicType(): void + { + $type = RelationType::OneToOne; + $relationship = new Relationship( + collection: 'a', + relatedCollection: 'b', + type: $type, + ); + + $this->assertSame(Relationship::class, $relationship::class); + $this->assertSame(RelationType::OneToOne, $relationship->type); + } + + public function testExtendsDocument(): void + { + $relationship = Relationship::oneToOne( + collection: 'posts', + relatedCollection: 'comments', + key: 'comments', + ); + + $this->assertSame('comments', $relationship->getId()); + $this->assertSame('posts', $relationship->getAttribute('collection')); + } + + public function testFromArrayHydratesFlatDocument(): void + { + $relationship = Relationship::fromArray([ + '$id' => 'comments', + 'key' => 'comments', + 'collection' => 'posts', + 'relatedCollection' => 'comments', + 'relationType' => RelationType::OneToMany->value, + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => ForeignKeyAction::Cascade->value, + 'side' => RelationSide::Parent->value, + ]); + + $this->assertSame('posts', $relationship->collection); + $this->assertSame('comments', $relationship->relatedCollection); + $this->assertSame(RelationType::OneToMany, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('comments', $relationship->key); + $this->assertSame('post', $relationship->twoWayKey); + $this->assertSame(ForeignKeyAction::Cascade, $relationship->onDelete); + $this->assertSame(RelationSide::Parent, $relationship->side); + } + + public function testFromArrayHydratesAttributeOptions(): void + { + $relationship = Relationship::fromArray([ + '$id' => 'author', + 'key' => 'author', + 'collection' => 'posts', + 'type' => 'relationship', + 'options' => [ + 'relatedCollection' => 'users', + 'relationType' => RelationType::ManyToOne->value, + 'twoWay' => false, + 'twoWayKey' => 'posts', + 'onDelete' => ForeignKeyAction::Restrict->value, + 'side' => RelationSide::Child->value, + ], + ]); + + $this->assertSame('posts', $relationship->collection); + $this->assertSame('users', $relationship->relatedCollection); + $this->assertSame(RelationType::ManyToOne, $relationship->type); + $this->assertFalse($relationship->twoWay); + $this->assertSame('author', $relationship->key); + $this->assertSame(RelationSide::Child, $relationship->side); + } + + public function testFromArrayRoundtrip(): void + { + $original = Relationship::manyToMany( + collection: 'posts', + relatedCollection: 'tags', + twoWay: true, + key: 'tags', + twoWayKey: 'posts', + onDelete: ForeignKeyAction::SetNull, + side: RelationSide::Parent, + ); + + $restored = Relationship::fromArray($original->getArrayCopy()); + + $this->assertSame($original->collection, $restored->collection); + $this->assertSame($original->relatedCollection, $restored->relatedCollection); + $this->assertSame($original->type, $restored->type); + $this->assertSame($original->twoWay, $restored->twoWay); + $this->assertSame($original->key, $restored->key); + $this->assertSame($original->twoWayKey, $restored->twoWayKey); + $this->assertSame($original->onDelete, $restored->onDelete); + $this->assertSame($original->side, $restored->side); + } + + public function testSetDocumentTypeAcceptsRelationship(): void + { + $database = $this->database(); + $database->setDocumentType('rels', Relationship::class); + + $this->assertSame(Relationship::class, $database->getDocumentType('rels')); + } + + public function testCreateDocumentInstanceHydratesRelationship(): void + { + $database = new Database(new Memory(), new Cache(new NoneAdapter())); + $database->setDatabase('relationships')->setNamespace('relationships'); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $database->createCollection(new Collection( + id: 'rels', + attributes: [ + Attribute::string(key: 'key', size: 64), + Attribute::string(key: 'collection', size: 64), + Attribute::string(key: 'relatedCollection', size: 64), + Attribute::string(key: 'relationType', size: 64), + Attribute::boolean(key: 'twoWay'), + Attribute::string(key: 'twoWayKey', size: 64), + Attribute::string(key: 'onDelete', size: 64), + Attribute::string(key: 'side', size: 64), + ], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + documentSecurity: false, + )); + $database->createDocument('rels', new Document([ + '$id' => 'comments', + 'key' => 'comments', + 'collection' => 'posts', + 'relatedCollection' => 'comments', + 'relationType' => RelationType::OneToMany->value, + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => ForeignKeyAction::Cascade->value, + 'side' => RelationSide::Parent->value, + ])); + $database->setDocumentType('rels', Relationship::class); + + $document = $database->getDocument('rels', 'comments'); + + $this->assertInstanceOf(Relationship::class, $document); + $this->assertSame('posts', $document->collection); + $this->assertSame('comments', $document->relatedCollection); + $this->assertSame(RelationType::OneToMany, $document->type); + $this->assertTrue($document->twoWay); + $this->assertSame('comments', $document->key); + $this->assertSame(ForeignKeyAction::Cascade, $document->onDelete); + } + + private function database(): Database + { + return new Database( + $this->createStub(Adapter::class), + new Cache(new NoneAdapter()), + ); + } + + public function testMagicReadsKeepStoredEnumsAndFallBackToAttributes(): void + { + $relationship = Relationship::oneToOne(collection: 'posts', relatedCollection: 'authors', key: 'author'); + $relationship->setAttribute('relationType', RelationType::ManyToMany); + $relationship->setAttribute('onDelete', ForeignKeyAction::Cascade); + $relationship->setAttribute('side', RelationSide::Child); + $relationship->setAttribute('status', 'available'); + + $this->assertSame(RelationType::ManyToMany, $relationship->type); + $this->assertSame(ForeignKeyAction::Cascade, $relationship->onDelete); + $this->assertSame(RelationSide::Child, $relationship->side); + $this->assertSame('available', $relationship->__get('status')); + $this->assertNull($relationship->__get('missing')); + } + + public function testPropertyWritesStoreTheirAttributes(): void + { + $relationship = Relationship::oneToOne(collection: 'posts', relatedCollection: 'authors', key: 'author'); + + $relationship->collection = 'articles'; + $relationship->relatedCollection = 'writers'; + $relationship->type = RelationType::ManyToOne; + $relationship->twoWay = true; + $relationship->key = 'writer'; + $relationship->twoWayKey = 'articles'; + $relationship->onDelete = ForeignKeyAction::SetNull; + $relationship->side = RelationSide::Child; + $relationship->__set('status', 'available'); + + $this->assertSame('articles', $relationship->getAttribute('collection')); + $this->assertSame('writers', $relationship->getAttribute('relatedCollection')); + $this->assertSame(RelationType::ManyToOne->value, $relationship->getAttribute('relationType')); + $this->assertTrue($relationship->getAttribute('twoWay')); + $this->assertSame('writer', $relationship->getAttribute('key')); + $this->assertSame('writer', $relationship->getId()); + $this->assertSame('articles', $relationship->getAttribute('twoWayKey')); + $this->assertSame(ForeignKeyAction::SetNull->value, $relationship->getAttribute('onDelete')); + $this->assertSame(RelationSide::Child->value, $relationship->getAttribute('side')); + $this->assertSame('available', $relationship->getAttribute('status')); + + $this->assertSame('articles', $relationship->collection); + $this->assertSame('writers', $relationship->relatedCollection); + $this->assertSame(RelationType::ManyToOne, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('writer', $relationship->key); + $this->assertSame('articles', $relationship->twoWayKey); + $this->assertSame(ForeignKeyAction::SetNull, $relationship->onDelete); + $this->assertSame(RelationSide::Child, $relationship->side); + } + + public function testFromArrayReadsOptionsHeldInADocument(): void + { + $relationship = Relationship::fromArray([ + Document::ID => 'comments', + 'collection' => 'posts', + 'options' => new Document([ + 'relatedCollection' => 'comments', + 'relationType' => RelationType::OneToMany->value, + 'twoWay' => true, + 'twoWayKey' => 'post', + 'onDelete' => ForeignKeyAction::Cascade->value, + 'side' => RelationSide::Child->value, + ]), + ]); + + $this->assertSame('comments', $relationship->relatedCollection); + $this->assertSame(RelationType::OneToMany, $relationship->type); + $this->assertTrue($relationship->twoWay); + $this->assertSame('post', $relationship->twoWayKey); + $this->assertSame(ForeignKeyAction::Cascade, $relationship->onDelete); + $this->assertSame(RelationSide::Child, $relationship->side); + } + + public function testFromArrayIgnoresScalarOptions(): void + { + $relationship = Relationship::fromArray([ + Document::ID => 'comments', + 'collection' => 'posts', + 'options' => 'oneToMany', + ]); + + $this->assertSame('', $relationship->relatedCollection); + $this->assertSame(RelationType::OneToOne, $relationship->type); + $this->assertFalse($relationship->twoWay); + $this->assertSame(ForeignKeyAction::Restrict, $relationship->onDelete); + $this->assertSame(RelationSide::Parent, $relationship->side); + } +} diff --git a/tests/unit/Relationships/DeleteRelatedUpdateTest.php b/tests/unit/Relationships/DeleteRelatedUpdateTest.php new file mode 100644 index 0000000000..9a41ec63e7 --- /dev/null +++ b/tests/unit/Relationships/DeleteRelatedUpdateTest.php @@ -0,0 +1,497 @@ + + */ + 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 testDeletingAParentReportsEachChildAsTheSetNullWroteIt(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, ForeignKeyAction::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 ($recorder->getPayloads(Event::DocumentUpdate) as $related) { + $this->assertInstanceOf(Document::class, $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, ForeignKeyAction::SetNull); + $this->createFamily($database, 'parent1', ['child1', 'child2']); + + $recorder = $this->record($database); + $database->deleteDocument('child', 'child1'); + + $this->assertSame(['parent1'], $this->reported($recorder)); + $related = $recorder->getPayloads(Event::DocumentUpdate)[0]; + $this->assertInstanceOf(Document::class, $related); + $this->assertSame('parent', $related->getCollection()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testACascadeReportsOnlyThePeersItDidNotRemove(Closure $adapter): void + { + $database = $this->database($adapter()); + $this->relateParentToChildren($database, ForeignKeyAction::Cascade); + $database->createCollection(new Collection(id: 'owner', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship(Relationship::manyToOne(collection: 'parent', relatedCollection: 'owner', twoWay: true, key: 'owner', twoWayKey: 'owned', onDelete: ForeignKeyAction::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, ForeignKeyAction::Cascade); + $database->createCollection(new Collection(id: 'owner', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship(Relationship::manyToOne(collection: 'parent', relatedCollection: 'owner', twoWay: true, key: 'owner', twoWayKey: 'owned', onDelete: ForeignKeyAction::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 instanceof DocumentUpdated ? $event->document->getId() : $event->documentId; + }); + $database->addHook($dispatcher); + } + + $database->enableProfiling(); + $this->assertTrue($database->deleteDocument('parent', 'parent1'), $case); + $reads[$case] = \count(\array_filter( + $database->getProfiler()?->getLogs() ?? [], + static fn (QueryLog $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, ForeignKeyAction::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(Relationship::manyToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(Relationship::oneToOne(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'partnerOf', onDelete: ForeignKeyAction::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(Relationship::oneToMany(collection: 'parent', relatedCollection: 'stray', key: 'strays', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::manyToOne(collection: 'parent', relatedCollection: 'stray', key: 'stray', twoWayKey: 'strayOf', onDelete: ForeignKeyAction::SetNull)); + $database->createDocument('stray', new Document(['$id' => 'stray1', '$permissions' => $this->documentPermissions()])); + $database->createRelationship(Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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, ForeignKeyAction::SetNull); + $database->createCollection(new Collection(id: 'pair', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship(Relationship::manyToOne(collection: 'parent', relatedCollection: 'pair', twoWay: true, key: 'owner', twoWayKey: 'owned', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::oneToOne(collection: 'parent', relatedCollection: 'pair', twoWay: true, key: 'buddy', twoWayKey: 'buddyOf', onDelete: ForeignKeyAction::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, ForeignKeyAction::SetNull); + $database->createCollection(new Collection(id: 'pair', permissions: $this->collectionPermissions(), documentSecurity: true)); + $database->createRelationship(Relationship::oneToOne(collection: 'parent', relatedCollection: 'pair', twoWay: true, key: 'buddy', twoWayKey: 'buddyOf', onDelete: ForeignKeyAction::Cascade)); + $database->createRelationship(Relationship::oneToOne(collection: 'pair', relatedCollection: 'child', twoWay: true, key: 'tail', twoWayKey: 'tailOf', onDelete: ForeignKeyAction::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, ForeignKeyAction::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, ForeignKeyAction::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, ForeignKeyAction::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, ForeignKeyAction::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, ForeignKeyAction::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)); + } + + public function testACommitThatFailsAndRetriesReportsEachPeerOnce(): void + { + $adapter = new class () extends Memory { + public bool $failNextCommit = false; + + public function commitTransaction(): bool + { + if ($this->failNextCommit && $this->inTransaction === 1) { + $this->failNextCommit = false; + + throw new RuntimeException('commit failed'); + } + + return parent::commitTransaction(); + } + }; + + $database = $this->database($adapter); + $this->relateParentToChildren($database, ForeignKeyAction::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)); + $database->addHook(new Permissions()); + + return $database; + } + + private function relateParentToChildren(Database $database, ForeignKeyAction $onDelete): void + { + $this->createCollections($database, 'parent', 'child'); + $database->createRelationship(Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: $onDelete)); + } + + private function createCollections(Database $database, string ...$ids): void + { + foreach ($ids as $id) { + $database->createCollection(new Collection(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])); + } + + private function record(Database $database): RecordingLifecycle + { + $recorder = new RecordingLifecycle(); + $database->addHook($recorder); + + return $recorder; + } + + /** + * @return list + */ + private function reported(RecordingLifecycle $recorder): array + { + $ids = []; + foreach ($recorder->getPayloads(Event::DocumentUpdate) as $related) { + $this->assertInstanceOf(Document::class, $related); + $ids[] = $related->getId(); + } + \sort($ids); + + return $ids; + } + + /** + * @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/ParallelPopulationTest.php b/tests/unit/Relationships/ParallelPopulationTest.php new file mode 100644 index 0000000000..dee009a40b --- /dev/null +++ b/tests/unit/Relationships/ParallelPopulationTest.php @@ -0,0 +1,353 @@ +markTestSkipped('ext-swoole is required for coroutine population'); + } + + $file = \tempnam(\sys_get_temp_dir(), 'parallel-population-'); + $this->assertIsString($file); + $this->file = $file; + } + + 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 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; + } + } + + private function pool(): Pool + { + return new Pool(new UtopiaPool(new Stack(), 'parallel-population', 8, $this->sqlite(...), timeout: 1.0)); + } + + private function sqlite(): SQLite + { + return new SQLite(new PDO('sqlite:' . $this->file)); + } + + 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)); + $database->addHook(new class ($this->select(...)) implements Transform { + public function __construct(private readonly Closure $select) + { + } + + 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) + { + } + + public function handle(Event $event, mixed $data): void + { + ($this->record)($event); + } + }); + + $open = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(new Collection(id: 'parents', permissions: $open, documentSecurity: false, attributes: [Attribute::string('name', 32)])); + $database->createCollection(new Collection(id: 'children', permissions: $open, documentSecurity: false, attributes: [Attribute::string('name', 32)])); + $database->createCollection(new Collection(id: 'labels', permissions: $open, documentSecurity: false, attributes: [Attribute::string('name', 32)])); + $database->createCollection(new Collection(id: 'secrets', permissions: [Permission::create(Role::any())], documentSecurity: true, attributes: [Attribute::string('name', 32)])); + $database->createRelationship(new Relationship(collection: 'parents', relatedCollection: 'children', type: RelationType::OneToMany, twoWay: true, key: 'children', twoWayKey: 'parent')); + $database->createRelationship(new Relationship(collection: 'parents', relatedCollection: 'labels', type: RelationType::ManyToMany, twoWay: true, key: 'labels', twoWayKey: 'parents')); + $database->createRelationship(new Relationship(collection: 'parents', relatedCollection: 'secrets', type: RelationType::ManyToOne, 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; + } + + /** + * @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/RelationshipHookCoverageTest.php b/tests/unit/Relationships/RelationshipHookCoverageTest.php new file mode 100644 index 0000000000..4950c00f17 --- /dev/null +++ b/tests/unit/Relationships/RelationshipHookCoverageTest.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:'))], + ]; + } + + /** + * @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(new Collection(id: 'countries', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions())); + $database->createRelationship(Relationship::manyToOne(collection: 'publishers', 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); + } + + 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)); + $database->createCollection(new Collection(id: 'parent', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'child', permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: true)); + $database->createRelationship(Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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'); + } + + /** + * @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($database)); + + foreach (['books' => 'title', 'authors' => 'name', 'publishers' => 'name'] as $collection => $attribute) { + $database->createCollection(new Collection(id: $collection, attributes: [Attribute::string(key: $attribute, size: 64)], permissions: $this->permissions())); + } + $database->createRelationship(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $database->createRelationship(Relationship::manyToOne(collection: 'authors', 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..d8ca33cca4 --- /dev/null +++ b/tests/unit/Relationships/RelationshipHookTest.php @@ -0,0 +1,1344 @@ + + */ + 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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'child', relatedCollection: 'parent', twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'child', relatedCollection: 'parent', twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::Cascade)); + $database->createCollection(new Collection(id: 'grandchild', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::oneToMany(collection: 'child', relatedCollection: 'grandchild', twoWay: true, key: 'grandchildren', twoWayKey: 'child', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull), + [Permission::create(Role::any()), Permission::read(Role::any())], + ); + $database->createCollection(new Collection(id: 'grandparent', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::oneToOne(collection: 'grandparent', relatedCollection: 'parent', key: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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())], ForeignKeyAction::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())], ForeignKeyAction::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'))], ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::Cascade)); + + $updates = new class () implements Lifecycle { + /** @var array */ + public array $options = []; + + public function handle(Event $event, mixed $data): void + { + if ($event === Event::AttributeUpdate && $data instanceof Document) { + $this->options[$data->getCollection().'.'.$data->getId()] = $data->getAttribute('options'); + } + } + }; + $database->addHook($updates); + + $this->assertTrue($database->updateRelationship('parent', 'children', newKey: 'kids', onDelete: ForeignKeyAction::SetNull)); + + $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 array $grandchildPermissions + */ + private function nestedCascadeDatabase(Closure $adapter, array $grandchildPermissions, ForeignKeyAction $onDelete): Database + { + $database = $this->database($adapter); + $this->relate( + $database, + Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::Cascade), + ); + $database->createCollection(new Collection(id: 'grandchild', permissions: $grandchildPermissions, documentSecurity: false)); + $database->createRelationship(Relationship::oneToMany(collection: 'child', 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)); + $database->addHook(new Permissions()); + + return $database; + } + + /** + * @param array $childPermissions + */ + private function relate(Database $database, Relationship $relationship, array $childPermissions = [], bool $childDocumentSecurity = true): void + { + $database->createCollection(new Collection(id: 'parent', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'child', permissions: $childPermissions === [] ? $this->permissions() : $childPermissions, documentSecurity: $childDocumentSecurity)); + $database->createRelationship($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(new Collection(id: 'grandparent', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::oneToOne(collection: 'grandparent', relatedCollection: 'parent', key: 'parent', onDelete: ForeignKeyAction::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 array + */ + 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(new Collection(id: 'owner', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'solo', attributes: [Attribute::string(key: 'name', size: 64)], permissions: [Permission::create(Role::any()), Permission::read(Role::any())], documentSecurity: false)); + $database->createCollection(new Collection(id: 'item', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'detail', permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::oneToOne(collection: 'owner', relatedCollection: 'solo', twoWay: true, key: 'solo', twoWayKey: 'owner', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::oneToMany(collection: 'owner', relatedCollection: 'item', twoWay: true, key: 'items', twoWayKey: 'owner', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::oneToMany(collection: 'item', relatedCollection: 'detail', twoWay: true, key: 'details', twoWayKey: 'item', onDelete: ForeignKeyAction::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(ForeignKeyAction::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(ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'child', twoWayKey: 'parent', onDelete: ForeignKeyAction::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 (ForeignKeyAction $onDelete): Relationship => Relationship::oneToOne(collection: 'parent', relatedCollection: 'child', key: 'child', twoWayKey: 'parent', onDelete: $onDelete), + $parentHoldsChild, + ], + 'one-to-many' => [ + fn (ForeignKeyAction $onDelete): Relationship => Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: $onDelete), + $childHoldsKey, + ], + 'many-to-one' => [ + fn (ForeignKeyAction $onDelete): Relationship => Relationship::manyToOne(collection: 'child', relatedCollection: 'parent', twoWay: true, key: 'parent', twoWayKey: 'children', onDelete: $onDelete), + $childHoldsKey, + ], + 'many-to-many' => [ + fn (ForeignKeyAction $onDelete): Relationship => Relationship::manyToMany(collection: 'parent', 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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::SetNull); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('adapters')] + public function testANewNestedManyToManyDocumentKeepsItsOwnPermissions(Closure $adapter): void + { + $database = $this->database($adapter); + $this->relate($database, Relationship::manyToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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 = []; + + 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(new Collection(id: 'parent', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'child', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'tag', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'label', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'owner', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::manyToMany(collection: 'child', relatedCollection: 'tag', twoWay: true, key: 'tags', twoWayKey: 'children', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::oneToMany(collection: 'tag', relatedCollection: 'label', twoWay: true, key: 'labels', twoWayKey: 'tag', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::manyToOne(collection: 'child', relatedCollection: 'owner', twoWay: true, key: 'owner', twoWayKey: 'children', onDelete: ForeignKeyAction::SetNull)); + $database->createRelationship(Relationship::manyToMany(collection: 'parent', relatedCollection: 'tag', twoWay: true, key: 'topics', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', key: 'partner', twoWayKey: 'parent', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', key: 'partner', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull); + $twoWayOneToOne = Relationship::oneToOne(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull); + $oneToMany = Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull); + $manyToOne = Relationship::manyToOne(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'child', twoWayKey: 'parents', onDelete: ForeignKeyAction::SetNull); + $manyToMany = Relationship::manyToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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'])); + $database->createDocument('parent', new Document(['$id' => 'parent1', ...($linked ? [$relationship->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(new Collection(id: 'store', attributes: [Attribute::string(key: 'name', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'product', attributes: [Attribute::string(key: 'name', size: 64), Attribute::string(key: 'sku', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::manyToOne(collection: 'product', relatedCollection: 'store', twoWay: true, key: 'store', twoWayKey: 'products', onDelete: ForeignKeyAction::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(new Collection(id: 'project', permissions: $this->permissions(), documentSecurity: false)); + $database->createCollection(new Collection(id: 'developer', attributes: [Attribute::string(key: 'devName', size: 64)], permissions: $this->permissions(), documentSecurity: false)); + $database->createRelationship(Relationship::manyToMany(collection: 'project', relatedCollection: 'developer', twoWay: true, key: 'developers', twoWayKey: 'projects', onDelete: ForeignKeyAction::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(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::SetNull)]; + yield "{$adapterName}: many-to-many" => [$adapter, Relationship::manyToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parents', onDelete: ForeignKeyAction::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 === RelationType::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($database)); + + $this->relate( + $database, + Relationship::oneToMany(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'children', twoWayKey: 'parent', onDelete: ForeignKeyAction::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/RelationshipSchemaTest.php b/tests/unit/Relationships/RelationshipSchemaTest.php new file mode 100644 index 0000000000..caedd18fd2 --- /dev/null +++ b/tests/unit/Relationships/RelationshipSchemaTest.php @@ -0,0 +1,793 @@ + + */ + 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(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $database->deleteIndex('books', '_index_author'); + + try { + $database->updateRelationship('books', 'author', newKey: '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 testIndexMetadataOfTheMetadataCollectionCannotBeUpdated(): void + { + $database = new class (new Memory(), new Cache(new None())) extends Database { + public function renameIndexAttributes(string $collection, string $id): Index + { + return $this->updateIndexMeta($collection, $id, static function (Index $index): void { + $index->setAttribute('attributes', ['changed']); + }); + } + }; + $this->prepare($database); + + try { + $database->renameIndexAttributes(Database::METADATA, '_key_title'); + $this->fail('the metadata collection\'s indexes must not be changed'); + } catch (DatabaseException $error) { + $this->assertSame('Cannot update metadata indexes', $error->getMessage()); + } + + try { + $database->renameIndexAttributes('books', 'missing'); + $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(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->updateRelationship('books', 'author', newKey: 'writer'); + $this->fail('a failed definition update must fail the rename'); + } catch (RuntimeException $error) { + $this->assertSame($failure, $error); + } + + $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(Relationship::manyToMany(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + + try { + $database->updateRelationship('books', 'writers', newKey: 'authors_of', newTwoWayKey: 'written'); + $this->fail('a failed junction definition update must fail the rename'); + } catch (RuntimeException $error) { + $this->assertSame($failure, $error); + } + + $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(Relationship::oneToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'library', twoWayKey: 'owner')); + $database->deleteIndex('authors', '_index_owner'); + $physical = $this->schemaIndexIds($database, 'books'); + + try { + $database->updateRelationship('books', 'library', newKey: 'shelf', newTwoWayKey: '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 (): bool => $database->createRelationship(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors')), + static fn (): bool => $database->updateRelationship('books', 'author', newKey: 'writer'), + static fn (): bool => $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(Relationship::manyToMany(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + } catch (DatabaseException $caught) { + $error = $caught; + } + }); + + $this->assertInstanceOf(DatabaseException::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->assertTrue($database->createRelationship(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books'))); + $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(Relationship::manyToMany(collection: 'books', 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(Relationship::oneToOne(collection: 'books', 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(Relationship::oneToOne(collection: 'books', 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(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + $before = $this->relationship($database, 'books', 'author'); + + $this->assertTrue($database->updateRelationship('books', 'author')); + $this->assertEquals($before, $this->relationship($database, 'books', 'author')); + + try { + $database->updateRelationship('books', 'missing', newKey: '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(Relationship::oneToMany(collection: 'authors', 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->assertTrue($database->updateRelationship('books', 'author', newKey: 'writer')); + + $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(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + $this->assertTrue($database->updateRelationship('authors', 'books', newTwoWayKey: 'writer')); + + $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 testAnAdapterThatDoesNotUpdateTheRelationshipFailsTheUpdate(Closure $adapter): void + { + $database = $this->database($this->refusingUpdates($adapter())); + $database->createRelationship(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->updateRelationship('books', 'author', newKey: 'writer'); + $this->fail('an adapter that does not update the relationship must fail the update'); + } catch (DatabaseException $error) { + $this->assertSame("Failed to update relationship 'author': Failed to update relationship", $error->getMessage()); + } + + $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 { + public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): bool + { + parent::updateRelationship($relationship, $newKey, $newTwoWayKey); + + throw new RuntimeException('the connection dropped after the rename'); + } + }; + $database = $this->database($adapter); + $database->createRelationship(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + $this->assertTrue($database->updateRelationship('books', 'author', newKey: 'writer')); + + $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(Relationship::manyToMany(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'writers', twoWayKey: 'works')); + $junction = $this->junction($database); + $database->deleteIndex($junction, '_index_writers'); + + try { + $database->updateRelationship('books', 'writers', newKey: 'authors_of', newTwoWayKey: '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(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + $this->assertTrue($database->deleteRelationship('books', 'author')); + + $this->assertNotContains('author', $this->attributeKeys($database, 'books')); + $this->assertNotContains('books', $this->attributeKeys($database, 'authors')); + } + + public function testAnAdapterThatDoesNotDeleteTheRelationshipFailsTheDelete(): void + { + $database = $this->database($this->memory(['deleteRelationship' => static fn (): bool => false])); + $database->createRelationship(Relationship::manyToOne(collection: 'books', relatedCollection: 'authors', twoWay: true, key: 'author', twoWayKey: 'books')); + + try { + $database->deleteRelationship('books', 'author'); + $this->fail('an adapter that does not drop the relationship must fail the delete'); + } catch (DatabaseException $error) { + $this->assertSame('Failed to delete relationship', $error->getMessage()); + } + + $this->assertContains('author', $this->attributeKeys($database, 'books')); + } + + 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(Relationship::manyToMany(collection: 'books', 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(Relationship::manyToOne(collection: 'books', 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')); + } + + /** + * @return list + */ + private function schemaIndexIds(Database $database, string $collection): array + { + $ids = \array_map(static fn (Document $index): string => $index->getId(), $database->getSchemaIndexes($collection)); + \sort($ids); + + return $ids; + } + + /** + * @return list + */ + private function attributeKeys(Database $database, string $collection): array + { + return \array_values(\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) { + return Relationship::fromArray(['collection' => $collection] + $attribute->getArrayCopy()); + } + } + + $this->fail("{$collection} has no relationship {$key}"); + } + + /** + * @return array + */ + private function attributes(Database $database, string $collection): array + { + /** @var array $attributes */ + $attributes = $database->getCollection($collection)->getAttribute('attributes', []); + + return $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($database)); + + $permissions = [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $database->createCollection(new Collection(id: 'books', attributes: [Attribute::string(key: 'title', size: 64)], permissions: $permissions)); + $database->createCollection(new Collection(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(); + } + + public function createRelationship(Relationship $relationship): bool + { + return $this->intercept(__FUNCTION__, [$relationship]) ?? parent::createRelationship($relationship); + } + + public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): bool + { + return $this->intercept(__FUNCTION__, [$relationship, $newKey, $newTwoWayKey]) ?? parent::updateRelationship($relationship, $newKey, $newTwoWayKey); + } + + public function deleteRelationship(Relationship $relationship): bool + { + return $this->intercept(__FUNCTION__, [$relationship]) ?? parent::deleteRelationship($relationship); + } + + public function deleteCollection(string $id): bool + { + if (! \str_starts_with($id, '_')) { + return parent::deleteCollection($id); + } + + return $this->intercept(__FUNCTION__, [$id]) ?? parent::deleteCollection($id); + } + + public function createIndex(string $collection, Index $index, array $indexAttributeTypes = [], array $collation = []): bool + { + return $this->intercept(__FUNCTION__, [$collection, $index]) ?? parent::createIndex($collection, $index, $indexAttributeTypes, $collation); + } + + 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); + } + + public function updateDocument(string $collection, string $id, Document $document): Document + { + if ($this->update !== null) { + ($this->update)($collection, $id, $document); + } + + return parent::updateDocument($collection, $id, $document); + } + + public function createDocument(string $collection, Document $document): Document + { + if ($this->create !== null) { + ($this->create)($collection); + } + + return parent::createDocument($collection, $document); + } + + protected function updateAttributeMeta(string $collection, string $id, callable $updateCallback, bool $triggerEvent = true): Attribute + { + if ($this->attributeMeta !== null) { + ($this->attributeMeta)($collection, $id); + } + + return parent::updateAttributeMeta($collection, $id, $updateCallback, $triggerEvent); + } + }; + + return $this->prepare($database); + } + + private function index(Database $database, string $collection, string $key): ?Index + { + /** @var array $indexes */ + $indexes = $database->getCollection($collection)->getAttribute('indexes', []); + foreach ($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 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(); + } + + private function refusingUpdates(Adapter $adapter): Adapter + { + if ($adapter instanceof SQLite) { + return new class (new PDO('sqlite::memory:')) extends SQLite { + public function updateRelationship(Relationship $relationship, ?string $newKey = null, ?string $newTwoWayKey = null): 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 { + public function deleteRelationship(Relationship $relationship): 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..b022abfa45 --- /dev/null +++ b/tests/unit/Relationships/RelationshipValidationTest.php @@ -0,0 +1,757 @@ + 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('getSharedTables')->willReturn(false); + $adapter->method('getTenant')->willReturn(null); + $adapter->method('getTenantPerDocument')->willReturn(false); + $adapter->method('getNamespace')->willReturn(''); + $adapter->method('getIdAttributeType')->willReturn(ColumnType::Integer->value); + $adapter->method('getMaxUIDLength')->willReturn(36); + $adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $adapter->method('getLimitForString')->willReturn(16777215); + $adapter->method('getLimitForInt')->willReturn(2147483647); + $adapter->method('getLimitForAttributes')->willReturn(0); + $adapter->method('getLimitForIndexes')->willReturn(64); + $adapter->method('getMaxIndexLength')->willReturn(768); + $adapter->method('getMaxVarcharLength')->willReturn(16383); + $adapter->method('getDocumentSizeLimit')->willReturn(0); + $adapter->method('getCountOfAttributes')->willReturn(0); + $adapter->method('getCountOfIndexes')->willReturn(0); + $adapter->method('getAttributeWidth')->willReturn(0); + $adapter->method('getInternalIndexesKeys')->willReturn([]); + $adapter->method('filter')->willReturnArgument(0); + $adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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($database)); + } + + 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' => RelationType::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' => RelationType::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(Relationship::oneToMany(collection: 'missing', 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(Relationship::oneToMany(collection: 'test', 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' => RelationType::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(Relationship::oneToMany(collection: 'test1', relatedCollection: 'test2', twoWay: true)); + } + + public function testCreateInvalidRelationship(): void + { + $this->expectException(\TypeError::class); + + (new \ReflectionClass(Relationship::class))->newInstance('test3', 'test4', 'invalid', 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' => RelationType::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' => RelationType::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' => RelationType::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' => RelationType::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' => RelationType::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' => RelationType::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' => RelationType::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', newKey: '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' => RelationType::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' => RelationType::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' => RelationType::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' => RelationType::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' => RelationType::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..ded4cefb05 --- /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)); + $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(new Collection( + id: $collection, + attributes: [Attribute::string(key: 'name', size: 64, required: false)], + permissions: $permissions, + documentSecurity: false, + )); + } + $database->createRelationship(Relationship::oneToOne(collection: 'parent', relatedCollection: 'child', twoWay: true, key: 'partner', twoWayKey: 'parent')); + $database->createRelationship(Relationship::oneToOne(collection: 'child', relatedCollection: 'toy', twoWay: true, key: 'toy', twoWayKey: 'owner')); + $database->createRelationship(Relationship::oneToOne(collection: 'toy', 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..b938a807c8 --- /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)); + $database->addHook(new Permissions()); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + $database->createCollection(new Collection(id: 'parent', permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(id: 'child', permissions: $permissions, documentSecurity: false)); + $database->createRelationship(Relationship::oneToOne(collection: 'parent', 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/RoleTest.php b/tests/unit/RoleTest.php index 2c1cbee27d..700d074de7 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; 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()); @@ -126,7 +128,7 @@ public function testInputFromRoles(): void $this->assertEquals('label:vip', $role->toString()); } - public function testInputFromID(): void + public function test_input_from_id(): void { $role = Role::user(ID::custom('123')); $this->assertEquals('user:123', $role->toString()); @@ -137,4 +139,24 @@ public function testInputFromID(): void $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..f1e701b9ef --- /dev/null +++ b/tests/unit/SQLFindTest.php @@ -0,0 +1,1083 @@ +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', '$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', '$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', '$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', '$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', '$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', '$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', '$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', '$id', 'mainId', '=', 'alpha'), + Query::join('peers', 'peerKey', '$id', '=', 'beta'), + 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', '$id', '$id', '=', 'twin'), + 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', '$id', 'mainId', '=', 'tail'), + 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', '$id', 'customerId', '=', 'b'), + 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', '$id', 'mainId', '=', 'alpha'), + 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', '$id', 'mainId', '=', 'meta'), + 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', '$id', 'mainId', '=', 'meta')], + 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', '$id', 'customerId', '=', 'orders'), + 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'])], + ); + + $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', '$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', '$id', 'mainId', '=', 'meta'), + $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; + } + + /** + * @param list $queries + */ + #[DataProvider('aggregationsNextToSearch')] + public function testAggregationNextToSearchLeavesRelevanceOut(array $queries, string $projection): void + { + $sql = $this->captureFindSql($queries); + + $this->assertStringStartsWith('SELECT '.$projection.' FROM ', $sql); + $this->assertStringContainsString('WHERE MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql); + $this->assertStringNotContainsString('_relevance', $sql); + $this->assertStringNotContainsString('ORDER BY', $sql); + } + + /** + * @param list $queries + */ + #[DataProvider('aggregationsNextToSearch')] + public function testPostgresAggregationNextToSearchLeavesRelevanceOut(array $queries, string $projection): void + { + $sql = $this->capturePostgresFindSql($queries); + + $this->assertStringStartsWith('SELECT '.\str_replace('`', '"', $projection).' FROM ', $sql); + $this->assertStringContainsString('@@ websearch_to_tsquery(?)', $sql); + $this->assertStringNotContainsString('_relevance', $sql); + $this->assertStringNotContainsString('ORDER BY', $sql); + } + + /** + * @return iterable, string}> + */ + public static function aggregationsNextToSearch(): iterable + { + yield 'an aggregate' => [ + [Query::count('*', 'total'), Query::search('name', 'Laptop')], + 'COUNT(*) AS `total`', + ]; + yield 'an aggregate and a groupBy' => [ + [Query::count('*', 'total'), Query::groupBy(['category']), Query::search('name', 'Laptop')], + 'COUNT(*) AS `total`, `category`', + ]; + yield 'a groupBy' => [ + [Query::groupBy(['category']), Query::search('name', 'Laptop')], + '`category`', + ]; + } + + public function testRowSearchWithoutAnOrderIsOrderedBySequence(): void + { + $sql = $this->captureFindSql( + [Query::search('name', 'Laptop')], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertStringStartsWith('SELECT * FROM ', $sql); + $this->assertStringEndsWith(' WHERE MATCH(`name`) AGAINST(? IN BOOLEAN MODE) ORDER BY `_id` ASC LIMIT ?', $sql); + } + + public function testPostgresRowSearchWithoutAnOrderIsOrderedBySequence(): void + { + $sql = $this->capturePostgresFindSql( + [Query::search('name', 'Laptop')], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertStringStartsWith('SELECT * FROM ', $sql); + $this->assertStringEndsWith(' @@ websearch_to_tsquery(?) ORDER BY "_id" ASC LIMIT ?', $sql); + } + + public function testExplicitOrderNextToSearchLeavesRelevanceOut(): void + { + $rows = $this->captureFindSql( + [Query::search('name', 'Laptop')], + orderAttributes: ['name', Document::SEQUENCE], + orderTypes: [OrderDirection::Asc, OrderDirection::Asc], + ); + + $this->assertStringStartsWith('SELECT * FROM ', $rows); + $this->assertStringNotContainsString('_relevance', $rows); + $this->assertStringContainsString('ORDER BY `name` ASC, `_id` ASC', $rows); + + $totals = $this->captureFindSql( + [Query::count('*', 'total'), Query::search('name', 'Laptop')], + orderAttributes: ['total'], + orderTypes: [OrderDirection::Desc], + ); + + $this->assertStringStartsWith('SELECT COUNT(*) AS `total` FROM ', $totals); + $this->assertStringNotContainsString('_relevance', $totals); + $this->assertStringContainsString('ORDER BY `total` DESC', $totals); + } + + /** + * @param list $queries + */ + #[DataProvider('aggregationsNextToVectorQuery')] + public function testPostgresAggregationNextToVectorQueryLeavesDistanceOrderOut(array $queries, string $projection): void + { + $sql = $this->capturePostgresFindSql($queries); + + $this->assertStringStartsWith('SELECT '.$projection.' FROM ', $sql); + $this->assertStringContainsString('"table_main"."embedding" IS NOT NULL', $sql); + $this->assertStringNotContainsString('<=>', $sql); + $this->assertStringNotContainsString('ORDER BY', $sql); + } + + /** + * @return iterable, string}> + */ + public static function aggregationsNextToVectorQuery(): iterable + { + yield 'an aggregate' => [ + [Query::count('*', 'total'), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + 'COUNT(*) AS "total"', + ]; + yield 'an aggregate and a groupBy' => [ + [Query::count('*', 'total'), Query::groupBy(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + 'COUNT(*) AS "total", "category"', + ]; + yield 'an aggregate next to a search' => [ + [Query::count('*', 'total'), Query::search('name', 'Laptop'), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + 'COUNT(*) AS "total"', + ]; + } + + public function testPostgresRowVectorQueryOrdersByDistance(): void + { + $sql = $this->capturePostgresFindSql([Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + + $this->assertStringStartsWith('SELECT *, ("table_main"."embedding" <=> ?::vector)::text AS "_distance" FROM ', $sql); + $this->assertStringContainsString('ORDER BY ("table_main"."embedding" <=> ?::vector)', $sql); + } + + /** + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + */ + private function capturePostgresFindSql(array $queries, array $orderAttributes = [], array $orderTypes = []): 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 Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'collection']), + $queries, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + ); + + $this->assertNotSame('', $sql); + + return $sql; + } + + public function testDistinctNextToSearchLeavesRelevanceOut(): void + { + $sql = $this->captureFindSql([Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop')]); + + $this->assertStringStartsWith('SELECT DISTINCT `category` FROM ', $sql); + $this->assertStringContainsString('WHERE MATCH(`name`) AGAINST(? IN BOOLEAN MODE)', $sql); + $this->assertStringNotContainsString('_relevance', $sql); + $this->assertStringNotContainsString('ORDER BY', $sql); + } + + public function testPostgresDistinctNextToSearchLeavesRelevanceOut(): void + { + $sql = $this->capturePostgresFindSql([Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop')]); + + $this->assertStringStartsWith('SELECT DISTINCT "category" FROM ', $sql); + $this->assertStringContainsString('@@ websearch_to_tsquery(?)', $sql); + $this->assertStringNotContainsString('_relevance', $sql); + $this->assertStringNotContainsString('ORDER BY', $sql); + } + + public function testExplicitOrderOnADistinctReadNextToSearchOrdersByItAlone(): void + { + $queries = [Query::distinct(), Query::select(['category']), Query::search('name', 'Laptop')]; + + $mysql = $this->captureFindSql($queries, orderAttributes: ['category'], orderTypes: [OrderDirection::Desc]); + + $this->assertStringStartsWith('SELECT DISTINCT `category` FROM ', $mysql); + $this->assertStringEndsWith(' ORDER BY `category` DESC LIMIT ?', $mysql); + + $postgres = $this->capturePostgresFindSql($queries, ['category'], [OrderDirection::Desc]); + + $this->assertStringStartsWith('SELECT DISTINCT "category" FROM ', $postgres); + $this->assertStringEndsWith(' ORDER BY "category" DESC LIMIT ?', $postgres); + } + + public function testSelectionWithoutDistinctKeepsTheSequenceAndDistanceOrders(): void + { + $search = $this->captureFindSql( + [Query::select(['category']), Query::search('name', 'Laptop')], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + ); + + $this->assertStringStartsWith('SELECT `category`, ', $search); + $this->assertStringNotContainsString('_relevance', $search); + $this->assertStringEndsWith(' WHERE MATCH(`name`) AGAINST(? IN BOOLEAN MODE) ORDER BY `_id` ASC LIMIT ?', $search); + + $vector = $this->capturePostgresFindSql([Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + + $this->assertStringContainsString(' ("table_main"."embedding" <=> ?::vector)::text AS "_distance" FROM ', $vector); + $this->assertStringContainsString('ORDER BY ("table_main"."embedding" <=> ?::vector)', $vector); + } + + /** + * @param list $orderAttributes + * @param list $orderTypes + * @param non-empty-string $clauses + */ + #[DataProvider('distinctVectorOrders')] + public function testPostgresDistinctNextToVectorQueryLeavesDistanceOut(array $orderAttributes, array $orderTypes, string $clauses): void + { + $sql = $this->capturePostgresFindSql( + [Query::distinct(), Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + $orderAttributes, + $orderTypes, + ); + + $this->assertStringStartsWith('SELECT DISTINCT "category" FROM ', $sql); + $this->assertStringEndsWith($clauses, $sql); + $this->assertStringNotContainsString('<=>', $sql); + } + + /** + * @return iterable, list, non-empty-string}> + */ + public static function distinctVectorOrders(): iterable + { + yield 'no order' => [[], [], ' WHERE "table_main"."embedding" IS NOT NULL LIMIT ?']; + yield 'an explicit order' => [['category'], [OrderDirection::Asc], ' WHERE "table_main"."embedding" IS NOT NULL ORDER BY "category" ASC LIMIT ?']; + } + + /** + * @param array $cursor + */ + #[DataProvider('distinctVectorCursors')] + public function testPostgresDistinctNextToVectorQueryPagesAlongItsOrder(array $cursor): void + { + $sql = $this->capturePostgresCursorFindSql( + [Query::distinct(), Query::select(['category']), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])], + ['category'], + [OrderDirection::Asc], + $cursor, + ); + + $this->assertStringStartsWith('SELECT DISTINCT "category" FROM ', $sql); + $this->assertStringEndsWith(' WHERE "category" > ? AND "table_main"."embedding" IS NOT NULL ORDER BY "category" ASC LIMIT ?', $sql); + $this->assertStringNotContainsString('<=>', $sql); + } + + /** + * @return iterable}> + */ + public static function distinctVectorCursors(): iterable + { + yield 'a cursor without a distance' => [['category' => 'clothing']]; + yield 'a cursor with a distance' => [['category' => 'clothing', Document::DISTANCE => 0.25]]; + } + + /** + * @param array $queries + * @param array $orderAttributes + * @param array $orderTypes + * @param array $cursor + */ + private function capturePostgresCursorFindSql(array $queries, array $orderAttributes, array $orderTypes, array $cursor): 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 Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $authorization = new Authorization(); + $authorization->disable(); + $adapter->setAuthorization($authorization); + + $adapter->find( + new Document(['$id' => 'collection']), + $queries, + orderAttributes: $orderAttributes, + orderTypes: $orderTypes, + cursor: $cursor, + ); + + $this->assertNotSame('', $sql); + + return $sql; + } + + public function testRowSearchWithNoOrderAttributesAddsNoOrder(): void + { + $sql = $this->captureFindSql([Query::search('name', 'Laptop')]); + + $this->assertStringStartsWith('SELECT * FROM ', $sql); + $this->assertStringEndsWith(' WHERE MATCH(`name`) AGAINST(? IN BOOLEAN MODE) LIMIT ?', $sql); + } + + public function testPostgresRowSearchWithNoOrderAttributesAddsNoOrder(): void + { + $sql = $this->capturePostgresFindSql([Query::search('name', 'Laptop')]); + + $this->assertStringStartsWith('SELECT * FROM ', $sql); + $this->assertStringEndsWith(' @@ websearch_to_tsquery(?) LIMIT ?', $sql); + } + + public function testRowSearchPagesAlongTheSequenceCursor(): void + { + $sql = $this->captureFindSql( + [Query::search('name', 'Laptop')], + orderAttributes: [Document::SEQUENCE], + orderTypes: [OrderDirection::Asc], + cursor: [Document::SEQUENCE => 10], + ); + + $this->assertStringStartsWith('SELECT * FROM ', $sql); + $this->assertStringEndsWith(' WHERE MATCH(`name`) AGAINST(? IN BOOLEAN MODE) AND `_id` > ? ORDER BY `_id` ASC LIMIT ?', $sql); + } + + public function testPostgresRowSearchPagesAlongTheSequenceCursor(): void + { + $sql = $this->capturePostgresCursorFindSql( + [Query::search('name', 'Laptop')], + [Document::SEQUENCE], + [OrderDirection::Asc], + [Document::SEQUENCE => 10], + ); + + $this->assertStringStartsWith('SELECT * FROM ', $sql); + $this->assertStringEndsWith(' @@ websearch_to_tsquery(?) AND "_id" > ? ORDER BY "_id" ASC LIMIT ?', $sql); + } + + public function testPostgresRowVectorQueryNextToSearchOrdersByDistanceAlone(): void + { + $sql = $this->capturePostgresFindSql([Query::search('name', 'Laptop'), Query::vectorCosine('embedding', [1.0, 0.0, 0.0])]); + + $this->assertStringStartsWith('SELECT *, ("table_main"."embedding" <=> ?::vector)::text AS "_distance" FROM ', $sql); + $this->assertStringEndsWith(' AND "table_main"."embedding" IS NOT NULL ORDER BY ("table_main"."embedding" <=> ?::vector) LIMIT ?', $sql); + } +} diff --git a/tests/unit/SQLGetDocumentTest.php b/tests/unit/SQLGetDocumentTest.php index d938b85d41..e773621a74 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', '$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', '$id', 'customerId'), + ]); + + $this->assertJoinProjection($sql); + $this->assertStringContainsString('LEFT JOIN', $sql); + } + + public function testJoinAliasWithoutSelectLeavesJoinedInternalsOut(): void + { + $sql = $this->captureGetDocumentSql([ + Query::join('orders', '$id', 'customerId', '=', 'rev'), + ]); + + $this->assertJoinProjection($sql, joinAlias: 'rev'); + } + + public function testGetDocumentFullOuterJoinUsesLeftJoinOnPostgres(): void + { + $sql = $this->captureGetDocumentSql( + [Query::fullOuterJoin('orders', '$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..85f3b98d20 --- /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(new Collection( + 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..25c88c5e89 --- /dev/null +++ b/tests/unit/SQLTimeoutScopeTest.php @@ -0,0 +1,253 @@ +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 = []; + + 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('movies', 'batch', 'score', 1, '2026-08-13 00:00:00.000'); + $adapter->increaseDocumentAttribute('movies', '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('movies', '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..4416ae59a3 100644 --- a/tests/unit/SQLTransactionTest.php +++ b/tests/unit/SQLTransactionTest.php @@ -4,10 +4,8 @@ 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\Transaction as TransactionException; final class SQLTransactionTest extends TestCase @@ -63,9 +61,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 +78,48 @@ 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(); + } } diff --git a/tests/unit/SQLiteArrayContainsTest.php b/tests/unit/SQLiteArrayContainsTest.php new file mode 100644 index 0000000000..3860700d00 --- /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(new Collection( + 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..a7d320165b --- /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(new Collection( + 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..d8a4df847a --- /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(new Collection( + 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..dd8aaf91b9 --- /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->enableProfiling()->getProfiler(); + $this->assertNotNull($profiler); + $profiler->reset(); + + $created = $database->createDocument(self::COLLECTION, $this->document('measured', $documentPermissions)); + + $statements = \array_map( + static fn (QueryLog $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(new Collection( + 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..0b0b353fc8 --- /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(new Collection( + 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..5697d648a0 --- /dev/null +++ b/tests/unit/SQLiteOperatorBehaviorTest.php @@ -0,0 +1,87 @@ +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..ca98b2a891 --- /dev/null +++ b/tests/unit/SQLitePlanTest.php @@ -0,0 +1,407 @@ +}> + */ + private ArrayObject $statements; + + protected function setUp(): void + { + $this->statements = new ArrayObject(); + } + + 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); + } + + protected function prepareStatement(string $sql, ?Event $event = null): DatabasePDOStatement|PDOStatementProxy|PDOStatement + { + $this->statements->append([$sql, []]); + + return parent::prepareStatement($sql, $event); + } + + 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(new Collection( + 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, '$id', '$id', '=', 'p'.$join), + \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..38a57bf479 --- /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->exists('database', 'movies'); + } +} diff --git a/tests/unit/SQLiteRegexTest.php b/tests/unit/SQLiteRegexTest.php new file mode 100644 index 0000000000..8f297e96d7 --- /dev/null +++ b/tests/unit/SQLiteRegexTest.php @@ -0,0 +1,92 @@ +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 testRegexIsAdvertisedOnlyWithTheUserFunction(): void + { + $registered = new SQLite(new DatabasePDO('sqlite::memory:', null, null)); + + $this->assertTrue($registered->supports(Capability::Regex)); + $this->assertTrue($registered->supports(Capability::PCRE)); + + $unregistered = new SQLite(new class () extends PDO { + public function __construct() + { + } + + public function createFunction(string $name, callable $callback, int $arguments = -1, int $flags = 0): bool + { + return false; + } + }); + + $this->assertFalse($unregistered->supports(Capability::Regex)); + $this->assertFalse($unregistered->supports(Capability::PCRE)); + } + + private function database(): Database + { + $database = new Database(new SQLite(new DatabasePDO('sqlite::memory:', null, null)), new Cache(new None())); + $database + ->setDatabase('regex') + ->setNamespace('regex') + ->setAuthorization(new Authorization()); + $database->create(); + + $database->createCollection(new Collection( + 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..547a424737 --- /dev/null +++ b/tests/unit/SQLiteSchemaIndexesTest.php @@ -0,0 +1,244 @@ + + */ + 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' => ['FULLTEXT', ['body']], + 'title_search' => ['FULLTEXT', ['title']], + ], $this->fulltextIndexes($database)); + + $database->renameIndex(self::COLLECTION, 'title_search', 'title_lookup'); + + $this->assertSame([ + 'body_search' => ['FULLTEXT', ['body']], + 'title_lookup' => ['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'])); + + $this->assertTrue($database->deleteIndex(self::COLLECTION, 'title_search')); + + $this->assertSame(['body_search' => ['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()); + } + + $this->assertTrue($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([1, [...$tenant, 'title']], $indexes['by_title'] ?? null); + $this->assertSame([0, [...$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(new Collection( + 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([1, ['_tenant', 'title']], $first['by_title'] ?? null); + $this->assertSame([1, ['_tenant', 'title']], $second['by_title'] ?? null); + $this->assertSame([1, ['_tenant', 'title']], $first['lookup'] ?? null); + $this->assertSame([0, ['_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([1, ['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([0, ['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)); + + $this->assertTrue($database->renameIndex(self::COLLECTION, 'by_title', 'by_heading')); + + $indexes = $this->indexes($database); + $this->assertArrayNotHasKey('by_title', $indexes); + $this->assertSame([1, [...($shared ? ['_tenant'] : []), 'title']], $indexes['by_heading'] ?? null, 'the metadata names an index the schema has'); + $this->assertSame(['by_heading'], \array_values(\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) { + $nonUnique = $index->getAttribute('nonUnique'); + $columns = $index->getAttribute('columns'); + $this->assertIsInt($nonUnique); + $this->assertIsArray($columns); + $this->assertArrayNotHasKey($index->getId(), $indexes, 'Each index is listed once'); + /** @var list $columns */ + $indexes[$index->getId()] = [$nonUnique, $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) { + $type = $index->getAttribute('indexType'); + $columns = $index->getAttribute('columns'); + $this->assertIsString($type); + $this->assertIsArray($columns); + if ($type !== 'FULLTEXT') { + continue; + } + /** @var list $columns */ + $indexes[$index->getId()] = [$type, $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(new Collection( + 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..761da4afc9 --- /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(new Collection( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'body', size: 128), + Attribute::integer(key: 'views'), + ], + indexes: [new Index(key: 'body_search', type: IndexType::Fulltext, 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..7e9bf5f533 --- /dev/null +++ b/tests/unit/SQLiteSearchJoinTest.php @@ -0,0 +1,179 @@ + '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, '$id', 'authorId', '=', self::ALIAS); + } + + /** + * @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(new Collection( + id: self::AUTHORS, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: $permissions, + documentSecurity: false, + )); + $database->createCollection(new Collection( + id: self::POSTS, + attributes: [ + Attribute::string(key: 'authorId', size: 64), + Attribute::string(key: 'body', size: 256), + ], + indexes: [new Index(key: 'body_search', type: IndexType::Fulltext, 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..b9b4a13ea3 --- /dev/null +++ b/tests/unit/SQLiteTenantIndexTest.php @@ -0,0 +1,110 @@ +database('acme.1'); + $database->createIndex(self::COLLECTION, Index::unique(key: 'email', attributes: ['email'])); + + $this->assertTrue($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(new Collection( + 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..60ec5a4311 --- /dev/null +++ b/tests/unit/SQLiteUserFunctionsTest.php @@ -0,0 +1,173 @@ +assertSame([], $deprecations); + $this->assertTrue($adapter->supports(Capability::PCRE)); + $this->assertRegexp($connection); + } + + public function testRegistersRegexpOnNativeSqliteSubclass(): void + { + $connection = PdoSqlite::connect('sqlite::memory:'); + + $this->assertInstanceOf(PdoSqlite::class, $connection); + + $adapter = new SQLite($connection); + + $this->assertTrue($adapter->supports(Capability::PCRE)); + $this->assertRegexp($connection); + } + + public function testReconnectRegistersRegexpOnReplacementConnection(): void + { + $connection = new DatabasePDO('sqlite::memory:', null, null); + $adapter = new SQLite($connection); + + $this->assertRegexp($connection); + + $adapter->reconnect(); + + $this->assertTrue($adapter->supports(Capability::PCRE)); + $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); + } + ); + + try { + $adapter = new SQLite(new PDO('sqlite::memory:')); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $deprecations); + $this->assertFalse($adapter->supports(Capability::PCRE)); + } + + public function testDispatchesToTheModernWrapperMethod(): void + { + $connection = new class () extends DatabasePDO { + /** @var array */ + public array $calls = []; + + public function __construct() + { + } + + public function __call(string $method, array $args): mixed + { + $this->calls[] = $method; + + return true; + } + }; + + $adapter = new SQLite($connection); + + $this->assertSame(['createFunction'], $connection->calls); + $this->assertTrue($adapter->supports(Capability::PCRE)); + } + + public function testDoesNotAdvertisePcreWhenRegistrationReturnsFalse(): 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; + } + }; + + $adapter = new SQLite($connection); + + $this->assertFalse($adapter->supports(Capability::PCRE)); + } + + public function testDoesNotAdvertisePcreWhenRegistrationThrows(): 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'); + } + }; + + $adapter = new SQLite($connection); + + $this->assertFalse($adapter->supports(Capability::PCRE)); + } + + 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..cc404e8e5a --- /dev/null +++ b/tests/unit/Schemaless/SchemalessValidationTest.php @@ -0,0 +1,263 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::TTLIndexes, + ]); + }); + $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' => $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->assertTrue($this->database->createIndex( + 'sl_idx_dup', + Index::key(key: 'duplicate', attributes: ['name'], lengths: [0], orders: [Order::Asc]) + )); + + try { + $this->database->createIndex( + 'sl_idx_dup', + Index::key(key: 'duplicate', attributes: ['name'], lengths: [0], orders: [Order::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->assertTrue($this->database->createIndex( + 'sl_ttl_dup', + Index::ttl(key: 'idx_ttl_expires', attributes: ['expiresAt'], orders: [Order::Asc], ttl: 3600) + )); + + try { + $this->database->createIndex( + 'sl_ttl_dup', + Index::ttl(key: 'idx_ttl_expires_duplicate', attributes: ['expiresAt'], orders: [Order::Asc], 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..17881a42f8 --- /dev/null +++ b/tests/unit/ScopedToggleCoroutineTest.php @@ -0,0 +1,744 @@ +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', + ], + 'skipValidation' => [ + static fn (Database $database, Closure $callback): mixed => $database->skipValidation($callback), + static fn (Database $database): bool => $database->isValidationEnabled(), + false, + true, + ], + 'withPreserveDates' => [ + static fn (Database $database, Closure $callback): mixed => $database->withPreserveDates($callback), + static fn (Database $database): bool => $database->getPreserveDates(), + true, + false, + ], + 'withPreserveSequence' => [ + static fn (Database $database, Closure $callback): mixed => $database->withPreserveSequence($callback), + static fn (Database $database): bool => $database->getPreserveSequence(), + 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', + ], + 'skipDuplicates' => [ + static fn (Database $database, Closure $callback): mixed => $database->skipDuplicates($callback), + self::duplicateOutcome(...), + 'skipped', + 'rejected', + ], + 'adapter skipDuplicates' => [ + static fn (Database $database, Closure $callback): mixed => $database->getAdapter()->skipDuplicates($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->enableValidation(); + $database->setPreserveDates(true); + + return [$database->isValidationEnabled(), $database->withPreserveDates(fn (): bool => $database->getPreserveDates())]; + }); + + $this->assertSame([true, true], $seen); + $this->assertTrue($database->isValidationEnabled()); + $this->assertTrue($database->getPreserveDates(), '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(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->enableValidation(); + $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->disableValidation()->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()->skipDuplicates($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( + fn (): mixed => $database->withPreserveSequence( + fn (): mixed => $database->withTenant( + self::SCOPED_TENANT, + fn (): mixed => $database->withRequestTimestamp( + new DateTime(self::PAST), + fn (): mixed => $database->skipDuplicates( + fn (): mixed => $database->skipFilters($callback, [self::FILTER]), + ), + ), + ), + ), + ), + ); + } + + /** + * @return array + */ + private function state(Database $database): array + { + return [ + 'validation' => $database->isValidationEnabled(), + 'preserveDates' => $database->getPreserveDates(), + 'preserveSequence' => $database->getPreserveSequence(), + '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 array + */ + private static function filters(): array + { + return [ + self::FILTER => [ + 'encode' => static fn (mixed $value): mixed => \is_string($value) ? \strtolower($value) : $value, + 'decode' => static fn (mixed $value): mixed => \is_string($value) ? \strtoupper($value) : $value, + ], + ]; + } + + private function collection(bool $fulltext = false): Collection + { + return new Collection( + id: self::COLLECTION, + attributes: [ + Attribute::string(key: 'title', size: 64, filters: [self::FILTER]), + Attribute::integer(key: 'views'), + ], + indexes: $fulltext ? [new Index(key: 'title_search', type: IndexType::Fulltext, 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..9a1fbbbf20 --- /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(new Collection(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..8e516ac62a 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\Query; +use Utopia\Query\Method; /** * Drives Database::find(), the entry point the HTTP layer calls, rather than the @@ -34,8 +38,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(new Collection(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 +47,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 +75,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/Spatial/SpatialValidationTest.php b/tests/unit/Spatial/SpatialValidationTest.php new file mode 100644 index 0000000000..07d93b3a56 --- /dev/null +++ b/tests/unit/Spatial/SpatialValidationTest.php @@ -0,0 +1,343 @@ +adapter = self::createStub(SpatialAdapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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 + { + $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' => [], + ]); + $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' => [], + ]); + $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' => [], + ]); + + $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::spatial(key: 'idx_multi', 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', attributes: ['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..51d412a154 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,8 +18,10 @@ 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); @@ -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..4b2d00684b --- /dev/null +++ b/tests/unit/State/CoroutineStateTest.php @@ -0,0 +1,238 @@ +markTestSkipped('ext-swoole is required for coroutine state'); + } + + $this->database = HookFixture::memory(); + $this->hook = new Relationships($this->database); + $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->getCollection(HookFixture::COLLECTION)->isEmpty(), + ); + + $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); + $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()); + } + + /** + * 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, + skipDuplicates: 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..994448d66d --- /dev/null +++ b/tests/unit/State/ValueTest.php @@ -0,0 +1,339 @@ +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()); + } + + 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..4bd829560a --- /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(Document::INTERNAL_ID, Storage::column(Document::INTERNAL_ID)); + $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/CountingMemory.php b/tests/unit/Support/CountingMemory.php new file mode 100644 index 0000000000..41e484788d --- /dev/null +++ b/tests/unit/Support/CountingMemory.php @@ -0,0 +1,66 @@ + $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/NativeFullOuterJoinSQLite.php b/tests/unit/Support/NativeFullOuterJoinSQLite.php new file mode 100644 index 0000000000..90a1783835 --- /dev/null +++ b/tests/unit/Support/NativeFullOuterJoinSQLite.php @@ -0,0 +1,25 @@ + $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 + */ + 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/swoole-absent.php b/tests/unit/Support/swoole-absent.php new file mode 100644 index 0000000000..4c1f176acd --- /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(new Collection( + 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..c096ce4c60 --- /dev/null +++ b/tests/unit/SwooleAbsentTest.php @@ -0,0 +1,72 @@ + $flags + * @return array{status: int, output: string} + */ + private function runFixture(array $flags): array + { + $command = \escapeshellarg(PHP_BINARY); + + foreach ($flags as $flag) { + $command .= ' ' . $flag; + } + + $command .= ' ' . \escapeshellarg(__DIR__ . '/Support/swoole-absent.php') . ' 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); + } + + /** + * 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/TransactionRetryTest.php b/tests/unit/TransactionRetryTest.php index 77fdf73d68..53b67b7225 100644 --- a/tests/unit/TransactionRetryTest.php +++ b/tests/unit/TransactionRetryTest.php @@ -29,19 +29,15 @@ protected function setUp(): void public function testTimeoutIsNotRetried(): void { $attempts = 0; - $thrown = null; try { $this->adapter->withTransaction(function () use (&$attempts) { $attempts++; throw new TimeoutException('Query timed out'); }); - } catch (TimeoutException $e) { - $thrown = $e; + } catch (TimeoutException) { + $this->assertSame(1, $attempts); } - - $this->assertInstanceOf(TimeoutException::class, $thrown); - $this->assertSame(1, $attempts); } /** @@ -51,19 +47,15 @@ public function testTimeoutIsNotRetried(): void public function testDuplicateIsNotRetried(): void { $attempts = 0; - $thrown = null; try { $this->adapter->withTransaction(function () use (&$attempts) { $attempts++; throw new DuplicateException('Duplicate'); }); - } catch (DuplicateException $e) { - $thrown = $e; + } catch (DuplicateException) { + $this->assertSame(1, $attempts); } - - $this->assertInstanceOf(DuplicateException::class, $thrown); - $this->assertSame(1, $attempts); } /** @@ -73,19 +65,15 @@ public function testDuplicateIsNotRetried(): void public function testGenericFailureIsRetried(): void { $attempts = 0; - $thrown = null; try { $this->adapter->withTransaction(function () use (&$attempts) { $attempts++; throw new \RuntimeException('transient'); }); - } catch (\RuntimeException $e) { - $thrown = $e; + } catch (\RuntimeException) { + $this->assertSame(3, $attempts); } - - $this->assertInstanceOf(\RuntimeException::class, $thrown); - $this->assertSame(3, $attempts); } /** @@ -119,22 +107,24 @@ 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 testRedisRollbackFailureEndsTheTransaction(): void { if (!\extension_loaded('redis')) { $this->markTestSkipped('redis extension not loaded'); } $adapter = new class (new \Redis()) extends RedisAdapter { + public bool $failReplay = true; + 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 +139,14 @@ protected function rollbackJournal(): void } $this->assertInstanceOf(\RuntimeException::class, $thrown); - - $inTransaction = new \ReflectionProperty(RedisAdapter::class, 'inTransaction'); - $inTransaction->setAccessible(true); - $this->assertSame(0, $inTransaction->getValue($adapter)); - - $journalStack = new \ReflectionProperty(RedisAdapter::class, 'journalStack'); - $journalStack->setAccessible(true); - $this->assertSame([], $journalStack->getValue($adapter)); + $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'); } } diff --git a/tests/unit/TransactionStateConnection.php b/tests/unit/TransactionStateConnection.php new file mode 100644 index 0000000000..c1dbbc138c --- /dev/null +++ b/tests/unit/TransactionStateConnection.php @@ -0,0 +1,159 @@ + + */ + 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; + } + + public function executeStatement(): bool + { + if (! $this->deadlocked) { + return true; + } + + $this->deadlocked = false; + $this->discardTransaction(); + + $message = 'SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction'; + $error = new class ($message) extends PDOException { + public function __construct(string $message) + { + parent::__construct($message); + $this->code = '40001'; + } + }; + $error->errorInfo = ['40001', 1213, $message]; + + throw $error; + } + + public function beginTransaction(): bool + { + $this->reconnectIfEnded(); + $this->begins++; + $this->transaction = true; + + return true; + } + + public function commit(): bool + { + if (! $this->transaction) { + throw new PDOException('There is no active transaction'); + } + + $this->commits++; + $this->discardTransaction(); + + return true; + } + + public function rollBack(): bool + { + if (! $this->transaction) { + throw new PDOException('There is no active transaction'); + } + + $this->discardTransaction(); + + return true; + } + + public function inTransaction(): bool + { + return $this->transaction; + } + + 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 + */ + 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..50985e96ba --- /dev/null +++ b/tests/unit/TransactionStateTest.php @@ -0,0 +1,311 @@ +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()); + } + + /** + * While the enclosing transaction holds, a failed nested attempt rolls back to its + * savepoint and runs again inside the same outer transaction. + */ + public function testNestedTransactionRetriesWhileTheOuterTransactionHolds(): void + { + $connection = $this->createConnection(); + $adapter = new MariaDB($connection); + $attempts = 0; + $stored = \uniqid(); + + $result = $adapter->withTransaction(function () use ($adapter, &$attempts, $stored): string { + return $adapter->withTransaction(function () use (&$attempts, $stored): string { + $attempts++; + if ($attempts === 1) { + throw new RuntimeException('Transient failure'); + } + + return $stored; + }); + }); + + $this->assertSame($stored, $result); + $this->assertSame(2, $attempts); + $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->exists('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->exists('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->exists('database', 'aggregations'); + }); + + $this->assertSame(2, $attempts); + $this->assertSame(2, $connection->begins); + $this->assertSame(1, $connection->commits); + $this->assertFalse($adapter->inTransaction()); + } + + /** + * @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/Type/CustomTypeTest.php b/tests/unit/Type/CustomTypeTest.php new file mode 100644 index 0000000000..af2d212ef0 --- /dev/null +++ b/tests/unit/Type/CustomTypeTest.php @@ -0,0 +1,180 @@ +database($adapter)->setTypeRegistry($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()->setTypeRegistry($this->registry(new Reversed())); + + $this->expectException(NotFoundException::class); + $this->database()->encode($this->notes(), new Document(['body' => 'hello'])); + } + + public function testHandlesWithoutTheRegistryCannotDecodeItsTypes(): void + { + $this->database()->setTypeRegistry($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 TypeRegistry(); + $this->database()->setTypeRegistry($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()->setTypeRegistry($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()->setTypeRegistry($this->registry(new Reversed())); + + $this->assertNotSame($before, $this->documentHash($typed)); + $this->assertSame($before, $this->documentHash($plain)); + } + + public function testCacheKeysFollowTheTypeClass(): void + { + $reversed = $this->database()->setTypeRegistry($this->registry(new Reversed('text'))); + $rot13 = $this->database()->setTypeRegistry($this->registry(new Rot13('text'))); + $sameClass = $this->database()->setTypeRegistry($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 testConstructorFiltersTakePrecedenceOverRegisteredTypes(): void + { + $identity = static fn (mixed $value): mixed => $value; + $filters = ['reversed' => ['encode' => $identity, 'decode' => $identity]]; + + $database = $this->database(filters: $filters)->setTypeRegistry($this->registry(new Reversed())); + $constructorOnly = $this->database(filters: $filters); + + $this->assertSame('olleh', $database->decode($this->notes(), new Document(['body' => 'olleh']))->getAttribute('body')); + $this->assertSame($this->documentHash($constructorOnly), $this->documentHash($database)); + } + + /** + * @param array $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(Custom $type): TypeRegistry + { + $registry = new TypeRegistry(); + $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 array $filters + */ + private function createNote(Database $database, string $body, array $filters = []): void + { + $database->create(); + $database->createCollection(new Collection(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/Type/Reversed.php b/tests/unit/Type/Reversed.php new file mode 100644 index 0000000000..a05cd59f0c --- /dev/null +++ b/tests/unit/Type/Reversed.php @@ -0,0 +1,27 @@ +name; + } + + public function encode(mixed $value): mixed + { + return \is_string($value) ? \strrev($value) : $value; + } + + public function decode(mixed $value): mixed + { + return \is_string($value) ? \strrev($value) : $value; + } +} diff --git a/tests/unit/Type/Rot13.php b/tests/unit/Type/Rot13.php new file mode 100644 index 0000000000..c3898e439b --- /dev/null +++ b/tests/unit/Type/Rot13.php @@ -0,0 +1,27 @@ +name; + } + + public function encode(mixed $value): mixed + { + return \is_string($value) ? \str_rot13($value) : $value; + } + + public function decode(mixed $value): mixed + { + return \is_string($value) ? \str_rot13($value) : $value; + } +} diff --git a/tests/unit/Type/TypeRegistryTest.php b/tests/unit/Type/TypeRegistryTest.php new file mode 100644 index 0000000000..62f2b8ecfe --- /dev/null +++ b/tests/unit/Type/TypeRegistryTest.php @@ -0,0 +1,107 @@ +register($type); + + $this->assertSame($type, $registry->get('reversed')); + $this->assertNull($registry->get('nonexistent')); + } + + public function testAll(): void + { + $registry = new TypeRegistry(); + $reversed = new Reversed(); + $rot13 = new Rot13(); + + $registry->register($reversed); + $registry->register($rot13); + + $this->assertSame(['reversed' => $reversed, 'rot13' => $rot13], $registry->all()); + } + + /** + * @return iterable + */ + public static function builtInFilters(): iterable + { + foreach (['json', 'datetime', 'point', 'linestring', 'polygon', 'vector', 'object'] as $name) { + yield $name => [$name]; + } + } + + #[DataProvider('builtInFilters')] + public function testBuiltInFilterNamesAreRejected(string $name): void + { + $registry = new TypeRegistry(); + + try { + $registry->register(new Reversed($name)); + $this->fail("registering a type named \"{$name}\" must be rejected"); + } catch (DuplicateException $exception) { + $this->assertStringContainsString("\"{$name}\"", $exception->getMessage()); + } + + $this->assertSame([], $registry->all()); + } + + public function testRegisteringLeavesTheGlobalFiltersUntouched(): void + { + (new TypeRegistry())->register(new Reversed()); + + $notes = new Document([ + '$id' => 'notes', + 'attributes' => [ + new Document([ + '$id' => 'body', + 'type' => ColumnType::String->value, + 'array' => false, + 'filters' => ['reversed'], + ]), + ], + ]); + + $this->expectException(NotFoundException::class); + (new Database(new Memory(), new Cache(new None())))->decode($notes, new Document(['body' => 'olleh'])); + } + + public function testDefaultFiltersNameEveryBuiltInFilter(): void + { + $previousFilters = FilterRegistry::filters(); + $previousRegistered = FilterRegistry::defaultsRegistered(); + + try { + FilterRegistry::clear(); + new Database(new Memory(), new Cache(new None())); + + $names = \array_keys(FilterRegistry::filters()); + $expected = Database::DEFAULT_FILTERS; + \sort($names); + \sort($expected); + + $this->assertSame($expected, $names); + } finally { + FilterRegistry::restore($previousFilters, $previousRegistered); + } + } +} diff --git a/tests/unit/UniqueViolationTest.php b/tests/unit/UniqueViolationTest.php index 8d7c8950c1..1e86f2f152 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\Helpers\Permission; +use Utopia\Database\Helpers\Role; +use Utopia\Database\Index; +use Utopia\Database\Query; +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->skipDuplicates(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(new Collection( + 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..4ce19e89b6 --- /dev/null +++ b/tests/unit/UpsertTenancyTest.php @@ -0,0 +1,224 @@ +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(new Collection( + 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::PERM_DOCUMENT.', '.Storage::PERM_TYPE.', '.Storage::TENANT + ." FROM {$table} ORDER BY ".Storage::PERM_DOCUMENT.', '.Storage::PERM_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..2a33c6c8ce --- /dev/null +++ b/tests/unit/Validator/AttributeDefaultTypeTest.php @@ -0,0 +1,92 @@ + + */ + 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, new AttributeVO(key: 'value', type: $type, default: $default)); + + $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), + new AttributeVO(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), + new AttributeVO(key: 'value', type: ColumnType::Id, 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(new AttributeVO(key: 'value', type: ColumnType::Object, default: ['nested' => 'x']))); + } + + private function refusal(Attribute $validator, AttributeVO $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): Attribute + { + return new Attribute( + attributes: [], + supportForVectors: $vectors, + supportForSpatialAttributes: $spatial, + supportForObject: true, + ); + } +} diff --git a/tests/unit/Validator/AttributeJsonDefaultTest.php b/tests/unit/Validator/AttributeJsonDefaultTest.php new file mode 100644 index 0000000000..fcd1a07542 --- /dev/null +++ b/tests/unit/Validator/AttributeJsonDefaultTest.php @@ -0,0 +1,85 @@ +validator = new AttributeValidator( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: 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: ['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: ['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: ['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: ['json']))); + } +} diff --git a/tests/unit/Validator/AttributeTest.php b/tests/unit/Validator/AttributeTest.php index 65a01c3a75..e8c5b05019 100644 --- a/tests/unit/Validator/AttributeTest.php +++ b/tests/unit/Validator/AttributeTest.php @@ -3,31 +3,117 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Utopia\Database\Attribute as AttributeVO; 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\Validator\Structure; +use Utopia\Query\Schema\ColumnType; class AttributeTest extends TestCase { - public function testDuplicateAttributeId(): void + public function testLegacyBigIntegerMetadataNormalizesToCanonicalType(): void + { + $attribute = AttributeVO::fromDocument(new Document([ + '$id' => 'total', + 'type' => 'bigint', + 'size' => 8, + ])); + + $this->assertSame(ColumnType::BigInteger, $attribute->type); + $this->assertSame(0, $attribute->size); + $this->assertSame('bigint', $attribute->toDocument()->getAttribute('type')); + + $arrayAttribute = AttributeVO::fromArray([ + '$id' => 'arrayTotal', + 'type' => 'bigint', + 'size' => 64, + ]); + + $this->assertSame(ColumnType::BigInteger, $arrayAttribute->type); + $this->assertSame(0, $arrayAttribute->size); + } + + public function testBigIntegerDefaultsSupportNativeAndStringBoundaries(): void + { + $validator = new Attribute( + attributes: [], + maxIntLength: 100, + maxBigIntLength: 100, + supportUnsignedBigInt: true, + ); + + $this->assertTrue($validator->isValid(new AttributeVO( + key: 'signed', + type: ColumnType::BigInteger, + size: PHP_INT_MAX, + default: PHP_INT_MAX, + ))); + $this->assertTrue($validator->isValid(new AttributeVO( + key: 'signedMinimum', + type: ColumnType::BigInteger, + default: '-9223372036854775808', + ))); + $this->assertTrue($validator->isValid(new AttributeVO( + key: 'unsigned', + type: ColumnType::BigInteger, + default: '18446744073709551615', + signed: false, + ))); + $this->assertTrue($validator->isValid(new AttributeVO( + key: 'values', + type: ColumnType::BigInteger, + default: ['-9223372036854775808', PHP_INT_MAX], + array: true, + ))); + } + + public function testBigIntegerDefaultRejectsValuesOutsideSignedRange(): void + { + $validator = new Attribute(attributes: []); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not match given type bigint'); + $validator->isValid(new AttributeVO( + key: 'total', + type: ColumnType::BigInteger, + default: '9223372036854775808', + )); + } + + public function testBigIntegerArrayDefaultValidatesEveryValue(): void + { + $validator = new Attribute(attributes: [], supportUnsignedBigInt: true); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not match given type bigint'); + $validator->isValid(new AttributeVO( + key: 'totals', + type: ColumnType::BigInteger, + default: ['1', '18446744073709551616'], + signed: false, + array: true, + )); + } + + public function test_duplicate_attribute_id(): void { $validator = new Attribute( attributes: [ new Document([ '$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, @@ -37,7 +123,7 @@ public function testDuplicateAttributeId(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -51,7 +137,7 @@ public function testDuplicateAttributeId(): void $validator->isValid($attribute); } - public function testValidStringAttribute(): void + public function test_valid_string_attribute(): void { $validator = new Attribute( attributes: [], @@ -63,7 +149,7 @@ public function testValidStringAttribute(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -75,7 +161,7 @@ public function testValidStringAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testStringSizeTooLarge(): void + public function test_string_size_too_large(): void { $validator = new Attribute( attributes: [], @@ -87,7 +173,7 @@ public function testStringSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 2000, 'required' => false, 'default' => null, @@ -101,7 +187,7 @@ public function testStringSizeTooLarge(): void $validator->isValid($attribute); } - public function testVarcharSizeTooLarge(): void + public function test_varchar_size_too_large(): void { $validator = new Attribute( attributes: [], @@ -113,7 +199,7 @@ public function testVarcharSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 2000, 'required' => false, 'default' => null, @@ -127,7 +213,7 @@ public function testVarcharSizeTooLarge(): void $validator->isValid($attribute); } - public function testTextSizeTooLarge(): void + public function test_text_size_too_large(): void { $validator = new Attribute( attributes: [], @@ -139,7 +225,7 @@ public function testTextSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 70000, 'required' => false, 'default' => null, @@ -153,7 +239,7 @@ public function testTextSizeTooLarge(): void $validator->isValid($attribute); } - public function testMediumtextSizeTooLarge(): void + public function test_mediumtext_size_too_large(): void { $validator = new Attribute( attributes: [], @@ -165,7 +251,7 @@ public function testMediumtextSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'size' => 20000000, 'required' => false, 'default' => null, @@ -179,7 +265,7 @@ public function testMediumtextSizeTooLarge(): void $validator->isValid($attribute); } - public function testIntegerSizeTooLarge(): void + public function test_integer_size_too_large(): void { $validator = new Attribute( attributes: [], @@ -191,7 +277,7 @@ public function testIntegerSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 200, 'required' => false, 'default' => null, @@ -205,7 +291,7 @@ public function testIntegerSizeTooLarge(): void $validator->isValid($attribute); } - public function testUnknownType(): void + public function test_unknown_type(): void { $validator = new Attribute( attributes: [], @@ -231,7 +317,7 @@ public function testUnknownType(): void $validator->isValid($attribute); } - public function testRequiredFiltersForDatetime(): void + public function test_required_filters_for_datetime(): void { $validator = new Attribute( attributes: [], @@ -243,7 +329,7 @@ public function testRequiredFiltersForDatetime(): void $attribute = new Document([ '$id' => ID::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => null, @@ -257,7 +343,7 @@ public function testRequiredFiltersForDatetime(): void $validator->isValid($attribute); } - public function testValidDatetimeWithFilter(): void + public function test_valid_datetime_with_filter(): void { $validator = new Attribute( attributes: [], @@ -269,7 +355,7 @@ public function testValidDatetimeWithFilter(): void $attribute = new Document([ '$id' => ID::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => null, @@ -281,7 +367,7 @@ 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( attributes: [], @@ -293,7 +379,7 @@ public function testDefaultValueOnRequiredAttribute(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => true, 'default' => 'default value', @@ -307,7 +393,7 @@ public function testDefaultValueOnRequiredAttribute(): void $validator->isValid($attribute); } - public function testDefaultValueTypeMismatch(): void + public function test_default_value_type_mismatch(): void { $validator = new Attribute( attributes: [], @@ -319,7 +405,7 @@ public function testDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 4, 'required' => false, 'default' => 'not_an_integer', @@ -329,11 +415,11 @@ 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( attributes: [], @@ -346,7 +432,7 @@ public function testVectorNotSupported(): void $attribute = new Document([ '$id' => ID::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 128, 'required' => false, 'default' => null, @@ -360,7 +446,7 @@ public function testVectorNotSupported(): void $validator->isValid($attribute); } - public function testVectorCannotBeArray(): void + public function test_vector_cannot_be_array(): void { $validator = new Attribute( attributes: [], @@ -373,7 +459,7 @@ public function testVectorCannotBeArray(): void $attribute = new Document([ '$id' => ID::custom('embeddings'), 'key' => 'embeddings', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 128, 'required' => false, 'default' => null, @@ -387,7 +473,7 @@ public function testVectorCannotBeArray(): void $validator->isValid($attribute); } - public function testVectorInvalidDimensions(): void + public function test_vector_invalid_dimensions(): void { $validator = new Attribute( attributes: [], @@ -400,7 +486,7 @@ public function testVectorInvalidDimensions(): void $attribute = new Document([ '$id' => ID::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 0, 'required' => false, 'default' => null, @@ -414,7 +500,7 @@ public function testVectorInvalidDimensions(): void $validator->isValid($attribute); } - public function testVectorDimensionsExceedsMax(): void + public function test_vector_dimensions_exceeds_max(): void { $validator = new Attribute( attributes: [], @@ -427,7 +513,7 @@ public function testVectorDimensionsExceedsMax(): void $attribute = new Document([ '$id' => ID::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 20000, 'required' => false, 'default' => null, @@ -441,7 +527,7 @@ public function testVectorDimensionsExceedsMax(): void $validator->isValid($attribute); } - public function testSpatialNotSupported(): void + public function test_spatial_not_supported(): void { $validator = new Attribute( attributes: [], @@ -454,7 +540,7 @@ public function testSpatialNotSupported(): void $attribute = new Document([ '$id' => ID::custom('location'), 'key' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 0, 'required' => false, 'default' => null, @@ -468,7 +554,7 @@ public function testSpatialNotSupported(): void $validator->isValid($attribute); } - public function testSpatialCannotBeArray(): void + public function test_spatial_cannot_be_array(): void { $validator = new Attribute( attributes: [], @@ -481,7 +567,7 @@ public function testSpatialCannotBeArray(): void $attribute = new Document([ '$id' => ID::custom('locations'), 'key' => 'locations', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 0, 'required' => false, 'default' => null, @@ -495,7 +581,7 @@ public function testSpatialCannotBeArray(): void $validator->isValid($attribute); } - public function testSpatialMustHaveEmptySize(): void + public function test_spatial_must_have_empty_size(): void { $validator = new Attribute( attributes: [], @@ -508,7 +594,7 @@ public function testSpatialMustHaveEmptySize(): void $attribute = new Document([ '$id' => ID::custom('location'), 'key' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 100, 'required' => false, 'default' => null, @@ -522,7 +608,7 @@ public function testSpatialMustHaveEmptySize(): void $validator->isValid($attribute); } - public function testObjectNotSupported(): void + public function test_object_not_supported(): void { $validator = new Attribute( attributes: [], @@ -535,7 +621,7 @@ public function testObjectNotSupported(): void $attribute = new Document([ '$id' => ID::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 0, 'required' => false, 'default' => null, @@ -549,7 +635,7 @@ public function testObjectNotSupported(): void $validator->isValid($attribute); } - public function testObjectCannotBeArray(): void + public function test_object_cannot_be_array(): void { $validator = new Attribute( attributes: [], @@ -562,7 +648,7 @@ public function testObjectCannotBeArray(): void $attribute = new Document([ '$id' => ID::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 0, 'required' => false, 'default' => null, @@ -576,7 +662,7 @@ public function testObjectCannotBeArray(): void $validator->isValid($attribute); } - public function testObjectMustHaveEmptySize(): void + public function test_object_must_have_empty_size(): void { $validator = new Attribute( attributes: [], @@ -589,7 +675,7 @@ public function testObjectMustHaveEmptySize(): void $attribute = new Document([ '$id' => ID::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 100, 'required' => false, 'default' => null, @@ -603,7 +689,7 @@ public function testObjectMustHaveEmptySize(): void $validator->isValid($attribute); } - public function testAttributeLimitExceeded(): void + public function test_attribute_limit_exceeded(): void { $validator = new Attribute( attributes: [], @@ -619,7 +705,7 @@ public function testAttributeLimitExceeded(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -633,7 +719,7 @@ public function testAttributeLimitExceeded(): void $validator->isValid($attribute); } - public function testRowWidthLimitExceeded(): void + public function test_row_width_limit_exceeded(): void { $validator = new Attribute( attributes: [], @@ -649,7 +735,7 @@ public function testRowWidthLimitExceeded(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -663,7 +749,7 @@ public function testRowWidthLimitExceeded(): void $validator->isValid($attribute); } - public function testVectorDefaultValueNotArray(): void + public function test_vector_default_value_not_array(): void { $validator = new Attribute( attributes: [], @@ -676,7 +762,7 @@ public function testVectorDefaultValueNotArray(): void $attribute = new Document([ '$id' => ID::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 3, 'required' => false, 'default' => 'not_an_array', @@ -690,7 +776,7 @@ public function testVectorDefaultValueNotArray(): void $validator->isValid($attribute); } - public function testVectorDefaultValueWrongElementCount(): void + public function test_vector_default_value_wrong_element_count(): void { $validator = new Attribute( attributes: [], @@ -703,7 +789,7 @@ public function testVectorDefaultValueWrongElementCount(): void $attribute = new Document([ '$id' => ID::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 3, 'required' => false, 'default' => [1.0, 2.0], @@ -717,7 +803,7 @@ public function testVectorDefaultValueWrongElementCount(): void $validator->isValid($attribute); } - public function testVectorDefaultValueNonNumericElements(): void + public function test_vector_default_value_non_numeric_elements(): void { $validator = new Attribute( attributes: [], @@ -730,7 +816,7 @@ public function testVectorDefaultValueNonNumericElements(): void $attribute = new Document([ '$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,7 +830,7 @@ public function testVectorDefaultValueNonNumericElements(): void $validator->isValid($attribute); } - public function testLongtextSizeTooLarge(): void + public function test_longtext_size_too_large(): void { $validator = new Attribute( attributes: [], @@ -756,7 +842,7 @@ public function testLongtextSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'size' => 5000000000, 'required' => false, 'default' => null, @@ -770,7 +856,7 @@ public function testLongtextSizeTooLarge(): void $validator->isValid($attribute); } - public function testValidVarcharAttribute(): void + public function test_valid_varchar_attribute(): void { $validator = new Attribute( attributes: [], @@ -782,7 +868,7 @@ public function testValidVarcharAttribute(): void $attribute = new Document([ '$id' => ID::custom('name'), 'key' => 'name', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 255, 'required' => false, 'default' => null, @@ -794,7 +880,7 @@ public function testValidVarcharAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidTextAttribute(): void + public function test_valid_text_attribute(): void { $validator = new Attribute( attributes: [], @@ -806,7 +892,7 @@ public function testValidTextAttribute(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 65535, 'required' => false, 'default' => null, @@ -818,7 +904,7 @@ public function testValidTextAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidMediumtextAttribute(): void + public function test_valid_mediumtext_attribute(): void { $validator = new Attribute( attributes: [], @@ -830,7 +916,7 @@ public function testValidMediumtextAttribute(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'size' => 16777215, 'required' => false, 'default' => null, @@ -842,7 +928,7 @@ public function testValidMediumtextAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidLongtextAttribute(): void + public function test_valid_longtext_attribute(): void { $validator = new Attribute( attributes: [], @@ -854,7 +940,7 @@ public function testValidLongtextAttribute(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'size' => 4294967295, 'required' => false, 'default' => null, @@ -866,7 +952,7 @@ public function testValidLongtextAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidFloatAttribute(): void + public function test_valid_float_attribute(): void { $validator = new Attribute( attributes: [], @@ -878,7 +964,7 @@ public function testValidFloatAttribute(): void $attribute = new Document([ '$id' => ID::custom('price'), 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 0, 'required' => false, 'default' => null, @@ -890,7 +976,7 @@ public function testValidFloatAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidBooleanAttribute(): void + public function test_valid_boolean_attribute(): void { $validator = new Attribute( attributes: [], @@ -902,7 +988,7 @@ public function testValidBooleanAttribute(): void $attribute = new Document([ '$id' => ID::custom('active'), 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 0, 'required' => false, 'default' => null, @@ -914,7 +1000,7 @@ 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( attributes: [], @@ -926,7 +1012,7 @@ public function testFloatDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('price'), 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 0, 'required' => false, 'default' => 'not_a_float', @@ -936,11 +1022,11 @@ 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( attributes: [], @@ -952,7 +1038,7 @@ public function testBooleanDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('active'), 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 0, 'required' => false, 'default' => 'not_a_boolean', @@ -962,11 +1048,11 @@ 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( attributes: [], @@ -978,7 +1064,7 @@ public function testStringDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => 123, @@ -992,7 +1078,7 @@ public function testStringDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testValidStringWithDefaultValue(): void + public function test_valid_string_with_default_value(): void { $validator = new Attribute( attributes: [], @@ -1004,7 +1090,7 @@ public function testValidStringWithDefaultValue(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => 'default title', @@ -1016,7 +1102,7 @@ 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( attributes: [], @@ -1028,7 +1114,7 @@ public function testValidIntegerWithDefaultValue(): void $attribute = new Document([ '$id' => ID::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 4, 'required' => false, 'default' => 42, @@ -1040,57 +1126,7 @@ public function testValidIntegerWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testInvalidBigIntDefaultValueTypeStringNotNumeric(): 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' => '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 + public function test_valid_float_with_default_value(): void { $validator = new Attribute( attributes: [], @@ -1102,7 +1138,7 @@ public function testValidFloatWithDefaultValue(): void $attribute = new Document([ '$id' => ID::custom('price'), 'key' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'size' => 0, 'required' => false, 'default' => 19.99, @@ -1114,7 +1150,7 @@ 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( attributes: [], @@ -1126,7 +1162,7 @@ public function testValidBooleanWithDefaultValue(): void $attribute = new Document([ '$id' => ID::custom('active'), 'key' => 'active', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'size' => 0, 'required' => false, 'default' => true, @@ -1138,7 +1174,7 @@ public function testValidBooleanWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testUnsignedIntegerSizeLimit(): void + public function test_unsigned_integer_size_limit(): void { $validator = new Attribute( attributes: [], @@ -1151,7 +1187,7 @@ public function testUnsignedIntegerSizeLimit(): void $attribute = new Document([ '$id' => ID::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 80, 'required' => false, 'default' => null, @@ -1163,7 +1199,7 @@ 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( attributes: [], @@ -1175,7 +1211,7 @@ public function testUnsignedIntegerSizeTooLarge(): void $attribute = new Document([ '$id' => ID::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 150, 'required' => false, 'default' => null, @@ -1189,71 +1225,21 @@ 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 - { - $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 + public function test_duplicate_attribute_id_case_insensitive(): void { $validator = new Attribute( attributes: [ new Document([ '$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, @@ -1263,7 +1249,7 @@ public function testDuplicateAttributeIdCaseInsensitive(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1277,7 +1263,7 @@ public function testDuplicateAttributeIdCaseInsensitive(): void $validator->isValid($attribute); } - public function testDuplicateInSchema(): void + public function test_duplicate_in_schema(): void { $validator = new Attribute( attributes: [], @@ -1285,9 +1271,9 @@ public function testDuplicateInSchema(): void new Document([ '$id' => ID::custom('existing_column'), 'key' => 'existing_column', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, - ]) + ]), ], maxStringLength: 16777216, maxVarcharLength: 65535, @@ -1298,7 +1284,7 @@ public function testDuplicateInSchema(): void $attribute = new Document([ '$id' => ID::custom('existing_column'), 'key' => 'existing_column', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1312,7 +1298,7 @@ public function testDuplicateInSchema(): void $validator->isValid($attribute); } - public function testSchemaCheckSkippedWhenMigrating(): void + public function test_schema_check_skipped_when_migrating(): void { $validator = new Attribute( attributes: [], @@ -1320,9 +1306,9 @@ public function testSchemaCheckSkippedWhenMigrating(): void new Document([ '$id' => ID::custom('existing_column'), 'key' => 'existing_column', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, - ]) + ]), ], maxStringLength: 16777216, maxVarcharLength: 65535, @@ -1335,7 +1321,7 @@ public function testSchemaCheckSkippedWhenMigrating(): void $attribute = new Document([ '$id' => ID::custom('existing_column'), 'key' => 'existing_column', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1347,7 +1333,7 @@ public function testSchemaCheckSkippedWhenMigrating(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidLinestringAttribute(): void + public function test_valid_linestring_attribute(): void { $validator = new Attribute( attributes: [], @@ -1360,7 +1346,7 @@ public function testValidLinestringAttribute(): void $attribute = new Document([ '$id' => ID::custom('route'), 'key' => 'route', - 'type' => Database::VAR_LINESTRING, + 'type' => ColumnType::Linestring->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1372,7 +1358,7 @@ public function testValidLinestringAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidPolygonAttribute(): void + public function test_valid_polygon_attribute(): void { $validator = new Attribute( attributes: [], @@ -1385,7 +1371,7 @@ public function testValidPolygonAttribute(): void $attribute = new Document([ '$id' => ID::custom('area'), 'key' => 'area', - 'type' => Database::VAR_POLYGON, + 'type' => ColumnType::Polygon->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1397,7 +1383,7 @@ public function testValidPolygonAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidPointAttribute(): void + public function test_valid_point_attribute(): void { $validator = new Attribute( attributes: [], @@ -1410,7 +1396,7 @@ public function testValidPointAttribute(): void $attribute = new Document([ '$id' => ID::custom('location'), 'key' => 'location', - 'type' => Database::VAR_POINT, + 'type' => ColumnType::Point->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1422,7 +1408,7 @@ public function testValidPointAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidVectorAttribute(): void + public function test_valid_vector_attribute(): void { $validator = new Attribute( attributes: [], @@ -1435,7 +1421,7 @@ public function testValidVectorAttribute(): void $attribute = new Document([ '$id' => ID::custom('embedding'), 'key' => 'embedding', - 'type' => Database::VAR_VECTOR, + 'type' => ColumnType::Vector->value, 'size' => 128, 'required' => false, 'default' => null, @@ -1447,7 +1433,7 @@ 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( attributes: [], @@ -1460,7 +1446,7 @@ public function testValidVectorWithDefaultValue(): void $attribute = new Document([ '$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,7 +1458,7 @@ public function testValidVectorWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidObjectAttribute(): void + public function test_valid_object_attribute(): void { $validator = new Attribute( attributes: [], @@ -1485,7 +1471,7 @@ public function testValidObjectAttribute(): void $attribute = new Document([ '$id' => ID::custom('metadata'), 'key' => 'metadata', - 'type' => Database::VAR_OBJECT, + 'type' => ColumnType::Object->value, 'size' => 0, 'required' => false, 'default' => null, @@ -1497,7 +1483,7 @@ public function testValidObjectAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testArrayStringAttribute(): void + public function test_array_string_attribute(): void { $validator = new Attribute( attributes: [], @@ -1509,7 +1495,7 @@ public function testArrayStringAttribute(): void $attribute = new Document([ '$id' => ID::custom('tags'), 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1521,7 +1507,7 @@ public function testArrayStringAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testArrayWithDefaultValues(): void + public function test_array_with_default_values(): void { $validator = new Attribute( attributes: [], @@ -1533,7 +1519,7 @@ public function testArrayWithDefaultValues(): void $attribute = new Document([ '$id' => ID::custom('tags'), 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => ['tag1', 'tag2', 'tag3'], @@ -1545,7 +1531,7 @@ 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( attributes: [], @@ -1557,7 +1543,7 @@ public function testArrayDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('tags'), 'key' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => ['tag1', 123, 'tag3'], @@ -1571,7 +1557,7 @@ public function testArrayDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testDatetimeDefaultValueMustBeString(): void + public function test_datetime_default_value_must_be_string(): void { $validator = new Attribute( attributes: [], @@ -1583,7 +1569,7 @@ public function testDatetimeDefaultValueMustBeString(): void $attribute = new Document([ '$id' => ID::custom('created'), 'key' => 'created', - 'type' => Database::VAR_DATETIME, + 'type' => ColumnType::Datetime->value, 'size' => 0, 'required' => false, 'default' => 12345, @@ -1597,7 +1583,7 @@ public function testDatetimeDefaultValueMustBeString(): void $validator->isValid($attribute); } - public function testValidDatetimeWithDefaultValue(): void + public function test_valid_datetime_with_default_value(): void { $validator = new Attribute( attributes: [], @@ -1609,7 +1595,7 @@ public function testValidDatetimeWithDefaultValue(): void $attribute = new Document([ '$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,7 +1607,7 @@ 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( attributes: [], @@ -1633,7 +1619,7 @@ public function testVarcharDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('name'), 'key' => 'name', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 255, 'required' => false, 'default' => 123, @@ -1647,7 +1633,7 @@ public function testVarcharDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testTextDefaultValueTypeMismatch(): void + public function test_text_default_value_type_mismatch(): void { $validator = new Attribute( attributes: [], @@ -1659,7 +1645,7 @@ public function testTextDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 65535, 'required' => false, 'default' => 123, @@ -1673,7 +1659,7 @@ public function testTextDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testMediumtextDefaultValueTypeMismatch(): void + public function test_mediumtext_default_value_type_mismatch(): void { $validator = new Attribute( attributes: [], @@ -1685,7 +1671,7 @@ public function testMediumtextDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'size' => 16777215, 'required' => false, 'default' => 123, @@ -1699,7 +1685,7 @@ public function testMediumtextDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testLongtextDefaultValueTypeMismatch(): void + public function test_longtext_default_value_type_mismatch(): void { $validator = new Attribute( attributes: [], @@ -1711,7 +1697,7 @@ public function testLongtextDefaultValueTypeMismatch(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'size' => 4294967295, 'required' => false, 'default' => 123, @@ -1725,7 +1711,7 @@ public function testLongtextDefaultValueTypeMismatch(): void $validator->isValid($attribute); } - public function testValidVarcharWithDefaultValue(): void + public function test_valid_varchar_with_default_value(): void { $validator = new Attribute( attributes: [], @@ -1737,7 +1723,7 @@ public function testValidVarcharWithDefaultValue(): void $attribute = new Document([ '$id' => ID::custom('name'), 'key' => 'name', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'size' => 255, 'required' => false, 'default' => 'default name', @@ -1749,7 +1735,7 @@ 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( attributes: [], @@ -1761,7 +1747,7 @@ public function testValidTextWithDefaultValue(): void $attribute = new Document([ '$id' => ID::custom('content'), 'key' => 'content', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'size' => 65535, 'required' => false, 'default' => 'default content', @@ -1773,7 +1759,7 @@ public function testValidTextWithDefaultValue(): void $this->assertTrue($validator->isValid($attribute)); } - public function testValidIntegerAttribute(): void + public function test_valid_integer_attribute(): void { $validator = new Attribute( attributes: [], @@ -1785,7 +1771,7 @@ public function testValidIntegerAttribute(): void $attribute = new Document([ '$id' => ID::custom('count'), 'key' => 'count', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'size' => 4, 'required' => false, 'default' => null, @@ -1797,7 +1783,7 @@ public function testValidIntegerAttribute(): void $this->assertTrue($validator->isValid($attribute)); } - public function testNullDefaultValueAllowed(): void + public function test_null_default_value_allowed(): void { $validator = new Attribute( attributes: [], @@ -1809,7 +1795,7 @@ public function testNullDefaultValueAllowed(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => null, @@ -1821,7 +1807,7 @@ 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( attributes: [], @@ -1833,7 +1819,7 @@ public function testArrayDefaultOnNonArrayAttribute(): void $attribute = new Document([ '$id' => ID::custom('title'), 'key' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'size' => 255, 'required' => false, 'default' => ['not', 'allowed'], @@ -1846,4 +1832,390 @@ 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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: 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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: 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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $this->assertEquals('object', $validator->getType()); + } + + public function test_get_description(): void + { + $validator = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $this->assertEquals('Invalid attribute', $validator->getDescription()); + } + + public function test_is_array(): void + { + $validator = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $this->assertFalse($validator->isArray()); + } + + public function test_is_valid_with_attribute_vo_directly(): void + { + $validator = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $attrVO = new AttributeVO( + key: 'directAttr', + type: ColumnType::String, + size: 255, + required: false, + default: null, + signed: true, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function test_attribute_does_not_collide_with_schema(): void + { + $validator = new Attribute( + attributes: [], + schemaAttributes: [ + new Document([ + '$id' => ID::custom('existing_column'), + 'key' => 'existing_column', + 'type' => ColumnType::String->value, + 'size' => 255, + ]), + ], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + supportForSchemaAttributes: 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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: 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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $attrVO = new AttributeVO( + key: 'myId', + type: ColumnType::Id, + size: 0, + required: false, + default: null, + signed: false, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function test_unknown_column_type_in_check_type(): void + { + $validator = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $attrVO = new AttributeVO( + key: 'badtype', + type: ColumnType::Enum, + size: 0, + required: false, + default: null, + signed: true, + array: false, + filters: [], + ); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unknown attribute type: enum'); + $validator->isValid($attrVO); + } + + public function test_null_default_value_in_validate_default_types(): void + { + $validator = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $attrVO = new AttributeVO( + key: 'nullableField', + type: ColumnType::String, + size: 255, + required: false, + default: null, + signed: true, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function test_vector_component_non_numeric_default_type(): void + { + $validator = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + supportForVectors: true, + ); + + $attrVO = new AttributeVO( + key: 'vec', + type: ColumnType::Vector, + size: 3, + required: false, + default: [1.0, 2.0, 3.0], + signed: true, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + + $validator2 = new Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + supportForVectors: true, + ); + + $attrVO2 = new AttributeVO( + key: 'vec2', + type: ColumnType::Vector, + size: 3, + required: false, + default: [1.0, 'notANumber', 3.0], + signed: true, + array: false, + filters: [], + ); + + $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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $attrVO = new AttributeVO( + key: 'baddefault', + type: ColumnType::Enum, + size: 0, + required: false, + default: 'somevalue', + signed: true, + array: false, + filters: [], + ); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unknown attribute type: enum'); + $validator->isValid($attrVO); + } + + public function test_schema_duplicate_check_with_filter_callback(): void + { + $validator = new Attribute( + attributes: [], + schemaAttributes: [ + new Document([ + '$id' => ID::custom('_prefix_column'), + 'key' => '_prefix_column', + 'type' => ColumnType::String->value, + 'size' => 255, + ]), + ], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + supportForSchemaAttributes: true, + filterCallback: 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 Attribute( + attributes: [], + maxStringLength: 16777216, + maxVarcharLength: 65535, + maxIntLength: PHP_INT_MAX, + ); + + $attrVO = new AttributeVO( + key: 'parent', + type: ColumnType::Relationship, + size: 0, + required: false, + default: null, + signed: false, + array: false, + filters: [], + ); + + $this->assertTrue($validator->isValid($attrVO)); + } + + public function testBigIntegerDefaultRejectsNonNumericString(): void + { + $validator = new Attribute(attributes: []); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not match given type bigint'); + $validator->isValid(new AttributeVO( + key: 'counter', + type: ColumnType::BigInteger, + default: 'not_a_bigint', + )); + } } diff --git a/tests/unit/Validator/AuthorizationTest.php b/tests/unit/Validator/AuthorizationTest.php index e8685549e3..b51763c07e 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\PermissionType; use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Authorization\Input; @@ -15,16 +15,16 @@ class AuthorizationTest extends TestCase { protected Authorization $authorization; - public function setUp(): void + protected function setUp(): void { $this->authorization = new Authorization(); } - public function tearDown(): void + protected function tearDown(): void { } - public function testValues(): void + public function test_values(): void { $this->authorization->addRole(Role::any()->toString()); @@ -42,8 +42,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->getRead())), 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 +54,37 @@ 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->getRead())), 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->getRead())), 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->getRead())), 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->getRead())), 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->getRead())), false); $this->authorization->setDefaultStatus(false); $this->authorization->disable(); - $this->assertEquals($object->isValid(new Input(Database::PERMISSION_READ, $document->getRead())), true); + $this->assertEquals($object->isValid(new Input(PermissionType::Read, $document->getRead())), 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->getRead())), 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->getRead())), false); $this->authorization->addRole('textX'); @@ -95,13 +95,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->getRead())), 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->getRead())); }), true); } - public function testNestedSkips(): void + public function test_nested_skips(): void { $this->assertEquals(true, $this->authorization->getStatus()); diff --git a/tests/unit/Validator/BigIntTest.php b/tests/unit/Validator/BigIntTest.php new file mode 100644 index 0000000000..5c862b0c77 --- /dev/null +++ b/tests/unit/Validator/BigIntTest.php @@ -0,0 +1,111 @@ +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')); + } +} diff --git a/tests/unit/Validator/DateTimeTest.php b/tests/unit/Validator/DateTimeTest.php index 106080c298..3a400b441a 100644 --- a/tests/unit/Validator/DateTimeTest.php +++ b/tests/unit/Validator/DateTimeTest.php @@ -9,35 +9,32 @@ 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() + protected function setUp(): void { - parent::__construct(); - $this->minAllowed = new \DateTime($this->minString); $this->maxAllowed = new \DateTime($this->maxString); } - public function setUp(): void + 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 +52,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 +89,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 +148,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 +188,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..edbfb7f7b0 100644 --- a/tests/unit/Validator/DocumentQueriesTest.php +++ b/tests/unit/Validator/DocumentQueriesTest.php @@ -4,63 +4,57 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; 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' => [], - ]) - ] + 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 + 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); $queries = [ Query::select(['title']), @@ -75,10 +69,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); $queries = [Query::limit(1)]; $this->assertEquals(false, $validator->isValid($queries)); } + + public function testJoinIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::join('orders', '$id', 'customerId'), + ]), $validator->getDescription()); + } + + public function testSelectWithJoinAliasIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::select(['ord.amount']), + Query::join('orders', '$id', 'customerId', '=', 'ord'), + ]), $validator->getDescription()); + } + + public function testSelectUnprefixedJoinAttributeIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(false, $validator->isValid([ + Query::select(['score']), + Query::leftJoin('reviews', '$id', 'prod_uid'), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: score', $validator->getDescription()); + } + + public function testSelectWithLeftJoinAliasIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::select(['rev.score']), + Query::leftJoin('reviews', '$id', 'prod_uid', '=', 'rev'), + ]), $validator->getDescription()); + } + + public function testCountIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(false, $validator->isValid([Query::count('*', 'cnt')])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testGroupByIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(false, $validator->isValid([Query::groupBy(['name'])])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testHavingIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $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()); + + $this->assertSame(false, $validator->isValid([Query::distinct()])); + $this->assertStringContainsString('Invalid query method', $validator->getDescription()); + } + + public function testUnionIsInvalid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $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()); + + $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()); + + $this->assertSame(false, $validator->isValid([Query::naturalJoin('orders')])); + $this->assertStringContainsString('Natural joins are not supported', $validator->getDescription()); + } + + public function testNestedJoinOnIsValid(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $this->assertSame(true, $validator->isValid([ + Query::leftJoin('orders', 'ord', [ + Query::on('$id', 'customerId'), + ]), + ]), $validator->getDescription()); + } + + public function testNestedJoinOnWithFilterIsValidWithoutFilterValidator(): void + { + $validator = new DocumentQueries($this->documentAttributes()); + + $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()); + + $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()); + + $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..7e57915dc0 --- /dev/null +++ b/tests/unit/Validator/DocumentsQueriesGrammarTest.php @@ -0,0 +1,131 @@ + + */ + private array $attributes; + + 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', '$id', 'customerId', '=', 'o'), 'join'], + 'left join' => [Query::leftJoin('orders', '$id', 'customerId', '=', 'o'), 'leftJoin'], + 'right join' => [Query::rightJoin('orders', '$id', 'customerId', '=', 'o'), 'rightJoin'], + 'cross join' => [Query::crossJoin('orders', 'o'), 'crossJoin'], + 'full outer join' => [Query::fullOuterJoin('orders', '$id', 'customerId', '=', 'o'), '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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + ); + + $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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportForJoins: true, + ); + + $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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportForAggregations: true, + ); + + $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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportForJoins: true, + ); + + $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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportForAggregations: true, + ); + + $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..796bc91124 100644 --- a/tests/unit/Validator/DocumentsQueriesTest.php +++ b/tests/unit/Validator/DocumentsQueriesTest.php @@ -4,129 +4,130 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\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 + 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 + 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, + ColumnType::Integer->value ); $queries = [ @@ -159,12 +160,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, + ColumnType::Integer->value ); $queries = ['{"method":"notEqual","attribute":"title","values":["Iron Man","Ant Man"]}']; @@ -181,12 +182,78 @@ 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, + ColumnType::Integer->value, + supportForJoins: true, + ); + + $this->assertTrue($validator->isValid([ + Query::equal('sec.amount', [777]), + Query::join('orders', '$id', 'customerId', '=', 'sec'), + ]), $validator->getDescription()); + } + + public function test_order_with_join_alias_is_valid(): void + { + $validator = new Documents( + $this->attributes, + $this->indexes, + ColumnType::Integer->value, + supportForJoins: true, + ); + + $this->assertTrue($validator->isValid([ + Query::orderAsc('sec.amount'), + Query::join('orders', '$id', 'customerId', '=', 'sec'), + ]), $validator->getDescription()); + } + + public function test_unknown_join_alias_filter_is_invalid(): void + { + $validator = new Documents( + $this->attributes, + $this->indexes, + ColumnType::Integer->value, + supportForJoins: true, + ); + $this->assertFalse($validator->isValid([ + Query::equal('other.amount', [777]), + Query::join('orders', '$id', 'customerId', '=', 'sec'), + ])); + $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, + ColumnType::Integer->value, + supportForJoins: true, + ); + + $this->assertTrue($validator->isValid([ + Query::join('meta', '$id', 'mainId', '=', 'meta'), + 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..80d3a26c16 --- /dev/null +++ b/tests/unit/Validator/DocumentsQueriesUnsignedBigIntTest.php @@ -0,0 +1,70 @@ + + */ + private array $attributes; + + 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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + ); + + $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( + attributes: $this->attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportUnsignedBigInt: false, + ); + + $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..23c83931b9 --- /dev/null +++ b/tests/unit/Validator/IndexFloatLengthTest.php @@ -0,0 +1,81 @@ + + */ + 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 IndexValidator( + attributes: [Attribute::string(key: 'title', size: 767), $number], + indexes: [], + maxLength: 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 IndexValidator( + attributes: [Attribute::string(key: 'title', size: 766), $number], + indexes: [], + maxLength: 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', size: 8)], + ]; + } + + #[DataProvider('eightByteIntegers')] + public function testBigIntColumnsCountEightBytes(Attribute $number): void + { + $over = new IndexValidator( + attributes: [Attribute::string(key: 'title', size: 767), $number], + indexes: [], + maxLength: 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 IndexValidator( + attributes: [Attribute::string(key: 'title', size: 766), $number], + indexes: [], + maxLength: 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..341ddccdaf --- /dev/null +++ b/tests/unit/Validator/IndexObjectPathTest.php @@ -0,0 +1,67 @@ +validator = new IndexValidator( + attributes: [ + Attribute::object(key: 'data'), + Attribute::string(key: 'title', size: 256), + ], + indexes: [], + maxLength: 768, + supportForObjectIndexes: true, + supportForTrigramIndexes: true, + supportForObjects: true, + ); + } + + 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..48217797f5 --- /dev/null +++ b/tests/unit/Validator/IndexRevalidationTest.php @@ -0,0 +1,41 @@ +setDatabase('index_revalidation') + ->setNamespace('index_revalidation_'.\uniqid()); + $database->getAuthorization()->addRole(Role::any()->toString()); + $database->create(); + $this->assertFalse($database->getAdapter()->supports(Capability::IdenticalIndexes)); + + $database->createCollection(new Collection( + id: self::COLLECTION, + attributes: [Attribute::string(key: 'name', size: 64)], + indexes: [Index::key(key: 'by_name', attributes: ['name'])], + )); + + $updated = $database->updateAttribute(self::COLLECTION, 'name', size: 128); + + $this->assertSame(128, $updated->getAttribute('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..5d0ba3d9ec 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -4,55 +4,39 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; +use Utopia\Database\Attribute; use Utopia\Database\Document; -use Utopia\Database\Helpers\ID; -use Utopia\Database\Validator\Index; +use Utopia\Database\Index; +use Utopia\Database\Validator\Index as IndexValidator; +use Utopia\Database\Validator\IndexedQueries; +use Utopia\Query\Schema\IndexType; +use Utopia\Query\Schema\Order; class IndexTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + 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', format: ''), + ]; + + $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 IndexValidator($attributes, $indexes, 768); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Invalid index attribute "not_exist" not found', $validator->getDescription()); } @@ -60,48 +44,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', format: ''), + Attribute::datetime(key: 'date', signed: false, format: '', filters: ['datetime']), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $indexes = [ + Index::fullText(key: 'index1', attributes: ['title', 'date']), + ]; + + $validator = new IndexValidator($attributes, $indexes, 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 +64,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, format: ''), + ]; + + $indexes = [ + Index::key(key: 'index1', attributes: ['title']), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexValidator($attributes, $indexes, 768); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Index length is longer than the maximum: 768', $validator->getDescription()); } @@ -147,93 +83,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, format: ''), + Attribute::string(key: 'description', size: 1024, format: ''), + ]; + + $indexes = [ + Index::fullText(key: 'index1', attributes: ['title']), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexValidator($attributes, $indexes, 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, format: ''), + ]; + + $indexes = [ + Index::key(key: 'index1'), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexValidator($attributes, $indexes, 768); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('No attributes provided for index', $validator->getDescription()); } @@ -241,86 +127,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, signed: false, format: ''), + Attribute::string(key: 'name', format: ''), + ]; + + /** @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 IndexValidator($attributes, $emptyIndexes, 768, [], false, false, false, false, supportForObjectIndexes: true); // 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', attributes: ['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', attributes: ['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::object(key: 'idx_gin_multi', 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::object(key: 'idx_gin_order', attributes: ['data'], orders: [Order::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 IndexValidator($attributes, $emptyIndexes, 768, [], false, false, false, false, false); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('Object indexes are not supported', $validatorNoSupport->getDescription()); } @@ -328,150 +168,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, signed: false, format: ''), + Attribute::object(key: 'metadata', signed: false, format: ''), + Attribute::string(key: 'name', format: ''), + ]; + + /** @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 IndexValidator($attributes, $emptyIndexes, 768, [], false, false, false, false, true, true, true, true, supportForObjects: true); // 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', attributes: ['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', attributes: ['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', attributes: ['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', format: ''), + ]; - $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 IndexValidator($attributes, $indexes, 768); + $index = $indexes[0]; $this->assertFalse($validator->isValid($index)); $this->assertEquals('Duplicate attributes provided', $validator->getDescription()); } @@ -479,233 +232,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', format: ''), + ]; + + $indexes = [ + Index::fullText(key: 'index1', attributes: ['title', 'title'], orders: [Order::Asc, Order::Desc]), + ]; - $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes'), 768); - $index = $collection->getAttribute('indexes')[0]; + $validator = new IndexValidator($attributes, $indexes, 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', format: ''), + ]; + + $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 IndexValidator($attributes, $indexes, 768, ['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, format: ''), + ]; + + $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 IndexValidator(attributes: $attributes, indexes: $indexes, maxLength: 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 IndexValidator(attributes: $attributes, indexes: $indexes, maxLength: 768, supportForAttributes: false); + $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', format: ''), + Attribute::string(key: 'description', size: 512, format: ''), + Attribute::integer(key: 'age', format: ''), + ]; + + /** @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 IndexValidator($attributes, $emptyIndexes, 768, [], false, false, false, false, false, false, false, false, supportForTrigramIndexes: true); // 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::trigram(key: 'idx_trigram_order', attributes: ['name'], orders: [Order::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::trigram(key: 'idx_trigram_length', 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 IndexValidator($attributes, $emptyIndexes, 768, [], false, false, false, false, false, false, false, false, false); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('Trigram indexes are not supported', $validatorNoSupport->getDescription()); } @@ -713,42 +341,20 @@ 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', signed: false, format: '', filters: ['datetime']), + Attribute::string(key: 'name', format: ''), + ]; + + /** @var array $emptyIndexes */ + $emptyIndexes = []; // Validator with supportForTTLIndexes enabled - $validator = new Index( - $collection->getAttribute('attributes'), - $collection->getAttribute('indexes', []), + $validator = new IndexValidator( + $attributes, + $emptyIndexes, 768, [], false, // supportForArrayIndexes @@ -768,80 +374,38 @@ public function testTTLIndexValidation(): void ); // 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', attributes: ['expiresAt'], orders: [Order::Asc], 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, - ]); + // Invalid: TTL index with ttl = 0 + $invalidIndexZero = Index::ttl(key: 'idx_ttl_zero', attributes: ['expiresAt'], orders: [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 - $invalidIndexNegative = new Document([ - '$id' => ID::custom('idx_ttl_negative'), - 'type' => Database::INDEX_TTL, - 'attributes' => ['expiresAt'], - 'lengths' => [], - 'orders' => [Database::ORDER_ASC], - 'ttl' => -100, - ]); + $invalidIndexNegative = Index::ttl(key: 'idx_ttl_negative', attributes: ['expiresAt'], orders: [Order::Asc], ttl: -100); $this->assertFalse($validator->isValid($invalidIndexNegative)); $this->assertEquals('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', attributes: ['name'], orders: [Order::Asc], 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::ttl(key: 'idx_ttl_multi', attributes: ['expiresAt', 'name'], orders: [Order::Asc, Order::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', attributes: ['expiresAt'], orders: [Order::Asc]); $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', []), + $indexesWithTTL = [$validIndex]; + $validatorWithExisting = new IndexValidator( + $attributes, + $indexesWithTTL, 768, [], false, // supportForArrayIndexes @@ -860,20 +424,121 @@ public function testTTLIndexValidation(): void true // supportForTTLIndexes ); - $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', attributes: ['expiresAt'], orders: [Order::Asc], 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 IndexValidator($attributes, $indexesWithTTL, 768, [], false, false, false, false, false, false, false, false, false); $this->assertFalse($validatorNoSupport->isValid($validIndex)); $this->assertEquals('TTL indexes are not supported', $validatorNoSupport->getDescription()); } + + public function testIndexWithoutATypeIsRejected(): void + { + $validator = new IndexValidator([Attribute::string(key: 'title', size: 64)], [], 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 IndexValidator( + attributes: [Attribute::datetime(key: 'expiresAt')], + indexes: [], + maxLength: 768, + supportForTTLIndexes: true, + ); + + $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 IndexValidator([Attribute::string(key: 'title', size: 64)], [], 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'], + ]); + + $this->assertSame(['title'], Index::fromDocument($stored)->attributes); + $this->assertTrue((new IndexedQueries([Attribute::string(key: 'title', size: 64)], [$stored]))->isValid([])); + } + + public function testTextAttributeWithoutASizeIsJudgedAgainstTheTextMaximum(): void + { + $validator = new IndexValidator([Attribute::text(key: 'body')], [], 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 IndexValidator( + attributes: [Attribute::string(key: 'title', size: 64)], + indexes: [], + maxLength: 768, + supportForKeyIndexes: false, + supportForUniqueIndexes: false, + ); + $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 testIndexTypeWithoutValidationRulesIsRejected(): void + { + $validator = new IndexValidator([Attribute::string(key: 'title', size: 64)], [], 768); + + $this->assertFalse($validator->isValid(Index::index(key: 'by_title', attributes: ['title']))); + $this->assertStringStartsWith('Unknown index type: index. Must be one of ', $validator->getDescription()); + } + + public function testOrderOnAnArrayAttributeIsRejected(): void + { + $validator = new IndexValidator( + attributes: [Attribute::string(key: 'tags', size: 64, array: true)], + indexes: [], + maxLength: 768, + supportForArrayIndexes: true, + ); + + $this->assertFalse($validator->isValid(Index::key(key: 'by_tags', attributes: ['tags'], lengths: [64], orders: [Order::Asc]))); + $this->assertSame('Invalid index order "'.Order::Asc->value.'" on array attribute "tags"', $validator->getDescription()); + + $this->assertTrue($validator->isValid(Index::key(key: 'by_tags', attributes: ['tags'], lengths: [64])), $validator->getDescription()); + } } diff --git a/tests/unit/Validator/IndexedQueriesTest.php b/tests/unit/Validator/IndexedQueriesTest.php index 409fcf3653..4ab5361c52 100644 --- a/tests/unit/Validator/IndexedQueriesTest.php +++ b/tests/unit/Validator/IndexedQueriesTest.php @@ -3,42 +3,44 @@ namespace Tests\Unit\Validator; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception; use Utopia\Database\Query; use Utopia\Database\Validator\IndexedQueries; use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Filter; +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\Query\Schema\ColumnType; +use Utopia\Query\Schema\IndexType; class IndexedQueriesTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } - public function testEmptyQueries(): void + public function test_empty_queries(): void { $validator = new IndexedQueries(); $this->assertEquals(true, $validator->isValid([])); } - public function testInvalidQuery(): void + public function test_invalid_query(): void { $validator = new IndexedQueries(); - $this->assertEquals(false, $validator->isValid(["this.is.invalid"])); + $this->assertEquals(false, $validator->isValid(['this.is.invalid'])); } - public function testInvalidMethod(): void + public function test_invalid_method(): void { $validator = new IndexedQueries(); $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); @@ -47,30 +49,30 @@ public function testInvalidMethod(): void $this->assertEquals(false, $validator->isValid(['equal("attr", "value")'])); } - public function testInvalidValue(): void + public function test_invalid_value(): void { $validator = new IndexedQueries([], [], [new Limit()]); $this->assertEquals(false, $validator->isValid(['limit(-1)'])); } - 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, ]), ]; $indexes = [ new Document([ - 'type' => Database::INDEX_KEY, + 'type' => IndexType::Key->value, 'attributes' => ['name'], ]), new Document([ - 'type' => Database::INDEX_FULLTEXT, + 'type' => IndexType::Fulltext->value, 'attributes' => ['name'], ]), ]; @@ -80,10 +82,10 @@ public function testValid(): void $indexes, [ new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), + new Filter($attributes, ColumnType::Integer->value), new Limit(), new Offset(), - new Order($attributes) + new Order($attributes), ] ); @@ -121,19 +123,19 @@ public function testValid(): void $this->assertEquals(true, $validator->isValid([$query])); } - public function testMissingIndex(): void + public function test_missing_index(): void { $attributes = [ new Document([ 'key' => 'name', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), ]; $indexes = [ new Document([ - 'type' => Database::INDEX_KEY, + 'type' => IndexType::Key->value, 'attributes' => ['name'], ]), ]; @@ -143,10 +145,10 @@ public function testMissingIndex(): void $indexes, [ new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), + new Filter($attributes, ColumnType::Integer->value), new Limit(), new Offset(), - new Order($attributes) + new Order($attributes), ] ); @@ -167,27 +169,219 @@ public function testMissingIndex(): void $this->assertEquals('Searching by attribute "name" requires a fulltext index.', $validator->getDescription()); } - public function testTwoAttributesFulltext(): void + 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 IndexedQueries( + $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', '$id', 'mainId', '=', 'meta'), + 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 IndexedQueries( + $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([ + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::search('name', 'needle'), + ]), + ])); + $this->assertSame( + 'Invalid query: Unsupported join ON condition: search', + $validator->getDescription() + ); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::search('meta.body', 'needle'), + ]), + ]), '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 IndexedQueries( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertFalse($validator->isValid([ + Query::vectorDot('embedding', [0.1, 0.2, 0.3]), + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::vectorCosine('embedding', [0.3, 0.4, 0.5]), + ]), + ])); + $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 IndexedQueries( + $attributes, + [], + [ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ] + ); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('meta', 'meta', [ + Query::on('$id', 'mainId'), + Query::vectorCosine('embedding', [0.3, 0.4, 0.5]), + ]), + ])); + $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' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), new Document([ '$id' => 'ft2', 'key' => 'ft2', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), ]; $indexes = [ new Document([ - 'type' => Database::INDEX_FULLTEXT, - 'attributes' => ['ft1','ft2'], + 'type' => IndexType::Fulltext->value, + 'attributes' => ['ft1', 'ft2'], ]), ]; @@ -196,18 +390,17 @@ public function testTwoAttributesFulltext(): void $indexes, [ new Cursor(), - new Filter($attributes, Database::VAR_INTEGER), + new Filter($attributes, ColumnType::Integer->value), new Limit(), new Offset(), - new Order($attributes) + new Order($attributes), ] ); $this->assertEquals(false, $validator->isValid([Query::search('ft1', 'value')])); } - - public function testJsonParse(): void + public function test_json_parse(): void { try { Query::parse('{"method":"equal","attribute":"name","values":["value"]'); // broken Json; @@ -216,4 +409,145 @@ public function testJsonParse(): void $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 IndexedQueries( + $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 IndexedQueries( + $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 IndexedQueries([], [], [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 IndexedQueries([], [], [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 IndexedQueries( + $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 IndexedQueries( + $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/JoinConditionColumnsTest.php b/tests/unit/Validator/JoinConditionColumnsTest.php new file mode 100644 index 0000000000..79c7286a76 --- /dev/null +++ b/tests/unit/Validator/JoinConditionColumnsTest.php @@ -0,0 +1,361 @@ +}> + */ + public static function joinsOverColumnsTheTablesHave(): iterable + { + $note = Query::join('notes', '$id', 'customerId', '=', 'note'); + + 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', 'note.$id', 'noteId', '=', 'reply')]]; + yield 'a join after a cross join it names' => [[Query::crossJoin('replies', 'reply'), Query::rightJoin('notes', 'reply.noteId', '$id', '=', 'note')]]; + yield 'internal attributes on both sides' => [[Query::join('notes', '$sequence', '$sequence', '<', 'note')]]; + yield 'a relationship that holds a column on the left' => [[Query::join('libraries', 'library', '$id', '=', 'lib')]]; + yield 'a relationship that holds a column on the right' => [[Query::leftJoin('books', '$id', 'owner', '=', 'book')]]; + yield 'a join without an alias' => [[Query::join('notes', '$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', '$id', 'customerId', '=', 'note'); + $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', 'nothing', 'customerId', '=', 'note')], $notFound.'nothing']; + yield 'an unknown right column' => [[Query::join('notes', '$id', 'nothing', '=', 'note')], $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', 'note.nothing', 'noteId', '=', 'reply')], $notFound.'note.nothing']; + yield 'an internal attribute a filter cannot compare' => [[Query::join('notes', '$permissions', 'customerId', '=', 'note')], $notFound.'$permissions']; + yield 'a derived internal attribute' => [[Query::join('notes', '$id', '$collection', '=', 'note')], $notFound.'$collection']; + yield 'a relationship without a column on the left' => [[Query::join('books', 'books', '$id', '=', 'book')], 'Invalid query: Cannot join on virtual relationship attribute: books']; + yield 'a relationship without a column on the right' => [[Query::join('libraries', '$id', 'person', '=', 'lib')], 'Invalid query: Cannot join on virtual relationship attribute: person']; + yield 'a join declared after it' => [[Query::join('replies', 'note.$id', 'noteId', '=', 'reply'), $note], $left.'note.$id']; + yield 'its own alias on the left' => [[Query::join('notes', 'note.customerId', '$id', '=', 'note')], $left.'note.customerId']; + yield 'an alias no join declares on the left' => [[Query::join('notes', 'other.$id', 'customerId', '=', 'note')], $left.'other.$id']; + yield 'the main alias on the left' => [[Query::join('notes', Query::DEFAULT_ALIAS.'.$id', 'customerId', '=', 'note')], $left.Query::DEFAULT_ALIAS.'.$id']; + yield 'an earlier join on the right' => [[$note, Query::join('replies', '$id', 'note.$id', '=', 'reply')], $right.'note.$id']; + yield 'the main alias on the right' => [[Query::join('notes', '$id', Query::DEFAULT_ALIAS.'.name', '=', 'note')], $right.Query::DEFAULT_ALIAS.'.name']; + yield 'no right column' => [[new Query(Method::Join, 'notes', ['$id', '='])], 'Invalid query: Join ON requires left and right columns']; + yield 'a right column that is not a string' => [[new Query(Method::LeftJoin, 'notes', ['$id', '=', 5, 'note'])], 'Invalid query: Join ON requires left and right columns']; + yield 'an operator no engine compares with' => [[Query::join('notes', '$id', 'customerId', '~', 'note')], '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 Queries([new Join()]); + + $this->assertTrue($validator->isValid([Query::join('orders', 'user_id', 'id')]), $validator->getDescription()); + $this->assertTrue($validator->isValid([Query::join('orders', 'user_id', 'id', '=', 'ord'), Query::join('items', 'ord.anything', 'orderId', '=', 'item')]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::join('items', 'ord.anything', 'orderId', '=', 'item'), Query::join('orders', 'user_id', 'id', '=', 'ord')]), '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 Queries([new Join($this->customers(), supportForAttributes: false)]); + $validator->setJoinedCollections($this->collections()); + + $this->assertTrue($validator->isValid([Query::join('notes', 'anything', 'whatever', '=', 'note')]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([Query::join('notes', 'reply.anything', 'whatever', '=', 'note')])); + $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()); + $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', '$id', 'customerId', '=', 'note')]), $validator->getDescription()); + $this->assertFalse($validator->isValid([Query::join('replies', 'note.$id', 'noteId', '=', 'reply')]), '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', '$id', 'customerId', '=', 'note'); + + 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', '$id', 'customerId', '=', 'note'), 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', '$id', 'customerId', '=', 'note'); + $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', '$id', 'customerId', '=', 'note'); + + 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()); + $document->setJoinedCollections($this->collections()); + + $joins = new Queries([new Join($this->customers())]); + $joins->setJoinedCollections($this->collections()); + + return ['documents' => $documents, 'document' => $document, 'join' => $joins]; + } + + private function documents(bool $sharedTables): DocumentsQueries + { + $validator = new DocumentsQueries( + attributes: $this->customers(), + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportForJoins: true, + supportForAggregations: true, + 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', RelationType::OneToOne, RelationSide::Parent), + $this->relationship('books', RelationType::OneToMany, RelationSide::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', RelationType::OneToOne, RelationSide::Child), + ]), + $this->collection('books', [ + $this->attribute('title', ColumnType::String), + $this->relationship('owner', RelationType::OneToMany, RelationSide::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, RelationType $type, RelationSide $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/JoinedAttributesTest.php b/tests/unit/Validator/JoinedAttributesTest.php new file mode 100644 index 0000000000..c86520ccc4 --- /dev/null +++ b/tests/unit/Validator/JoinedAttributesTest.php @@ -0,0 +1,303 @@ +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), + $this->attribute('customer', ColumnType::Relationship), + ]); + $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', '$id', 'customerId', '=', 'profile'), + 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', '$id', 'customerId', '=', 'note'), + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + Query::groupBy(['status']), + ]), $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + Query::join('notes', '$id', 'customerId'), + Query::join('orders', '$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', '$id', 'customerId', '=', 'j'), + 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', '$id', 'customerId', '=', 'j'), + 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', '$id', 'customerId', '=', 'alpha'), + Query::join('refunds', '$id', 'customerId', '=', 'beta'), + ]; + + $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', '$id', 'customerId', '=', 'first'), + Query::leftJoin('orders', '$id', 'customerId', '=', 'second'), + ]; + + $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', '$id', 'customerId', '=', 'purchase'), + Query::sum('amount', 'total'), + ])); + $this->assertSame('Invalid query: Attribute not found in schema: amount', $validator->getDescription()); + + $this->assertTrue($validator->isValid([ + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + Query::sum('purchase.amount', 'total'), + ]), $validator->getDescription()); + } + + public function testJoinedRelationshipAttributeIsNotResolvedByItsBareName(): void + { + $validator = $this->validator([$this->orders]); + + $this->assertFalse($validator->isValid([ + Query::join('orders', '$id', 'customerId', '=', 'purchase'), + 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', '$id', 'customerId', '=', 'purchase'), + 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', '$id', 'customerId', '=', 'purchase'), + 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', '$id', 'customerId', '=', 'purchase'), + 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', '$id', 'customerId', '=', 'note'), + Query::search('note.body', 'needle'), + ]), $validator->getDescription()); + } + + public function testSearchInAJoinConditionIsRefusedWithOrWithoutAFulltextIndex(): 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('Invalid query: Unsupported join ON condition: search', $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('notes', 'note', [ + Query::on('$id', 'customerId'), + Query::search('note.body', 'needle'), + ]), + ]), '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', '$id', 'customerId', '=', 'note'), + 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', '$id', 'customerId', '=', 'purchase'), + 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: $attributes, + indexes: [], + idAttributeType: ColumnType::Integer->value, + supportForJoins: true, + supportForAggregations: true, + ); + + 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/JoinedColumnTest.php b/tests/unit/Validator/JoinedColumnTest.php new file mode 100644 index 0000000000..8c8c750999 --- /dev/null +++ b/tests/unit/Validator/JoinedColumnTest.php @@ -0,0 +1,348 @@ +}> + */ + 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', '$id', 'customerId', '=', 'purchase')]; + + $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', '$id', 'customerId', '=', 'again')]; + + $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()); + $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()); + $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', '$id', 'customerId', '=', 'note'); + } + + /** + * @param array $joinedCollections + */ + private function validator(array $joinedCollections = [], bool $supportForAttributes = true): Queries + { + $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 Queries([ + 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' => ['relationType' => RelationType::ManyToMany->value, 'side' => RelationSide::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..fbc8d1ddfa 100644 --- a/tests/unit/Validator/KeyTest.php +++ b/tests/unit/Validator/KeyTest.php @@ -7,21 +7,18 @@ class KeyTest extends TestCase { - /** - * @var Key - */ - protected ?Key $object = null; + protected Key $object; - public function setUp(): void + protected function setUp(): void { $this->object = new Key(); } - public function tearDown(): void + 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..2bc93420d7 100644 --- a/tests/unit/Validator/LabelTest.php +++ b/tests/unit/Validator/LabelTest.php @@ -7,21 +7,18 @@ class LabelTest extends TestCase { - /** - * @var Label - */ - protected ?Label $object = null; + protected Label $object; - public function setUp(): void + protected function setUp(): void { $this->object = new Label(); } - public function tearDown(): void + 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 +59,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/ObjectTest.php b/tests/unit/Validator/ObjectTest.php index 4563fe2eb8..24dc50b6ba 100644 --- a/tests/unit/Validator/ObjectTest.php +++ b/tests/unit/Validator/ObjectTest.php @@ -7,7 +7,7 @@ class ObjectTest extends TestCase { - public function testValidAssociativeObjects(): void + public function test_valid_associative_objects(): void { $validator = new ObjectValidator(); @@ -15,9 +15,9 @@ public function testValidAssociativeObjects(): void $this->assertTrue($validator->isValid([ 'a' => [ 'b' => [ - 'c' => 123 - ] - ] + 'c' => 123, + ], + ], ])); $this->assertTrue($validator->isValid([ @@ -25,28 +25,28 @@ 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(); @@ -55,11 +55,33 @@ public function testInvalidStructures(): void $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(); + + $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 ObjectValidator(); + + $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 ObjectValidator(); diff --git a/tests/unit/Validator/OperatorTest.php b/tests/unit/Validator/OperatorTest.php index e89d391042..10c1563162 100644 --- a/tests/unit/Validator/OperatorTest.php +++ b/tests/unit/Validator/OperatorTest.php @@ -3,16 +3,16 @@ 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 + protected function setUp(): void { $this->collection = new Document([ '$id' => 'test_collection', @@ -20,50 +20,50 @@ 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 + 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 +76,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 +85,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 +93,7 @@ public function testParseStringOperatorWithInvalidMethod(): void $invalidOperator = json_encode([ 'method' => 'invalidMethod', 'attribute' => 'count', - 'values' => [1] + 'values' => [1], ]); $this->assertFalse($validator->isValid($invalidOperator)); @@ -101,7 +101,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 +111,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 +122,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 +132,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 +142,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 +153,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 +165,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 +175,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 +186,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 +196,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 +207,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 +217,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 +228,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 +238,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 +248,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 +260,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 +270,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 +281,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 +292,7 @@ public function testStringReplace(): void } // Test boolean operators - public function testToggle(): void + public function test_toggle(): void { $validator = new OperatorValidator($this->collection); @@ -302,7 +302,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 +314,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 +324,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 +335,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 +345,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 +356,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 +367,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 +379,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 +397,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..e5b1e2b76e --- /dev/null +++ b/tests/unit/Validator/OperatorValidatorTest.php @@ -0,0 +1,301 @@ + + */ + public static function singleValueSides(): array + { + return [ + 'one-to-one parent' => [RelationType::OneToOne, RelationSide::Parent], + 'one-to-one child' => [RelationType::OneToOne, RelationSide::Child], + 'one-to-many child' => [RelationType::OneToMany, RelationSide::Child], + 'many-to-one parent' => [RelationType::ManyToOne, RelationSide::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(RelationType $type, RelationSide $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(RelationType::ManyToMany, RelationSide::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(RelationType::ManyToMany, RelationSide::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' => [RelationType::OneToMany, RelationSide::Parent, true], + 'one-to-many child' => [RelationType::OneToMany, RelationSide::Child, false], + 'many-to-one child' => [RelationType::ManyToOne, RelationSide::Child, true], + 'many-to-one parent' => [RelationType::ManyToOne, RelationSide::Parent, false], + ]; + } + + #[DataProvider('enumSides')] + public function testASideGivenAsTheEnumDecidesWhetherTheRelationshipHoldsAList(RelationType $type, RelationSide $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(RelationType $type, RelationSide|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..96a5fd47b5 100644 --- a/tests/unit/Validator/PermissionsTest.php +++ b/tests/unit/Validator/PermissionsTest.php @@ -13,18 +13,18 @@ class PermissionsTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } /** * @throws DatabaseException */ - public function testSingleMethodSingleValue(): void + public function test_single_method_single_value(): void { $object = new Permissions(); @@ -95,7 +95,7 @@ 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(); @@ -120,21 +120,21 @@ public function testMultipleMethodSingleValue(): void $document['$permissions'] = [ Permission::read(Role::user(ID::custom('123abc'))), Permission::create(Role::user(ID::custom('123abc'))), - Permission::update(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::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::update(Role::team(ID::custom('123abc'), 'viewer')), ]; $this->assertTrue($object->isValid($document->getPermissions())); @@ -153,7 +153,7 @@ 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(); @@ -177,19 +177,19 @@ public function testMultipleMethodMultipleValues(): void 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::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::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 +239,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 @@ -270,11 +270,11 @@ public function testInvalidPermissions(): void // 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')))])); @@ -308,7 +308,7 @@ 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(); @@ -327,23 +327,23 @@ 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); } } diff --git a/tests/unit/Validator/QueriesTest.php b/tests/unit/Validator/QueriesTest.php index 40e8d7671b..82622f6279 100644 --- a/tests/unit/Validator/QueriesTest.php +++ b/tests/unit/Validator/QueriesTest.php @@ -4,43 +4,50 @@ 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\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 + protected function setUp(): void { } - public function tearDown(): void + protected function tearDown(): void { } - public function testEmptyQueries(): void + public function test_empty_queries(): void { $validator = new Queries(); $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"])])); + $this->assertEquals(false, $validator->isValid([Query::equal('attr', ['value'])])); $validator = new Queries([new Limit()]); - $this->assertEquals(false, $validator->isValid([Query::equal('attr', ["value"])])); + $this->assertEquals(false, $validator->isValid([Query::equal('attr', ['value'])])); } - public function testInvalidValue(): void + public function test_invalid_value(): void { $validator = new Queries([new Limit()]); $this->assertEquals(false, $validator->isValid([Query::limit(-1)])); @@ -49,19 +56,19 @@ public function testInvalidValue(): void /** * @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, ]), ]; @@ -69,10 +76,10 @@ public function testValid(): void $validator = new Queries( [ 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 +112,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 +123,555 @@ public function testValid(): void ]) ); } + + public function test_non_array_value_returns_false(): void + { + $validator = new Queries(); + + $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 Queries([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 Queries([ + 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 Queries([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 Queries([new Distinct()]); + + $this->assertTrue($validator->isValid([Query::distinct()])); + } + + public function test_group_by_method_type_mapping(): void + { + $validator = new Queries([new GroupBy(supportForAttributes: false)]); + + $this->assertTrue($validator->isValid([Query::groupBy(['category'])])); + } + + public function test_having_method_type_mapping(): void + { + $validator = new Queries([new Having()]); + + $this->assertTrue($validator->isValid([Query::having([Query::greaterThan('count', 5)])])); + } + + public function test_join_method_type_mapping(): void + { + $validator = new Queries([new Join()]); + + $this->assertTrue($validator->isValid([Query::join('orders', '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 Queries([ + 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', 'productId', '$id', '=', 'rev'), + 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 Queries([ + 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 Queries([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'productId', '$id', '=', 'rev'), + Query::sum('revv.score'), + ]), 'an aggregate qualified with an undeclared alias must not pass'); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'productId', '$id', '=', 'rev'), + Query::groupBy(['revv.score']), + ]), 'a groupBy qualified with an undeclared alias must not pass'); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'productId', '$id', '=', 'rev'), + 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 Queries([ + new Aggregate($attributes), + new GroupBy($attributes), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::leftJoin('reviews', 'productId', '$id', '=', 'rev'), + Query::sum('rev.score'), + ])); + + $this->assertFalse($validator->isValid([ + Query::leftJoin('reviews', 'productId', '$id', '=', 'other'), + 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 Queries([ + 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', 'productId', '$id', '=', 'rev'), + 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 Queries([ + new Select($attributes), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::select(['ord.amount']), + Query::join('orders', '$id', 'customer_uid', '=', 'ord'), + ]), $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 Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::equal('sec.amount', [777]), + Query::join('orders', '$id', 'customer_uid', '=', 'sec'), + ]), $validator->getDescription()); + } + + public function testNestedJoinAliasIsCollected(): void + { + $attributes = [ + new Document([ + '$id' => 'name', + 'key' => 'name', + 'type' => ColumnType::String->value, + 'array' => false, + ]), + ]; + + $validator = new Queries([ + 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 Queries([ + 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 Queries([ + new Order($attributes), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::orderAsc('sec.amount'), + Query::join('orders', '$id', 'customer_uid', '=', 'sec'), + ]), $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 Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::equal('other.amount', [777]), + Query::join('orders', '$id', 'customer_uid', '=', 'sec'), + ])); + $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 Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::join('meta', '$id', 'mainId', '=', 'meta'), + 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 Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::join('meta', '$id', 'mainId', '=', 'meta'), + 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 Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertFalse($validator->isValid([ + Query::join('meta', '$id', 'mainId', '=', 'meta'), + 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 Queries([ + new Filter($attributes, ColumnType::Integer->value), + new Join(), + ]); + + $this->assertTrue($validator->isValid([ + Query::join('meta', '$id', 'mainId', '=', 'meta'), + Query::equal('meta.score', [10]), + ]), $validator->getDescription()); + + $this->assertFalse($validator->isValid([ + Query::join('peer', '$id', 'mainId', '=', 'peer'), + 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 Queries([ + 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 Queries(); + + $this->assertTrue($validator->isArray()); + } + + public function test_get_type(): void + { + $validator = new Queries(); + + $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..3491e59886 --- /dev/null +++ b/tests/unit/Validator/Query/AggregateTest.php @@ -0,0 +1,61 @@ +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/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index 21ba8f4046..d8b28cf666 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -3,83 +3,94 @@ 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 + 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 +108,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 +124,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 +145,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 +157,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 +167,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 +181,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 +195,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 +209,85 @@ 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()); + } } diff --git a/tests/unit/Validator/Query/FilterTypeBranchesTest.php b/tests/unit/Validator/Query/FilterTypeBranchesTest.php new file mode 100644 index 0000000000..6c6b9f003d --- /dev/null +++ b/tests/unit/Validator/Query/FilterTypeBranchesTest.php @@ -0,0 +1,104 @@ +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()); + } + + 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..87491af268 --- /dev/null +++ b/tests/unit/Validator/Query/GroupByTest.php @@ -0,0 +1,72 @@ +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..4d108c65e4 --- /dev/null +++ b/tests/unit/Validator/Query/JoinAliasTest.php @@ -0,0 +1,59 @@ +assertTrue($validator->isValid($join), $validator->getDescription()); + } + + /** + * @return iterable + */ + public static function acceptedJoins(): iterable + { + yield 'no alias' => [Query::join('orders', '$id', 'customerId')]; + yield 'an identifier' => [Query::join('orders', '$id', 'customerId', '=', 'ord')]; + yield 'an identifier with digits and underscores' => [Query::leftJoin('orders', '$id', 'customerId', '=', '_order_2')]; + 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 'the main collection alias' => [Query::join('orders', '$id', 'customerId', '=', $main), "Join alias \"{$main}\" is reserved for the main collection"]; + yield 'the main collection alias in upper case' => [Query::join('orders', '$id', 'customerId', '=', $upper), "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', '$id', 'customerId', '=', 'my-alias'), $invalid]; + yield 'a leading digit' => [Query::leftJoin('orders', '$id', 'customerId', '=', '1st'), $invalid]; + yield 'a dot' => [Query::join('orders', '$id', 'customerId', '=', 'a.b'), $invalid]; + yield 'a quote' => [Query::crossJoin('orders', 'x`y'), $invalid]; + } +} 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..e697c6c478 100644 --- a/tests/unit/Validator/Query/OrderTest.php +++ b/tests/unit/Validator/Query/OrderTest.php @@ -3,41 +3,40 @@ 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\Base; 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 + 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 +44,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 +57,73 @@ 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: [ + new Document([ + '$id' => 'profile', + 'key' => 'profile', + 'type' => ColumnType::Relationship->value, + 'array' => false, + ]), + ], + ); + + $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..438925c0e2 100644 --- a/tests/unit/Validator/Query/SelectTest.php +++ b/tests/unit/Validator/Query/SelectTest.php @@ -2,48 +2,49 @@ namespace Tests\Unit\Validator\Query; +use PHPUnit\Framework\Attributes\DataProvider; 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\Base; 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 + protected function setUp(): void { $this->validator = new Select( attributes: [ new Document([ '$id' => 'attr', 'key' => 'attr', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'array' => false, ]), new Document([ '$id' => 'artist', 'key' => 'artist', - 'type' => Database::VAR_RELATIONSHIP, + 'type' => ColumnType::Relationship->value, 'array' => false, ]), ], ); } - 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 +58,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 +105,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..fb256f9311 --- /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', '$id', 'product', '=', 'review'), + 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..6e2c36ceda --- /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 = []): IndexedQueries + { + $attributes = $this->attributes(); + + return new IndexedQueries($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..fa26a2a404 --- /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', '$id', 'customer', '=', 'ord'), 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..9d5241fb93 --- /dev/null +++ b/tests/unit/Validator/QueryShapeJoinCapTest.php @@ -0,0 +1,56 @@ + + */ + 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 Queries([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', '$id', 'customer', '=', 'first'), + Query::leftJoin('orders', '$id', 'customer', '=', 'second'), + Query::rightJoin('orders', '$id', 'customer', '=', 'third'), + Query::fullOuterJoin('orders', '$id', 'customer', '=', 'fourth'), + 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()); + + $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..8b579e43a6 --- /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 Queries([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 Queries([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 Queries([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 Queries([ + 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..539c531cd4 100644 --- a/tests/unit/Validator/QueryTest.php +++ b/tests/unit/Validator/QueryTest.php @@ -4,10 +4,11 @@ use Exception; use PHPUnit\Framework\TestCase; -use Utopia\Database\Database; 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 +20,13 @@ class QueryTest extends TestCase /** * @throws Exception */ - public function setUp(): void + 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 +36,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 +46,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 +56,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 +66,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 +76,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 +86,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 +100,16 @@ public function setUp(): void } } - public function tearDown(): void + 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, [], ColumnType::Integer->value); $this->assertEquals(true, $validator->isValid([Query::equal('$id', ['Iron Man', 'Ant Man'])])); $this->assertEquals(true, $validator->isValid([Query::equal('$id', ['Iron Man'])])); @@ -117,8 +118,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 +137,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::equal('name', ['Iron Man'])]); $this->assertEquals(false, $response); @@ -152,9 +153,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::equal('title', [1776])]); $this->assertEquals(false, $response); @@ -164,9 +165,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::greaterThan('birthDay', '1960-01-01 10:10:10')]); $this->assertEquals(true, $response); @@ -175,9 +176,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::limit(25)]); $this->assertEquals(true, $response); @@ -189,9 +190,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::offset(25)]); $this->assertEquals(true, $response); @@ -203,9 +204,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::orderAsc('title')]); $this->assertEquals(true, $response); @@ -223,9 +224,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::cursorAfter(new Document(['$id' => 'asdf']))]); $this->assertEquals(true, $response); @@ -234,7 +235,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 +243,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 +272,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 +295,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 +303,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, [], ColumnType::Integer->value); $response = $validator->isValid([Query::equal('title', [''])]); $this->assertEquals(true, $response); @@ -334,9 +332,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, [], ColumnType::Integer->value); $this->assertFalse($validator->isValid( [Query::or( @@ -351,7 +349,7 @@ public function testOrQuery(): void Query::or( [ Query::equal('price', [0]), - Query::equal('not_found', ['']) + Query::equal('not_found', ['']), ] )] )); @@ -364,7 +362,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..4cd792f87a --- /dev/null +++ b/tests/unit/Validator/QueryValidationTest.php @@ -0,0 +1,473 @@ +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)); + $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(new Relationship( + collection: 'posts', + relatedCollection: 'authors', + type: RelationType::ManyToOne, + key: 'author', + onDelete: ForeignKeyAction::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(new Relationship( + collection: 'owners', + relatedCollection: 'items', + type: RelationType::OneToMany, + twoWay: true, + key: 'items', + twoWayKey: 'owner', + onDelete: ForeignKeyAction::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', 'owner', '$id', '=', 'author'), + 'on() condition' => Query::join('users', 'author', [Query::on('owner', '$id')]), + 'left join' => Query::leftJoin('users', 'owner', '$id', '=', 'author'), + ] 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->find('posts', [Query::join('users', 'owner', '$id', '=', 'author'), Query::count('*', 'rows'), Query::select(['author.*'])]), + 'an aggregate next to the relationship wildcard', + ); + + $joined = $this->database->find('posts', [Query::join('users', 'owner', '$id', '=', 'usr'), 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', 'owner', '$id', '=', 'usr'), 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' => RelationType::ManyToOne->value, 'side' => 'parent', 'relatedCollection' => 'authors']]), + ]); + + $this->assertFalse($validator->isValid(Query::join('users', 'owner', '$id', '=', 'author'))); + $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', 'owner', '$id', '=', 'Author')), 'relationship keys are matched as the relationship hook matches them, by exact name'); + + $validator->resetJoinAliases(); + $this->assertTrue($validator->isValid(Query::join('users', 'owner', '$id', '=', 'owner')), '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', '$id', '$id', '=', 'nested'), '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 builder compiles a join's ON list from on() conditions and plain filters only, and refuses + * the rest while it builds the statement. + */ + #[DataProvider('refusedJoinConditions')] + public function testAJoinOnListAcceptsOnlyConditionsAndPlainFilters(Query $condition, string $method): void + { + $queries = [Query::join('items', 'it', [Query::on('$id', 'ownerRef'), $condition])]; + $message = 'Unsupported join ON condition: '.$method; + + $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', '$id', 'ownerRef', '=', 'it'), 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', '$id', 'ownerRef', '=', 'it'); + + 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', '$id', 'ownerRef', '=', 'it'); + + $rows = $this->database->find('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]->getAttribute('first')); + $this->assertSame(4, $rows[0]->getAttribute('best')); + $this->assertSame(7, $rows[0]->getAttribute('dearest')); + $this->assertSame('cup', $rows[0]->getAttribute('title')); + + $owners = $this->database->find('items', [Query::groupBy(['owner']), Query::count('*', 'rows'), Query::max('owner', 'last'), Query::orderAsc('owner')]); + $this->assertSame(['ann', 'bob'], \array_map(static fn (Document $group): mixed => $group->getAttribute('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', '$id', 'ownerRef', '=', 'it'), + 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', 'ownerRef', '$id', '=', 'ow'), 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', 'ownerRef', '$id', '=', 'ow'), 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', '$id', 'customerId', '=', 'note'), + 'on() list with a filter' => Query::join('notes', 'note', [Query::on('$id', 'customerId'), Query::equal('note.body', ['x'])]), + ]; + + $enabled = new DocumentQueries($attributes); + $disabled = new DocumentQueries($attributes, supportForJoins: false); + + 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(new Collection(id: 'customers', attributes: [Attribute::string(key: 'name', size: 32)], permissions: $permissions, documentSecurity: false)); + $database->createCollection(new Collection(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', '$id', 'customerId', '=', 'note')])); + $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]->getAttribute('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 array $attributes + */ + private function createCollection(string $id, array $attributes): void + { + $this->database->createCollection(new Collection( + 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..e46dd17c4c --- /dev/null +++ b/tests/unit/Validator/QueryValidatorCoverageTest.php @@ -0,0 +1,282 @@ +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 testAnAttributeTypeWithoutAValueRuleIsAnUnknownDataType(string $type): void + { + $filter = new Filter([new Document([ + Document::ID => 'amount', + 'key' => 'amount', + 'type' => $type, + 'array' => false, + ])], ColumnType::Integer->value, self::MAX_VALUES); + + $this->assertFalse($filter->isValid(Query::equal('amount', ['1.5']))); + $this->assertSame('Unknown Data type', $filter->getDescription()); + $this->assertTrue($filter->isValid(Query::isNull('amount')), 'a query without values never reaches the type rule'); + } + + 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 IndexedQueries($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 IndexedQueries( + $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([$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, 'authorId', 'id', alias: 'author'))); + $this->assertSame('Join requires a table name', $validator->getDescription()); + + $this->assertTrue($validator->isValid(Query::join('authors', 'authorId', 'id', alias: 'author')), $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' => RelationType::ManyToOne->value, + 'twoWay' => false, + 'twoWayKey' => 'books', + 'side' => RelationSide::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..a4d6de5d19 100644 --- a/tests/unit/Validator/RolesTest.php +++ b/tests/unit/Validator/RolesTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Validator; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; @@ -9,18 +10,18 @@ class RolesTest extends TestCase { - public function setUp(): void + protected function setUp(): void { } - public function tearDown(): void + 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()])); @@ -32,59 +33,105 @@ public function testValidRole(): void $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..dc954e0523 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,33 +46,33 @@ 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)')); @@ -82,30 +82,30 @@ public function testWKTStrings(): void $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..60305f062f --- /dev/null +++ b/tests/unit/Validator/StructureFormatTest.php @@ -0,0 +1,125 @@ + + */ + private array $received = []; + + 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); + } + + 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 testAFormatSeesAnAttributeDefinedAsAPlainArrayAsItIs(string $code, bool $valid): void + { + $definition = $this->definition(); + $collection = $this->collection(); + $collection->setAttribute('attributes', [$definition]); + + $validator = new Structure($collection, ColumnType::Integer->value); + + $this->assertSame($valid, $validator->isValid($this->document($code)), $validator->getDescription()); + $this->assertSame([$definition], $this->received, 'the format callback receives the stored array unchanged'); + } + + #[DataProvider('codes')] + public function testAFormatSeesAnAttributeDefinedAsADocumentAsAnArray(string $code, bool $valid): void + { + $definition = $this->definition(); + $collection = $this->collection(); + $collection->setAttribute('attributes', [new Document($definition)]); + + $validator = new Structure($collection, ColumnType::Integer->value); + + $this->assertSame($valid, $validator->isValid($this->document($code)), $validator->getDescription()); + $this->assertSame([$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' => [], + ]; + } + + 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 71c90577cb..204563aa59 100644 --- a/tests/unit/Validator/StructureTest.php +++ b/tests/unit/Validator/StructureTest.php @@ -10,6 +10,7 @@ use Utopia\Database\Helpers\ID; use Utopia\Database\Operator; use Utopia\Database\Validator\Structure; +use Utopia\Query\Schema\ColumnType; class StructureTest extends TestCase { @@ -23,7 +24,7 @@ class StructureTest extends TestCase 'attributes' => [ [ '$id' => 'title', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => '', 'size' => 256, 'required' => true, @@ -33,7 +34,7 @@ class StructureTest extends TestCase ], [ '$id' => 'description', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => '', 'size' => 1000000, 'required' => false, @@ -43,7 +44,7 @@ class StructureTest extends TestCase ], [ '$id' => 'rating', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'format' => '', 'size' => 5, 'required' => true, @@ -53,7 +54,7 @@ class StructureTest extends TestCase ], [ '$id' => 'reviews', - 'type' => Database::VAR_INTEGER, + 'type' => ColumnType::Integer->value, 'format' => '', 'size' => 5, 'required' => false, @@ -63,7 +64,7 @@ class StructureTest extends TestCase ], [ '$id' => 'price', - 'type' => Database::VAR_FLOAT, + 'type' => ColumnType::Double->value, 'format' => '', 'size' => 5, 'required' => true, @@ -73,7 +74,7 @@ class StructureTest extends TestCase ], [ '$id' => 'published', - 'type' => Database::VAR_BOOLEAN, + 'type' => ColumnType::Boolean->value, 'format' => '', 'size' => 5, 'required' => true, @@ -83,7 +84,7 @@ class StructureTest extends TestCase ], [ '$id' => 'tags', - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => '', 'size' => 55, 'required' => false, @@ -93,7 +94,7 @@ class StructureTest extends TestCase ], [ '$id' => 'id', - 'type' => Database::VAR_ID, + 'type' => ColumnType::Id->value, 'format' => '', 'size' => 0, 'required' => false, @@ -103,7 +104,7 @@ class StructureTest extends TestCase ], [ '$id' => 'varchar_field', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'format' => '', 'size' => 255, 'required' => false, @@ -113,7 +114,7 @@ class StructureTest extends TestCase ], [ '$id' => 'text_field', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'format' => '', 'size' => 65535, 'required' => false, @@ -123,7 +124,7 @@ class StructureTest extends TestCase ], [ '$id' => 'mediumtext_field', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'format' => '', 'size' => 16777215, 'required' => false, @@ -133,7 +134,7 @@ class StructureTest extends TestCase ], [ '$id' => 'longtext_field', - 'type' => Database::VAR_LONGTEXT, + 'type' => ColumnType::LongText->value, 'format' => '', 'size' => 4294967295, 'required' => false, @@ -145,18 +146,23 @@ class StructureTest extends TestCase 'indexes' => [], ]; - public function setUp(): void + 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'][] = [ + /** @var array> $attrs */ + $attrs = $this->collection['attributes']; + $attrs[] = [ '$id' => ID::custom('feedback'), - 'type' => Database::VAR_STRING, + 'type' => ColumnType::String->value, 'format' => 'email', 'size' => 55, 'required' => true, @@ -164,17 +170,18 @@ public function setUp(): void 'array' => false, 'filters' => [], ]; + $this->collection['attributes'] = $attrs; } - public function tearDown(): void + protected function tearDown(): void { } - public function testDocumentInstance(): void + public function test_document_instance(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid('string')); @@ -185,11 +192,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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document())); @@ -197,11 +204,11 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -214,17 +221,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -236,17 +243,17 @@ 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 testNullValues(): void + public function test_null_values(): void { $validator = new Structure( new Document($this->collection), - Database::VAR_INTEGER + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -273,15 +280,15 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -295,17 +302,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -318,17 +325,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -341,15 +348,15 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -362,127 +369,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -495,7 +392,7 @@ 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()); @@ -510,7 +407,7 @@ 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()); @@ -525,7 +422,7 @@ 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([ @@ -538,7 +435,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()); @@ -547,11 +444,11 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -564,15 +461,15 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -585,15 +482,15 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -606,7 +503,7 @@ 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()); @@ -621,17 +518,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -645,7 +542,7 @@ 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([ @@ -659,7 +556,7 @@ 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([ @@ -673,7 +570,7 @@ 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([ @@ -687,17 +584,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -710,7 +607,7 @@ 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()); @@ -725,17 +622,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -748,7 +645,7 @@ 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()); @@ -763,17 +660,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -786,17 +683,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -809,17 +706,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -832,17 +729,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(false, $validator->isValid(new Document([ @@ -855,15 +752,15 @@ 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 + ColumnType::Integer->value ); $sqlId = '1000'; @@ -899,7 +796,7 @@ public function testId(): void $validator = new Structure( new Document($this->collection), - Database::VAR_UUID7 + ColumnType::Uuid7->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -931,11 +828,11 @@ 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 + ColumnType::Integer->value ); // Operators should be skipped during structure validation @@ -949,15 +846,15 @@ 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 + ColumnType::Integer->value ); // Multiple operators should all be skipped @@ -971,15 +868,15 @@ 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 + ColumnType::Integer->value ); // Missing required field (not replaced by operator) should still fail @@ -993,17 +890,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -1017,7 +914,7 @@ 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([ @@ -1031,7 +928,7 @@ 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()); @@ -1047,17 +944,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -1090,7 +987,7 @@ 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([ @@ -1104,7 +1001,7 @@ 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()); @@ -1120,17 +1017,17 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -1144,7 +1041,7 @@ 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([ @@ -1158,7 +1055,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()); @@ -1176,7 +1073,7 @@ public function testMediumtextSizedValidation(): void 'attributes' => [ [ '$id' => 'mediumtext', - 'type' => Database::VAR_MEDIUMTEXT, + 'type' => ColumnType::MediumText->value, 'format' => '', 'size' => 100, 'required' => false, @@ -1188,7 +1085,7 @@ public function testMediumtextSizedValidation(): void 'indexes' => [], ]); - $validator = new Structure($collection, Database::VAR_INTEGER); + $validator = new Structure($collection, ColumnType::Integer->value); $base = [ '$collection' => ID::custom('posts'), @@ -1215,11 +1112,11 @@ 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 + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ @@ -1233,7 +1130,7 @@ 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([ @@ -1247,13 +1144,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, @@ -1262,7 +1159,7 @@ public function testStringTypeArrayValidation(): void 'attributes' => [ [ '$id' => 'varchar_array', - 'type' => Database::VAR_VARCHAR, + 'type' => ColumnType::Varchar->value, 'format' => '', 'size' => 128, 'required' => false, @@ -1272,7 +1169,7 @@ public function testStringTypeArrayValidation(): void ], [ '$id' => 'text_array', - 'type' => Database::VAR_TEXT, + 'type' => ColumnType::Text->value, 'format' => '', 'size' => 65535, 'required' => false, @@ -1286,21 +1183,21 @@ public function testStringTypeArrayValidation(): void $validator = new Structure( new Document($collection), - Database::VAR_INTEGER + ColumnType::Integer->value ); $this->assertEquals(true, $validator->isValid(new Document([ '$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'), '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()); @@ -1309,60 +1206,66 @@ public function testStringTypeArrayValidation(): void '$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([ + ]); + $validator = new Structure($collection, ColumnType::Integer->value); + $document = new Document([ '$collection' => ID::custom('posts'), - 'bigint_signed' => (string)PHP_INT_MAX, + '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, @@ -1372,65 +1275,63 @@ public function testBigIntUnsignedAcceptsLargeNumericStringAsString(): void ], ], 'indexes' => [], - ]; - - $validator = new Structure( - new Document($collection), - Database::VAR_INTEGER - ); + ]); - $unsignedMax = '18446744073709551615'; + $validator = new Structure($collection, ColumnType::Integer->value); - $doc = new Document([ + $document = new Document([ '$collection' => ID::custom('posts'), - 'bigint_unsigned' => $unsignedMax, + '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, ColumnType::Integer->value); - $doc = new Document([ + $base = [ '$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->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..9b7fbe615d --- /dev/null +++ b/tests/unit/Vector/VectorValidationTest.php @@ -0,0 +1,466 @@ +adapter = self::createStub(Adapter::class); + $this->adapter->method('getSharedTables')->willReturn(false); + $this->adapter->method('getTenant')->willReturn(null); + $this->adapter->method('getTenantPerDocument')->willReturn(false); + $this->adapter->method('getNamespace')->willReturn(''); + $this->adapter->method('getIdAttributeType')->willReturn('string'); + $this->adapter->method('getMaxUIDLength')->willReturn(36); + $this->adapter->method('getMinDateTime')->willReturn(new DateTime('0000-01-01')); + $this->adapter->method('getMaxDateTime')->willReturn(new DateTime('9999-12-31')); + $this->adapter->method('getLimitForString')->willReturn(16777215); + $this->adapter->method('getLimitForInt')->willReturn(2147483647); + $this->adapter->method('getLimitForAttributes')->willReturn(0); + $this->adapter->method('getLimitForIndexes')->willReturn(64); + $this->adapter->method('getMaxIndexLength')->willReturn(768); + $this->adapter->method('getMaxVarcharLength')->willReturn(16383); + $this->adapter->method('getDocumentSizeLimit')->willReturn(0); + $this->adapter->method('getCountOfAttributes')->willReturn(0); + $this->adapter->method('getCountOfIndexes')->willReturn(0); + $this->adapter->method('getAttributeWidth')->willReturn(0); + $this->adapter->method('getInternalIndexesKeys')->willReturn([]); + $this->adapter->method('filter')->willReturnArgument(0); + $this->adapter->method('supports')->willReturnCallback(function (Capability $cap) { + return in_array($cap, [ + Capability::Index, + Capability::IndexArray, + Capability::UniqueIndex, + 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::vector(key: 'bad_embedding', 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', size: 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', attributes: ['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..b6f17ef6f4 100644 --- a/tests/unit/WithCacheLeaseTest.php +++ b/tests/unit/WithCacheLeaseTest.php @@ -2,19 +2,25 @@ 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\Query; class WithCacheLeaseTest extends TestCase { - private LeasableMemoryCache $cacheAdapter; + private Cache $cache; + + private DatabaseMemory $adapter; private Database $database; @@ -22,15 +28,16 @@ class WithCacheLeaseTest extends TestCase 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(new Collection(id: 'projects')); + $this->database->createAttribute('projects', Attribute::string(key: 'name')); $this->database->createDocument('projects', new Document([ '$id' => 'project', '$permissions' => [ @@ -42,23 +49,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 { - [$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); + $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->assertTrue($this->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('changed', $plain()); + $this->assertSame('changed', $projected()); + } + + public function testAReadInFlightDuringAPurgeCannotCacheWhatItRead(): void + { + $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(new Collection(id: 'projects')); + $database->createAttribute('projects', Attribute::string(key: 'name')); + $database->createDocument('projects', new Document([ + '$id' => 'project', + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'fresh', + ])); + + $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->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 +138,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 +166,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,7 +187,7 @@ class LeasableMemoryCache implements Adapter, Leasable private const string GENERATION_FIELD = '__utopia_gen__'; /** - * @var array> + * @var array|string}>> */ private array $store = []; @@ -124,7 +206,7 @@ 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 + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if (empty($key) || empty($data)) { return false; @@ -145,7 +227,9 @@ public function save(string $key, array|string $data, string $hash = ''): bool|s 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'; } public function saveWithLease(string $key, array|string $data, string $hash, string $generation): bool|string|array