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.
- sqlassert_dbt/__init__.py +19 -0
- sqlassert_dbt/__main__.py +115 -0
- sqlassert_dbt/expectations.py +336 -0
- sqlassert_dbt/jinja.py +183 -0
- sqlassert_dbt/manifest.py +254 -0
- sqlassert_dbt/program.py +686 -0
- sqlassert_dbt/project.py +340 -0
- sqlassert_dbt/report.py +158 -0
- sqlassert_dbt-0.3.1.dist-info/METADATA +153 -0
- sqlassert_dbt-0.3.1.dist-info/RECORD +14 -0
- sqlassert_dbt-0.3.1.dist-info/WHEEL +5 -0
- sqlassert_dbt-0.3.1.dist-info/entry_points.txt +2 -0
- sqlassert_dbt-0.3.1.dist-info/licenses/LICENSE +21 -0
- sqlassert_dbt-0.3.1.dist-info/top_level.txt +1 -0
sqlassert_dbt/project.py
ADDED
|
@@ -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))
|
sqlassert_dbt/report.py
ADDED
|
@@ -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,,
|