sqlassert-dbt 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ """Analyze a dbt project from its `target/manifest.json`, without dbt.
2
+
3
+ Each model is analyzed in a Program of its own (`program.py`): its own tests
4
+ are Assertions, the passing tests of what it reads are Property Declarations.
5
+ An incremental model is analyzed once per `is_incremental()` branch, and a
6
+ claim about it holds only when it holds in both.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from sqlassert_dbt.expectations import Expectation, PropertyKind, expectations
16
+ from sqlassert_dbt.manifest import Manifest, Relation, load_manifest
17
+ from sqlassert_dbt.program import QUERY_MATERIALIZATIONS, Body, BranchResult, Builder, branches, raw_line_of
18
+ from sqlassert_dbt.report import DbtReport, Location, MarkerOutcome, Problem, Status, TestOutcome, Untested
19
+ from sqlassert.dialects import MODELLED_DIALECTS
20
+ from sqlassert.ir.model import LiteralValue
21
+ from sqlassert.reporting import Outcome
22
+ from sqlassert.sql_parse import _marker_shapes
23
+
24
+ # The dialects sqlassert analyzes soundly: those `dialects.py` models. A dbt
25
+ # adapter maps onto its SQLGlot dialect; one not modelled is analyzed only
26
+ # under a dialect the user names explicitly.
27
+ SUPPORTED_DIALECTS = MODELLED_DIALECTS
28
+ _ADAPTER_DIALECTS = {
29
+ "duckdb": "duckdb", "postgres": "postgres", "redshift": "redshift", "snowflake": "snowflake", "bigquery": "bigquery",
30
+ "databricks": "databricks", "spark": "spark", "trino": "trino", "mysql": "mysql", "sqlserver": "tsql",
31
+ "fabric": "tsql", "athena": "athena", "clickhouse": "clickhouse", "oracle": "oracle",
32
+ }
33
+
34
+ # Incremental strategies whose table stays unique on `unique_key` when every
35
+ # batch is: matching rows are replaced, never appended.
36
+ _REPLACING_STRATEGIES = {"merge", "delete+insert"}
37
+
38
+ UNSUPPORTED_DIALECT = "dbt-unsupported-dialect"
39
+ UNANALYZED_MODEL = "dbt-unanalyzed-model"
40
+ UNANALYZED_BRANCH = "dbt-unanalyzed-branch"
41
+ UNKNOWN_COLUMNS = "dbt-unknown-columns"
42
+ EPHEMERAL_NOT_LOCATED = "dbt-ephemeral-not-located"
43
+ UNTRUSTED_UPSTREAM_TESTS = "dbt-untrusted-upstream-tests"
44
+
45
+
46
+ class ProjectError(Exception):
47
+ """The project cannot be analyzed at all: no manifest, or an unreadable one."""
48
+
49
+
50
+ def analyze_project(
51
+ project_dir: Path, *, manifest_path: Path | None = None, dialect: str | None = None, trust_upstream_tests: bool = False,
52
+ ) -> DbtReport:
53
+ """Every test and marker of the dbt project at `project_dir`.
54
+
55
+ `dialect` overrides the one the manifest's adapter names; without it, an
56
+ adapter sqlassert does not support leaves every model unanalyzed.
57
+ `trust_upstream_tests` makes the passing tests of what a model reads facts
58
+ about it, which holds when they run before it, as under `dbt build`.
59
+ """
60
+ from sqlassert_dbt.manifest import ManifestError
61
+
62
+ path = manifest_path or project_dir / "target" / "manifest.json"
63
+ try:
64
+ manifest = load_manifest(path)
65
+ except ManifestError as error:
66
+ raise ProjectError(f"{error}; run `dbt compile` (or `dbt build`) first") from error
67
+ return _Analysis(manifest, tuple(expectations(manifest)), trust_upstream_tests).run(dialect)
68
+
69
+
70
+ @dataclass
71
+ class _Analysis:
72
+ manifest: Manifest
73
+ expectations: tuple[Expectation, ...]
74
+ trust_upstream_tests: bool = False
75
+
76
+ def run(self, requested: str | None) -> DbtReport:
77
+ problems: list[Problem] = []
78
+ dialect = self._dialect(requested, problems)
79
+ tests: list[TestOutcome] = []
80
+ markers: list[MarkerOutcome] = []
81
+ untested: list[Untested] = []
82
+ undeclared: set[str] = set()
83
+ builder = (
84
+ Builder(self.manifest.relations, self.expectations, dialect, self.trust_upstream_tests)
85
+ if dialect is not None else None
86
+ )
87
+ for relation in sorted(self.manifest.relations.values(), key=lambda relation: relation.unique_id):
88
+ own = [e for e in self.expectations if e.node == relation.unique_id]
89
+ if builder is None:
90
+ tests.extend(self._outcome(e, relation, Status.NOT_PROVED, "the project's dialect is not supported") for e in own)
91
+ continue
92
+ outcome = self._model(builder, relation, own)
93
+ tests.extend(outcome.tests)
94
+ markers.extend(outcome.markers)
95
+ untested.extend(outcome.untested)
96
+ problems.extend(outcome.problems)
97
+ undeclared |= outcome.undeclared
98
+ if builder is not None and builder.untrusted:
99
+ problems.append(Problem(
100
+ UNTRUSTED_UPSTREAM_TESTS,
101
+ f"{len(builder.untrusted)} passing test(s) of the relations models read were not used as facts, since nothing "
102
+ "says they ran before those models were built: pass --trust-upstream-tests when they do (dbt build), "
103
+ "or run through `sqlassert-dbt build`",
104
+ "warning",
105
+ ))
106
+ for node in sorted(undeclared):
107
+ relation = self.manifest.relations[node]
108
+ supply = (
109
+ "an enforced model contract listing every column's data_type declares them"
110
+ if relation.resource_type == "model"
111
+ else "only the warehouse's catalog lists them"
112
+ )
113
+ problems.append(Problem(
114
+ UNKNOWN_COLUMNS,
115
+ f"the columns of {relation.resource_type} {relation.label} are not all known from the manifest, so its keys "
116
+ f"and nullability come from its passing tests only: {supply}",
117
+ "warning", Location(relation.patch_path or relation.original_file_path), relation.label,
118
+ ))
119
+ return DbtReport(dialect, tuple(tests), tuple(markers), tuple(untested), tuple(_unique(problems)))
120
+
121
+ def _dialect(self, requested: str | None, problems: list[Problem]) -> str | None:
122
+ if requested is not None:
123
+ return requested
124
+ adapter = self.manifest.adapter_type
125
+ dialect = _ADAPTER_DIALECTS.get(adapter or "")
126
+ if dialect in SUPPORTED_DIALECTS:
127
+ return dialect
128
+ problems.append(Problem(
129
+ UNSUPPORTED_DIALECT,
130
+ f"the {adapter or 'unknown'} adapter's SQL dialect is not supported (supported: {', '.join(sorted(SUPPORTED_DIALECTS))}), "
131
+ "so no model was analyzed; pass --dialect to analyze it under a dialect whose semantics you vouch for",
132
+ "error",
133
+ ))
134
+ return None
135
+
136
+ def _model(self, builder: Builder, relation: Relation, own: list[Expectation]) -> _ModelOutcome:
137
+ outcome = _ModelOutcome()
138
+ if relation.resource_type != "model":
139
+ outcome.tests.extend(
140
+ self._outcome(e, relation, Status.NOT_PROVED, f"a {relation.resource_type} is a table: only its own data shows this")
141
+ for e in own
142
+ )
143
+ return outcome
144
+ if relation.language != "sql" or relation.compiled_code is None:
145
+ why = f"it is a {relation.language} model" if relation.language != "sql" else "it has no compiled code; run `dbt compile`"
146
+ # Its own markers go unchecked: that fails the check, not a fact a connection could supply.
147
+ severity = "error" if relation.language == "sql" and _has_markers(relation.raw_code) else "warning"
148
+ outcome.problems.append(Problem(
149
+ UNANALYZED_MODEL, f"model {relation.name} was not analyzed: {why}", severity,
150
+ Location(relation.original_file_path), relation.name,
151
+ ))
152
+ outcome.tests.extend(self._outcome(e, relation, Status.NOT_PROVED, "its model was not analyzed") for e in own)
153
+ return outcome
154
+
155
+ found = branches(relation, self.manifest.relations)
156
+ for warning in found.warnings:
157
+ outcome.problems.append(Problem(EPHEMERAL_NOT_LOCATED, warning, "warning", Location(relation.original_file_path), relation.name))
158
+ if found.unrendered is not None:
159
+ outcome.problems.append(Problem(
160
+ UNANALYZED_BRANCH, f"model {relation.name}: {found.unrendered}", "warning", Location(relation.original_file_path), relation.name,
161
+ ))
162
+ analyzed = tuple(e for e in own if e.asserted is not None and e.unprovable is None)
163
+ results = [(body, builder.analyze(relation, body, analyzed)) for body in found.bodies]
164
+ complete = found.unrendered is None
165
+ for _, result in results:
166
+ outcome.undeclared |= result.undeclared
167
+
168
+ for expectation in own:
169
+ outcome.tests.append(self._test(expectation, relation, results, complete, found.unrendered))
170
+ outcome.markers.extend(self._markers(relation, results, found.unrendered))
171
+ outcome.problems.extend(self._problems(relation, results))
172
+ if complete and relation.materialized != "ephemeral":
173
+ outcome.untested.extend(self._untested(relation, own, results))
174
+ return outcome
175
+
176
+ def _outcome(self, e: Expectation, relation: Relation, status: Status, reason: str, explanation: str = "") -> TestOutcome:
177
+ return TestOutcome(e.unique_id, e.name, e.kind, relation.label, Location(e.path), status, reason, explanation)
178
+
179
+ def _test(
180
+ self, e: Expectation, relation: Relation, results: list[tuple[Body, BranchResult]], complete: bool, unrendered: str | None,
181
+ ) -> TestOutcome:
182
+ if e.asserted is None or e.unprovable is not None:
183
+ return self._outcome(e, relation, Status.NOT_PROVED, e.unprovable or "it states no Property")
184
+ found = [test for _, result in results if (test := result.tests.get(e.unique_id)) is not None]
185
+ failed = next((test for test in found if not test.proved), None)
186
+ if failed is not None or len(found) != len(results):
187
+ reason = "its Property is not proved from the SQL"
188
+ return self._outcome(e, relation, Status.NOT_PROVED, reason, failed.explanation if failed else "")
189
+ explanation = found[0].explanation
190
+ if not complete:
191
+ return self._outcome(e, relation, Status.NOT_PROVED, unrendered or "a branch was not analyzed", explanation)
192
+ reason = self._materialization_gap(e, relation)
193
+ if reason is not None:
194
+ return self._outcome(e, relation, Status.NOT_PROVED, reason, explanation)
195
+ return self._outcome(e, relation, Status.PROVED, "", explanation)
196
+
197
+ def _materialization_gap(self, e: Expectation, relation: Relation) -> str | None:
198
+ """Why a Property of the model's query is not one of its relation, or None."""
199
+ if relation.materialized in QUERY_MATERIALIZATIONS | {"ephemeral"}:
200
+ return None
201
+ if relation.materialized != "incremental":
202
+ return f"a {relation.materialized} materialization's rows are not known to be its query's"
203
+ match e.asserted:
204
+ case "not_null" | "enum":
205
+ return None # Every row of the table came from one batch.
206
+ case "unique" | "key":
207
+ if relation.incremental_predicates:
208
+ return (
209
+ "its incremental_predicates can keep an earlier row out of the match on its unique_key, "
210
+ "so a key of every batch is not one of the table"
211
+ )
212
+ key = {column.lower() for column in relation.unique_key}
213
+ if relation.incremental_strategy in _REPLACING_STRATEGIES and key == {column.lower() for column in e.columns}:
214
+ return None
215
+ return (
216
+ "an incremental table keeps rows of earlier runs, so a key of every batch is a key of the table only "
217
+ "under a merge or delete+insert strategy on that unique_key"
218
+ )
219
+ case _:
220
+ return "an incremental table keeps rows of earlier runs, whose references may since have gone"
221
+
222
+ def _markers(self, relation: Relation, results: list[tuple[Body, BranchResult]], unrendered: str | None) -> list[MarkerOutcome]:
223
+ grouped: dict[tuple, list] = {}
224
+ for index, (_, result) in enumerate(results):
225
+ for marker in result.markers:
226
+ key = (
227
+ (marker.raw_line, marker.place, marker.marker) if marker.raw_line is not None
228
+ else (None, index, marker.line, marker.place, marker.marker)
229
+ )
230
+ grouped.setdefault(key, []).append(marker)
231
+ found = []
232
+ for markers in grouped.values():
233
+ first = markers[0]
234
+ failed = next((marker for marker in markers if marker.outcome is Outcome.UNKNOWN), None)
235
+ outcome, explanation = (Outcome.UNKNOWN, failed.explanation) if failed else (Outcome.PROVED, first.explanation)
236
+ if outcome is Outcome.PROVED and unrendered is not None:
237
+ outcome, explanation = Outcome.UNKNOWN, f"{explanation}\nnot proved for every run: {unrendered}"
238
+ location = (
239
+ Location(relation.original_file_path, first.raw_line) if first.raw_line is not None
240
+ else Location(relation.compiled_path or relation.original_file_path, first.line)
241
+ )
242
+ found.append(MarkerOutcome(relation.name, location, first.marker, outcome, explanation))
243
+ return found
244
+
245
+ def _problems(self, relation: Relation, results: list[tuple[Body, BranchResult]]) -> list[Problem]:
246
+ found = []
247
+ for body, result in results:
248
+ for problem in result.problems:
249
+ diagnostic = problem.diagnostic
250
+ if problem.raw_line is not None:
251
+ location = Location(relation.original_file_path, problem.raw_line)
252
+ elif problem.line is not None:
253
+ location = Location(relation.compiled_path or relation.original_file_path, problem.line)
254
+ else:
255
+ location = Location(relation.original_file_path)
256
+ message = _raw_lines_in(diagnostic.message, body) if problem.raw_line is not None else diagnostic.message
257
+ found.append(Problem(diagnostic.code, message, "warning" if problem.warning else "error", location, relation.name))
258
+ return found
259
+
260
+ def _untested(self, relation: Relation, own: list[Expectation], results: list[tuple[Body, BranchResult]]) -> list[Untested]:
261
+ incremental = relation.materialized == "incremental"
262
+ if relation.materialized not in QUERY_MATERIALIZATIONS and not incremental:
263
+ return []
264
+ additions = [
265
+ {(a.kind, a.columns, a.values): a for a in result.additions
266
+ if not incremental or a.kind in {"not_null", "enum"}}
267
+ for _, result in results
268
+ ]
269
+ common = [addition for key, addition in additions[0].items() if all(key in other for other in additions[1:])]
270
+ stated = [(e.asserted, {c.lower() for c in e.columns}) for e in own]
271
+ location = Location(relation.patch_path or relation.original_file_path)
272
+ found = []
273
+ for addition in common:
274
+ columns = {column.lower() for column in addition.columns}
275
+ entry = _schema_entry(addition.kind, addition.columns, addition.values, stated, columns)
276
+ if entry is not None:
277
+ column = addition.columns[0] if len(addition.columns) == 1 else None
278
+ found.append(Untested(relation.name, location, column, entry, addition.explanation))
279
+ return found
280
+
281
+
282
+ @dataclass
283
+ class _ModelOutcome:
284
+ tests: list[TestOutcome]
285
+ markers: list[MarkerOutcome]
286
+ untested: list[Untested]
287
+ problems: list[Problem]
288
+ undeclared: set[str]
289
+
290
+ def __init__(self) -> None:
291
+ self.tests, self.markers, self.untested, self.problems, self.undeclared = [], [], [], [], set()
292
+
293
+
294
+ def _schema_entry(
295
+ kind: PropertyKind, columns: tuple[str, ...], values: tuple[LiteralValue, ...],
296
+ stated: list[tuple[PropertyKind | None, set[str]]], members: set[str],
297
+ ) -> str | None:
298
+ """The schema.yml test stating a proved Property, None when a test already
299
+ states it or no standard test does (a nullable composite Unique Set)."""
300
+ def tested(*kinds: str) -> bool:
301
+ return any(asserted in kinds and found == members for asserted, found in stated)
302
+
303
+ match kind:
304
+ case "not_null":
305
+ return None if tested("not_null") or any(a == "key" and members <= found for a, found in stated) else "not_null"
306
+ case "enum":
307
+ if tested("enum"):
308
+ return None
309
+ listed = ", ".join(value.sql() if value.type == "string" else value.text for value in values)
310
+ quote = "" if all(value.type == "string" for value in values) else ", quote: false"
311
+ return f"accepted_values: {{values: [{listed}]{quote}}}"
312
+ case "unique" | "key" if len(columns) == 1:
313
+ return None if tested("unique", "key") else "unique"
314
+ case "key":
315
+ if tested("unique", "key"):
316
+ return None
317
+ return f"dbt_utils.unique_combination_of_columns: {{combination_of_columns: [{', '.join(columns)}]}}"
318
+ case _:
319
+ return None
320
+
321
+
322
+ def _raw_lines_in(message: str, body: Body) -> str:
323
+ """`message` with each body line it names replaced by the model file's line."""
324
+ def replaced(match: re.Match[str]) -> str:
325
+ line = raw_line_of(body, int(match.group(1)))
326
+ return f"line {line}" if line is not None else match.group(0)
327
+
328
+ return re.sub(r"\bline (\d+)\b", replaced, message)
329
+
330
+
331
+ def _unique(problems: list[Problem]) -> list[Problem]:
332
+ return list(dict.fromkeys(problems))
333
+
334
+
335
+ _PROPERTY_MARKER = re.compile(r"/\*\*\s*(ASSERT|DECLARE)\b", re.IGNORECASE)
336
+
337
+
338
+ def _has_markers(sql: str) -> bool:
339
+ """Whether `sql` writes a Property Marker."""
340
+ return any(_PROPERTY_MARKER.match(match.group()) for match in _marker_shapes(sql))
@@ -0,0 +1,158 @@
1
+ """What analyzing a dbt project found, as plain values, and its text rendering.
2
+
3
+ Every item has a `Location` (a project-relative path and a line when known),
4
+ so each maps onto a `check` finding: a path, a line, a code, a message, and a
5
+ severity.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from typing import Literal
13
+
14
+ from sqlassert.check import EXIT_FINDINGS, EXIT_OK
15
+ from sqlassert.reporting import Outcome
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Location:
20
+ path: str
21
+ line: int | None = None
22
+
23
+ def __str__(self) -> str:
24
+ return self.path if self.line is None else f"{self.path}:{self.line}"
25
+
26
+
27
+ class Status(Enum):
28
+ PROVED = "proved"
29
+ NOT_PROVED = "not proved"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TestOutcome:
34
+ """A dbt test or contract constraint, checked against its model's SQL.
35
+
36
+ A PROVED test can never fail, so dbt may skip it; a NOT_PROVED one still
37
+ carries coverage only the warehouse run gives. `reason` says why it was
38
+ not proved; `explanation` is the proof or the failed attempt.
39
+ """
40
+
41
+ unique_id: str
42
+ name: str
43
+ kind: Literal["test", "constraint"]
44
+ model: str
45
+ location: Location
46
+ status: Status
47
+ reason: str = ""
48
+ explanation: str = ""
49
+
50
+ @property
51
+ def skippable(self) -> bool:
52
+ return self.kind == "test" and self.status is Status.PROVED
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class MarkerOutcome:
57
+ """A Property Marker written in a model file. `location` is the model file
58
+ when its line is known, else the compiled file and its line."""
59
+
60
+ model: str
61
+ location: Location
62
+ marker: str
63
+ outcome: Outcome
64
+ explanation: str = ""
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Untested:
69
+ """A Property proved of a model that none of its tests states: `test` is
70
+ the entry to add under the column (or, with no column, the model) in `location`."""
71
+
72
+ model: str
73
+ location: Location
74
+ column: str | None
75
+ test: str
76
+ explanation: str = ""
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Problem:
81
+ """A Diagnostic: a part of the project analysis could not model. A warning
82
+ names a fact a connection or the project could supply; it never fails. An
83
+ error fails the check: a model whose own markers went unchecked."""
84
+
85
+ code: str
86
+ message: str
87
+ severity: Literal["error", "warning"]
88
+ location: Location | None = None
89
+ model: str | None = None
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class DbtReport:
94
+ dialect: str | None
95
+ tests: tuple[TestOutcome, ...] = ()
96
+ markers: tuple[MarkerOutcome, ...] = ()
97
+ untested: tuple[Untested, ...] = ()
98
+ problems: tuple[Problem, ...] = ()
99
+
100
+ @property
101
+ def failed(self) -> bool:
102
+ """A marker not proved, or an error. Tests not proved do not fail it:
103
+ the warehouse run still checks them."""
104
+ return any(marker.outcome is Outcome.UNKNOWN for marker in self.markers) or any(
105
+ problem.severity == "error" for problem in self.problems
106
+ )
107
+
108
+
109
+ def exit_code(report: DbtReport, *, require_proved: bool = False) -> int:
110
+ """`EXIT_FINDINGS` when the report failed or, under `require_proved`, a
111
+ test is not proved: for a project whose every test the SQL must back."""
112
+ unproved = require_proved and any(test.kind == "test" and test.status is Status.NOT_PROVED for test in report.tests)
113
+ return EXIT_FINDINGS if report.failed or unproved else EXIT_OK
114
+
115
+
116
+ def format_text(report: DbtReport, *, explain: bool = False) -> str:
117
+ lines: list[str] = []
118
+
119
+ def evidence(text: str) -> None:
120
+ if explain and text:
121
+ lines.extend(" " + line for line in text.splitlines())
122
+
123
+ for marker in report.markers:
124
+ lines.append(f"{marker.location}: {marker.outcome.value.upper()} {marker.marker} ({marker.model})")
125
+ if marker.outcome is Outcome.UNKNOWN or explain:
126
+ evidence(marker.explanation)
127
+ for problem in report.problems:
128
+ where = f"{problem.location}: " if problem.location is not None else ""
129
+ lines.append(f"{where}{problem.severity} {problem.code}: {problem.message}")
130
+ proved = [test for test in report.tests if test.status is Status.PROVED]
131
+ unproved = [test for test in report.tests if test.status is Status.NOT_PROVED]
132
+ if proved:
133
+ lines.append("")
134
+ lines.append("Proved statically (a test here can be skipped):")
135
+ for test in proved:
136
+ lines.append(f" {test.location} {test.name} ({test.kind} on {test.model})")
137
+ evidence(test.explanation)
138
+ if unproved:
139
+ lines.append("")
140
+ lines.append("Not proved (the warehouse run still checks these):")
141
+ for test in unproved:
142
+ lines.append(f" {test.location} {test.name} ({test.kind} on {test.model}): {test.reason}")
143
+ evidence(test.explanation)
144
+ if report.untested:
145
+ lines.append("")
146
+ lines.append("Proved but untested (schema.yml additions):")
147
+ for item in report.untested:
148
+ target = f"{item.model}.{item.column}" if item.column else item.model
149
+ lines.append(f" {item.location} {target}: {item.test}")
150
+ evidence(item.explanation)
151
+ markers_unknown = sum(marker.outcome is Outcome.UNKNOWN for marker in report.markers)
152
+ lines.append("")
153
+ lines.append(
154
+ f"{len(proved)} proved, {len(unproved)} not proved, {len(report.untested)} untested properties, "
155
+ f"{len(report.markers)} markers ({markers_unknown} unknown), "
156
+ f"{sum(p.severity == 'error' for p in report.problems)} errors, {sum(p.severity == 'warning' for p in report.problems)} warnings"
157
+ )
158
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlassert-dbt
3
+ Version: 0.3.1
4
+ Summary: Check a dbt project's tests and sqlassert markers from its compiled manifest.
5
+ Author: Fred Israel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/polvoazul/sqlassert/tree/main/integrations/dbt
8
+ Project-URL: Issues, https://github.com/polvoazul/sqlassert/issues
9
+ Keywords: dbt,sql,static-analysis,data-quality,sqlassert
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Topic :: Database
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.13
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: sqlassert
18
+ Requires-Dist: sqlglot<31,>=30.30
19
+ Provides-Extra: test
20
+ Requires-Dist: dbt-core>=1.12; extra == "test"
21
+ Requires-Dist: dbt-duckdb>=1.11; extra == "test"
22
+ Requires-Dist: pytest; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # sqlassert-dbt
26
+
27
+ Prove your dbt tests from the SQL, and skip the ones that can't fail.
28
+
29
+ A `unique` or `not_null` test scans a table in the warehouse on every run, and
30
+ it only tells you about today's data. Many of those tests follow from the SQL
31
+ itself: `customer_id` is unique because the model groups by it, `status` is
32
+ never null because of a `coalesce`. [sqlassert](https://github.com/polvoazul/sqlassert)
33
+ proves them from the compiled model before anything runs, so dbt can leave them
34
+ out: faster CI, a smaller warehouse bill, and the same guarantee for *any*
35
+ data, not just the rows there today.
36
+
37
+ ```bash
38
+ pip install sqlassert-dbt
39
+ ```
40
+
41
+ _Alpha: the markers and dbt test mappings are stable; the CLI output may
42
+ change._
43
+
44
+ ## Skip the tests the SQL proves
45
+
46
+ Run dbt through the plugin:
47
+
48
+ ```bash
49
+ sqlassert-dbt build . -- --target prod # or: sqlassert-dbt test
50
+ ```
51
+
52
+ It runs `dbt compile`, checks every model, then runs `dbt build` (or
53
+ `dbt test`) with each proved test passed as `--exclude`. Arguments after `--`
54
+ go to dbt unchanged. The exit code is dbt's when dbt fails, otherwise the
55
+ check's.
56
+
57
+ The tests of the sources, seeds and models a model reads are trusted as
58
+ facts, since `dbt build` runs them first in the same invocation. If the
59
+ arguments after `--` select nodes (`--select`, `--exclude`, `--selector`, ...),
60
+ an upstream test may not run, so none is trusted.
61
+
62
+ ## Check without dbt
63
+
64
+ `check` reads only `target/manifest.json`, so dbt does not need to be installed
65
+ where it runs (a CI step after `dbt compile`, say):
66
+
67
+ ```bash
68
+ $ dbt compile
69
+ $ sqlassert-dbt check . --trust-upstream-tests
70
+ models/customers.sql:12: UNKNOWN /**ASSERT NOT NULL(first_name)**/ (customers)
71
+
72
+ Proved statically (a test here can be skipped):
73
+ models/schema.yml unique_stg_customers_customer_id (test on stg_customers)
74
+
75
+ Not proved (the warehouse run still checks these):
76
+ models/schema.yml accepted_values_stg_orders_status__placed__shipped__returned (test on stg_orders): its Property is not proved from the SQL
77
+
78
+ Proved but untested (schema.yml additions):
79
+ models/schema.yml customers.customer_id: not_null
80
+ ```
81
+
82
+ Three lists come out:
83
+
84
+ - **Proved statically:** tests the SQL already guarantees. Skip them.
85
+ - **Not proved:** tests that still need the warehouse. Nothing is wrong; the
86
+ SQL alone can't show the property.
87
+ - **Proved but untested:** properties sqlassert proved that no test states.
88
+ Free `schema.yml` additions, which cost nothing to keep.
89
+
90
+ | Option | |
91
+ |---|---|
92
+ | `--trust-upstream-tests` | Treat passing tests of upstream nodes as facts (true under `dbt build`). Without it, a `dbt-untrusted-upstream-tests` warning lists them. |
93
+ | `--require-proved` | Fail when any test is not proved, not only when a marker is `UNKNOWN`. |
94
+ | `--explain` | Print each proof, and each failed proof. |
95
+ | `--dialect NAME` | Analyze under this dialect instead of the adapter's. |
96
+ | `--manifest PATH` | Read another manifest (default `PROJECT/target/manifest.json`). |
97
+
98
+ Exit codes: `0` every marker proved; `1` a marker is `UNKNOWN` or an error was
99
+ reported (unproved tests fail it only with `--require-proved`); `2` the
100
+ manifest cannot be read.
101
+
102
+ ## What it understands
103
+
104
+ | dbt | sqlassert Property |
105
+ |---|---|
106
+ | `unique` | Unique Set |
107
+ | `not_null` | Not Null |
108
+ | `accepted_values` | Enum |
109
+ | `relationships` | Referential Coverage (Foreign Key) |
110
+ | `dbt_utils.unique_combination_of_columns` | Non-Null Unique Set (it counts NULL equal to NULL) |
111
+ | contract `primary_key`, `unique`, `not_null`, `foreign_key` | the same Properties |
112
+
113
+ A model's own tests are what gets proved. Upstream tests are evidence, used
114
+ only when they fail on any failing row: not a test with `severity: warn`, a
115
+ `where:` config, or `error_if`/`fail_calc` thresholds. A constraint counts only
116
+ on a contracted table whose adapter enforces it.
117
+
118
+ ## Markers in models
119
+
120
+ You can also write sqlassert markers straight into a model, and they are checked
121
+ and reported at their line in the model file:
122
+
123
+ ```sql
124
+ -- models/customer_orders.sql
125
+ select c.customer_id, c.first_name, o.order_id
126
+ from {{ ref('stg_orders') }} o
127
+ /**ASSERT MANY TO ONE**/ join {{ ref('stg_customers') }} c
128
+ on o.customer_id = c.customer_id
129
+ ```
130
+
131
+ If someone later loads duplicate customers upstream, or drops the `unique`
132
+ test that made this provable, the marker turns `UNKNOWN` and CI fails before
133
+ the join fans out in production. See the
134
+ [sqlassert README](https://github.com/polvoazul/sqlassert#readme) for every
135
+ marker.
136
+
137
+ ## Details
138
+
139
+ - **Manifest:** only the latest schema (v12, written by dbt 1.8 to 1.12).
140
+ - **Ephemeral models** are analyzed where dbt inlined them.
141
+ - **Incremental models** are analyzed in both `is_incremental()` branches. A
142
+ test holds only if it holds in both, and a key only under a `merge` or
143
+ `delete+insert` strategy on that `unique_key` with no
144
+ `incremental_predicates`. The other branch is rendered without dbt only when
145
+ the model uses `ref`, `source`, `this`, `config` and `is_incremental()`
146
+ alone; otherwise it is reported unanalyzed.
147
+ - **Adapters:** analyzed when sqlassert models their dialect (DuckDB,
148
+ PostgreSQL, MySQL, Snowflake, BigQuery, Databricks / Spark, Redshift, Trino).
149
+ - A model with markers but no compiled code is an error, not skipped.
150
+
151
+ ## License
152
+
153
+ MIT
@@ -0,0 +1,14 @@
1
+ sqlassert_dbt/__init__.py,sha256=wNTLoPSO-V8sYtEVlxok9oGMDmGAJTUhZpdsl_ZB5qo,864
2
+ sqlassert_dbt/__main__.py,sha256=et37YAtNV-b3k9bGl0UJrTELxH38MFS52SRIRaMxzm8,5374
3
+ sqlassert_dbt/expectations.py,sha256=6mubjTwnwpPDBl1P5V2ImB7FwpFKK0grL6oHp7eOoWg,15480
4
+ sqlassert_dbt/jinja.py,sha256=L5m_9Tf85HH-VJGOMQ0NUWS7BT782-eJOoL_ppCK-Vk,7029
5
+ sqlassert_dbt/manifest.py,sha256=YFwXrxdpSKvNlimdqMWnDqRfINAWaUdgUB2YzuqVPWU,9472
6
+ sqlassert_dbt/program.py,sha256=A6wv01ywKQTW_9LZK-GSA--TYxR3RwMUG33g0VJvpWo,32312
7
+ sqlassert_dbt/project.py,sha256=S6JEXfxKtdOScouQoepEdj5tLhuvdPOzq1AwXg9nSro,17732
8
+ sqlassert_dbt/report.py,sha256=mx4xy_XPXjo7QzViXUx5-3hJX5L-vE_7IpyFk6Wrok8,5600
9
+ sqlassert_dbt-0.3.1.dist-info/licenses/LICENSE,sha256=KCR0_j-gr5ufUNxamIKNzeyyiI6g-S69oecRjZ07l_A,1068
10
+ sqlassert_dbt-0.3.1.dist-info/METADATA,sha256=rmazLHsDpx6SRz36VVNWIOos1wyelKHTaWjntGhmlMU,6148
11
+ sqlassert_dbt-0.3.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ sqlassert_dbt-0.3.1.dist-info/entry_points.txt,sha256=4J9EK9q9ozlR8A8fJj7oPJmYtvl5HzM0oXX49HDB_vk,62
13
+ sqlassert_dbt-0.3.1.dist-info/top_level.txt,sha256=w2-3Vq1UafNmPEBbCwdq4aZ-hsvRaBwKvouODJDNc1M,14
14
+ sqlassert_dbt-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlassert-dbt = sqlassert_dbt.__main__:main