Skip to content

feat(join): read a join without an alias under its table ID - #1038

Merged
abnegate merged 2 commits into
mainfrom
fix/join-default-alias
Oct 16, 2026
Merged

abnegate merged 2 commits into
mainfrom
fix/join-default-alias

Conversation

@abnegate

@abnegate abnegate commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

What

A join given no alias is now read under the ID of the table it joins. Rows come back keyed "<tableId>.<column>". When that ID can't serve as an alias, the query is refused with a message that names the ID, gives the reason, and asks for an alias:

Table ID Refusal
not an identifier (my-orders, orders.v2: Appwrite IDs may hold - and ., and . clashes with alias.column) Table ID "my-orders" cannot be used as the join alias: an alias must start with a letter or an underscore and contain only letters, digits and underscores. Pass an alias for the join
the reserved main alias (table_main, any case) Table ID "table_main" cannot be used as the join alias: it is reserved for the main collection. Pass an alias for the join
a relationship attribute key Table ID "customers" cannot be used as the join alias: it is the key of the relationship attribute "customers". Pass an alias for the join
already used by another join (joining a table twice without aliases) Table ID "customers" cannot be used as the join alias: another join of the query already uses it as its alias. Pass an alias for the join

An explicit alias keeps today's messages exactly (Join alias must start with…, Join alias "x" is reserved…, …give the join another alias, Join alias "x" is declared more than once). The duplicate check stays in SQL::assertJoinAliases(), where it already lived, so its wording and status are unchanged.

A self-join needs no alias. The main collection is always read under the reserved alias, never under its own ID, so the defaulted alias names only the joined copy. isValidLeftColumn() keeps refusing a left column qualified by that ID. Both the validator and e2e tests pin this.

How

  • utopia-php/query 0.7.2 (feat(query): read a join without an alias under its collection appwrite/appwrite#14594, released as query/0.7.2) defaults an empty alias in its factories and parser and adds Query::isAliasDefaulted(). The flag is derived when the query is built (the alias equals the collection), so it survives toArray()/parse() and a later clone + setAttribute(). No database-specific rule lives there.
  • Utopia\Database\Query::__construct() applies the same default, so joins built directly (new Query(Method::Join, 'orders', [...])) read the same as the factories.
  • Validator\Query\Join::describeInvalidAlias(string $alias, bool $defaulted = false) and the new describeDuplicateAlias() choose the wording from isAliasDefaulted(). The relationship-key refusal in isValidQuery() does the same. SQL::assertJoinAliases() passes the flag for the skip-validation path.
  • Ordering: Database::rewriteQueries() runs the query hooks before validation. Appwrite's Joins hook swaps the public table ID for database_<n>_collection_<seq> via clone + setAttribute(), and the alias and the flag are fixed before that. So rows stay keyed by the public ID, and a refusal names the public ID, never the internal one. testJoinWithoutAliasKeepsTheTableIdAHookRewrites pins this with a rewriting hook (tests/e2e/Adapter/Support/JoinTables.php).
  • Bump side effect: 0.7.2 also refuses NaN/±INF distances when compiling a distance filter, so PostgresDistanceTest now expects that refusal for those three inputs.

Tests: fail before, pass after

"main + 0.7.1" is this PR's tests against main's src/ with utopia-php/query 0.7.1. "main + 0.7.2" swaps in 0.7.2 only, which shows the database side is needed beyond the bump.

E2E (tests/e2e/Adapter/Scopes/JoinAliasTests.php, 9 tests):

Test main + 0.7.1 main + 0.7.2 this PR
testJoinWithoutAliasIsReadUnderItsTableId (rows and jda_customers.name keys; count(); parsed join) error (Join alias is required) pass pass
testLeftJoinWithoutAliasIsReadUnderItsTableId error pass pass
testCrossJoinWithoutAliasIsReadUnderItsTableId error pass pass
testSelfJoinWithoutAliasIsReadUnderItsTableId error pass pass
testJoinWithoutAliasRefusesATableIdThatIsNotAnAlias (- and .) error fail (explicit-alias wording) pass
testJoinWithoutAliasRefusesTheReservedMainAlias error fail pass
testJoinWithoutAliasRefusesARelationshipKey error fail pass
testJoiningATableTwiceWithoutAliasesIsRefused error fail pass
testJoinWithoutAliasKeepsTheTableIdAHookRewrites error fail pass

By adapter, for those 9 tests:

Adapter main + 0.7.1 this PR
MySQL 9 errors OK (9 tests, 28 assertions)
MariaDB 9 errors OK (28 assertions)
Postgres 9 errors OK (28 assertions)
SQLite 9 errors OK (28 assertions)
Pool 9 errors OK (28 assertions)
Mirror 9 errors OK (28 assertions)
MongoDB, Memory, Redis skipped (no Capability::Joins) skipped

Unit:

Test main + 0.7.1 this PR
Validator\Query\JoinAliasTest (defaulted accepts, 8 refused table IDs, relationship key, self-join left/right columns, wording helpers) 5 errors (providers throw Join alias is required; missing methods) OK (30 tests)
JoinAliasTest::testAJoinWithoutAnAliasIsReadUnderItsCollection (factory, constructor, parsed; with and without validation) error pass
JoinAliasTest::testJoiningACollectionTwiceWithoutAliasesNamesTheCollection (validator and skip-validation paths) error pass
JoinAliasTest::testCollidingAliasIsRejected* (2 new defaulted cases) errors (provider throws) pass
QuerySerializationTest join-without-alias (5 parse layouts, round trips, built and renamed joins) 6 errors, 1 failure pass

Full local runs with this branch: unit OK (13095 tests). E2E for MySQL, Postgres, MariaDB, SQLite, MongoDB, Memory, Pool and Redis pass in full. Pint and both PHPStan configs are clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Joins without an explicit alias can now be read under the joined collection’s name.
    • When a collection name cannot be used safely as an alias—for example, if it conflicts with another alias or a relationship key—the query is rejected with a more specific error. Explicit aliases remain supported.

abnegate and others added 2 commits October 16, 2026 23:55
0.7.2 reads a join given no alias under the collection it joins and exposes
isAliasDefaulted(), which the join validator needs to explain why a table
ID cannot serve as an alias. It also refuses a NaN or infinite distance when
compiling a distance filter, so the Postgres builder test now expects that
refusal instead of a compiled exact check for those three inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A join given no alias is now read under the ID of the table it joins, so
rows come back keyed "<tableId>.<column>". Query's constructor applies the
same default as the query library's factories and parser, so joins built
directly read the same way. Self-joins need no alias: the main collection is
always read under the reserved alias, never under its own ID.

When the table ID can't be an alias, the query is refused with the reason,
the ID, and a request for an alias. That covers an ID that isn't an
identifier (Appwrite IDs may hold '-' and '.', and '.' would clash with
alias.column), the reserved main alias, a relationship key, and an ID another
join already uses (joining a table twice). An explicit alias keeps today's
messages. The defaulted state comes from Query::isAliasDefaulted(), which is
set when the query is built and kept through clone + setAttribute(). Query
hooks rewrite the joined table (Appwrite swaps its public ID for the internal
collection) before validation runs, so the alias and the error wording both
stay on the ID the caller gave, never the internal name. An e2e test with a
rewriting hook pins this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: utopia-php/database/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a65d3811-ced3-4ede-b619-ffc88885ebf9
📥 Commits

Reviewing files that changed from the base of the PR and between 5221d4f and 2e6e085.

⛔ Files ignored due to path filters: 1
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing: 11
  • composer.json
  • src/Database/Adapter/SQL.php
  • src/Database/Query.php
  • src/Database/Validator/Query/Join.php
  • tests/e2e/Adapter/Base.php
  • tests/e2e/Adapter/Scopes/JoinAliasTests.php
  • tests/e2e/Adapter/Support/JoinTables.php
  • tests/unit/Builder/PostgresDistanceTest.php
  • tests/unit/JoinAliasTest.php
  • tests/unit/QuerySerializationTest.php
  • tests/unit/Validator/Query/JoinAliasTest.php

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

📝 Walkthrough

Join queries without explicit aliases now use the joined collection attribute as the alias. Validation and adapter tests cover alias conflicts and joined-field access. PostgreSQL distance tests add rejection checks for non-finite values.

Changes: Join Alias Defaults

Layer / File(s) Summary
Default aliases and serialization
composer.json, src/Database/Query.php, tests/unit/QuerySerializationTest.php
Join queries with an empty alias now use their query attribute. Serialization tests cover parsed and constructed joins, round trips, and renamed collections.
Alias validation and SQL diagnostics
src/Database/Validator/Query/Join.php, src/Database/Adapter/SQL.php, tests/unit/Validator/Query/JoinAliasTest.php, tests/unit/JoinAliasTest.php
Validation distinguishes defaulted aliases from explicit aliases when reporting invalid, reserved, duplicate, and relationship-key conflicts. Unit tests cover alias acceptance and collision behavior.
Adapter join behavior
tests/e2e/Adapter/Base.php, tests/e2e/Adapter/Scopes/JoinAliasTests.php, tests/e2e/Adapter/Support/JoinTables.php
Adapter tests cover joined-field access under default aliases across join types, alias refusals, and hook-rewritten joins.

PostgreSQL Distance Validation Tests

Layer / File(s) Summary
Non-finite distance test coverage
tests/unit/Builder/PostgresDistanceTest.php
Tests assert that meter-based distance query compilation rejects NAN and positive or negative infinity with a validation exception.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Query
  participant JoinValidator
  participant SQL
  Query->>JoinValidator: pass alias and defaulted status
  JoinValidator->>SQL: provide alias validation descriptions
  SQL->>JoinValidator: request duplicate-alias description
Loading
Merge Risk: ⚪ Minimal · up to 2e6e0

Joins without an alias now read under their table ID, with clear refusal messages for unusable IDs. The change is well covered by tests, and no merge-blocking risk is evident.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 45 functions across 10 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the primary change: joins without explicit aliases are read under their table ID.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 45 functions across 10 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@abnegate
abnegate merged commit 634d934 into main Oct 16, 2026
24 checks passed
@abnegate
abnegate deleted the fix/join-default-alias branch October 16, 2026 11:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant