From 944a0e4f8b7f0d5d543593d58f7d0a551d3c70c0 Mon Sep 17 00:00:00 2001 From: vitaliyolos Date: Tue, 10 Nov 2026 01:34:36 +0200 Subject: [PATCH 1/2] feat: filter by a related path through relations of any kind `Filter::related()` takes a dotted relation path, such as `tags.category`, and nests one related filter 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 still give them conditions or a visibility of their own. The path is expanded when the filter is built, so the compiler and the comparison of load settings see the same value as the hand-written nesting. Unlike a path in a criteria key, which stays BelongsTo-only, the path goes through to-many relations: all conditions of the call match one row of the last target. Empty segments and SQL fragments are rejected as before. Tests cover paths through BelongsTo, BelongsToMany and three relations, equal to the nested calls in value and SQL, the visibility of the last relation, and a path in the nested-locale test on the real databases. --- CHANGELOG.md | 9 ++++ composer.json | 2 +- docs/.vitepress/config.js | 4 +- docs/guide/api.md | 6 ++- docs/guide/details.md | 2 + docs/package-lock.json | 4 +- docs/package.json | 2 +- src/Filter.php | 19 +++++-- tests/Core/RelatedFilterTest.php | 63 ++++++++++++++++++++++-- tests/Database/RelationsDatabaseTest.php | 2 +- 10 files changed, 97 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d70ef47..7ea1a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 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). + ## 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..937d176 100644 --- a/docs/guide/api.md +++ b/docs/guide/api.md @@ -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. diff --git a/docs/guide/details.md b/docs/guide/details.md index c4953c8..87160dd 100644 --- a/docs/guide/details.md +++ b/docs/guide/details.md @@ -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/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/tests/Core/RelatedFilterTest.php b/tests/Core/RelatedFilterTest.php index 1eaa30e..b4d5c06 100644 --- a/tests/Core/RelatedFilterTest.php +++ b/tests/Core/RelatedFilterTest.php @@ -357,18 +357,75 @@ public static function writeCriteria(): iterable yield 'relation path' => [['category.code' => 'chairs']]; } - 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/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)); From ef89f0e5cafa995ca50936532848659d846eb82e Mon Sep 17 00:00:00 2001 From: vitaliyolos Date: Tue, 10 Nov 2026 05:17:39 +0200 Subject: [PATCH 2/2] fix: reject criteria paths with a numeric segment or in a write as paths A criteria path whose first segment is a number, such as `1.code`, threw a TypeError: PHP keeps the segment as an integer key of the array that groups the conditions of one target. It is an unknown relation, as any other name no relation has, and is rejected with InvalidArgumentException. A write whose criteria name a path through an unknown relation said the relation is unknown, where a path through a known one said that paths are for reads. The check for a read now comes before the relation is looked up, so both say the latter. findOrFail() is described by what it does: it answers as not found an ID that the type of the key rejects, such as "007" for an integer key, not every ID no row can have. What the column holds is known to the server only, so an ID that only the server rejects stays its error: on PostgreSQL, a number beyond the column's range, or a string that is not a UUID for a uuid column. The API reference names the version it describes, 1.5, where it still said 1.2. Tests cover paths that start and end with a number, and writes with a path or a related filter on an unknown relation. --- CHANGELOG.md | 7 +++++++ docs/guide/api.md | 6 +++--- docs/guide/details.md | 2 +- docs/guide/index.md | 3 ++- src/Internal/PredicateCompiler.php | 20 ++++++++++++++------ src/Internal/RowMapper.php | 7 ++++--- src/ReadQuery.php | 6 ++++-- src/RecordNotFoundException.php | 7 ++++--- tests/Core/RelatedFilterTest.php | 4 ++++ tests/Core/RowsOnlyMappingTest.php | 2 +- 10 files changed, 44 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea1a55..c884a65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ 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/docs/guide/api.md b/docs/guide/api.md index 937d176..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. @@ -556,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 87160dd..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. 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/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 b4d5c06..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,6 +357,8 @@ 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 testEmptySegmentsAndSqlFragmentsAreNotRelationPaths(): void 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]),