Skip to content

Add support for FINAL when joining tables - #261

Open
cbisnett wants to merge 3 commits into
PNixx:masterfrom
huntresslabs:claude/upstream-joins-final
Open

Add support for FINAL when joining tables#261
cbisnett wants to merge 3 commits into
PNixx:masterfrom
huntresslabs:claude/upstream-joins-final

Conversation

@cbisnett

@cbisnett cbisnett commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

ClickHouse's final modifier only wraps the query's primary FROM source, so .final.joins(:assoc) emits a plain INNER JOIN <table> against the un-merged joined table (wrong row mapping, duplicates). This adds two query methods that render FINAL on the joined table:

AppControlEvent.final.joins_final(:binary)
# SELECT ... FROM app_control_events FINAL
#   INNER JOIN binaries FINAL ON ...

AppControlEvent.left_joins_final(:binary)
# SELECT ... FROM app_control_events
#   LEFT OUTER JOIN binaries FINAL ON ...

joins_final adds the association like joins (INNER JOIN); left_joins_final adds it like left_outer_joins (LEFT OUTER JOIN) for optional joins whose rows must be preserved when there is no match — while still merging the joined table with FINAL.

Implementation

  • Arel::Nodes::FinalTable — a new Unary marker node wrapping a join's table source. Because every Arel join visitor (InnerJoin, OuterJoin, …) emits its table via visit o.left, a single visit_Arel_Nodes_FinalTable covers all join types.
  • Relation#joins_final / #left_joins_final (+ ! variants) add the join(s), record the requested association names in joins_final_values, and — in build_arel, before the FROM-level FINAL is applied — wrap matching join sources in FinalTable.
  • Joins are matched by table name (handles Arel::Table and TableAlias). If the same physical table is joined more than once, every join of that table receives FINAL (documented on the methods).
  • Class-level delegation (Model.joins_final), chaining, dedup with an existing .joins, and unscope(:joins_final) are all supported.
  • Raises ActiveRecord::ActiveRecordError on non-ClickHouse connections, consistent with the existing final/settings clauses.

Changes

  • lib/arel/nodes/final_table.rb — new marker node
  • lib/arel/visitors/clickhouse.rbvisit_Arel_Nodes_FinalTable
  • lib/active_record/connection_adapters/clickhouse_adapter.rb — require the node, delegate the new methods
  • lib/core_extensions/active_record/relation.rb — the query methods, join-marking, and unscope support
  • README.md, CHANGELOG.md — docs
  • spec/single/model_spec.rb — specs covering generated SQL, FINAL combination with .final, dedup, unscope, INNER-vs-LEFT, mixed same-association use, and real execution against ClickHouse (FINAL requires a supporting engine, so the execution test self-joins the sample table)

claude added 3 commits July 9, 2026 13:58
ClickHouse's `final` modifier only wrapped the query's primary FROM
source, so `.final.joins(:assoc)` emitted a plain `INNER JOIN <table>`
against the un-merged table. Code had to work around this with
hand-written `INNER JOIN (SELECT * FROM <table> FINAL) alias ON ...`.

Add `joins_final(*associations)`, which adds the join(s) like `joins`
and renders `INNER JOIN <table> FINAL ON ...` via the ClickHouse Arel
visitor. Implemented with a small `Arel::Nodes::FinalTable` marker that
wraps the join's table source; because every join visitor emits its
table via `visit o.left`, one visitor method covers all join types.
Joins are matched by table name and finalized before the FROM-level
FINAL node is applied. Supports chaining, dedup with existing joins,
and `unscope(:joins_final)`.

Add gem specs covering generated SQL and real execution against
ClickHouse (FINAL requires an engine that supports it, e.g.
ReplacingMergeTree).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeQx7AkCPiyk5yqj7m3koX
joins_final only emits INNER JOIN ... FINAL. left_joins_final is the
LEFT OUTER counterpart: it adds the association via left_outer_joins and
renders FINAL on the joined table, for optional/catalog joins whose rows
must be preserved when there is no match while still merging the joined
table with FINAL. It shares joins_final_values, so the existing
final-join marking and unscope(:joins_final) handling apply unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uhz69nyLNfuciaTh8UhSa
Addresses review feedback: cover the edge case where the same
association is added via both joins_final and left_joins_final. The
shared joins_final_values store dedups via |= and ActiveRecord collapses
an association present in both joins and left_outer_joins into a single
INNER JOIN, so FINAL is applied exactly once. Behavior verified
consistent across ActiveRecord 7.2.3, 8.0.1, and 8.1.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014uhz69nyLNfuciaTh8UhSa
@cbisnett cbisnett changed the title Claude/upstream joins final Add support for FINAL when joining tables Jul 9, 2026
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