From fddf2def6952245c57da6a9efb6879a54fda1f97 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:52:58 +0100 Subject: [PATCH 1/3] fix: keep set operations after subquery predicates at the outer query The SQL parser attached a UNION, EXCEPT or INTERSECT that followed a parenthesized subquery operand, such as `WHERE x < (SELECT ...)`, to the subquery instead of the outer SELECT. Compile then raised a false B216 column-count error, and the formatter indented the set operation under the predicate. Bump polyglot-sql to the parser fix and cover compile, build, Rules and format through the CLI on DuckDB, plus formatter layout across dialects. --- Cargo.lock | 6 +- Cargo.toml | 4 +- .../src/sql_lint/tests.rs | 2 + .../tests/test_set_operation_formatter.rs | 59 +++++ .../cli/commands/main/compile/_test_types.py | 26 +++ .../cli/commands/main/compile/helpers.py | 105 ++++++++- ...st_set_operation_after_subquery_operand.py | 216 ++++++++++++++++++ 7 files changed, 412 insertions(+), 6 deletions(-) create mode 100644 crates/sqlbuild-rules-native/src/sql_lint/tests/test_set_operation_formatter.rs create mode 100644 tests/e2e/src/sqlbuild/cli/commands/main/compile/test_set_operation_after_subquery_operand.py diff --git a/Cargo.lock b/Cargo.lock index 365f7dddc0..572b8715aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,7 +329,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "polyglot-sql" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=25ad011c49f2c7cd48f4427abb8fc0a6af2e4120#25ad011c49f2c7cd48f4427abb8fc0a6af2e4120" +source = "git+https://github.com/chio-labs/polyglot.git?rev=7f9c029f53053e802710f740a6a47acae415375c#7f9c029f53053e802710f740a6a47acae415375c" dependencies = [ "polyglot-sql-ast-derive", "polyglot-sql-function-catalogs", @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=25ad011c49f2c7cd48f4427abb8fc0a6af2e4120#25ad011c49f2c7cd48f4427abb8fc0a6af2e4120" +source = "git+https://github.com/chio-labs/polyglot.git?rev=7f9c029f53053e802710f740a6a47acae415375c#7f9c029f53053e802710f740a6a47acae415375c" dependencies = [ "proc-macro2", "quote", @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=25ad011c49f2c7cd48f4427abb8fc0a6af2e4120#25ad011c49f2c7cd48f4427abb8fc0a6af2e4120" +source = "git+https://github.com/chio-labs/polyglot.git?rev=7f9c029f53053e802710f740a6a47acae415375c#7f9c029f53053e802710f740a6a47acae415375c" [[package]] name = "portable-atomic" diff --git a/Cargo.toml b/Cargo.toml index 600870c0f1..5d00dc10f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ globset = "0.4" mimalloc = { version = "0.1", default-features = false, features = ["local_dynamic_tls"] } proc-macro2 = { version = "1", features = ["span-locations"] } pyo3 = { version = "0.26", features = ["abi3-py312"] } -polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "25ad011c49f2c7cd48f4427abb8fc0a6af2e4120", default-features = false, features = [ +polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "7f9c029f53053e802710f740a6a47acae415375c", default-features = false, features = [ "ast-tools", "builder", "dialect-bigquery", @@ -29,7 +29,7 @@ polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "25ad0 "semantic", "stacker", ] } -polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "25ad011c49f2c7cd48f4427abb8fc0a6af2e4120", default-features = false, features = [ +polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "7f9c029f53053e802710f740a6a47acae415375c", default-features = false, features = [ "dialect-duckdb", ] } quote = "1" diff --git a/crates/sqlbuild-rules-native/src/sql_lint/tests.rs b/crates/sqlbuild-rules-native/src/sql_lint/tests.rs index 7b2b923819..c46d727733 100644 --- a/crates/sqlbuild-rules-native/src/sql_lint/tests.rs +++ b/crates/sqlbuild-rules-native/src/sql_lint/tests.rs @@ -16,6 +16,8 @@ mod helpers; mod layout_rules; #[path = "tests/test_line_wrap.rs"] mod line_wrap; +#[path = "tests/test_set_operation_formatter.rs"] +mod set_operation_formatter; #[path = "tests/test_sql_lint.rs"] mod sql_lint; #[path = "tests/test_types.rs"] diff --git a/crates/sqlbuild-rules-native/src/sql_lint/tests/test_set_operation_formatter.rs b/crates/sqlbuild-rules-native/src/sql_lint/tests/test_set_operation_formatter.rs new file mode 100644 index 0000000000..9f8e7fbe85 --- /dev/null +++ b/crates/sqlbuild-rules-native/src/sql_lint/tests/test_set_operation_formatter.rs @@ -0,0 +1,59 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +const DIALECTS: [&str; 6] = [ + "bigquery", + "databricks", + "duckdb", + "postgres", + "snowflake", + "tsql", +]; + +#[test] +fn given_set_operation_after_scalar_subquery_operand_when_formatting_then_keyword_stays_outer() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "UNION ALL after WHERE scalar subquery", + sql: "SELECT o.id, o.total FROM orders AS o WHERE o.total < (SELECT MAX(l.amount) FROM limits AS l) UNION ALL SELECT o.id, o.total FROM orders AS o", + expected_sql: "SELECT\n o.id,\n o.total\nFROM orders AS o\nWHERE\n o.total < (\n SELECT MAX(l.amount)\n FROM limits AS l\n )\nUNION ALL\nSELECT\n o.id,\n o.total\nFROM orders AS o", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "EXCEPT after WHERE scalar subquery", + sql: "SELECT o.id FROM orders AS o WHERE o.total > (SELECT MAX(l.amount) FROM limits AS l) EXCEPT SELECT o.id FROM orders AS o", + expected_sql: "SELECT o.id\nFROM orders AS o\nWHERE\n o.total > (\n SELECT MAX(l.amount)\n FROM limits AS l\n )\nEXCEPT\nSELECT o.id\nFROM orders AS o", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "INTERSECT after WHERE scalar subquery", + sql: "SELECT o.id FROM orders AS o WHERE o.total > (SELECT MAX(l.amount) FROM limits AS l) INTERSECT SELECT o.id FROM orders AS o", + expected_sql: "SELECT o.id\nFROM orders AS o\nWHERE\n o.total > (\n SELECT MAX(l.amount)\n FROM limits AS l\n )\nINTERSECT\nSELECT o.id\nFROM orders AS o", + expected_changed: true, + }, + ]; + + for dialect in DIALECTS { + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": dialect}).to_string(), + )?; + let payload: Value = + serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{dialect}: {}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{dialect}: {}", + test_case.description + ); + } + } + Ok(()) +} diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py index 8d1d5a5a68..b211276671 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py @@ -445,3 +445,29 @@ class RequireSqlAnalysisCase: expected_returncode: int expected_diagnostics: tuple[tuple[str, str | None, int | None], ...] expected_text_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class SetOperationModel: + description: str + name: str + query_sql: str + expected_row_count: int | None = None + + +@dataclass(frozen=True) +class SetOperationLifecycleTestCase: + description: str + models: tuple[SetOperationModel, ...] + expected_column_count: int + expected_diagnostics: tuple[tuple[str, str], ...] + expected_rule_summary: str + expected_indented_set_operation_lines: tuple[str, ...] + + +@dataclass(frozen=True) +class SetOperationArityMismatchTestCase: + description: str + models: tuple[SetOperationModel, ...] + expected_exit_code: int + expected_diagnostics: tuple[tuple[str, str], ...] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py index a178d3dfa3..b6edcd2961 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py @@ -19,6 +19,7 @@ from types import FrameType from typing import Any, NamedTuple, cast +import duckdb import pytest from scripts.cold_compile_performance.main.read_compile_measurement import read_compile_measurement @@ -29,8 +30,9 @@ from tests.e2e.src.sqlbuild.cli.commands.main.compile._test_types import ( FreshProcessCompileCachePerformanceGuardTestCase, SemanticCorpusCase, + SetOperationModel, ) -from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb _DBT_SHAPED_SQL_SIZE_PROFILE: tuple[tuple[float, int], ...] = ( (0.50, 1_800), @@ -2600,4 +2602,105 @@ def require_sql_analysis_output( return ( tuple((item["code"], item.get("path"), item.get("line")) for item in diagnostics), text, + + +_INDENTED_SET_OPERATION_PATTERN: re.Pattern[str] = re.compile( + r"^[ \t]+(?:UNION|EXCEPT|INTERSECT)\b.*$", re.MULTILINE +) + + +class SetOperationCompileResult(NamedTuple): + exit_code: int + diagnostics: tuple[tuple[str, str], ...] + column_counts: dict[str, int] + output: str + + +class SetOperationLifecycleResult(NamedTuple): + compiled: SetOperationCompileResult + build: subprocess.CompletedProcess[str] + relation_shapes: dict[str, tuple[int, int]] + rules: subprocess.CompletedProcess[str] + format: subprocess.CompletedProcess[str] + indented_set_operation_lines: tuple[str, ...] + recompiled: SetOperationCompileResult + + +def write_set_operation_project( + *, project_dir: Path, models: tuple[SetOperationModel, ...] +) -> Path: + """Write a DuckDB project with one view per model and return its database path.""" + + database: Path = project_dir / "orders.duckdb" + files: dict[str, str] = { + "sqlbuild_project.toml": ( + f'name = "orders"\nadapter = "duckdb"\n[connection]\ndatabase = "{database}"\n' + '[rules]\nselect = ["SQBRSQL020"]\n' + ), + } + for model in models: + files[f"models/{model.name}.sql"] = ( + f"MODEL (materialized view, schema analytics);\n\n{model.query_sql}\n" + ) + prepare_inline_project( + tmp_path=project_dir.parent, project_name=project_dir.name, repo_files=files + ) + return database + + +def compile_set_operation_project(*, project_dir: Path) -> SetOperationCompileResult: + """Compile without cache and return diagnostics and per-model output column counts.""" + + result: subprocess.CompletedProcess[str] = run_sqb( + project_dir=project_dir, command=("compile", "--no-cache", "--json") + ) + payload: dict[str, Any] = json.loads(result.stdout) + return SetOperationCompileResult( + exit_code=result.returncode, + diagnostics=tuple((item["code"], item["message"]) for item in payload["diagnostics"]), + column_counts={ + model["name"]: model["column_count"] for model in payload["resources"]["models"] + }, + output=result.stdout + result.stderr, + ) + + +def _indented_set_operation_lines(path: Path) -> list[str]: + lines: list[str] = _INDENTED_SET_OPERATION_PATTERN.findall(path.read_text(encoding="utf-8")) + return [f"{path.name}: {line}" for line in lines] + + +def run_set_operation_lifecycle( + *, project_dir: Path, models: tuple[SetOperationModel, ...] +) -> SetOperationLifecycleResult: + """Compile, build, run Rules, format, and recompile a set-operation project.""" + + database: Path = write_set_operation_project(project_dir=project_dir, models=models) + compiled: SetOperationCompileResult = compile_set_operation_project(project_dir=project_dir) + build: subprocess.CompletedProcess[str] = run_sqb(project_dir=project_dir, command=("build",)) + relation_shapes: dict[str, tuple[int, int]] = {} + with duckdb.connect(str(database), read_only=True) as connection: + for model in models: + relation: duckdb.DuckDBPyRelation = connection.sql( + f"SELECT * FROM analytics.{model.name}" + ) + relation_shapes[model.name] = (len(relation.columns), len(relation.fetchall())) + rules: subprocess.CompletedProcess[str] = run_sqb( + project_dir=project_dir, command=("rules", "run", "SQBRSQL020") + ) + models_dir: Path = project_dir / "models" + formatted: subprocess.CompletedProcess[str] = run_sqb( + project_dir=project_dir, command=("format", str(models_dir)) + ) + indented_set_operation_lines: list[str] = [] + for path in sorted(models_dir.glob("*.sql")): + indented_set_operation_lines.extend(_indented_set_operation_lines(path)) + return SetOperationLifecycleResult( + compiled=compiled, + build=build, + relation_shapes=relation_shapes, + rules=rules, + format=formatted, + indented_set_operation_lines=tuple(indented_set_operation_lines), + recompiled=compile_set_operation_project(project_dir=project_dir), ) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_set_operation_after_subquery_operand.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_set_operation_after_subquery_operand.py new file mode 100644 index 0000000000..05c010250e --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_set_operation_after_subquery_operand.py @@ -0,0 +1,216 @@ +"""Set operations after subquery operands combine the outer queries through the real CLI.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.compile._test_types import ( + SetOperationArityMismatchTestCase, + SetOperationLifecycleTestCase, + SetOperationModel, +) +from tests.e2e.src.sqlbuild.cli.commands.main.compile.helpers import ( + SetOperationCompileResult, + SetOperationLifecycleResult, + compile_set_operation_project, + run_set_operation_lifecycle, + write_set_operation_project, +) + +_INPUTS: str = ( + "WITH orders AS (\n" + " SELECT * FROM (VALUES (1, 20), (2, 5)) AS v(order_id, amount)\n" + "), limits AS (SELECT 10 AS max_amount)\n" +) +_ALL_ORDERS: str = "SELECT o.order_id, o.amount FROM orders AS o" + + +@pytest.mark.parametrize( + "test_case", + [ + SetOperationLifecycleTestCase( + description="subquery operands before set operations", + models=( + SetOperationModel( + description="scalar subquery in WHERE before UNION ALL", + name="where_scalar_union_all", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE o.amount < (SELECT l.max_amount FROM limits AS l)\n" + f"UNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=3, + ), + SetOperationModel( + description="scalar subquery in WHERE before EXCEPT", + name="where_scalar_except", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE o.amount > (SELECT l.max_amount FROM limits AS l)\n" + f"EXCEPT\n{_ALL_ORDERS} WHERE o.order_id = 2" + ), + expected_row_count=1, + ), + SetOperationModel( + description="scalar subquery in WHERE before INTERSECT", + name="where_scalar_intersect", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE o.amount > (SELECT l.max_amount FROM limits AS l)\n" + f"INTERSECT\n{_ALL_ORDERS}" + ), + expected_row_count=1, + ), + SetOperationModel( + description="IN subquery before UNION ALL", + name="where_in_union_all", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE o.amount IN (SELECT l.max_amount * 2 FROM limits AS l)\n" + f"UNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=3, + ), + SetOperationModel( + description="correlated EXISTS before UNION ALL", + name="where_exists_union_all", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE EXISTS (SELECT 1 FROM limits AS l WHERE l.max_amount > o.amount)\n" + f"UNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=3, + ), + SetOperationModel( + description="nested scalar subqueries before UNION ALL", + name="where_nested_scalar_union_all", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE o.amount < (\n" + " SELECT MAX(l.max_amount) FROM limits AS l\n" + " WHERE l.max_amount > (SELECT MIN(o2.amount) FROM orders AS o2)\n" + ")\n" + f"UNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=3, + ), + SetOperationModel( + description="scalar subquery in HAVING before UNION ALL", + name="having_scalar_union_all", + query_sql=( + f"{_INPUTS}SELECT o.order_id, SUM(o.amount) AS amount FROM orders AS o\n" + "GROUP BY o.order_id\n" + "HAVING SUM(o.amount) > (SELECT l.max_amount FROM limits AS l)\n" + f"UNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=3, + ), + SetOperationModel( + description="scalar subquery in JOIN ON before UNION ALL", + name="join_on_scalar_union_all", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "JOIN limits AS l\n" + " ON o.amount > (SELECT MIN(l2.max_amount) FROM limits AS l2)\n" + f"UNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=3, + ), + SetOperationModel( + description="scalar subquery in SELECT list before UNION ALL", + name="select_list_scalar_union_all", + query_sql=( + f"{_INPUTS}SELECT o.order_id, " + "(SELECT l.max_amount FROM limits AS l) AS amount\n" + f"FROM orders AS o\nUNION ALL\n{_ALL_ORDERS}" + ), + expected_row_count=4, + ), + SetOperationModel( + description="parenthesized branches around a scalar subquery", + name="parenthesized_branches_union_all", + query_sql=( + f"{_INPUTS}({_ALL_ORDERS}\n" + "WHERE o.amount < (SELECT l.max_amount FROM limits AS l))\n" + f"UNION ALL\n({_ALL_ORDERS})" + ), + expected_row_count=3, + ), + ), + expected_column_count=2, + expected_diagnostics=(), + expected_rule_summary="10 models evaluated, 0 findings", + expected_indented_set_operation_lines=(), + ) + ], + ids=lambda case: case.description, +) +def test_given_set_operation_after_subquery_operand_when_compiling_building_and_formatting_then_outer_queries_combine( + test_case: SetOperationLifecycleTestCase, + tmp_path: Path, +) -> None: + result: SetOperationLifecycleResult = run_set_operation_lifecycle( + project_dir=tmp_path / "orders_project", models=test_case.models + ) + expected_column_counts: dict[str, int] = { + model.name: test_case.expected_column_count for model in test_case.models + } + + assert result.compiled.exit_code == 0, result.compiled.output + assert result.compiled.diagnostics == test_case.expected_diagnostics + assert result.compiled.column_counts == expected_column_counts + assert result.build.returncode == 0, result.build.stdout + result.build.stderr + assert result.relation_shapes == { + model.name: (test_case.expected_column_count, model.expected_row_count) + for model in test_case.models + } + assert result.rules.returncode == 0, result.rules.stdout + result.rules.stderr + assert test_case.expected_rule_summary in result.rules.stdout + result.rules.stderr + assert result.format.returncode == 0, result.format.stdout + result.format.stderr + assert result.indented_set_operation_lines == test_case.expected_indented_set_operation_lines + assert result.recompiled.exit_code == 0, result.recompiled.output + assert result.recompiled.diagnostics == test_case.expected_diagnostics + assert result.recompiled.column_counts == expected_column_counts + + +@pytest.mark.parametrize( + "test_case", + [ + SetOperationArityMismatchTestCase( + description="outer branch drops a column after a scalar subquery predicate", + models=( + SetOperationModel( + description="outer branch drops a column", + name="mismatched_branches", + query_sql=( + f"{_INPUTS}{_ALL_ORDERS}\n" + "WHERE o.amount < (SELECT l.max_amount FROM limits AS l)\n" + "UNION ALL\nSELECT o.order_id FROM orders AS o" + ), + ), + ), + expected_exit_code=1, + expected_diagnostics=( + ("B216", "UNION operands return different column counts: left 2, right 1"), + ), + ) + ], + ids=lambda case: case.description, +) +def test_given_mismatched_outer_branch_after_subquery_operand_when_compiling_then_b216_reports_outer_counts( + test_case: SetOperationArityMismatchTestCase, + tmp_path: Path, +) -> None: + project_dir: Path = tmp_path / "orders_project" + write_set_operation_project(project_dir=project_dir, models=test_case.models) + + result: SetOperationCompileResult = compile_set_operation_project(project_dir=project_dir) + + assert result.exit_code == test_case.expected_exit_code, result.output + assert result.diagnostics == test_case.expected_diagnostics + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) From 9235f6a5e4d45ed8342aa9342931f545663961da Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:49:10 +0100 Subject: [PATCH 2/3] perf: analyze long UNION ALL test fixtures in linear time Bump polyglot-sql to linear set-operation scope building. SQL tests whose fixtures are long UNION ALL chains previously grew quadratically in compile analysis; 400-row fixtures now analyze in a fraction of the time. Add a performance guard comparing native analysis time for 50- and 200-row UNION ALL fixtures across several SQL tests. --- Cargo.lock | 6 +- Cargo.toml | 4 +- .../cli/commands/main/compile/_test_types.py | 12 +++ .../cli/commands/main/compile/helpers.py | 73 +++++++++++++++++++ .../main/compile/test_compile_performance.py | 58 +++++++++++++++ 5 files changed, 148 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 572b8715aa..31a20f3d05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,7 +329,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "polyglot-sql" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=7f9c029f53053e802710f740a6a47acae415375c#7f9c029f53053e802710f740a6a47acae415375c" +source = "git+https://github.com/chio-labs/polyglot.git?rev=9ba2a98e1a9df2be7b33d105a0e5e4e15294632a#9ba2a98e1a9df2be7b33d105a0e5e4e15294632a" dependencies = [ "polyglot-sql-ast-derive", "polyglot-sql-function-catalogs", @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=7f9c029f53053e802710f740a6a47acae415375c#7f9c029f53053e802710f740a6a47acae415375c" +source = "git+https://github.com/chio-labs/polyglot.git?rev=9ba2a98e1a9df2be7b33d105a0e5e4e15294632a#9ba2a98e1a9df2be7b33d105a0e5e4e15294632a" dependencies = [ "proc-macro2", "quote", @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=7f9c029f53053e802710f740a6a47acae415375c#7f9c029f53053e802710f740a6a47acae415375c" +source = "git+https://github.com/chio-labs/polyglot.git?rev=9ba2a98e1a9df2be7b33d105a0e5e4e15294632a#9ba2a98e1a9df2be7b33d105a0e5e4e15294632a" [[package]] name = "portable-atomic" diff --git a/Cargo.toml b/Cargo.toml index 5d00dc10f8..b66282e964 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ globset = "0.4" mimalloc = { version = "0.1", default-features = false, features = ["local_dynamic_tls"] } proc-macro2 = { version = "1", features = ["span-locations"] } pyo3 = { version = "0.26", features = ["abi3-py312"] } -polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "7f9c029f53053e802710f740a6a47acae415375c", default-features = false, features = [ +polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ba2a98e1a9df2be7b33d105a0e5e4e15294632a", default-features = false, features = [ "ast-tools", "builder", "dialect-bigquery", @@ -29,7 +29,7 @@ polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "7f9c0 "semantic", "stacker", ] } -polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "7f9c029f53053e802710f740a6a47acae415375c", default-features = false, features = [ +polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ba2a98e1a9df2be7b33d105a0e5e4e15294632a", default-features = false, features = [ "dialect-duckdb", ] } quote = "1" diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py index b211276671..e69b56b2d8 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py @@ -471,3 +471,15 @@ class SetOperationArityMismatchTestCase: models: tuple[SetOperationModel, ...] expected_exit_code: int expected_diagnostics: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True) +class UnionFixtureCompileScalingTestCase: + description: str + sql_test_count: int + small_fixture_rows: int + large_fixture_rows: int + measured_runs: int + expected_sql_tests: int + expected_errors: int + expected_max_scaling_ratio: float diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py index b6edcd2961..b48caa7a62 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py @@ -2704,3 +2704,76 @@ def run_set_operation_lifecycle( indented_set_operation_lines=tuple(indented_set_operation_lines), recompiled=compile_set_operation_project(project_dir=project_dir), ) + + +class UnionFixtureCompileMeasurement(NamedTuple): + analysis_native_ms: int + sql_tests: int + errors: int + + +def _union_all_fixture_rows(*, row_count: int, status: str) -> str: + return " UNION ALL\n".join( + f" SELECT {row} AS order_id, CAST({row} AS DOUBLE) AS amount, '{status}' AS status" + for row in range(row_count) + ) + + +def write_union_fixture_test_project( + *, project_dir: Path, sql_test_count: int, fixture_row_count: int +) -> None: + """Write a DuckDB project whose SQL tests use long UNION ALL fixture chains.""" + + files: dict[str, str] = { + "sqlbuild_project.toml": ( + f'name = "orders"\nadapter = "duckdb"\n[connection]\n' + f'database = "{project_dir / "orders.duckdb"}"\n[rules]\nselect = []\n' + ), + "models/base_orders.sql": ( + "MODEL (materialized view);\n\n" + "SELECT 1 AS order_id, CAST(1 AS DOUBLE) AS amount, 'open' AS status\n" + ), + "models/orders.sql": ( + "MODEL (materialized view);\n\n" + 'SELECT b.order_id, b.amount, b.status FROM __ref("base_orders") AS b\n' + ), + } + for index in range(sql_test_count): + fixture_rows: str = _union_all_fixture_rows( + row_count=fixture_row_count, status=f"status_{index:02d}" + ) + files[f"tests/unit/orders_case_{index:02d}.sql"] = ( + f'TEST (name "orders_case_{index:02d}");\n\n' + f"WITH\n__ref__base_orders AS (\n{fixture_rows}\n),\n" + f"__expected__orders AS (\n{fixture_rows}\n)\nSELECT 1\n" + ) + prepare_inline_project( + tmp_path=project_dir.parent, project_name=project_dir.name, repo_files=files + ) + + +def measure_union_fixture_compile( + *, project_dir: Path, runs: int +) -> UnionFixtureCompileMeasurement: + """Return the fastest native analysis time of fresh uncached compiles.""" + + skip_actions: dict[bool, Callable[[], None]] = { + False: _continue_compile_benchmark, + True: _skip_compile_benchmark, + } + skip_actions[os.environ.get("SQLBUILD_SKIP_PERFORMANCE_TESTS") == "1"]() + measurements: list[UnionFixtureCompileMeasurement] = [] + for _ in range(runs): + result: subprocess.CompletedProcess[str] = run_installed_sqb( + project_dir=project_dir, args=("compile", "--no-cache", "--json"), env={} + ) + assert result.returncode == 0, result.stdout + result.stderr + payload: dict[str, Any] = json.loads(result.stdout) + measurements.append( + UnionFixtureCompileMeasurement( + analysis_native_ms=payload["compile_timings"]["analysis_native_ms"], + sql_tests=payload["summary"]["tests"], + errors=payload["summary"]["errors"], + ) + ) + return min(measurements) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_performance.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_performance.py index d0f011d914..e74c6cfcee 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_performance.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_compile_performance.py @@ -14,20 +14,24 @@ LayeredProductionCompilePerformanceGuardTestCase, SemanticCompilePerformanceGuardTestCase, SqlTestHeavyCompilePerformanceGuardTestCase, + UnionFixtureCompileScalingTestCase, ) from tests.e2e.src.sqlbuild.cli.commands.main.compile.helpers import ( CompileBenchmarkMeasurement, DbtShapedCompileBenchmarkResult, LayeredProductionCompileBenchmarkResult, SemanticCompileBenchmarkResult, + UnionFixtureCompileMeasurement, measure_compiled_test_sql_bytes, measure_declared_model_columns, measure_model_sql_bytes, + measure_union_fixture_compile, run_advanced_compile_benchmark, run_dbt_shaped_compile_benchmark, run_layered_production_compile_benchmark, run_semantic_compile_benchmark, run_test_heavy_compile_benchmark, + write_union_fixture_test_project, ) _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -123,6 +127,60 @@ def test_given_wide_scan_heavy_projects_when_doubling_sql_size_then_compile_scal assert scaling_ratio < test_case.expected_max_scaling_ratio +@pytest.mark.performance +@pytest.mark.parametrize( + "test_case", + [ + UnionFixtureCompileScalingTestCase( + description="quadrupling UNION ALL fixture rows keeps SQL test analysis linear", + sql_test_count=4, + small_fixture_rows=50, + large_fixture_rows=200, + measured_runs=3, + expected_sql_tests=4, + expected_errors=0, + expected_max_scaling_ratio=6.0, + ) + ], + ids=lambda case: case.description, +) +def test_given_sql_tests_with_union_all_fixtures_when_quadrupling_rows_then_analysis_scales_linearly( + tmp_path: Path, + test_case: UnionFixtureCompileScalingTestCase, +) -> None: + small_project_dir: Path = tmp_path / "union_fixtures_small" + large_project_dir: Path = tmp_path / "union_fixtures_large" + write_union_fixture_test_project( + project_dir=small_project_dir, + sql_test_count=test_case.sql_test_count, + fixture_row_count=test_case.small_fixture_rows, + ) + write_union_fixture_test_project( + project_dir=large_project_dir, + sql_test_count=test_case.sql_test_count, + fixture_row_count=test_case.large_fixture_rows, + ) + small: UnionFixtureCompileMeasurement = measure_union_fixture_compile( + project_dir=small_project_dir, runs=test_case.measured_runs + ) + large: UnionFixtureCompileMeasurement = measure_union_fixture_compile( + project_dir=large_project_dir, runs=test_case.measured_runs + ) + scaling_ratio: float = large.analysis_native_ms / max(small.analysis_native_ms, 1) + _LOGGER.info( + f"union fixture compile small={small.analysis_native_ms}ms " + f"large={large.analysis_native_ms}ms ratio={scaling_ratio:.2f} " + f"budget={test_case.expected_max_scaling_ratio:.1f}" + ) + + assert (small.sql_tests, large.sql_tests) == ( + test_case.expected_sql_tests, + test_case.expected_sql_tests, + ) + assert (small.errors, large.errors) == (test_case.expected_errors, test_case.expected_errors) + assert scaling_ratio < test_case.expected_max_scaling_ratio + + @pytest.mark.performance @pytest.mark.parametrize( "test_case", From 43e395135d5f8c27a244f4d9f7fb6effa50c0d20 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:27:37 +0100 Subject: [PATCH 3/3] build: pin polyglot to the merged set-operation fixes --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 31a20f3d05..07f2fe0f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,7 +329,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "polyglot-sql" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=9ba2a98e1a9df2be7b33d105a0e5e4e15294632a#9ba2a98e1a9df2be7b33d105a0e5e4e15294632a" +source = "git+https://github.com/chio-labs/polyglot.git?rev=a7413439c520d7949924149b7ea02fa3817463fc#a7413439c520d7949924149b7ea02fa3817463fc" dependencies = [ "polyglot-sql-ast-derive", "polyglot-sql-function-catalogs", @@ -344,7 +344,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=9ba2a98e1a9df2be7b33d105a0e5e4e15294632a#9ba2a98e1a9df2be7b33d105a0e5e4e15294632a" +source = "git+https://github.com/chio-labs/polyglot.git?rev=a7413439c520d7949924149b7ea02fa3817463fc#a7413439c520d7949924149b7ea02fa3817463fc" dependencies = [ "proc-macro2", "quote", @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" version = "0.13.8" -source = "git+https://github.com/chio-labs/polyglot.git?rev=9ba2a98e1a9df2be7b33d105a0e5e4e15294632a#9ba2a98e1a9df2be7b33d105a0e5e4e15294632a" +source = "git+https://github.com/chio-labs/polyglot.git?rev=a7413439c520d7949924149b7ea02fa3817463fc#a7413439c520d7949924149b7ea02fa3817463fc" [[package]] name = "portable-atomic" diff --git a/Cargo.toml b/Cargo.toml index b66282e964..75ba4276c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ globset = "0.4" mimalloc = { version = "0.1", default-features = false, features = ["local_dynamic_tls"] } proc-macro2 = { version = "1", features = ["span-locations"] } pyo3 = { version = "0.26", features = ["abi3-py312"] } -polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ba2a98e1a9df2be7b33d105a0e5e4e15294632a", default-features = false, features = [ +polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "a7413439c520d7949924149b7ea02fa3817463fc", default-features = false, features = [ "ast-tools", "builder", "dialect-bigquery", @@ -29,7 +29,7 @@ polyglot-sql = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ba2a "semantic", "stacker", ] } -polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "9ba2a98e1a9df2be7b33d105a0e5e4e15294632a", default-features = false, features = [ +polyglot-sql-function-catalogs = { git = "https://github.com/chio-labs/polyglot.git", rev = "a7413439c520d7949924149b7ea02fa3817463fc", default-features = false, features = [ "dialect-duckdb", ] } quote = "1" diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py index b48caa7a62..90a91415c4 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py @@ -2602,6 +2602,7 @@ def require_sql_analysis_output( return ( tuple((item["code"], item.get("path"), item.get("line")) for item in diagnostics), text, + ) _INDENTED_SET_OPERATION_PATTERN: re.Pattern[str] = re.compile(