diff --git a/CHANGELOG.md b/CHANGELOG.md index d70ef47..c884a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.5.0 — 2026-11-10 + +### Features + +- `Filter::related()` takes a dotted path through relations of any kind, such as + `tags.category`, and nests one related filter per relation. As in a `Load` path, + the criteria and visibility belong to the last relation. + See [Criteria and Filter](https://solophp.github.io/Repository/guide/api#criteria-and-filter). + +### Fixes + +- A criteria path whose first segment is a number, such as `1.code`, is rejected with + `InvalidArgumentException`, as any other unknown relation, instead of a `TypeError`. +- A write whose criteria name a path through an unknown relation says that paths are + for reads, as it does for a known relation. + ## 1.4.0 — 2026-11-09 ### Features diff --git a/composer.json b/composer.json index 081d2a5..949ef7a 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "prefer-stable": true, "extra": { "branch-alias": { - "dev-main": "1.4.x-dev" + "dev-main": "1.5.x-dev" } }, "config": { diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 825a94f..6f7cf98 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -22,12 +22,12 @@ export default defineConfig({ { text: 'Guide', link: '/guide/' }, { text: 'API', link: '/guide/api' }, { text: 'Details', link: '/guide/details' }, - { text: 'v1.4.0', link: 'https://github.com/SoloPHP/Repository/releases' }, + { text: 'v1.5.0', link: 'https://github.com/SoloPHP/Repository/releases' }, ], sidebar: [ { - text: 'Solo Repository · 1.4', + text: 'Solo Repository · 1.5', items: [ { text: 'Getting started', link: '/guide/' }, { text: 'API reference', link: '/guide/api' }, diff --git a/docs/guide/api.md b/docs/guide/api.md index e0636ea..3f52110 100644 --- a/docs/guide/api.md +++ b/docs/guide/api.md @@ -1,6 +1,6 @@ # Public API reference -This reference describes Solo Repository 1.2: PHP 8.3+, Doctrine DBAL `^4.3`, and the `Solo\Repository` namespace. Relation definitions live in `Solo\Repository\Relations`. See the [design and integration guide](details.md) and `examples/definitions.php` in the source checkout. +This reference describes Solo Repository 1.5: PHP 8.3+, Doctrine DBAL `^4.3`, and the `Solo\Repository` namespace. Relation definitions live in `Solo\Repository\Relations`. See the [design and integration guide](details.md) and `examples/definitions.php` in the source checkout. `Repository`, `Mapping` and `ReadQuery` use PHPDoc generics, where `T` is an object. `T` is covariant, so `Repository` is accepted where `Repository` is expected. Relation definitions are generic over their owner and target models, so static analysis checks every `assign` callback against the target mapping. The runtime checks that hydration and relation assignment return objects; it does not enforce their concrete class. `Mapping`, `ReadQuery`, `TransactionManager`, the configuration objects and the write helpers are `final readonly`; `Repository` is `readonly` and may be extended into a [typed repository](#typed-repositories). Readonly configuration does not make the shared DBAL connection, returned models or objects captured by callbacks immutable. @@ -183,7 +183,7 @@ ReadQuery::queryBuilder(string $alias, array|Filter $criteria = []): QueryBuilde In `findBy()`, `findOneBy()` and `rows()`, an `orderBy` key may also be a dotted path through `BelongsTo` relations to a readable field of their target, such as `category.name` or `category.parent.name`. Each target on a path is LEFT JOINed once per statement on its primary key, over the rows that a load of the same relation path in the read sees: its active rows, unless the read loads that relation with another visibility. A list that shows soft-deleted categories through `with([new Load('category', visibility: Visibility::All)])` thus also orders by their names; only the load's visibility applies, not its criteria. A row whose reference is NULL, dangling or not visible orders as one with NULL there; where NULL sorts follows the database (first in ascending order on MySQL, MariaDB and SQLite, last on PostgreSQL). A translated target field is read in the query locale and fallback, which the target inherits as relation loads do. Paths through `HasOne`, `HasMany` or `BelongsToMany`, which could match several rows, are rejected before SQL. The default order of a to-many relation and the order of a `Load` take the same paths, resolved from the target. - `find()` and `findOneBy()` return NULL when no visible row matches. They limit the root SELECT to one row. -- `findOrFail()` is `find()` for an ID from outside, such as a route parameter. It throws `RecordNotFoundException`, with the mapping's `table` and the `id` as given, when no visible row has the ID, and also, without SQL, for an ID no row can have, which `find()` rejects as invalid. A mapping without a primary key or hydrator, or a locking read outside a transaction, still throws `LogicException`, whatever the ID. +- `findOrFail()` is `find()` for an ID from outside, such as a route parameter. It throws `RecordNotFoundException`, with the mapping's `table` and the `id` as given, when no visible row has the ID, and also, without SQL, for an ID the type of the key rejects, which `find()` rejects as invalid. An ID that only the server rejects stays the driver's exception: on PostgreSQL, a number beyond the column's range, or a string that is not a UUID for a `uuid` column; where a route can carry one, constrain its parameter. A mapping without a primary key or hydrator, or a locking read outside a transaction, still throws `LogicException`, whatever the ID. - `findBy()` materializes the requested models and their includes. No automatic pagination count is performed; [`page()`](#pages) reads a page with its total. - `rows()` returns only the selected, DBAL-converted fields and never calls the hydrator. It rejects nonempty includes; use `with([])` on that branch. - `count()` and `exists()` do not hydrate models or resolve/load includes. Relation predicates in their criteria still apply. `exists()` selects a constant with a one-row limit. @@ -280,7 +280,7 @@ A `Filter` may also be the value of an array entry, under a string key that only ['status' => 'active', 'q' => Filter::any(['sku LIKE' => '%chair%'], ['name LIKE' => '%chair%'])] ``` -`related()` means EXISTS and accepts a single relation name. Conditions inside one call must match the same related row. Separate calls can match different rows. Use nested calls for deeper relationships and `not(related(...))` for NOT EXISTS: +`related()` means EXISTS. Conditions inside one call must match the same related row. Separate calls can match different rows. Use `not(related(...))` for NOT EXISTS and a dotted path or nested calls for deeper relationships: ```php use Solo\Repository\Filter; @@ -288,10 +288,12 @@ use Solo\Repository\Filter; $criteria = Filter::all( Filter::related('tags', ['slug' => 'sale', 'enabled' => true]), Filter::not(Filter::related('tags', ['slug' => 'blocked'])), - Filter::related('category', Filter::related('parent', ['id' => 10])), + Filter::related('category.parent', ['id' => 10]), ); ``` +A path goes through relations of any kind and nests one call per relation: `related('tags.category', $criteria, $visibility)` is `related('tags', related('category', $criteria, $visibility))`. As in a `Load` path, the criteria and visibility belong to the last relation, and the relations before it see their active rows; nested calls give them conditions or a visibility of their own. + Related predicates filter owners without duplicating their rows. They do not restrict what `with()` loads. Raw SQL, implicit scopes and collection sorting are not accepted. Case sensitivity, collation and NULL ordering follow the database; LIKE escaping does not (see [LIKE patterns](#like-patterns)). In read criteria, a key may name a field of a `BelongsTo` target by the dotted path an order takes, such as `category.code` or `category.parent.name LIKE`. It reads as `Filter::related()` on that target, so it sees active targets only, unlike an order path, which follows the visibility of the relation's load; conditions of one array on the same target share one EXISTS. Inside `Filter::related()`, a path starts at its target. Paths through `HasOne`, `HasMany` or `BelongsToMany`, such as `tags.slug`, are rejected before SQL: whether their conditions must match one related row is for `Filter::related()` to say. Writes reject paths. @@ -554,7 +556,7 @@ Everything the caller adds is plain DBAL: joined tables get no visibility or loc | Failure | Expected category | |---|---| | Invalid metadata, unknown fields, unsupported filters/options, criteria paths through to-many relations or in writes, a LIKE pattern ending in an unpaired `\`, invalid IDs, page requests or pages, or parameter budget | `InvalidArgumentException` | -| `findOrFail()` of an ID no visible row has, or no row can have | `RecordNotFoundException` (a `RuntimeException`) | +| `findOrFail()` of an ID no visible row has, or the type of the key rejects | `RecordNotFoundException` (a `RuntimeException`) | | Missing translation metadata, restore without soft deletion, unsupported generated-ID/upsert platform, locking read outside a transaction, aggregate, page or query builder of a locking read | `LogicException` | | Non-object hydration/assignment result, invalid relation factory result, multiple HasOne matches, invalid generated-ID result | `UnexpectedValueException` | | SQL, FK, UNIQUE or DBAL type-conversion failure | Original DBAL/driver exception | diff --git a/docs/guide/details.md b/docs/guide/details.md index c4953c8..e97cdf1 100644 --- a/docs/guide/details.md +++ b/docs/guide/details.md @@ -178,7 +178,7 @@ queryBuilder(string $alias, array|Filter $criteria = []): QueryBuilder; Every `with*` returns a new object. Terminal methods do not reset the settings: a query can be repeated and branched independently. `with()` replaces the whole list of loads, and `with([])` clears it. A DBAL builder is never kept as shared mutable state. -`find` and `findOneBy` limit the SQL to one row. `findOrFail` is `find` for an ID from outside, such as a route parameter: it throws `RecordNotFoundException` when no visible row has the ID, and also, without SQL, for an ID no row can have (`"007"` for an integer key), which `find` rejects as invalid. A mapping that cannot address rows by ID and a locking read outside a transaction still throw `LogicException`. `exists` selects a constant with LIMIT 1. `count` counts root rows; `with()` loads do not affect these two operations. None of these methods runs an extra COUNT for pagination. +`find` and `findOneBy` limit the SQL to one row. `findOrFail` is `find` for an ID from outside, such as a route parameter: it throws `RecordNotFoundException` when no visible row has the ID, and also, without SQL, for an ID the type of the key rejects (`"007"` for an integer key), which `find` rejects as invalid. The column's range and UUID syntax stay the database's: an ID only the server rejects, as PostgreSQL does a number beyond the column's range, is its error. A mapping that cannot address rows by ID and a locking read outside a transaction still throw `LogicException`. `exists` selects a constant with LIMIT 1. `count` counts root rows; `with()` loads do not affect these two operations. None of these methods runs an extra COUNT for pagination. `sum`, `min`, `max` and `avg` aggregate one readable field (a translated one too, in the query's locale) over the same rows `count` counts: the same criteria, relation predicates and visibility. The total of a filtered list matches its filter. `sum`, `min` and `max` convert the result by the field's DBAL type: the sum of a decimal is an exact string, `max('created_at')` is a `DateTimeImmutable`. Without matching rows `sum` returns the zero of the field's type and the others return NULL. `sum` and `avg` take only smallint, integer, bigint, decimal, float and smallfloat fields; other fields are rejected before SQL. Aggregates over several tables are built with DBAL. @@ -223,6 +223,8 @@ Filter::related( `related('tags', ['color' => 'red', 'size' => 'large'])` means **one** related row with both properties and compiles to EXISTS. Two separate `related('tags', ...)` inside `all` allow two different rows. `not(related(...))` is NOT EXISTS. OR inside related stays OR. Root rows are not multiplied by matching relations. +A relation path nests one `related` per relation: `related('tags.category', ['code' => 'chairs'])` is `related('tags', related('category', ['code' => 'chairs']))`. As in a `Load` path, the criteria and visibility belong to the last relation, and the relations before it see their active rows. Unlike a path in a criteria key, it goes through relations of any kind, since all conditions of the call match one row of the last target. + A key may name a field of a `BelongsTo` target by the path an order takes: `['category.code' => 'chairs']` is `related('category', ['code' => 'chairs'])`, over the target's active rows, and conditions of one array on the same target share one EXISTS, since an owner has one such row. Paths through to-many relations such as `tags.slug` are rejected: whether their conditions must match one related row is for `related` to say. SQL fragments, hidden scope keys and unknown fields are not accepted. Each EXISTS has its own alias and parameters. Ordering by a collection is not supported: its aggregate and JOIN are written with DBAL. An arbitrary root filter with a large IN is not split into several queries: that would change the semantics of OR, ordering and limits. Exceeding the parameter budget is an explicit error; another way to pass a large set is written with DBAL. Automatic splitting applies only to the loader's internal batches and to bulk inserts. diff --git a/docs/guide/index.md b/docs/guide/index.md index 9b5c14a..521a525 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -51,7 +51,8 @@ $product = $products->read()->find($id); // Read explicitly when you need a mode and `findOneBy`; updates do not throw solely because no record matched. For an ID from outside, such as a route parameter, `findOrFail()` throws -`RecordNotFoundException` instead, also for an ID no row can have, such as `"007"`. +`RecordNotFoundException` instead, also for an ID the type of the key rejects, such as +`"007"` for an integer key. ## Read, filter, and paginate diff --git a/docs/package-lock.json b/docs/package-lock.json index e8b27fc..2fc7681 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,12 +1,12 @@ { "name": "solo-repository-docs", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solo-repository-docs", - "version": "1.4.0", + "version": "1.5.0", "devDependencies": { "vitepress": "2.0.0-alpha.20" }, diff --git a/docs/package.json b/docs/package.json index 217adde..0f631cb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "solo-repository-docs", - "version": "1.4.0", + "version": "1.5.0", "private": true, "type": "module", "description": "Documentation for Solo Repository", diff --git a/src/Filter.php b/src/Filter.php index 957a5e2..10e2525 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -55,16 +55,27 @@ public static function not(array|self $part): self return new self(FilterOperator::Not, [$part]); } - /** @param array|self $criteria */ + /** + * A dotted path nests one related filter per relation; the criteria and visibility + * apply to the last one, and the relations before it see their active rows. + * + * @param array|self $criteria + */ public static function related( string $relation, array|self $criteria = [], Visibility $visibility = Visibility::Active, ): self { - if (!Identifier::isValid($relation)) { - throw new InvalidArgumentException('A related filter requires a plain relation name.'); + if (!Identifier::isPath($relation)) { + throw new InvalidArgumentException('A related filter requires a relation name or a dotted relation path.'); + } + + $path = explode('.', $relation); + $filter = new self(FilterOperator::Related, [$criteria], array_pop($path), $visibility); + foreach (array_reverse($path) as $owner) { + $filter = new self(FilterOperator::Related, [$filter], $owner); } - return new self(FilterOperator::Related, [$criteria], $relation, $visibility); + return $filter; } } diff --git a/src/Internal/PredicateCompiler.php b/src/Internal/PredicateCompiler.php index a5d6b64..1de87d5 100644 --- a/src/Internal/PredicateCompiler.php +++ b/src/Internal/PredicateCompiler.php @@ -103,6 +103,9 @@ private function normalize(array|Filter $criteria, Mapping $mapping): Condition| // One EXISTS per target: an owner has a single row of each BelongsTo target foreach ($targets as $relation => $conditions) { + // PHP keeps a numeric segment such as "1" as an integer key + $relation = (string) $relation; + $this->assertReadQuery(); $mapping->requireBelongsTo($relation, 'A criteria path'); $parts[] = $this->normalizeExists(Filter::related($relation, $conditions), $mapping); } @@ -113,12 +116,7 @@ private function normalize(array|Filter $criteria, Mapping $mapping): Condition| /** @param Mapping $owner */ private function normalizeExists(Filter $filter, Mapping $owner): Exists|bool { - if ($this->fields === null) { - throw new InvalidArgumentException( - 'Related filters and relation paths are supported only for read queries.', - ); - } - + $this->assertReadQuery(); $relation = $owner->relation($filter->relation); $target = $relation->resolve($owner); SoftDelete::assertSupported($target, $filter->visibility); @@ -131,6 +129,16 @@ private function normalizeExists(Filter $filter, Mapping $owner): Exists|bool return new Exists($relation, $owner, $target, $criteria === true ? null : $criteria, $filter->visibility); } + /** Write criteria name base columns only, so a relation is rejected before it is looked up. */ + private function assertReadQuery(): void + { + if ($this->fields === null) { + throw new InvalidArgumentException( + 'Related filters and relation paths are supported only for read queries.', + ); + } + } + /** @param list $parts */ private static function fold(FilterOperator $operator, array $parts): Condition|Group|Exists|bool { diff --git a/src/Internal/RowMapper.php b/src/Internal/RowMapper.php index 03cdc7d..e87d84a 100644 --- a/src/Internal/RowMapper.php +++ b/src/Internal/RowMapper.php @@ -125,9 +125,10 @@ public function ids(array $ids): array } /** - * The ID as the primary key holds it. InvalidArgumentException means no row can have the ID, - * which ReadQuery::findOrFail() answers as not found; a mapping that cannot address rows by ID - * at all throws LogicException. + * The ID as the primary key holds it. InvalidArgumentException means the type of the key rejects + * the ID, which ReadQuery::findOrFail() answers as not found; a mapping that cannot address rows + * by ID at all throws LogicException. What the column holds is known to the server only: an ID + * it rejects, such as a number beyond the column's range, stays its error. */ public function id(int|string $id): int|string { diff --git a/src/ReadQuery.php b/src/ReadQuery.php index 729f0d5..d416769 100644 --- a/src/ReadQuery.php +++ b/src/ReadQuery.php @@ -103,8 +103,10 @@ public function find(int|string $id): ?object } /** - * find() for an ID from outside, such as a route parameter: an ID no row can have ("007" - * for an integer key) is not found either, where find() rejects it as invalid. + * find() for an ID from outside, such as a route parameter: an ID the type of the key rejects + * ("007" for an integer key) is not found either, where find() rejects it as invalid. An ID + * only the server rejects, such as a number beyond the column's range or, on PostgreSQL, a + * string that is not a UUID for a uuid column, stays the server's error. * * @return T * @throws RecordNotFoundException When the read sees no row with the ID. diff --git a/src/RecordNotFoundException.php b/src/RecordNotFoundException.php index 955a53a..2cb95b0 100644 --- a/src/RecordNotFoundException.php +++ b/src/RecordNotFoundException.php @@ -7,9 +7,10 @@ use RuntimeException; /** - * No row the read can see has the ID asked for, or no row can have it, such as "007" for an - * integer key. Thrown by ReadQuery::findOrFail(); an application answers it as "not found", - * a 404 over HTTP. The ID came from outside, so the message leaves it out; it is kept in $id. + * No row the read can see has the ID asked for, or the type of the key rejects the ID, such as + * "007" for an integer key. Thrown by ReadQuery::findOrFail(); an application answers it as + * "not found", a 404 over HTTP. The ID came from outside, so the message leaves it out; it is + * kept in $id. */ final class RecordNotFoundException extends RuntimeException { diff --git a/tests/Core/RelatedFilterTest.php b/tests/Core/RelatedFilterTest.php index 1eaa30e..4956dfe 100644 --- a/tests/Core/RelatedFilterTest.php +++ b/tests/Core/RelatedFilterTest.php @@ -326,6 +326,8 @@ public static function invalidCriteria(): iterable yield 'path to an unknown target field' => [['category.title' => 'x']]; yield 'path with an empty segment' => [['category..code' => 'x']]; yield 'path ending in a dot' => [['category.' => 'x']]; + yield 'path starting with a number' => [['1.code' => 'x']]; + yield 'path ending in a number' => [['category.1' => 'x']]; yield 'unknown field in a Filter value' => [['q' => Filter::any(['sku' => 'sku-1'], ['title' => 'x'])]]; } @@ -355,20 +357,79 @@ public static function writeCriteria(): iterable yield 'under false' => [Filter::all(['id IN' => []], Filter::related('tags'))]; yield 'empty related set' => [Filter::related('tags', ['id IN' => []])]; yield 'relation path' => [['category.code' => 'chairs']]; + yield 'path through an unknown relation' => [['maker.name' => 'x']]; + yield 'unknown related filter' => [Filter::related('maker')]; } - public function testDotPathsAndSqlFragmentsAreNotRelationNames(): void + public function testEmptySegmentsAndSqlFragmentsAreNotRelationPaths(): void { - foreach (['tags.category', 'tags WHERE 1=1', '', 'tags; DROP TABLE tags'] as $name) { + $names = ['', 'tags.', '.tags', 'tags..category', 'tags WHERE 1=1', 'tags.category; DROP TABLE tags']; + foreach ($names as $name) { try { Filter::related($name); - self::fail('A related filter must use a plain relation name.'); + self::fail('A related filter must use a relation name or a dotted relation path.'); } catch (InvalidArgumentException) { self::assertSame([], $this->log->sql); } } } + /** @return iterable, Filter, list}> */ + public static function relationPaths(): iterable + { + yield 'through BelongsTo' => [ + 'category.parent', + ['code' => 'furniture'], + Filter::related('category', Filter::related('parent', ['code' => 'furniture'])), + [2, 6], + ]; + yield 'through BelongsToMany' => [ + 'tags.category', + ['code' => 'chairs'], + Filter::related('tags', Filter::related('category', ['code' => 'chairs'])), + [1, 2], + ]; + yield 'three relations' => [ + 'tags.category.parent', + ['code' => 'furniture'], + Filter::related('tags', Filter::related('category', Filter::related('parent', ['code' => 'furniture']))), + [1, 2], + ]; + } + + /** + * @param array $criteria + * @param list $ids + */ + #[DataProvider('relationPaths')] + public function testARelationPathIsTheNestedRelatedFilters( + string $path, + array $criteria, + Filter $nested, + array $ids, + ): void { + $related = Filter::related($path, $criteria); + self::assertEquals($nested, $related); + self::assertSame($ids, $this->ids($this->products->read(), $related)); + self::assertSame($ids, $this->ids($this->products->read(), $nested)); + self::assertSame($this->log->sql[1], $this->log->sql[0]); + self::assertSame($this->log->parameters[1], $this->log->parameters[0]); + } + + public function testAPathAppliesItsVisibilityToTheLastRelation(): void + { + // Tag 4, active and linked to product 2, joins the deleted category of the deleted tag 5 + $this->connection->executeStatement('UPDATE tags SET category_id = 3 WHERE id = 4'); + + $all = Filter::related('tags.category', ['code' => 'hidden'], Visibility::All); + $nested = Filter::related('tags', Filter::related('category', ['code' => 'hidden'], Visibility::All)); + self::assertEquals($nested, $all); + $active = Filter::related('tags.category', ['code' => 'hidden']); + self::assertSame([], $this->ids($this->products->read(), $active)); + // Products 3 and 6 reach the category only through the deleted tag 5 + self::assertSame([2], $this->ids($this->products->read(), $all)); + } + /** @return iterable, Filter, list}> */ public static function belongsToPaths(): iterable { diff --git a/tests/Core/RowsOnlyMappingTest.php b/tests/Core/RowsOnlyMappingTest.php index 7f4b4ec..3699ce8 100644 --- a/tests/Core/RowsOnlyMappingTest.php +++ b/tests/Core/RowsOnlyMappingTest.php @@ -176,7 +176,7 @@ public static function entityReads(): iterable 'products', 'Reading entities', ]; - yield 'find or fail by an ID no row can have' => [ + yield 'find or fail by an ID the type of the key rejects' => [ static fn (Repository $rows, Repository $products, Connection $connection): mixed => (new Repository( $connection, new Mapping('products', ['id' => Types::INTEGER, 'code' => Types::STRING]), diff --git a/tests/Database/RelationsDatabaseTest.php b/tests/Database/RelationsDatabaseTest.php index 2f90132..b4a0fb5 100644 --- a/tests/Database/RelationsDatabaseTest.php +++ b/tests/Database/RelationsDatabaseTest.php @@ -88,7 +88,7 @@ public function testCatalogUsesFourSelectsAndLimitsOwnersBeforeLoadingCollection public function testNestedLoadsAndNestedExistsShareInheritedLocaleAndFallback(): void { $query = $this->items->withLocale('uk', 'en')->with(['category.parent']); - $criteria = Filter::related('category', Filter::related('parent', ['name' => 'Parent EN'])); + $criteria = Filter::related('category.parent', ['name' => 'Parent EN']); $items = $query->findBy($criteria, ['id' => 'ASC']); self::assertSame([1, 2], self::ids($items));