Skip to content

Commit e250b75

Browse files
authored
fix: error a unit test when its tested output query fails (#1208)
* fix: error a unit test when its tested output query fails Storing a unit test's actual output once and comparing against it fell back to the inline comparison on any storage error. On a warehouse that prunes unread columns, the inline comparison of the expected columns can pass when computing the full output fails, for example when a fixture value cannot be converted in a column the expected rows leave out, so the test reported PASS over a warehouse error. When storing fails, the output query now runs on its own; if it fails too, the test errors with the warehouse's message, and only a failure to store a computable output compares inline. * fix: check a unit test output that cannot be stored on its own, inline When a later chain step's output could not be stored, the probe ran that step's query reading the earlier steps' stored outputs, which had just been dropped, so it reported a missing table instead of comparing inline. The probe now renders the failing step fully inline, runs only when storing itself failed rather than rendering, and keeps the storage error in the debug log when the step's query fails on its own. * fix: compare a unit test inline when DuckDB fails to store it internally DuckDB 1.5.0 to 1.5.4 raise an internal binder error for two identical window functions over a GROUP BY of a constant input, which is the shape of a unit test's fixture CTE. Storing the output and running it on its own both hit it, so the output check reported the engine bug as the model's error while the inline comparison computes every column correctly. An internal engine error while storing an output now compares inline.
1 parent 8e3cc5c commit e250b75

10 files changed

Lines changed: 500 additions & 13 deletions

File tree

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,12 @@ SQLBuild drops after the test, including after a failure or timeout. PostgreSQL
919919
one transaction with an `ON COMMIT DROP` temporary table that the transaction's end removes, so the
920920
reads stay on one server session behind a transaction-mode connection pooler. Snowflake reads the
921921
first query's persisted result through `RESULT_SCAN`. If a stored output can no longer be read,
922-
the test falls back to the inline comparison. Other adapters compare the inline query as before. `target/run/tests/` keeps the self-contained inline comparison query for each test.
922+
the test falls back to the inline comparison. If storing an output fails, SQLBuild runs the output
923+
query on its own: when that query fails too, for example because a fixture value cannot be
924+
converted in a column the expected rows leave out, the test errors with the warehouse's message
925+
instead of passing on a comparison of the other columns; otherwise it compares inline. On DuckDB and
926+
MotherDuck, an internal engine error while storing an output is a DuckDB defect rather than a
927+
problem with the model, so the test compares inline. Other adapters compare the inline query as before. `target/run/tests/` keeps the self-contained inline comparison query for each test.
923928

924929
## Skipping SQL analysis
925930

‎src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
CANCELLABLE_DURATION_LIMIT_SECONDS,
4141
DIFF_LEFT_SIDE,
4242
DIFF_RIGHT_SIDE,
43+
DUCKDB_INTERNAL_ERROR_CLASS_NAME,
4344
DUCKDB_MISSING_OBJECT_MARKER,
4445
DUCKDB_NOT_FOUND_ERROR_CLASS_NAMES,
4546
QUALIFIED_NAME_SEPARATOR,
@@ -91,6 +92,8 @@
9192
from sqlbuild.adapter.type_system.main.normalize_numeric_family import normalize_numeric_family
9293
from sqlbuild.adapter.type_system.main.types_equal import types_equal
9394
from sqlbuild.compiler.compile.types import FunctionLanguage
95+
from sqlbuild.diagnostics.classes.diagnostic_record_redactor import DiagnosticRecordRedactor
96+
from sqlbuild.diagnostics.main.log_debug_event import log_debug_event
9497
from sqlbuild.diagnostics.main.log_sql import log_sql
9598
from sqlbuild.runtime.user_code.main.user_code_spec import user_code_spec
9699
from sqlbuild.spec.contracts.constants import DEFAULT_SEED_CSV_SETTINGS
@@ -118,9 +121,19 @@ def get_columns_for_relations(
118121
execution_duration_limit_seconds: ClassVar[int | None] = CANCELLABLE_DURATION_LIMIT_SECONDS
119122

120123
def create_sql_test_output(self, *, connection: Any, sql: str, name: str) -> str | None:
121-
"""Store a unit test's actual output once in a session temporary table."""
122-
123-
_ = self.execute(connection=connection, sql=f"CREATE TEMPORARY TABLE {name} AS {sql}")
124+
"""Store a unit test's output in a temporary table; compare inline on an engine bug."""
125+
126+
try:
127+
_ = self.execute(connection=connection, sql=f"CREATE TEMPORARY TABLE {name} AS {sql}")
128+
except Exception as error:
129+
if type(error).__name__ != DUCKDB_INTERNAL_ERROR_CLASS_NAME:
130+
raise
131+
log_debug_event(
132+
logger=logging.getLogger("sqlbuild.adapter.duckdb"),
133+
message="DuckDB could not store a SQL test output; comparing inline",
134+
sqlbuild_error=DiagnosticRecordRedactor.text(str(error)),
135+
)
136+
return None
124137
return name
125138

126139
def drop_sql_test_output(self, *, connection: Any, relation: str) -> None:

‎src/sqlbuild/adapter/contract/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@
1717
{"CatalogException", "BinderException"}
1818
)
1919
DUCKDB_MISSING_OBJECT_MARKER: str = "does not exist"
20+
DUCKDB_INTERNAL_ERROR_CLASS_NAME: str = "InternalException"
2021
CANCELLABLE_DURATION_LIMIT_SECONDS: int = 2_147_483

‎src/sqlbuild/executor/testing/classes/sql_test_actual_outputs.py‎

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,22 +50,19 @@ def materialize(self) -> dict[int, str]:
5050
"""Store each compared step's output, or return no relations to compare inline."""
5151

5252
token: str = uuid4().hex[:12]
53+
storing: int | None = None
5354
try:
5455
for index in range(len(self._test_entry.chain)):
55-
(sql,) = render_sql_test_comparison_sql(
56-
(self._test_entry,),
57-
set_difference_operator=self._adapter.render_set_difference_operator(),
58-
sql_analysis_dialect=self._adapter.sql_analysis_dialect(),
59-
output_step_index=index,
60-
actual_relations=dict(self.relations),
61-
)
56+
sql: str = self._output_sql(index=index, relations=dict(self.relations))
6257
if not sql:
6358
continue
59+
storing = index
6460
relation: str | None = self._adapter.create_sql_test_output(
6561
connection=self._connection,
6662
sql=sql,
6763
name=f"{_OUTPUT_PREFIX}_{token}_{index}",
6864
)
65+
storing = None
6966
if relation is None:
7067
self.release()
7168
return {}
@@ -75,16 +72,50 @@ def materialize(self) -> dict[int, str]:
7572
except (ItemTimeoutError, ExecutionDurationLimitError):
7673
raise
7774
except Exception as error:
75+
self.release()
76+
self._evaluate_output(index=storing, storage_error=error)
7877
log_debug_event(
7978
logger=_LOGGER,
8079
message="SQL test output could not be stored; comparing inline",
8180
test_name=self._test_entry.name,
8281
sqlbuild_error=DiagnosticRecordRedactor.text(str(error)),
8382
)
84-
self.release()
8583
return {}
8684
return dict(self.relations)
8785

86+
def _output_sql(self, *, index: int, relations: dict[int, str]) -> str:
87+
"""The SQL of step ``index``'s output, reading the given stored outputs of earlier steps."""
88+
89+
(sql,) = render_sql_test_comparison_sql(
90+
(self._test_entry,),
91+
set_difference_operator=self._adapter.render_set_difference_operator(),
92+
sql_analysis_dialect=self._adapter.sql_analysis_dialect(),
93+
output_step_index=index,
94+
actual_relations=relations,
95+
)
96+
return sql
97+
98+
def _evaluate_output(self, *, index: int | None, storage_error: Exception) -> None:
99+
"""Run a step that could not be stored fully inline, raising the query's own error."""
100+
101+
if index is None:
102+
return
103+
try:
104+
cursor: Any = self._adapter.execute(
105+
connection=self._connection, sql=self._output_sql(index=index, relations={})
106+
)
107+
_ = cursor.fetchall()
108+
except (ItemTimeoutError, ExecutionDurationLimitError):
109+
raise
110+
except Exception:
111+
log_debug_event(
112+
logger=_LOGGER,
113+
message="SQL test output could not be stored, and its query fails on its own",
114+
test_name=self._test_entry.name,
115+
sqlbuild_error=DiagnosticRecordRedactor.text(str(storage_error)),
116+
)
117+
raise
118+
88119
def release(self) -> None:
89120
"""Drop every stored output; later comparisons run inline."""
90121

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,16 @@ class TemporaryFunctionConnectionE2ETestCase:
470470
description: str
471471
expected_exit_code: int
472472
expected_stderr_fragment: str
473+
474+
475+
@dataclass(frozen=True)
476+
class OutputEvaluationErrorE2ETestCase:
477+
"""How a unit test ends when storing its tested model's output fails for a given reason."""
478+
479+
description: str
480+
adapter_source: str
481+
extra_files: dict[str, str]
482+
expected_exit_code: int
483+
expected_fragments: tuple[str, ...]
484+
expected_json_fragments: tuple[str, ...]
485+
unexpected_fragments: tuple[str, ...]

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2096,3 +2096,20 @@ def slow_promotion_files(
20962096
f"SELECT {customer_id} AS customer_id\n"
20972097
),
20982098
}
2099+
2100+
2101+
def order_totals_test_files(total_sql: str) -> dict[str, str]:
2102+
"""Return an order_totals model computing ``total_sql`` and a test expecting both models."""
2103+
2104+
return {
2105+
"models/order_totals.sql": (
2106+
"MODEL (description 'Test model order_totals.');\n\n"
2107+
f'SELECT order_id, {total_sql} AS total FROM __ref("orders")\n'
2108+
),
2109+
"tests/unit/test_orders.sql": (
2110+
'TEST (name "orders_ids");\n\nWITH\n'
2111+
"__source__raw_orders AS (\n SELECT 1 AS order_id, '7' AS code\n),\n"
2112+
"__expected__orders AS (\n SELECT 1 AS order_id\n),\n"
2113+
"__expected__order_totals AS (\n SELECT 1 AS order_id\n)\nSELECT 1\n"
2114+
),
2115+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""E2E coverage: a warehouse error in a tested model's output fails the unit test."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import subprocess
7+
from pathlib import Path
8+
from typing import Any
9+
10+
import pytest
11+
12+
from tests.e2e.src.sqlbuild.cli.commands.main.test._test_types import (
13+
OutputEvaluationErrorE2ETestCase,
14+
)
15+
from tests.e2e.src.sqlbuild.cli.commands.main.test.helpers import order_totals_test_files
16+
from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import (
17+
execute_duckdb,
18+
prepare_inline_project,
19+
run_sqb,
20+
)
21+
22+
_CONVERSION_ERROR: str = "Numeric value 'cluster::r1' is not recognized"
23+
# Like a warehouse that prunes unread columns: the stored output computes every column and
24+
# fails on the fixture, while an inline comparison of the expected columns alone succeeds.
25+
_PRUNING_WAREHOUSE: str = (
26+
"from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter\n\n\n"
27+
"class PruningDuckDbAdapter(DuckDbAdapter):\n"
28+
' adapter_name = "pruning_duckdb"\n'
29+
" failing_outputs = set()\n\n"
30+
" def create_sql_test_output(self, *, connection, sql, name):\n"
31+
" self.failing_outputs.add(sql)\n"
32+
f' raise RuntimeError("{_CONVERSION_ERROR}")\n\n'
33+
" def execute(self, *, connection, sql):\n"
34+
" if sql in self.failing_outputs:\n"
35+
f' raise RuntimeError("{_CONVERSION_ERROR}")\n'
36+
" return super().execute(connection=connection, sql=sql)\n"
37+
)
38+
# A session that cannot keep test outputs at all, while every query runs.
39+
_NO_TEMPORARY_TABLES: str = (
40+
"from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter\n\n\n"
41+
"class PruningDuckDbAdapter(DuckDbAdapter):\n"
42+
' adapter_name = "pruning_duckdb"\n\n'
43+
" def create_sql_test_output(self, *, connection, sql, name):\n"
44+
' raise PermissionError("temporary tables are disabled on this session")\n'
45+
)
46+
# A session that keeps one test output and refuses every later one.
47+
_ONE_TEMPORARY_TABLE: str = (
48+
"from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter\n\n\n"
49+
"class PruningDuckDbAdapter(DuckDbAdapter):\n"
50+
' adapter_name = "pruning_duckdb"\n'
51+
" stored = []\n\n"
52+
" def create_sql_test_output(self, *, connection, sql, name):\n"
53+
" self.stored.append(name)\n"
54+
" if len(self.stored) > 1:\n"
55+
' raise PermissionError("no more temporary tables in this session")\n'
56+
" return super().create_sql_test_output(connection=connection, sql=sql, name=name)\n"
57+
)
58+
59+
60+
_PROJECT_FILES: dict[str, str] = {
61+
"sqlbuild_project.toml": (
62+
'name = "orders"\nadapter = "pruning_duckdb"\n\n'
63+
'[connection]\ndatabase = "orders.duckdb"\n\n'
64+
'[defaults]\nmaterialized = "table"\n'
65+
),
66+
"sources/raw_orders.yml": (
67+
"sources:\n"
68+
" - name: raw_orders\n description: Test source raw_orders.\n"
69+
" schema: main\n"
70+
" table: raw_orders\n"
71+
" columns:\n"
72+
" - name: order_id\n type: INTEGER\n"
73+
" - name: code\n type: VARCHAR\n"
74+
),
75+
"models/orders.sql": (
76+
"MODEL (description 'Test model orders.');\n\n"
77+
'SELECT order_id, TRY_CAST(code AS INTEGER) AS code_number FROM __source("raw_orders")\n'
78+
),
79+
"tests/unit/test_orders.sql": (
80+
'TEST (name "orders_ids");\n\nWITH\n'
81+
"__source__raw_orders AS (\n SELECT 1 AS order_id, 'cluster::r1' AS code\n),\n"
82+
"__expected__orders AS (\n SELECT 1 AS order_id\n)\nSELECT 1\n"
83+
),
84+
}
85+
86+
87+
@pytest.mark.parametrize(
88+
"test_case",
89+
[
90+
OutputEvaluationErrorE2ETestCase(
91+
description="an output the warehouse cannot compute errors the test",
92+
adapter_source=_PRUNING_WAREHOUSE,
93+
extra_files={},
94+
expected_exit_code=1,
95+
expected_fragments=(
96+
"test 'orders_ids' encountered an execution error while running 'orders'",
97+
_CONVERSION_ERROR,
98+
"PASS=0 FAIL=1",
99+
),
100+
expected_json_fragments=('"error"', _CONVERSION_ERROR),
101+
unexpected_fragments=("PASS=1 FAIL=0",),
102+
),
103+
OutputEvaluationErrorE2ETestCase(
104+
description="a session that cannot store outputs still compares inline",
105+
adapter_source=_NO_TEMPORARY_TABLES,
106+
extra_files={},
107+
expected_exit_code=0,
108+
expected_fragments=("PASS=1 FAIL=0",),
109+
expected_json_fragments=('"pass"',),
110+
unexpected_fragments=("encountered an execution error",),
111+
),
112+
OutputEvaluationErrorE2ETestCase(
113+
description="a computable later output that cannot be stored compares inline",
114+
adapter_source=_ONE_TEMPORARY_TABLE,
115+
extra_files=order_totals_test_files("code_number + 1"),
116+
expected_exit_code=0,
117+
expected_fragments=("PASS=1 FAIL=0",),
118+
expected_json_fragments=('"pass"',),
119+
unexpected_fragments=("encountered an execution error", "does not exist"),
120+
),
121+
OutputEvaluationErrorE2ETestCase(
122+
description="a later output that fails on its own reports the warehouse error",
123+
adapter_source=_ONE_TEMPORARY_TABLE,
124+
extra_files=order_totals_test_files("CAST('cluster::r1' AS INTEGER)"),
125+
expected_exit_code=1,
126+
expected_fragments=("Could not convert string 'cluster::r1'", "PASS=0 FAIL=1"),
127+
expected_json_fragments=('"error"', "Could not convert string"),
128+
unexpected_fragments=("does not exist",),
129+
),
130+
],
131+
ids=lambda case: case.description,
132+
)
133+
def test_given_output_store_failure_when_testing_then_only_storage_problems_fall_back(
134+
test_case: OutputEvaluationErrorE2ETestCase, tmp_path: Path
135+
) -> None:
136+
"""An inline comparison of some columns must not hide an error computing the others."""
137+
138+
project_dir: Path = prepare_inline_project(
139+
tmp_path=tmp_path,
140+
project_name="orders",
141+
repo_files={
142+
**_PROJECT_FILES,
143+
"adapters/pruning_duckdb.py": test_case.adapter_source,
144+
**test_case.extra_files,
145+
},
146+
)
147+
config_path: Path = project_dir / "sqlbuild_project.toml"
148+
_ = config_path.write_text(
149+
config_path.read_text(encoding="utf-8").replace(
150+
'"orders.duckdb"', f'"{(project_dir / "orders.duckdb").as_posix()}"'
151+
),
152+
encoding="utf-8",
153+
)
154+
execute_duckdb(
155+
db_path=project_dir / "orders.duckdb",
156+
sql="CREATE TABLE raw_orders AS SELECT 1 AS order_id, '1' AS code",
157+
)
158+
159+
tested: subprocess.CompletedProcess[str] = run_sqb(
160+
command=("--no-color", "test"), project_dir=project_dir
161+
)
162+
as_json: subprocess.CompletedProcess[str] = run_sqb(
163+
command=("--no-color", "test", "--json"), project_dir=project_dir
164+
)
165+
payload: dict[str, Any] = json.loads(as_json.stdout)
166+
167+
output: str = tested.stdout + tested.stderr
168+
assert tested.returncode == test_case.expected_exit_code, output
169+
assert as_json.returncode == test_case.expected_exit_code, as_json.stdout
170+
assert all(fragment in output for fragment in test_case.expected_fragments), output
171+
assert not any(fragment in output for fragment in test_case.unexpected_fragments), output
172+
assert all(fragment in json.dumps(payload) for fragment in test_case.expected_json_fragments), (
173+
as_json.stdout
174+
)

‎tests/integration/src/sqlbuild/executor/testing/_test_types.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,36 @@ class SqlTestBuildParityTestCase:
6464
model_name: str
6565
expected_whole_body_rendered: bool
6666
expected_verbatim_fragments: tuple[str, ...] = ()
67+
68+
69+
@dataclass(frozen=True)
70+
class OutputStorageTestCase:
71+
"""A tested output that cannot be stored, and whether the test still runs inline."""
72+
73+
description: str
74+
chain_steps: tuple[tuple[str, str, str | None], ...]
75+
storage_error: str
76+
output_fails: bool
77+
expected_outcome: SqlTestOutcome
78+
expected_error_fragment: str
79+
80+
81+
@dataclass(frozen=True)
82+
class LaterStepStorageTestCase:
83+
"""A chain whose first output is stored and whose second cannot be, and how the test ends."""
84+
85+
description: str
86+
chain_steps: tuple[tuple[str, str, str | None], ...]
87+
expected_outcome: SqlTestOutcome
88+
expected_error_fragment: str
89+
unexpected_error_fragment: str
90+
91+
92+
@dataclass(frozen=True)
93+
class EngineInternalErrorTestCase:
94+
"""A tested output DuckDB fails to store with an engine assertion error."""
95+
96+
description: str
97+
chain_steps: tuple[tuple[str, str, str | None], ...]
98+
raise_internal_error: bool
99+
expected_outcome: SqlTestOutcome

0 commit comments

Comments
 (0)