From 27f95fe0fb223a072ba9ff9ee94dcae53d0db7ec Mon Sep 17 00:00:00 2001 From: Takahiro Ebato Date: Sun, 26 Jul 2026 15:57:10 +0900 Subject: [PATCH 1/2] fix: continue an ARRAY JOIN's operand list across FROM commas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClickHouse's comma after an ARRAY JOIN continues its operand list — a table can't follow — but sqlparser parses each further operand as an independent FROM item, so ARRAY JOIN arr1 AS a, arr2 AS b surfaced arr2 as a phantom table read (the single-operand special case landed with the sqlparser 0.62 bump; the comma form stayed behind). bind_from now tracks whether the previous item's join chain ended in an ARRAY JOIN and binds a following bare-table item as an operand (a column read of the left relation with its alias exposed); a join-carrying or non-table item resets to the ordinary cross join. The alias outputs trace to the operands at function granularity — the zip pairing stays a per-function refinement, like UNNEST's. Co-Authored-By: Claude Fable 5 --- sql-insight/src/resolver/binder.rs | 9 ++++++ sql-insight/src/resolver/binder/query.rs | 20 ++++++++++++- .../column_operation_extractor/joins_sets.rs | 28 +++++++++++++++++++ .../tests/table_operation_extractor.rs | 27 ++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/sql-insight/src/resolver/binder.rs b/sql-insight/src/resolver/binder.rs index 6e0f4a6..34ecbdc 100644 --- a/sql-insight/src/resolver/binder.rs +++ b/sql-insight/src/resolver/binder.rs @@ -859,6 +859,15 @@ fn is_apply(op: &JoinOperator) -> bool { matches!(op, JoinOperator::CrossApply | JoinOperator::OuterApply) } +/// Whether a `FROM` item's join chain ends in an `ARRAY JOIN` — a following +/// comma item then continues the operand list, not the cross join (see +/// [`Binder::bind_from`]). +fn ends_with_array_join(twj: &TableWithJoins) -> bool { + twj.joins + .last() + .is_some_and(|j| is_array_join(&j.join_operator)) +} + /// Whether a join operator is a ClickHouse `ARRAY JOIN` / `LEFT ARRAY JOIN` / /// `INNER ARRAY JOIN` — which unnests an array expression inline rather than /// joining a relation, so its operand is a column expression, not a scanned diff --git a/sql-insight/src/resolver/binder/query.rs b/sql-insight/src/resolver/binder/query.rs index 8057b75..8feb504 100644 --- a/sql-insight/src/resolver/binder/query.rs +++ b/sql-insight/src/resolver/binder/query.rs @@ -752,8 +752,26 @@ impl<'a> Binder<'a> { }; let (mut node, mut scope) = self.bind_table_with_joins(first, &[]); // Comma-separated FROM items are a cross join; a later item sees the - // earlier ones only if it is LATERAL. + // earlier ones only if it is LATERAL. Exception: after an item whose + // join chain ends in an `ARRAY JOIN`, a comma continues the ARRAY + // JOIN's *operand list* (ClickHouse grammar — a table can't follow; + // the construct itself pins the dialect family), but sqlparser + // parses each further operand as an independent FROM item, which + // surfaced `arr2` in `ARRAY JOIN arr1 AS a, arr2 AS b` as a phantom + // table read. A join-carrying or non-table item can't be an operand + // and binds as usual. + let mut in_array_join = ends_with_array_join(first); for twj in iter { + if in_array_join + && twj.joins.is_empty() + && matches!(twj.relation, TableFactor::Table { .. }) + { + let (right, right_scope) = self.bind_array_join(&twj.relation, &scope.relations); + scope.absorb(right_scope); + node = join(node, right, Vec::new()); + continue; + } + in_array_join = ends_with_array_join(twj); let (right, right_scope) = self.bind_table_with_joins(twj, &scope.relations); scope.absorb(right_scope); node = join(node, right, Vec::new()); diff --git a/sql-insight/tests/column_operation_extractor/joins_sets.rs b/sql-insight/tests/column_operation_extractor/joins_sets.rs index da68fbd..ff9bb2e 100644 --- a/sql-insight/tests/column_operation_extractor/joins_sets.rs +++ b/sql-insight/tests/column_operation_extractor/joins_sets.rs @@ -1097,6 +1097,34 @@ mod join_arm_coverage { ); } + #[test] + fn array_join_comma_continues_the_operand_list() { + // ClickHouse's comma after an ARRAY JOIN continues its *operand + // list* (a table can't follow), but sqlparser parses each further + // operand as an independent FROM item — `arr2` surfaced as a + // phantom table read. Every operand now reads as a column of the + // left relation; the alias outputs trace to the operands at + // function granularity (the zip pairing is a per-function + // refinement, like UNNEST's). + assert_unordered_eq!( + join_reads( + "SELECT a, b FROM t ARRAY JOIN arr1 AS a, arr2 AS b", + &GenericDialect {} + ), + vec![read("t", "arr1"), read("t", "arr2")] + ); + // Three operands chain; an ordinary comma cross join elsewhere is + // untouched (`u` stays a table read — pinned at the table level in + // `table_operation_extractor`). + assert_unordered_eq!( + join_reads( + "SELECT a, b, c FROM t ARRAY JOIN x AS a, y AS b, z AS c", + &GenericDialect {} + ), + vec![read("t", "x"), read("t", "y"), read("t", "z")] + ); + } + #[test] fn array_join_expression_operand_reads_its_arguments() { // `ARRAY JOIN f(args)` is an array-producing *expression*: its argument diff --git a/sql-insight/tests/table_operation_extractor.rs b/sql-insight/tests/table_operation_extractor.rs index 16681ab..b3d9554 100644 --- a/sql-insight/tests/table_operation_extractor.rs +++ b/sql-insight/tests/table_operation_extractor.rs @@ -245,6 +245,33 @@ mod select { ); } + #[test] + fn array_join_operands_are_not_table_reads() { + // The comma continues the ARRAY JOIN operand list (ClickHouse + // grammar) — `arr2` used to surface as a phantom table read; an + // ordinary comma join stays a cross join of table reads. + assert_ops( + "SELECT a, b FROM t ARRAY JOIN arr1 AS a, arr2 AS b", + TableOperation { + statement_kind: StatementKind::Select, + reads: vec![read("t")], + writes: vec![], + lineage: vec![], + diagnostics: vec![], + }, + ); + assert_ops( + "SELECT 1 FROM t, u", + TableOperation { + statement_kind: StatementKind::Select, + reads: vec![read("t"), read("u")], + writes: vec![], + lineage: vec![], + diagnostics: vec![], + }, + ); + } + #[test] fn bare_values_emits_nothing() { // `VALUES (1, 2)` parses as a query whose body is a VALUES From 48c1d910237dc333f16c7fdc8dddb4f3cda6e566 Mon Sep 17 00:00:00 2001 From: Takahiro Ebato Date: Sun, 26 Jul 2026 16:05:13 +0900 Subject: [PATCH 2/2] test: pin the expression-operand continuation and the conservative stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement review of the operand-list continuation: a continued operand can be an array-producing expression (its argument columns read), and a join-carrying item deliberately stops the continuation — in the doubtful ARRAY JOIN a, b JOIN u shape, b stays a table rather than risk demoting a real table to a column. Multi-table bases resolve operands open-world (Ambiguous, catalog-narrowable) and derived items reset the state; probed healthy. Co-Authored-By: Claude Fable 5 --- sql-insight/src/resolver/binder/query.rs | 4 ++- .../column_operation_extractor/joins_sets.rs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/sql-insight/src/resolver/binder/query.rs b/sql-insight/src/resolver/binder/query.rs index 8feb504..e9a10ba 100644 --- a/sql-insight/src/resolver/binder/query.rs +++ b/sql-insight/src/resolver/binder/query.rs @@ -759,7 +759,9 @@ impl<'a> Binder<'a> { // parses each further operand as an independent FROM item, which // surfaced `arr2` in `ARRAY JOIN arr1 AS a, arr2 AS b` as a phantom // table read. A join-carrying or non-table item can't be an operand - // and binds as usual. + // and binds as usual — deliberately conservative: in the doubtful + // `ARRAY JOIN a, b JOIN u ON …` shape, `b` stays a table rather + // than risk demoting a real table to a column. let mut in_array_join = ends_with_array_join(first); for twj in iter { if in_array_join diff --git a/sql-insight/tests/column_operation_extractor/joins_sets.rs b/sql-insight/tests/column_operation_extractor/joins_sets.rs index ff9bb2e..5bfa112 100644 --- a/sql-insight/tests/column_operation_extractor/joins_sets.rs +++ b/sql-insight/tests/column_operation_extractor/joins_sets.rs @@ -1125,6 +1125,33 @@ mod join_arm_coverage { ); } + #[test] + fn array_join_comma_operand_can_be_an_expression() { + // A continued operand can be an array-producing expression too — + // its argument columns read, the function name stays phantom-free. + assert_unordered_eq!( + join_reads( + "SELECT a, m FROM t ARRAY JOIN arr AS a, arrayConcat(x, y) AS m", + &GenericDialect {} + ), + vec![read("t", "arr"), read("t", "x"), read("t", "y")] + ); + } + + #[test] + fn array_join_continuation_stops_at_a_join_carrying_item() { + // Deliberately conservative: in the doubtful `ARRAY JOIN a, b JOIN u` + // shape, `b` binds as a table (the old behavior) rather than risk + // demoting a real table to a column. + assert_unordered_eq!( + join_reads( + "SELECT 1 FROM t ARRAY JOIN a, b JOIN u ON u.id = 1", + &GenericDialect {} + ), + vec![read("t", "a"), read("u", "id")] + ); + } + #[test] fn array_join_expression_operand_reads_its_arguments() { // `ARRAY JOIN f(args)` is an array-producing *expression*: its argument