Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions sql-insight/src/resolver/binder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion sql-insight/src/resolver/binder/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
55 changes: 55 additions & 0 deletions sql-insight/tests/column_operation_extractor/joins_sets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions sql-insight/tests/table_operation_extractor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down