Skip to content

Commit b13811d

Browse files
committed
fix: keep set operations after a parenthesised FROM query at the outer query
Adopt the Polyglot parser fix that stops a parenthesised query in FROM from absorbing a following UNION, INTERSECT or EXCEPT. Such models no longer raise false column-count errors, their star columns trace to every set-operation branch, and the formatter no longer nests the set operation inside the FROM query. Add end-to-end compile coverage for DuckDB, Snowflake, BigQuery and PostgreSQL, plus a DuckDB build case.
1 parent 80a682c commit b13811d

5 files changed

Lines changed: 177 additions & 20 deletions

File tree

‎Cargo.lock‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ junction = { version = "2.1.0", default-features = false }
6464
mimalloc = { version = "0.1", default-features = false, features = ["local_dynamic_tls"] }
6565
proc-macro2 = { version = "1", features = ["span-locations"] }
6666
pyo3 = { version = "0.26", features = ["abi3-py312"] }
67-
polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "ca9e7b6e405fb6f5484472bb08780268789c5e04", default-features = false, features = [
67+
polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "b18dc647e797e480d2faadf3af407077f8e063df", default-features = false, features = [
6868
"ast-tools",
6969
"builder",
7070
"all-dialects",
7171
"semantic",
7272
"stacker",
7373
] }
74-
polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "ca9e7b6e405fb6f5484472bb08780268789c5e04", default-features = false, features = [
74+
polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "b18dc647e797e480d2faadf3af407077f8e063df", default-features = false, features = [
7575
"dialect-duckdb",
7676
] }
7777
blake3 = { version = "1", features = ["rayon"] }

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,17 @@ class SetOperationLifecycleTestCase:
538538
expected_indented_set_operation_lines: tuple[str, ...]
539539

540540

541+
@dataclass(frozen=True)
542+
class FromQuerySetOperationTestCase:
543+
description: str
544+
adapter: str
545+
models: tuple[SetOperationModel, ...]
546+
expected_column_counts: tuple[tuple[str, int], ...]
547+
lineage_target: str
548+
expected_lineage: tuple[tuple[str, str, str], ...]
549+
expected_rule_summary: str
550+
551+
541552
@dataclass(frozen=True)
542553
class SetOperationArityMismatchTestCase:
543554
description: str

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py‎

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2879,15 +2879,21 @@ class SetOperationLifecycleResult(NamedTuple):
28792879

28802880

28812881
def write_set_operation_project(
2882-
*, project_dir: Path, models: tuple[SetOperationModel, ...]
2882+
*, project_dir: Path, models: tuple[SetOperationModel, ...], adapter: str = "duckdb"
28832883
) -> Path:
2884-
"""Write a DuckDB project with one view per model and return its database path."""
2884+
"""Write a project with one view per model and return its DuckDB database path.
2885+
2886+
Other adapters compile offline against a default database and never connect.
2887+
"""
28852888

28862889
database: Path = project_dir / "orders.duckdb"
2890+
target: str = {
2891+
True: f'[connection]\ndatabase = "{database}"\n',
2892+
False: '[defaults]\ndatabase = "example"\n',
2893+
}[adapter == "duckdb"]
28872894
files: dict[str, str] = {
28882895
"sqlbuild_project.toml": (
2889-
f'name = "orders"\nadapter = "duckdb"\n[connection]\ndatabase = "{database}"\n'
2890-
'[rules]\nselect = ["SQBRSQL020"]\n'
2896+
f'name = "orders"\nadapter = "{adapter}"\n{target}[rules]\nselect = ["SQBRSQL020"]\n'
28912897
),
28922898
}
28932899
for model in models:
@@ -2938,25 +2944,56 @@ def run_set_operation_lifecycle(
29382944
f"SELECT * FROM analytics.{model.name}"
29392945
)
29402946
relation_shapes[model.name] = (len(relation.columns), len(relation.fetchall()))
2941-
rules: subprocess.CompletedProcess[str] = run_sqb(
2942-
project_dir=project_dir, command=("rules", "run", "SQBRSQL020")
2947+
rules: subprocess.CompletedProcess[str] = run_set_arity_rule(project_dir=project_dir)
2948+
formatted: subprocess.CompletedProcess[str]
2949+
indented_set_operation_lines: tuple[str, ...]
2950+
formatted, indented_set_operation_lines = format_set_operation_models(project_dir=project_dir)
2951+
return SetOperationLifecycleResult(
2952+
compiled=compiled,
2953+
build=build,
2954+
relation_shapes=relation_shapes,
2955+
rules=rules,
2956+
format=formatted,
2957+
indented_set_operation_lines=indented_set_operation_lines,
2958+
recompiled=compile_set_operation_project(project_dir=project_dir),
29432959
)
2960+
2961+
2962+
def run_set_arity_rule(*, project_dir: Path) -> subprocess.CompletedProcess[str]:
2963+
"""Run the set-operation arity Rule over every model."""
2964+
2965+
return run_sqb(project_dir=project_dir, command=("rules", "run", "SQBRSQL020"))
2966+
2967+
2968+
def format_set_operation_models(
2969+
*, project_dir: Path
2970+
) -> tuple[subprocess.CompletedProcess[str], tuple[str, ...]]:
2971+
"""Format every model and return the result and any set-operation lines left indented."""
2972+
29442973
models_dir: Path = project_dir / "models"
29452974
formatted: subprocess.CompletedProcess[str] = run_sqb(
29462975
project_dir=project_dir, command=("format", str(models_dir))
29472976
)
29482977
indented_set_operation_lines: list[str] = []
29492978
for path in sorted(models_dir.glob("*.sql")):
29502979
indented_set_operation_lines.extend(_indented_set_operation_lines(path))
2951-
return SetOperationLifecycleResult(
2952-
compiled=compiled,
2953-
build=build,
2954-
relation_shapes=relation_shapes,
2955-
rules=rules,
2956-
format=formatted,
2957-
indented_set_operation_lines=tuple(indented_set_operation_lines),
2958-
recompiled=compile_set_operation_project(project_dir=project_dir),
2980+
return formatted, tuple(indented_set_operation_lines)
2981+
2982+
2983+
def column_lineage_edges(
2984+
*, project_dir: Path, target: str
2985+
) -> tuple[subprocess.CompletedProcess[str], tuple[tuple[str, str, str], ...]]:
2986+
"""Return the lineage result and sorted `(model, column, transform)` upstream edges."""
2987+
2988+
result: subprocess.CompletedProcess[str] = run_sqb(
2989+
project_dir=project_dir, command=("lineage", target, "--json")
29592990
)
2991+
payload: dict[str, Any] = json.loads(result.stdout or '{"trace": []}')
2992+
edges: list[tuple[str, str, str]] = [
2993+
(edge["source"]["resource_name"], edge["source"]["column_name"], edge["transform"])
2994+
for edge in payload["trace"]
2995+
]
2996+
return result, tuple(sorted(edges))
29602997

29612998

29622999
class UnionFixtureCompileMeasurement(NamedTuple):

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/test_set_operation_after_subquery_operand.py‎

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,24 @@
22

33
from __future__ import annotations
44

5+
import subprocess
56
from pathlib import Path
67

78
import pytest
89

910
from tests.e2e.src.sqlbuild.cli.commands.main.compile._test_types import (
11+
FromQuerySetOperationTestCase,
1012
SetOperationArityMismatchTestCase,
1113
SetOperationLifecycleTestCase,
1214
SetOperationModel,
1315
)
1416
from tests.e2e.src.sqlbuild.cli.commands.main.compile.helpers import (
1517
SetOperationCompileResult,
1618
SetOperationLifecycleResult,
19+
column_lineage_edges,
1720
compile_set_operation_project,
21+
format_set_operation_models,
22+
run_set_arity_rule,
1823
run_set_operation_lifecycle,
1924
write_set_operation_project,
2025
)
@@ -25,6 +30,43 @@
2530
"), limits AS (SELECT 10 AS max_amount)\n"
2631
)
2732
_ALL_ORDERS: str = "SELECT o.order_id, o.amount FROM orders AS o"
33+
_ORDERS_QUERY: str = 'SELECT o.order_id, o.amount, o.status FROM __ref("orders") AS o'
34+
_RETURNS_QUERY: str = 'SELECT r.order_id, r.amount FROM __ref("returns") AS r'
35+
_FROM_QUERY_MODELS: tuple[SetOperationModel, ...] = (
36+
SetOperationModel(
37+
description="three-column orders",
38+
name="orders",
39+
query_sql="SELECT 1 AS order_id, 20 AS amount, 'open' AS status",
40+
),
41+
SetOperationModel(
42+
description="two-column returns",
43+
name="returns",
44+
query_sql="SELECT 2 AS order_id, 5 AS amount",
45+
),
46+
SetOperationModel(
47+
description="narrowed FROM query before UNION ALL",
48+
name="union_all_orders",
49+
query_sql=f"SELECT order_id, amount FROM ({_ORDERS_QUERY})\nUNION ALL\n{_RETURNS_QUERY}",
50+
),
51+
SetOperationModel(
52+
description="narrowed FROM query before EXCEPT",
53+
name="except_orders",
54+
query_sql=f"SELECT order_id, amount FROM ({_ORDERS_QUERY})\nEXCEPT\n{_RETURNS_QUERY}",
55+
),
56+
SetOperationModel(
57+
description="narrowed FROM query before INTERSECT",
58+
name="intersect_orders",
59+
query_sql=f"SELECT order_id, amount FROM ({_ORDERS_QUERY})\nINTERSECT\n{_RETURNS_QUERY}",
60+
),
61+
SetOperationModel(
62+
description="star over a FROM query before UNION ALL",
63+
name="starred_orders",
64+
query_sql=(
65+
'SELECT * FROM (SELECT o.order_id, o.amount FROM __ref("orders") AS o)\n'
66+
f"UNION ALL\n{_RETURNS_QUERY}"
67+
),
68+
),
69+
)
2870

2971

3072
@pytest.mark.parametrize(
@@ -128,6 +170,16 @@
128170
),
129171
expected_row_count=4,
130172
),
173+
SetOperationModel(
174+
description="narrowed FROM query before UNION ALL",
175+
name="from_query_union_all",
176+
query_sql=(
177+
f"{_INPUTS}SELECT order_id, amount\n"
178+
"FROM (SELECT o.order_id, o.amount, 'open' AS status FROM orders AS o)\n"
179+
f"UNION ALL\n{_ALL_ORDERS}"
180+
),
181+
expected_row_count=4,
182+
),
131183
SetOperationModel(
132184
description="parenthesized branches around a scalar subquery",
133185
name="parenthesized_branches_union_all",
@@ -141,7 +193,7 @@
141193
),
142194
expected_column_count=2,
143195
expected_diagnostics=(),
144-
expected_rule_summary="10 models evaluated, 0 findings",
196+
expected_rule_summary="11 models evaluated, 0 findings",
145197
expected_indented_set_operation_lines=(),
146198
)
147199
],
@@ -175,6 +227,63 @@ def test_given_set_operation_after_subquery_operand_when_compiling_building_and_
175227
assert result.recompiled.column_counts == expected_column_counts
176228

177229

230+
@pytest.mark.parametrize(
231+
"test_case",
232+
[
233+
FromQuerySetOperationTestCase(
234+
description=f"{adapter} FROM query before a set operation",
235+
adapter=adapter,
236+
models=_FROM_QUERY_MODELS,
237+
expected_column_counts=(
238+
("union_all_orders", 2),
239+
("except_orders", 2),
240+
("intersect_orders", 2),
241+
),
242+
lineage_target="starred_orders.order_id",
243+
expected_lineage=(("orders", "order_id", "direct"), ("returns", "order_id", "direct")),
244+
expected_rule_summary="6 models evaluated, 0 findings",
245+
)
246+
for adapter in ("duckdb", "snowflake", "bigquery", "postgres")
247+
],
248+
ids=lambda case: case.description,
249+
)
250+
def test_given_set_operation_after_parenthesized_from_query_when_compiling_then_outer_queries_combine(
251+
test_case: FromQuerySetOperationTestCase,
252+
tmp_path: Path,
253+
) -> None:
254+
project_dir: Path = tmp_path / "orders_project"
255+
write_set_operation_project(
256+
project_dir=project_dir, models=test_case.models, adapter=test_case.adapter
257+
)
258+
259+
compiled: SetOperationCompileResult = compile_set_operation_project(project_dir=project_dir)
260+
lineage: subprocess.CompletedProcess[str]
261+
lineage_edges: tuple[tuple[str, str, str], ...]
262+
lineage, lineage_edges = column_lineage_edges(
263+
project_dir=project_dir, target=test_case.lineage_target
264+
)
265+
rules: subprocess.CompletedProcess[str] = run_set_arity_rule(project_dir=project_dir)
266+
formatted: subprocess.CompletedProcess[str]
267+
indented_set_operation_lines: tuple[str, ...]
268+
formatted, indented_set_operation_lines = format_set_operation_models(project_dir=project_dir)
269+
recompiled: SetOperationCompileResult = compile_set_operation_project(project_dir=project_dir)
270+
271+
expected_column_counts: dict[str, int] = dict(test_case.expected_column_counts)
272+
assert compiled.exit_code == 0, compiled.output
273+
assert compiled.diagnostics == ()
274+
assert {
275+
name: compiled.column_counts[name] for name in expected_column_counts
276+
} == expected_column_counts
277+
assert lineage.returncode == 0, lineage.stdout + lineage.stderr
278+
assert lineage_edges == test_case.expected_lineage
279+
assert rules.returncode == 0, rules.stdout + rules.stderr
280+
assert test_case.expected_rule_summary in rules.stdout + rules.stderr
281+
assert formatted.returncode == 0, formatted.stdout + formatted.stderr
282+
assert indented_set_operation_lines == ()
283+
assert recompiled.exit_code == 0, recompiled.output
284+
assert recompiled.diagnostics == ()
285+
286+
178287
@pytest.mark.parametrize(
179288
"test_case",
180289
[

0 commit comments

Comments
 (0)