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..e9a10ba 100644 --- a/sql-insight/src/resolver/binder/query.rs +++ b/sql-insight/src/resolver/binder/query.rs @@ -752,8 +752,28 @@ 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 — 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 + && 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..5bfa112 100644 --- a/sql-insight/tests/column_operation_extractor/joins_sets.rs +++ b/sql-insight/tests/column_operation_extractor/joins_sets.rs @@ -1097,6 +1097,61 @@ 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_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 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