Skip to content

Commit f8567e5

Browse files
abnegateclaude
andcommitted
fix(joins): return joined internal attributes for alias.* and joined orders
select(['alias.*']) stood for the join's implicit projection (the joined $id and attributes), so it never returned the joined $sequence, $createdAt, $updatedAt or $permissions a direct read of the joined collection returns. On a left, right or full outer join it also lost alias.$id: the Database layer treated alias.* as not selecting the joined $id, selected it only to tell unmatched rows apart and stripped it from the result, so the next page threw "Cursor has no value for order attribute 'alias.$id'". A read ordered by a joined internal attribute (alias.$sequence, alias.$createdAt) without a named select could not page either: the implicit projection leaves joined internals out, so no returned row carried the order value its cursor needs. select(['*', 'alias.$createdAt']) did not help because any '*' replaced the whole select with the implicit projection, silently dropping the named joined columns. alias.* now returns the joined row as a direct read does: $id, $sequence, $createdAt, $updatedAt and $permissions next to the attributes. $tenant stays out (it is the read's own tenant, and callers strip only the main $tenant before responding); it remains selectable by name. A read without a select or with '*' projects the joined columns its order names and the joined columns named next to '*', on every SQL engine and in both halves of the UNION ALL full outer join emulation, so its rows page in both directions. Only the projection changes: the order, tie keys and the MariaDB bounded join page are untouched, so no read gains a sort. The implicit projection without such an order keeps its documented shape. An unmatched outer row's alias.$permissions is null like its other joined values instead of []. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a99400f commit f8567e5

6 files changed

Lines changed: 472 additions & 28 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,19 +70,22 @@ have to make, with the 7.x and 8.0 forms side by side.
7070
- A join without a select returns the main document plus, under each join alias, the joined collection's `$id`
7171
and attributes as `alias.$id` and `alias.attribute`. The joined collection's internal attributes (`$tenant`,
7272
`$permissions`, `$sequence`, `$createdAt`, `$updatedAt`) and its relationship attributes that hold a column (the
73-
side that stores the related document's id) are returned only when a select names them. Joined values are never
74-
returned under a bare attribute name.
75-
- `select('alias.*')` next to other selects returns the joined `$id` and attributes, as a join without a select
76-
does. An order may name a joined attribute by its bare name when only one join's collection declares it and the
77-
main collection does not; a name several joins declare throws `Exception\Query`.
73+
side that stores the related document's id) are returned only when a select names them, or, for the internal
74+
attributes other than `$tenant`, when the select names `alias.*` or the read orders by them. Joined values are
75+
never returned under a bare attribute name.
76+
- `select('alias.*')`, alone or next to other selects, returns the joined row as a direct read of the joined
77+
collection returns it: its `$id`, `$sequence`, `$createdAt`, `$updatedAt`, `$permissions` and attributes, but not
78+
its `$tenant`. Joined columns named next to `*` (`select(['*', 'alias.$createdAt'])`) are returned with
79+
everything `*` returns. An order may name a joined attribute by its bare name when only one join's collection
80+
declares it and the main collection does not; a name several joins declare throws `Exception\Query`.
7881
- Joined attributes are returned as a direct read of the joined collection returns them: cast to their types and
7982
passed through every decode filter they declare, so encrypted attributes are decrypted, JSON and arrays decoded
8083
and datetimes formatted. This applies to the implicit projection and to `select('alias.attribute')` alike. A
8184
decode filter receives a document built from the joined row: `$id`, `$collection` and the joined attributes the
82-
query returned (`$sequence` and the other internal attributes only when selected). When an outer join matches no
83-
row, its attributes are null, whether or not the select names `alias.$id` (outside `distinct()` reads, which
84-
select only what they name). A cursor taken from a joined result can be passed back with `cursorAfter()` or
85-
`cursorBefore()`: its joined values are encoded with the joined collection's filters.
85+
query returned (`$sequence` and the other internal attributes only when the query returns them). When an outer
86+
join matches no row, its attributes are null, whether or not the select names `alias.$id` (outside `distinct()`
87+
reads, which select only what they name). A cursor taken from a joined result can be passed back with
88+
`cursorAfter()` or `cursorBefore()`: its joined values are encoded with the joined collection's filters.
8689
- A column under a join alias (`alias.column`) must be valid on the joined collection for the query type it is
8790
used in: an attribute the joined collection declares, or an internal attribute the query type accepts on the
8891
main collection (`alias.$permissions` can be selected but not filtered or ordered by, and `alias.$collection` is

‎UPGRADE.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,9 +1345,16 @@ API might expect. [CHANGELOG.md](CHANGELOG.md) describes the features themselves
13451345
- A select of joined attributes returns what it names. The main collection's unselected attributes are left out, as
13461346
for any select; only a select of a related document's attributes (`relationship.attribute`) returns the others as
13471347
well, as in 7.x.
1348-
- A select may name `alias.*` next to other selects: it stands for what the join returns without a select, the
1349-
joined collection's `$id` and attributes as `alias.$id` and `alias.attribute`. An aggregation query still rejects
1350-
it as an ungrouped select, and an alias the query does not join is not found.
1348+
- A join without a select, or with `select(['*'])`, returns under each join alias the joined collection's `$id` and
1349+
attributes as `alias.$id` and `alias.attribute`, plus each joined internal attribute the read orders by
1350+
(`alias.$sequence`, `alias.$createdAt`, `alias.$updatedAt`), so its rows can be passed back as a cursor.
1351+
- A select may name `alias.*`, alone or next to other selects: it returns the joined row as a direct read of the
1352+
joined collection returns it, its `$id`, `$sequence`, `$createdAt`, `$updatedAt`, `$permissions` and attributes as
1353+
`alias.$id`, `alias.$sequence`, ... and `alias.attribute`. It never returns the joined `$tenant`: select
1354+
`alias.$tenant` to read it. A row an outer join left unmatched holds null for each of them. An aggregation query
1355+
still rejects `alias.*` as an ungrouped select, and an alias the query does not join is not found.
1356+
- Joined columns named next to `*` are returned with everything `*` returns: `select(['*', 'alias.$createdAt'])`
1357+
returns the main document, the joined `$id` and attributes, and `alias.$createdAt`.
13511358
- An order may name a joined attribute by its bare name when the main collection does not declare it and exactly one
13521359
join's collection does (`orderAsc('price')` over a join whose collection declares `price`); a name the main
13531360
collection declares always orders by the main collection. A bare name more than one join declares throws
@@ -1397,7 +1404,9 @@ API might expect. [CHANGELOG.md](CHANGELOG.md) describes the features themselves
13971404
- Pass a row the same read returned as the cursor. It has to carry every value the read orders by, under the name the
13981405
read orders by (`note.score`, `$sequence`, `note.$id`); a missing value throws `Utopia\Database\Exception\Order`
13991406
(`Cursor has no value for order attribute 'note.$id'. …`). A value is never taken from the main document's attribute
1400-
of the same name. A read whose `select()` leaves a paged join's `alias.$id` out has to select it to be paged.
1407+
of the same name. A read whose `select()` names attributes without `*` has to select every joined value it orders
1408+
by, a paged join's `alias.$id` included, to be paged; `alias.*` selects them all. A read without a select, or
1409+
with `*`, returns them.
14011410
`cursor()` and `iterate()` check the last row of each full batch before yielding the batch, so such a read throws
14021411
before the first row; a read that fits in one batch is not paged and needs no such value.
14031412
- A value may be null (a row an outer join did not match, a nullable attribute). Nulls keep the engine's position:

‎src/Database/Adapter/SQL.php‎

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ abstract class SQL extends Adapter implements Feature\RawQuery, Feature\QueryBui
8888

8989
private const string FOJ_ROWS_ALIAS = 'foj_rows';
9090

91+
/**
92+
* The internal attributes `alias.*` returns next to the joined `$id`: those a direct read of the joined
93+
* collection returns, but `$tenant`, which is the read's own tenant on every joined row.
94+
*/
95+
private const array JOINED_ROW_INTERNALS = [
96+
Document::SEQUENCE,
97+
Document::CREATED_AT,
98+
Document::UPDATED_AT,
99+
Document::PERMISSIONS,
100+
];
101+
91102
/**
92103
* MariaDB, MySQL and SQLite accept OFFSET only after a LIMIT; this one bounds nothing on any engine.
93104
*/
@@ -1706,6 +1717,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
17061717
$alias,
17071718
$roles,
17081719
$forPermission,
1720+
orderAttributes: $orderAttributes,
17091721
);
17101722
$this->applyFullOuterJoinOrderProjection(
17111723
$left,
@@ -1739,6 +1751,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
17391751
$alias,
17401752
$roles,
17411753
$forPermission,
1754+
orderAttributes: $orderAttributes,
17421755
);
17431756
$this->applyFullOuterJoinOrderProjection(
17441757
$right,
@@ -1784,6 +1797,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25
17841797
$alias,
17851798
$roles,
17861799
$forPermission,
1800+
orderAttributes: $orderAttributes,
17871801
);
17881802

17891803
$vectorDistance = null;
@@ -4075,11 +4089,11 @@ protected function executeUpsertBatch(
40754089
* database column names (like _uid, _id) and ensures internal columns
40764090
* are always included.
40774091
*
4078-
* An `alias.*` selection stands for the columns the join returns without a select, from $joinSelections.
4092+
* An `alias.*` selection stands for the joined columns $joinSelections lists under that alias.
40794093
*
40804094
* @param array<string> $selections
40814095
* @param array<string> $joinAliases
4082-
* @param array<string, list<string>> $joinSelections The selections a read without a select makes under each join alias
4096+
* @param array<string, list<string>> $joinSelections The selections `alias.*` makes under each join alias
40834097
*/
40844098
private function applySelectionProjection(
40854099
SQLBuilder $builder,
@@ -4273,21 +4287,34 @@ protected function mapSelectionsToColumns(array $selections, bool $includeIntern
42734287
}
42744288

42754289
/**
4276-
* The projection of a join without a select: every column of the main table, and under each join
4277-
* alias the joined collection's `$id` and the attributes the Database layer handed over for it.
4278-
* A joined table's internal columns are returned only when a select names them.
4290+
* The projection of a join without a select or with `*`: every column of the main table, and under each
4291+
* join alias the joined collection's `$id` and the attributes the Database layer handed over for it. A
4292+
* joined table's internal columns are returned only when a select names them or when the read orders by
4293+
* them, so that every row it returns can be passed back as its cursor.
42794294
*
42804295
* @param list<array{table: string, alias: string}> $joinTablePrefixes
4296+
* @param array<string> $additions Selections next to `*` and order attributes; those under a join alias are projected too
42814297
*/
4282-
private function applyJoinProjection(SQLBuilder $builder, Document $collection, array $joinTablePrefixes, string $alias): void
4298+
private function applyJoinProjection(SQLBuilder $builder, Document $collection, array $joinTablePrefixes, string $alias, array $additions = []): void
42834299
{
42844300
$builder->select([$this->filter($alias).'.*']);
42854301

4302+
$joinAliases = \array_column($joinTablePrefixes, 'alias');
4303+
$aliasSet = \array_fill_keys($joinAliases, true);
4304+
$selections = \array_merge(...\array_values($this->joinSelections($collection, $joinTablePrefixes)));
4305+
foreach ($additions as $addition) {
4306+
$dot = \strpos($addition, '.');
4307+
if ($dot !== false && isset($aliasSet[\substr($addition, 0, $dot)])) {
4308+
$selections[] = $addition;
4309+
}
4310+
}
4311+
42864312
$this->applySelectionProjection(
42874313
$builder,
4288-
\array_merge(...\array_values($this->joinSelections($collection, $joinTablePrefixes))),
4314+
$selections,
42894315
includeInternal: false,
4290-
joinAliases: \array_column($joinTablePrefixes, 'alias'),
4316+
joinAliases: $joinAliases,
4317+
joinSelections: $this->joinWildcardSelections($collection, $joinTablePrefixes),
42914318
);
42924319
}
42934320

@@ -4317,6 +4344,26 @@ private function joinSelections(Document $collection, array $joinTablePrefixes):
43174344
return $selections;
43184345
}
43194346

4347+
/**
4348+
* What `alias.*` selects under each join alias: what a read without a select returns there, and the joined
4349+
* collection's internal attributes a direct read of it returns.
4350+
*
4351+
* @param list<array{table: string, alias: string}> $joinTablePrefixes
4352+
* @return array<string, list<string>>
4353+
*/
4354+
private function joinWildcardSelections(Document $collection, array $joinTablePrefixes): array
4355+
{
4356+
$selections = $this->joinSelections($collection, $joinTablePrefixes);
4357+
foreach ($selections as $joinAlias => $columns) {
4358+
foreach (self::JOINED_ROW_INTERNALS as $internal) {
4359+
$columns[] = $joinAlias.'.'.$internal;
4360+
}
4361+
$selections[$joinAlias] = $columns;
4362+
}
4363+
4364+
return $selections;
4365+
}
4366+
43204367
/**
43214368
* Map Database type constants to Schema Table column definitions.
43224369
*
@@ -4807,6 +4854,7 @@ private function rewriteFullOuterJoins(array $queries, Method $replacement): arr
48074854
* @param list<array{table: string, alias: string}> $joinTablePrefixes
48084855
* @param array<Query> $adapterFilterQueries
48094856
* @param array<string> $roles
4857+
* @param array<string> $orderAttributes The attributes the read orders by
48104858
*/
48114859
private function configureFindBuilder(
48124860
SQLBuilder $builder,
@@ -4821,6 +4869,7 @@ private function configureFindBuilder(
48214869
array $roles,
48224870
PermissionType $forPermission,
48234871
bool $qualifyCollidingGroups = true,
4872+
array $orderAttributes = [],
48244873
): bool {
48254874
$hasSelectionProjection = false;
48264875
if (! $hasAggregation) {
@@ -4839,14 +4888,20 @@ private function configureFindBuilder(
48394888
$selections,
48404889
includeInternal: ! $hasDistinct,
48414890
joinAliases: \array_column($joinTablePrefixes, 'alias'),
4842-
joinSelections: $this->joinSelections($collection, $joinTablePrefixes),
4891+
joinSelections: $this->joinWildcardSelections($collection, $joinTablePrefixes),
48434892
);
48444893
// The projection replaces the select; forwarded as well, the builder would compile the caller's
48454894
// raw attribute names whenever the projection holds only aliased joined columns.
48464895
$queries = \array_values(\array_filter($queries, static fn (BaseQuery $query): bool => $query->getMethod() !== Method::Select));
48474896
$hasSelectionProjection = true;
48484897
} elseif (! empty($joinTablePrefixes)) {
4849-
$this->applyJoinProjection($builder, $collection, $joinTablePrefixes, $alias);
4898+
$this->applyJoinProjection(
4899+
$builder,
4900+
$collection,
4901+
$joinTablePrefixes,
4902+
$alias,
4903+
$hasDistinct ? $selections : [...$selections, ...$orderAttributes],
4904+
);
48504905
$hasSelectionProjection = true;
48514906
}
48524907
}
@@ -6249,7 +6304,7 @@ private function remapRow(array &$row): void
62496304
}
62506305

62516306
$value = $row[$key];
6252-
if ($bare === Storage::PERMISSIONS || $public === Document::PERMISSIONS) {
6307+
if ($value !== null && ($bare === Storage::PERMISSIONS || $public === Document::PERMISSIONS)) {
62536308
$value = \json_decode(\is_string($value) ? $value : '[]', true);
62546309
}
62556310
if (! \array_key_exists($dotted, $row) || $key === $dotted) {

‎src/Database/Traits/Documents.php‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5145,9 +5145,10 @@ private function joinedCollectionsByAlias(array $joins, ?array $joinedCollection
51455145
}
51465146

51475147
/**
5148-
* The `alias.$id` of each join whose attributes a select names without it, when an outer join
5149-
* can leave a joined row unmatched: the joined `$id` is what tells an unmatched row from a
5150-
* matched one when the row is decoded, so it is selected for that and left out of the result.
5148+
* The `alias.$id` of each join whose attributes a select names without it or `alias.*`, when an
5149+
* outer join can leave a joined row unmatched: the joined `$id` is what tells an unmatched row
5150+
* from a matched one when the row is decoded, so it is selected for that and left out of the
5151+
* result.
51515152
*
51525153
* @param array<Query> $selects
51535154
* @param array<Query> $joins
@@ -5189,7 +5190,7 @@ private function outerJoinIdSelections(array $selects, array $joins, array $join
51895190

51905191
$ids = [];
51915192
foreach ($selectedAliases as $alias => $attributes) {
5192-
if (isset($joinedCollections[$alias]) && ! isset($attributes[Document::ID])) {
5193+
if (isset($joinedCollections[$alias]) && ! isset($attributes[Document::ID]) && ! isset($attributes['*'])) {
51935194
$ids[] = $alias.'.'.Document::ID;
51945195
}
51955196
}

0 commit comments

Comments
 (0)