Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"prefer-stable": true,
"extra": {
"branch-alias": {
"dev-main": "1.4.x-dev"
"dev-main": "1.5.x-dev"
}
},
"config": {
Expand Down
4 changes: 2 additions & 2 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
12 changes: 7 additions & 5 deletions docs/guide/api.md
Original file line number Diff line number Diff line change
@@ -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<T>`, `Mapping<T>` and `ReadQuery<T>` use PHPDoc generics, where `T` is an object. `T` is covariant, so `Repository<Product>` is accepted where `Repository<object>` 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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -280,18 +280,20 @@ 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;

$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.
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion docs/guide/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 15 additions & 4 deletions src/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,27 @@ public static function not(array|self $part): self
return new self(FilterOperator::Not, [$part]);
}

/** @param array<string, mixed>|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<string, mixed>|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;
}
}
20 changes: 14 additions & 6 deletions src/Internal/PredicateCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -113,12 +116,7 @@ private function normalize(array|Filter $criteria, Mapping $mapping): Condition|
/** @param Mapping<object> $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);
Expand All @@ -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<Condition|Group|Exists|bool> $parts */
private static function fold(FilterOperator $operator, array $parts): Condition|Group|Exists|bool
{
Expand Down
7 changes: 4 additions & 3 deletions src/Internal/RowMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 4 additions & 2 deletions src/ReadQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions src/RecordNotFoundException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading
Loading