Skip to content

Commit a5923fa

Browse files
authored
feat: improve Dagster checks and test fixtures (#759)
* fix: stream standalone checks to Dagster * fix: normalize Dagster check metadata * feat: add contract-driven test fixtures * docs: clarify pull request babysitting
1 parent b93d47f commit a5923fa

24 files changed

Lines changed: 1262 additions & 58 deletions

File tree

‎AGENTS.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@
8888
- PR titles must follow Conventional Commits. Descriptions must be no longer than 2,000 characters and contain non-empty `## Why`, `## Changes`, and `## Verification` sections in that order.
8989
- Before creating or editing a PR, validate its metadata with `make check-pr-metadata PR_TITLE='type: summary' PR_BODY_FILE=/path/to/body.md`.
9090
- Monitor CI after every push and follow it through completion. Address failures before considering delivery complete.
91+
- When asked to babysit or watch a pull request, keep exactly one active background CI watcher for
92+
the surviving pull request until its latest commit reaches a terminal state. Do not stop at the
93+
initial push, PR creation, or an intermediate green run superseded by a later push. If pull
94+
requests are consolidated, stop obsolete watchers and retain one watcher for the combined PR.
95+
- Babysitting includes inspecting failures, applying and pushing required fixes, restarting the
96+
watcher after each push, and reporting the final green state. It does not authorize merging when
97+
the user has asked for the pull request to remain unmerged.
9198
- Push follow-up commits only for CI failures or correctness findings that could not reasonably have been found before the first push.
9299
- For deployable changes, continue through auto-merge, release workflow completion, package publication, and published-version verification. Do not stop at PR creation unless the user explicitly asks.
93100

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ model SQL or warehouse defaults. Required columns declared with `nullable false`
9494
unknown types must be supplied explicitly. When the relation's complete column set is authoritative,
9595
misspelled or unknown supplied fixture columns are rejected.
9696

97+
A direct `NULL AS column_name` projection receives the authoritative relation type in compiled test
98+
SQL, including expected-output CTEs for contracted models. To represent a contracted upstream with
99+
no rows, use `SELECT * FROM __empty_fixture()` as the complete body of a `__ref__`, `__source__`, or
100+
`__seed__` fixture CTE, or a contracted `__expected__` CTE. SQLBuild expands it to the relation's
101+
full typed schema with a false filter.
102+
97103
See the [documentation](https://docs.sqlbuild.com) for incremental models, scenarios, loaders, and more.
98104

99105
### Python project layout

‎src/sqlbuild/cli/commands/main/project/_format.py‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
from dataclasses import replace
77
from pathlib import Path
88

9+
from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter
910
from sqlbuild.cli.commands._helpers.lint.runs import (
1011
prepare_lint_run,
1112
render_lint_result,
1213
render_lint_result_json,
1314
)
1415
from sqlbuild.cli.commands._helpers.lint.selection import resolve_lint_inputs
15-
from sqlbuild.compiler.compile.types import TypedSqlValueRenderer
16+
from sqlbuild.compiler.pipeline.main.graph import build_project_graph
17+
from sqlbuild.compiler.pipeline.models import ProjectGraph
18+
from sqlbuild.lint.main.has_fixture_typed_null_candidates import (
19+
has_fixture_typed_null_candidates,
20+
)
1621
from sqlbuild.lint.main.run_format import run_format
1722
from sqlbuild.lint.models import LintConfig, LintRunResult
1823
from sqlbuild.presentation.main.supports_color import supports_color
@@ -34,24 +39,43 @@ def run_format_command(
3439
prepared: tuple[LintConfig, str | None] = prepare_lint_run(project_dir=base_dir)
3540
if prepared[1] is not None:
3641
print(f"WARN {prepared[1]}")
37-
value_renderer: TypedSqlValueRenderer | None = None
38-
selected_paths: frozenset[Path] | None = None
3942
config: LintConfig = prepared[0]
40-
if select or exclude:
41-
value_renderer, selected_paths, _discovered_inputs = resolve_lint_inputs(
42-
project_dir=base_dir,
43-
select=select,
44-
exclude=exclude,
45-
)
46-
config = replace(
47-
config,
48-
dialect=value_renderer.sql_analysis_dialect_name or "generic",
49-
)
43+
value_renderer: BaseAdapter | None = None
44+
selected_paths: frozenset[Path] | None = None
45+
graph: ProjectGraph | None = None
46+
has_fixture_candidates: bool = has_fixture_typed_null_candidates(project_dir=base_dir)
47+
if select or exclude or has_fixture_candidates:
48+
try:
49+
value_renderer, selected_paths, discovered_inputs = resolve_lint_inputs(
50+
project_dir=base_dir,
51+
select=select,
52+
exclude=exclude,
53+
)
54+
except Exception:
55+
if select or exclude:
56+
raise
57+
else:
58+
config = replace(
59+
config,
60+
dialect=value_renderer.sql_analysis_dialect_name or "generic",
61+
)
62+
if has_fixture_candidates and selected_paths is None:
63+
try:
64+
graph = build_project_graph(
65+
discovered_inputs=discovered_inputs,
66+
adapter=value_renderer,
67+
)
68+
except Exception:
69+
graph = None
5070
result: LintRunResult = run_format(
5171
project_dir=base_dir,
5272
config=config,
5373
value_renderer=value_renderer,
5474
selected_paths=selected_paths,
75+
compiled_project=(
76+
graph.project if graph is not None and graph.project.settings.sql_analysis else None
77+
),
78+
adapter=value_renderer,
5579
write=not (check or diff),
5680
)
5781
if diff:

‎src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from sqlbuild.compiler.compile._helpers.refs.references import extract_sql_references
1515
from sqlbuild.compiler.compile._helpers.render.macros import find_macro_call_names
16+
from sqlbuild.compiler.compile._helpers.sql_tests.empty_fixture import is_empty_fixture_query
1617
from sqlbuild.compiler.compile.constants import (
1718
ASSERT_TEST_CTE_PREFIX,
1819
DBT_REF_TEST_CTE_PREFIX,
@@ -489,7 +490,11 @@ def _classify_model_sql_test_ctes(
489490
file_label=file_label,
490491
)
491492
)
492-
_validate_expected_cte_query(cte=cte, file_label=file_label)
493+
_validate_expected_cte_query(
494+
cte=cte,
495+
file_label=file_label,
496+
allow_empty_fixture=True,
497+
)
493498
expected_ctes.append(cte)
494499
continue
495500
if cte.name.startswith(ASSERT_TEST_CTE_PREFIX):
@@ -736,8 +741,14 @@ def _read_sql_string_literal(*, sql: str, start: int) -> tuple[str, int]:
736741

737742

738743
def _validate_expected_cte_query(
739-
*, cte: CompileSqlTestCte, file_label: str, label: str = "__expected__<model>"
744+
*,
745+
cte: CompileSqlTestCte,
746+
file_label: str,
747+
label: str = "__expected__<model>",
748+
allow_empty_fixture: bool = False,
740749
) -> None:
750+
if allow_empty_fixture and is_empty_fixture_query(cte.sql_body):
751+
return
741752
if _contains_select_star(cte.sql_body):
742753
raise CompileInputError(f"SQL test '{file_label}' must not use SELECT * in {label} CTEs")
743754
branch_column_names: tuple[tuple[str, ...], ...] = _extract_expected_branch_column_names(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Recognition for the first-class empty relation fixture marker."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
7+
_EMPTY_FIXTURE_PATTERN: re.Pattern[str] = re.compile(
8+
r"^\s*SELECT\s+\*\s+FROM\s+__empty_fixture\s*\(\s*\)\s*$",
9+
re.IGNORECASE,
10+
)
11+
12+
13+
def is_empty_fixture_query(sql: str) -> bool:
14+
"""Return whether SQL is exactly the empty relation fixture marker."""
15+
16+
return _EMPTY_FIXTURE_PATTERN.fullmatch(sql) is not None

‎src/sqlbuild/compiler/planner/_helpers/fixtures/completion.py‎

Lines changed: 131 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
from sqlbuild.compiler.lineage.types import (
2626
ColumnLineageConfidence,
2727
ColumnTransformKind,
28-
InferredNullability,
2928
)
29+
from sqlbuild.compiler.planner._helpers.fixtures.empty_fixture import is_empty_fixture_query
3030
from sqlbuild.compiler.planner.models import (
3131
FixtureColumnMetadata,
3232
FixtureRelationMetadata,
@@ -57,24 +57,24 @@ def build_relation_fixture_completion(
5757
project=project
5858
)
5959
model_map: dict[str, CompiledModel] = context.models_by_name
60-
fixture_sql_by_key: dict[FixtureKey, str] = {}
61-
inferred_by_fixture: dict[FixtureKey, tuple[InferredColumn, ...]] = {}
62-
null_literal_names_by_fixture: dict[FixtureKey, frozenset[str]] = {}
63-
quoted_names_by_fixture: dict[FixtureKey, frozenset[str]] = {}
64-
for resource_type, fixtures in fixture_groups:
65-
for name, sql in fixtures.items():
66-
key: FixtureKey = (resource_type, name)
67-
fixture_sql_by_key[key] = sql
68-
inference: FixtureColumnInference | None = infer_fixture_column_facts(
69-
query_sql=sql,
70-
inference_profile=adapter.expression_inference_profile(),
71-
)
72-
if inference is not None:
73-
inferred_by_fixture[key] = inference.columns
74-
null_literal_names_by_fixture[key] = inference.null_literal_names
75-
quoted_names_by_fixture[key] = inference.quoted_names
76-
7760
relations: dict[FixtureKey, FixtureRelationMetadata] = context.relations
61+
fixture_sql_by_key: dict[FixtureKey, str]
62+
inferred_by_fixture: dict[FixtureKey, tuple[InferredColumn, ...]]
63+
null_literal_names_by_fixture: dict[FixtureKey, frozenset[str]]
64+
quoted_names_by_fixture: dict[FixtureKey, frozenset[str]]
65+
diagnostics: list[RelationFixtureDiagnostic]
66+
(
67+
fixture_sql_by_key,
68+
inferred_by_fixture,
69+
null_literal_names_by_fixture,
70+
quoted_names_by_fixture,
71+
diagnostics,
72+
) = _prepare_fixture_inputs(
73+
fixture_groups=fixture_groups,
74+
relations=relations,
75+
adapter=adapter,
76+
)
77+
7878
fixture_keys_requiring_analysis: frozenset[FixtureKey] = _fixture_keys_requiring_analysis(
7979
inferred_by_fixture=inferred_by_fixture,
8080
authoritative_columns=context.authoritative_columns,
@@ -98,10 +98,9 @@ def build_relation_fixture_completion(
9898
null_literal_names_by_fixture=null_literal_names_by_fixture,
9999
expected_types=expected_types,
100100
)
101-
diagnostics: list[RelationFixtureDiagnostic] = list(
101+
diagnostics.extend(
102102
_unknown_fixture_column_diagnostics(
103-
inferred_by_fixture=inferred_by_fixture,
104-
relations=relations,
103+
inferred_by_fixture=inferred_by_fixture, relations=relations
105104
)
106105
)
107106

@@ -212,6 +211,116 @@ def build_relation_fixture_completion(
212211
)
213212

214213

214+
def _prepare_fixture_inputs(
215+
*,
216+
fixture_groups: FixtureGroups,
217+
relations: dict[FixtureKey, FixtureRelationMetadata],
218+
adapter: BaseAdapter,
219+
) -> tuple[
220+
dict[FixtureKey, str],
221+
dict[FixtureKey, tuple[InferredColumn, ...]],
222+
dict[FixtureKey, frozenset[str]],
223+
dict[FixtureKey, frozenset[str]],
224+
list[RelationFixtureDiagnostic],
225+
]:
226+
fixture_sql_by_key: dict[FixtureKey, str] = {}
227+
inferred_by_fixture: dict[FixtureKey, tuple[InferredColumn, ...]] = {}
228+
null_literal_names_by_fixture: dict[FixtureKey, frozenset[str]] = {}
229+
quoted_names_by_fixture: dict[FixtureKey, frozenset[str]] = {}
230+
diagnostics: list[RelationFixtureDiagnostic] = []
231+
for resource_type, fixtures in fixture_groups:
232+
for name, sql in fixtures.items():
233+
key: FixtureKey = (resource_type, name)
234+
fixture_sql: str = sql
235+
if is_empty_fixture_query(fixture_sql):
236+
empty_sql, empty_error = complete_empty_fixture_sql(
237+
sql=fixture_sql,
238+
relation=relations.get(key),
239+
adapter=adapter,
240+
)
241+
if empty_error is not None:
242+
diagnostics.append(
243+
RelationFixtureDiagnostic(
244+
key=key,
245+
message=f"mock {key[0].value} '{key[1]}' {empty_error}",
246+
)
247+
)
248+
fixture_sql_by_key[key] = fixture_sql
249+
continue
250+
fixture_sql = empty_sql
251+
fixture_sql_by_key[key] = fixture_sql
252+
inference: FixtureColumnInference | None = infer_fixture_column_facts(
253+
query_sql=fixture_sql,
254+
inference_profile=adapter.expression_inference_profile(),
255+
)
256+
if inference is not None:
257+
inferred_by_fixture[key] = inference.columns
258+
null_literal_names_by_fixture[key] = inference.null_literal_names
259+
quoted_names_by_fixture[key] = inference.quoted_names
260+
return (
261+
fixture_sql_by_key,
262+
inferred_by_fixture,
263+
null_literal_names_by_fixture,
264+
quoted_names_by_fixture,
265+
diagnostics,
266+
)
267+
268+
269+
def complete_typed_null_columns(
270+
*,
271+
sql: str,
272+
inference: FixtureColumnInference,
273+
expected_types: dict[str, str],
274+
adapter: BaseAdapter,
275+
) -> str:
276+
"""Apply authoritative types to direct bare-NULL projections."""
277+
278+
typed_nulls: dict[str, str] = {
279+
name: expected_types[name]
280+
for name in inference.null_literal_names
281+
if name in expected_types
282+
}
283+
if not typed_nulls:
284+
return sql
285+
return _completed_fixture_sql(
286+
sql=sql,
287+
inferred=inference.columns,
288+
typed_nulls=typed_nulls,
289+
quoted_names=inference.quoted_names,
290+
completed=(),
291+
adapter=adapter,
292+
)
293+
294+
295+
def complete_empty_fixture_sql(
296+
*,
297+
sql: str,
298+
relation: FixtureRelationMetadata | None,
299+
adapter: BaseAdapter,
300+
) -> tuple[str, str | None]:
301+
"""Expand an empty-fixture marker from authoritative relation metadata."""
302+
303+
if not is_empty_fixture_query(sql):
304+
return sql, None
305+
if relation is None or not relation.authoritative_names:
306+
return sql, "uses __empty_fixture() but its column schema is not authoritative"
307+
unknown_types: tuple[str, ...] = tuple(
308+
column.name for column in relation.columns if column.type is None
309+
)
310+
if unknown_types:
311+
return (
312+
sql,
313+
f"uses __empty_fixture() but columns have unknown types: {', '.join(unknown_types)}",
314+
)
315+
if not relation.columns:
316+
return sql, "uses __empty_fixture() but its schema has no columns"
317+
projections: str = ",\n ".join(
318+
f"CAST(NULL AS {column.type}) AS {adapter.render_identifier(column.name)}"
319+
for column in relation.columns
320+
)
321+
return f"SELECT\n {projections}\nWHERE FALSE", None
322+
323+
215324
def _typed_null_fixture_types(
216325
*,
217326
null_literal_names_by_fixture: dict[FixtureKey, frozenset[str]],
@@ -520,7 +629,7 @@ def _model_fixture_metadata(*, model: CompiledModel) -> FixtureRelationMetadata:
520629
columns_by_name[key] = FixtureColumnMetadata(
521630
name=column.name,
522631
type=column.type,
523-
nullable=_inferred_nullable(column.nullability),
632+
nullable=None,
524633
)
525634
for column in model.schema_entry.columns if model.schema_entry is not None else ():
526635
key = column.name.casefold()
@@ -621,14 +730,6 @@ def _unknown_fixture_column_diagnostics(
621730
return tuple(diagnostics)
622731

623732

624-
def _inferred_nullable(value: InferredNullability) -> bool | None:
625-
if value == InferredNullability.NULLABLE:
626-
return True
627-
if value == InferredNullability.NON_NULL:
628-
return False
629-
return None
630-
631-
632733
def _models_reading_columns(
633734
*, column_names: tuple[str, ...], required_columns: dict[str, set[str]]
634735
) -> tuple[str, ...]:
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Recognition for the planner's empty relation fixture marker."""
2+
3+
import re
4+
5+
_EMPTY_FIXTURE_PATTERN: re.Pattern[str] = re.compile(
6+
r"^\s*SELECT\s+\*\s+FROM\s+__empty_fixture\s*\(\s*\)\s*$",
7+
re.IGNORECASE,
8+
)
9+
10+
11+
def is_empty_fixture_query(sql: str) -> bool:
12+
"""Return whether SQL is exactly the empty relation fixture marker."""
13+
14+
return _EMPTY_FIXTURE_PATTERN.fullmatch(sql) is not None

‎src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def plan_test(
240240
mock_ref_names=frozenset(mock_refs),
241241
)
242242
if sql_analysis_enabled and validate_fixtures:
243-
mock_refs, mock_sources, mock_seeds = build_validated_test_fixtures(
243+
mock_refs, mock_sources, mock_seeds, expected_map = build_validated_test_fixtures(
244244
test=test,
245245
project=project,
246246
adapter=adapter,

0 commit comments

Comments
 (0)