Skip to content

Offer the join operators SQLite offers - #1569

Merged
trueqbit merged 5 commits into
devfrom
feature/join-operators
Oct 11, 2026
Merged

trueqbit merged 5 commits into
devfrom
feature/join-operators

Conversation

@trueqbit

@trueqbit trueqbit commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1568: based on its branch, so this diff shows only the changes on top of it.

Summary

SQLite's grammar combines join operators and constraints freely: [NATURAL] [LEFT [OUTER] | RIGHT [OUTER] | FULL [OUTER] | INNER | CROSS] JOIN, followed by ON expr, USING (columns), or nothing (and nothing for NATURAL). sqlite_orm offered six fixed combinations. #1568 made every join carry a constraint, explicit or implicit, and this PR builds the rest on that.

One generic join node

The six join structs become aliases of one node, join_clause<Keyword, T, Constraint>, as the binary operators are aliases of binary_operator since #1552:

template<class T, class O>
using left_join_t = join_clause<left_join_string, T, O>;

template<class T>
using cross_join_t = join_clause<cross_join_string, T, implicit_join_constraint>;

is_any_join_v now recognizes the node instead of listing six templates. The node names, the factories and the serialization stay as they are. The static tests that name internal::left_join_t<…> keep working through the aliases.

The new join operators

Factory SQL Constraint
join<T>(), inner_join<T>(), left_join<T>(), left_outer_join<T>() as named implicit; new overloads next to the constrained ones
natural_inner_join<T>(), natural_left_join<T>(), natural_left_outer_join<T>() NATURAL INNER JOIN, … implicit, by construction
right_join, right_outer_join, full_join, full_outer_join RIGHT [OUTER] JOIN, FULL [OUTER] JOIN ON/USING, or implicit; SQLite 3.39+
natural_right_join, natural_right_outer_join, natural_full_join, natural_full_outer_join NATURAL RIGHT [OUTER] JOIN, … implicit; SQLite 3.39+
  • NATURAL is spelled with dedicated factories, matching the existing natural_join and left_outer_join. Since they take no constraint, NATURAL can't get an ON or USING.
  • Every factory has a C++20 overload taking an alias, like the existing ones.
  • RIGHT and FULL are guarded by SQLITE_VERSION_NUMBER >= 3039000.
  • TODO.md drops RIGHT/FULL JOIN and the combination entry from Treat every join as constrained, explicitly or implicitly #1568.

CROSS JOIN … ON (which SQLite also accepts) is left out, as it's an inner join in all but name.

Test plan

The first commit adds the tests on their own; the generic node and the new operators follow.

  • New tests/statement_serializer_tests/ast/join_operators.cpp: every new operator serializes, with the constraint left out or spelled out, including an aliased NATURAL LEFT JOIN.
  • tests/join_tests.cpp: executed against two tables that share the column id, the joins yield the rows SQLite defines. That's JOIN and LEFT JOIN without a constraint, NATURAL INNER/LEFT/LEFT OUTER, and on SQLite 3.39+ RIGHT JOIN, NATURAL RIGHT JOIN, FULL OUTER JOIN USING and NATURAL FULL JOIN.
  • tests/static_tests/node_tuple.cpp: every new operator is a join node, with an implicit constraint where none is spelled out, and left_join<User>() is left_join_t<User, implicit_join_constraint>.
  • CI covers the SQLite versions on both sides of 3.39: 3.31.1, 3.34.1 and 3.35.5 without RIGHT/FULL, 3.51.0 with them.

🤖 Generated with Claude Code

SQLite combines its join operators freely with an explicit constraint, an
implicit one, and NATURAL:

- JOIN, INNER JOIN, LEFT [OUTER] JOIN, and as of SQLite 3.39 RIGHT [OUTER]
  JOIN and FULL [OUTER] JOIN serialize with their constraint left out.
- NATURAL combines with INNER, LEFT [OUTER], and as of SQLite 3.39 RIGHT
  [OUTER] and FULL [OUTER] JOIN.
- Executed, they yield the rows SQLite defines for them.
- Every one is a join node, with an implicit constraint where none is
  spelled out.

These tests fail until sqlite_orm offers these join operators.

Co-Authored-By: Claude Opus 5.5
The six join nodes were six near-identical structs, told apart by their
keyword alone since every join carries a constraint. They are now aliases
of one node, join_clause<Keyword, T, Constraint>, like the binary operators
are of binary_operator:

    template<class T, class O>
    using left_join_t = join_clause<left_join_string, T, O>;

    template<class T>
    using cross_join_t = join_clause<cross_join_string, T, implicit_join_constraint>;

is_any_join_v recognizes the node instead of listing the six. The node
names, the factories and the serialization stay as they are.

Co-Authored-By: Claude Opus 5.5
SQLite combines its join operators freely with ON, USING or no constraint,
and with NATURAL. Each combination is a keyword and an alias of the join
node, with its factory:

- join<T>(), inner_join<T>(), left_join<T>() and left_outer_join<T>()
  without a constraint, next to their constrained overloads.
- natural_inner_join<T>(), natural_left_join<T>() and
  natural_left_outer_join<T>(), which take no constraint by construction.
- As of SQLite 3.39: right_join, right_outer_join, full_join and
  full_outer_join, with or without a constraint, and natural_right_join,
  natural_right_outer_join, natural_full_join and natural_full_outer_join.

Every factory has a C++20 overload taking an alias, like the existing
ones. TODO.md loses RIGHT/FULL JOIN and the combinations.

Co-Authored-By: Claude Opus 5.5
Base automatically changed from refactor/join-constraints to dev October 7, 2026 13:41
@trueqbit
trueqbit marked this pull request as ready for review October 7, 2026 17:07
@trueqbit
trueqbit requested a review from fnc12 October 7, 2026 17:07
@trueqbit
trueqbit merged commit 50fe6ae into dev Oct 11, 2026
20 checks passed
@trueqbit
trueqbit deleted the feature/join-operators branch October 11, 2026 10:06
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.

2 participants