sverify-dbt 0.7.0__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.
- sverify_dbt/__init__.py +30 -0
- sverify_dbt/check.py +568 -0
- sverify_dbt/cli.py +115 -0
- sverify_dbt/manifest.py +283 -0
- sverify_dbt/mapping.py +310 -0
- sverify_dbt/result.py +374 -0
- sverify_dbt-0.7.0.dist-info/METADATA +195 -0
- sverify_dbt-0.7.0.dist-info/RECORD +11 -0
- sverify_dbt-0.7.0.dist-info/WHEEL +4 -0
- sverify_dbt-0.7.0.dist-info/entry_points.txt +2 -0
- sverify_dbt-0.7.0.dist-info/licenses/LICENSE +373 -0
sverify_dbt/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Check a dbt project's tests and model contracts with sverify, before the
|
|
2
|
+
models run.
|
|
3
|
+
|
|
4
|
+
import sverify_dbt
|
|
5
|
+
|
|
6
|
+
result = sverify_dbt.check("path/to/project") # after `dbt compile`
|
|
7
|
+
print(result)
|
|
8
|
+
result.write_selectors("path/to/project/selectors.yml")
|
|
9
|
+
|
|
10
|
+
Each model's compiled SQL is proved with `sverify.verify` against the tests
|
|
11
|
+
on the models and sources it reads. A test proved there holds on every run
|
|
12
|
+
whose inputs pass their tests, so `dbt build` may skip it
|
|
13
|
+
(`dbt build --exclude selector:sverify_proved`).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from sverify_dbt.check import check
|
|
17
|
+
from sverify_dbt.manifest import Project, ProjectError, load
|
|
18
|
+
from sverify_dbt.result import CheckResult, ContractIssue, ModelResult, Needed, TestResult
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CheckResult",
|
|
22
|
+
"ContractIssue",
|
|
23
|
+
"ModelResult",
|
|
24
|
+
"Needed",
|
|
25
|
+
"Project",
|
|
26
|
+
"ProjectError",
|
|
27
|
+
"TestResult",
|
|
28
|
+
"check",
|
|
29
|
+
"load",
|
|
30
|
+
]
|
sverify_dbt/check.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Check a compiled dbt project with sverify: each model's tests and
|
|
2
|
+
contract, proved from the tests on what it reads.
|
|
3
|
+
|
|
4
|
+
Each model is checked on its own, as dbt builds it: its compiled SQL is
|
|
5
|
+
`sv.verify`'s transformation over its inputs (its `ref`s and `source`s), the
|
|
6
|
+
tests on those inputs are `requires`, and its own tests and contract
|
|
7
|
+
constraints are `ensures`. Under `dbt build` a failing test with severity
|
|
8
|
+
error skips every model downstream, so a model's inputs meet their tests
|
|
9
|
+
whenever it runs: they are its preconditions, proved or not.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import warnings
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import duckdb
|
|
21
|
+
import sqlglot as sg
|
|
22
|
+
import sverify as sv
|
|
23
|
+
|
|
24
|
+
from sverify_dbt import mapping
|
|
25
|
+
from sverify_dbt.manifest import Project, Relation, Test, load
|
|
26
|
+
from sverify_dbt.result import (
|
|
27
|
+
CheckResult,
|
|
28
|
+
ContractIssue,
|
|
29
|
+
ModelResult,
|
|
30
|
+
Needed,
|
|
31
|
+
TestResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check(
|
|
36
|
+
project: str | Path | Project,
|
|
37
|
+
*,
|
|
38
|
+
target_path: str | Path = "target",
|
|
39
|
+
select: Iterable[str] | None = None,
|
|
40
|
+
counterexamples: int = 200,
|
|
41
|
+
) -> CheckResult:
|
|
42
|
+
"""Check the tests and contracts of a dbt project after `dbt compile`.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
project: The dbt project's directory, or a `Project` already loaded.
|
|
46
|
+
target_path: The project's target directory, relative to it.
|
|
47
|
+
select: Model names to report on (all by default). Their upstream
|
|
48
|
+
models are checked too, since their tests are the inputs'.
|
|
49
|
+
counterexamples: How many small inputs sverify tries for each
|
|
50
|
+
unproved test; 0 skips the search.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A `CheckResult`: per model, the tests proved, not proved and not
|
|
54
|
+
checked, the contract, and the source tests it needs.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
sverify_dbt.ProjectError: No manifest, or not a DuckDB project.
|
|
58
|
+
"""
|
|
59
|
+
proj = project if isinstance(project, Project) else load(project, target_path)
|
|
60
|
+
return _Checker(proj, counterexamples).run(set(select) if select else None)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Checker:
|
|
64
|
+
def __init__(self, project: Project, counterexamples: int):
|
|
65
|
+
self.p = project
|
|
66
|
+
self.counterexamples = counterexamples
|
|
67
|
+
self.names = _input_names(project.relations.values())
|
|
68
|
+
self.con = duckdb.connect()
|
|
69
|
+
self.results: dict[str, ModelResult] = {}
|
|
70
|
+
self.proved: set[str] = set()
|
|
71
|
+
"""Unique ids of the tests proved so far, upstream first."""
|
|
72
|
+
self.created: dict[str, str | None] = {}
|
|
73
|
+
"""Per relation, None once it is a table or view of the scratch
|
|
74
|
+
database, else why not."""
|
|
75
|
+
|
|
76
|
+
def run(self, select: set[str] | None) -> CheckResult:
|
|
77
|
+
models = self.p.models
|
|
78
|
+
if select:
|
|
79
|
+
wanted = {m.unique_id for m in models if m.name in select}
|
|
80
|
+
unknown = select - {m.name for m in models}
|
|
81
|
+
if unknown:
|
|
82
|
+
raise ValueError(f"no model named {', '.join(sorted(unknown))}")
|
|
83
|
+
wanted = self._upstream(wanted)
|
|
84
|
+
models = [m for m in models if m.unique_id in wanted]
|
|
85
|
+
for m in models:
|
|
86
|
+
self.results[m.unique_id] = self._model(m)
|
|
87
|
+
shown = [r for r in self.results.values() if not select or r.name in select]
|
|
88
|
+
return CheckResult(
|
|
89
|
+
project=self.p.name,
|
|
90
|
+
root=str(self.p.root),
|
|
91
|
+
models=shown,
|
|
92
|
+
tests={t.unique_id: t for t in self.p.tests.values()},
|
|
93
|
+
unit_tests=self.p.unit_tests,
|
|
94
|
+
relations=self.p.relations,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _upstream(self, uids: set[str]) -> set[str]:
|
|
98
|
+
out = set(uids)
|
|
99
|
+
todo = list(uids)
|
|
100
|
+
while todo:
|
|
101
|
+
for d in self.p.relations[todo.pop()].depends_on:
|
|
102
|
+
if d not in out and d in self.p.relations:
|
|
103
|
+
out.add(d)
|
|
104
|
+
todo.append(d)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
# ---- inputs -------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _ensure_relation(self, r: Relation) -> str | None:
|
|
110
|
+
"""Make `r` a table (sources, seeds) or view (models) of the scratch
|
|
111
|
+
DuckDB database, with the project's column types; None when done,
|
|
112
|
+
else why it cannot be."""
|
|
113
|
+
if r.unique_id in self.created:
|
|
114
|
+
return self.created[r.unique_id]
|
|
115
|
+
why: str | None = None
|
|
116
|
+
name = self.names[r.unique_id]
|
|
117
|
+
if r.is_model:
|
|
118
|
+
res = self.results.get(r.unique_id)
|
|
119
|
+
if res is None or res.sql is None:
|
|
120
|
+
why = f"{r.ref} could not be read" + (f" ({res.refused})" if res else "")
|
|
121
|
+
else:
|
|
122
|
+
try:
|
|
123
|
+
self.con.execute(f"CREATE VIEW {_q(name)} AS {res.sql}")
|
|
124
|
+
except duckdb.Error as e:
|
|
125
|
+
why = f"DuckDB cannot plan {r.ref}: {_first_line(e)}"
|
|
126
|
+
else:
|
|
127
|
+
types = r.declared_types()
|
|
128
|
+
if types is None:
|
|
129
|
+
why = (
|
|
130
|
+
f"{r.ref} has no column types: give each of its columns a data_type in "
|
|
131
|
+
"its YAML, or run `dbt docs generate` so that target/catalog.json has them"
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
cols = ", ".join(f"{_q(c)} {t}" for c, t in types.items())
|
|
135
|
+
try:
|
|
136
|
+
self.con.execute(f"CREATE TABLE {_q(name)} ({cols})")
|
|
137
|
+
except duckdb.Error as e:
|
|
138
|
+
why = f"DuckDB does not know the column types of {r.ref}: {_first_line(e)}"
|
|
139
|
+
self.created[r.unique_id] = why
|
|
140
|
+
return why
|
|
141
|
+
|
|
142
|
+
def _not_null(self, r: Relation) -> set[str]:
|
|
143
|
+
"""The columns of `r` its tests or contract keep from being NULL."""
|
|
144
|
+
out = set()
|
|
145
|
+
for t in self.p.tests_on(r.unique_id):
|
|
146
|
+
if (
|
|
147
|
+
self._holds(t)
|
|
148
|
+
and t.kind
|
|
149
|
+
in (
|
|
150
|
+
"not_null",
|
|
151
|
+
"dbt_expectations.expect_column_values_to_not_be_null",
|
|
152
|
+
)
|
|
153
|
+
and t.column
|
|
154
|
+
and not t.where
|
|
155
|
+
and not t.kwargs.get("row_condition")
|
|
156
|
+
):
|
|
157
|
+
out.add(t.column)
|
|
158
|
+
if r.contract:
|
|
159
|
+
for c in r.columns.values():
|
|
160
|
+
for k in c.constraints:
|
|
161
|
+
if k.get("type") in ("not_null", "primary_key"):
|
|
162
|
+
out.add(c.name)
|
|
163
|
+
for k in r.constraints:
|
|
164
|
+
if k.get("type") in ("not_null", "primary_key"):
|
|
165
|
+
out.update(k.get("columns") or [])
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
def _holds(self, t: Test) -> bool:
|
|
169
|
+
"""The test holds when the model downstream runs: `dbt build` stops
|
|
170
|
+
on its failure, or it is proved."""
|
|
171
|
+
return t.blocks or t.unique_id in self.proved
|
|
172
|
+
|
|
173
|
+
def _requires(self, m: Relation, inputs: list[Relation]) -> tuple[dict, list[str]]:
|
|
174
|
+
"""The preconditions per input table, and what was left out."""
|
|
175
|
+
out: dict[str, list] = {}
|
|
176
|
+
skipped: list[str] = []
|
|
177
|
+
|
|
178
|
+
def table_of(uid: str) -> str | None:
|
|
179
|
+
return self.names[uid] if uid in m.depends_on else None
|
|
180
|
+
|
|
181
|
+
for r in inputs:
|
|
182
|
+
name = self.names[r.unique_id]
|
|
183
|
+
conds: list = []
|
|
184
|
+
nn = self._not_null(r)
|
|
185
|
+
for t in self.p.tests_on(r.unique_id):
|
|
186
|
+
if not self._holds(t):
|
|
187
|
+
sev = t.severity.lower()
|
|
188
|
+
skipped.append(f"{r.ref}: {t.short} (severity {sev}, not proved)")
|
|
189
|
+
continue
|
|
190
|
+
got = mapping.test_conditions(t, side="requires", table_of=table_of, not_null=nn)
|
|
191
|
+
if got.conditions:
|
|
192
|
+
conds += got.conditions
|
|
193
|
+
elif got.reason and t.kind != "relationships":
|
|
194
|
+
skipped.append(f"{r.ref}: {t.short}: {got.reason}")
|
|
195
|
+
if r.is_model and r.contract:
|
|
196
|
+
res = self.results.get(r.unique_id)
|
|
197
|
+
for c in res.constraints if res else []:
|
|
198
|
+
if c.status == "proved":
|
|
199
|
+
conds += c.conditions
|
|
200
|
+
out[name] = conds
|
|
201
|
+
return out, skipped
|
|
202
|
+
|
|
203
|
+
# ---- one model ----------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def _model(self, m: Relation) -> ModelResult:
|
|
206
|
+
res = ModelResult(name=m.name, unique_id=m.unique_id, path=m.path, ref=m.ref)
|
|
207
|
+
if m.language != "sql":
|
|
208
|
+
return res.refuse(f"a {m.language} model: sverify reads SQL models only")
|
|
209
|
+
if not m.compiled_code:
|
|
210
|
+
return res.refuse(
|
|
211
|
+
"no compiled SQL in the manifest: run `dbt compile` (or `dbt build`) first; "
|
|
212
|
+
"`dbt parse`, `dbt ls` and a `dbt test` without the model leave it out"
|
|
213
|
+
)
|
|
214
|
+
if m.materialized == "incremental":
|
|
215
|
+
res.notes.append("an incremental model: checked as its full refresh, as compiled")
|
|
216
|
+
inputs = [self.p.relations[d] for d in m.depends_on if d in self.p.relations]
|
|
217
|
+
for r in inputs:
|
|
218
|
+
why = self._ensure_relation(r)
|
|
219
|
+
if why:
|
|
220
|
+
return res.refuse(f"it reads {why}")
|
|
221
|
+
try:
|
|
222
|
+
res.sql = _rewrite(
|
|
223
|
+
m.compiled_code,
|
|
224
|
+
{r.relation_name: self.names[r.unique_id] for r in inputs if r.relation_name},
|
|
225
|
+
)
|
|
226
|
+
except _Unreadable as e:
|
|
227
|
+
return res.refuse(str(e))
|
|
228
|
+
try:
|
|
229
|
+
out = self.con.execute(f"DESCRIBE {res.sql}").fetchall()
|
|
230
|
+
except duckdb.Error as e:
|
|
231
|
+
res.sql = None
|
|
232
|
+
return res.refuse(f"DuckDB cannot plan the compiled SQL: {_first_line(e)}")
|
|
233
|
+
res.duckdb_types = {row[0]: row[1] for row in out}
|
|
234
|
+
# sverify reads DuckDB's type names (`verify(tables=...)`).
|
|
235
|
+
tables = {
|
|
236
|
+
self.names[r.unique_id]: {
|
|
237
|
+
row[0]: row[1]
|
|
238
|
+
for row in self.con.execute(
|
|
239
|
+
f"DESCRIBE SELECT * FROM {_q(self.names[r.unique_id])}"
|
|
240
|
+
).fetchall()
|
|
241
|
+
}
|
|
242
|
+
for r in inputs
|
|
243
|
+
}
|
|
244
|
+
requires, res.not_assumed = self._requires(m, inputs)
|
|
245
|
+
ensures = self._ensures(m, res)
|
|
246
|
+
report = self._verify(res, tables, requires, ensures)
|
|
247
|
+
if report is None:
|
|
248
|
+
return res
|
|
249
|
+
self._read(report, m, res, ensures)
|
|
250
|
+
return res
|
|
251
|
+
|
|
252
|
+
def _ensures(self, m: Relation, res: ModelResult) -> list[tuple[Any, list]]:
|
|
253
|
+
"""The conditions to prove, each with its test or constraint result."""
|
|
254
|
+
cols = set(res.duckdb_types)
|
|
255
|
+
out: list[tuple[Any, list]] = []
|
|
256
|
+
for t in self.p.tests_on(m.unique_id):
|
|
257
|
+
tr = TestResult(test=t.unique_id, name=t.name, label=t.short, kind="test")
|
|
258
|
+
res.tests.append(tr)
|
|
259
|
+
got = mapping.test_conditions(t, side="ensures", table_of=lambda _: None)
|
|
260
|
+
missing = [c for c in got.columns if c not in cols]
|
|
261
|
+
if missing:
|
|
262
|
+
tr.skip(f"the model has no column {', '.join(missing)}")
|
|
263
|
+
elif not got.conditions:
|
|
264
|
+
tr.skip(got.reason or "no condition")
|
|
265
|
+
else:
|
|
266
|
+
tr.conditions = got.conditions
|
|
267
|
+
out.append((tr, got.conditions))
|
|
268
|
+
if m.contract:
|
|
269
|
+
pairs = [(c.name, k) for c in m.columns.values() for k in c.constraints]
|
|
270
|
+
pairs += [(None, k) for k in m.constraints]
|
|
271
|
+
for col, k in pairs:
|
|
272
|
+
tr = TestResult(
|
|
273
|
+
test=None, name=None, label=mapping.constraint_label(k, col), kind="constraint"
|
|
274
|
+
)
|
|
275
|
+
res.constraints.append(tr)
|
|
276
|
+
got = mapping.constraint_conditions(k, col)
|
|
277
|
+
missing = [c for c in got.columns if c not in cols]
|
|
278
|
+
if missing:
|
|
279
|
+
tr.skip(f"the model has no column {', '.join(missing)}")
|
|
280
|
+
elif not got.conditions:
|
|
281
|
+
tr.skip(got.reason or "no condition")
|
|
282
|
+
else:
|
|
283
|
+
tr.conditions = got.conditions
|
|
284
|
+
out.append((tr, got.conditions))
|
|
285
|
+
return out
|
|
286
|
+
|
|
287
|
+
def _verify(self, res: ModelResult, tables: dict, requires: dict, ensures: list) -> Any:
|
|
288
|
+
"""`sv.verify` of the model; a condition sverify cannot read is
|
|
289
|
+
dropped with its reason, and a model it cannot read is refused."""
|
|
290
|
+
conds = [c for _, cs in ensures for c in cs]
|
|
291
|
+
|
|
292
|
+
def go(req: dict, ens: list) -> Any:
|
|
293
|
+
with warnings.catch_warnings():
|
|
294
|
+
warnings.simplefilter("ignore")
|
|
295
|
+
return sv.verify(
|
|
296
|
+
res.sql, # type: ignore[arg-type]
|
|
297
|
+
tables=tables,
|
|
298
|
+
requires=req,
|
|
299
|
+
ensures=ens,
|
|
300
|
+
semantics="duckdb",
|
|
301
|
+
on_inferred="error",
|
|
302
|
+
counterexamples=self.counterexamples,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
return go(requires, conds)
|
|
307
|
+
except ValueError as e: # sverify's Unsupported, and a bad condition
|
|
308
|
+
first = e
|
|
309
|
+
try:
|
|
310
|
+
go({t: [] for t in requires}, [])
|
|
311
|
+
except ValueError as e:
|
|
312
|
+
res.refuse(_refusal(e))
|
|
313
|
+
return None
|
|
314
|
+
# The SQL is fine: find the conditions sverify cannot read.
|
|
315
|
+
for t, cs in requires.items():
|
|
316
|
+
keep = []
|
|
317
|
+
for c in cs:
|
|
318
|
+
try:
|
|
319
|
+
go({**{k: [] for k in requires}, t: [c]}, [])
|
|
320
|
+
keep.append(c)
|
|
321
|
+
except ValueError as e:
|
|
322
|
+
res.not_assumed.append(f"{t}: {_label(c)}: sverify cannot read it ({e})")
|
|
323
|
+
requires[t] = keep
|
|
324
|
+
for tr, cs in ensures:
|
|
325
|
+
try:
|
|
326
|
+
go({t: [] for t in requires}, cs)
|
|
327
|
+
except ValueError as e:
|
|
328
|
+
tr.skip(f"sverify cannot read its condition: {_first_line(e)}")
|
|
329
|
+
conds = [c for tr, cs in ensures if tr.status != "not checked" for c in cs]
|
|
330
|
+
try:
|
|
331
|
+
return go(requires, conds)
|
|
332
|
+
except ValueError:
|
|
333
|
+
res.refuse(_refusal(first))
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def _read(self, report: Any, m: Relation, res: ModelResult, ensures: list) -> None:
|
|
337
|
+
# `report.ensures` is in the order of the conditions given, each
|
|
338
|
+
# with its condition (`CheckedCondition.condition`).
|
|
339
|
+
results = iter(report.ensures)
|
|
340
|
+
needed = report.needed
|
|
341
|
+
res.needed = _needed(report, self, m)
|
|
342
|
+
res.may_fail = [str(i) for i in report.errors]
|
|
343
|
+
res.deterministic = report.deterministic
|
|
344
|
+
if not report.deterministic:
|
|
345
|
+
res.notes.append(
|
|
346
|
+
"rows may tie in an ORDER BY of a window or ordered aggregate, so DuckDB "
|
|
347
|
+
"picks the order: a test that depends on it is not proved"
|
|
348
|
+
)
|
|
349
|
+
res.assumed = [i.shown or i.expr for i in report.assumed]
|
|
350
|
+
for tr, cs in ensures:
|
|
351
|
+
if tr.status == "not checked":
|
|
352
|
+
continue
|
|
353
|
+
checked = [next(results) for _ in cs]
|
|
354
|
+
if any(r.condition is not c for r, c in zip(checked, cs, strict=True)):
|
|
355
|
+
tr.skip("sverify reported on another condition")
|
|
356
|
+
continue
|
|
357
|
+
bad = [c for c in checked if not c.portable]
|
|
358
|
+
if not bad and needed:
|
|
359
|
+
tr.status = "not proved"
|
|
360
|
+
tr.why = "proved only with the source tests listed under 'needs'"
|
|
361
|
+
elif not bad:
|
|
362
|
+
tr.status = "proved"
|
|
363
|
+
else:
|
|
364
|
+
c = bad[0]
|
|
365
|
+
tr.status = "not proved"
|
|
366
|
+
tr.why = _why(c)
|
|
367
|
+
tr.counterexample = str(c.counterexample) if c.counterexample else None
|
|
368
|
+
tr.would_hold = {
|
|
369
|
+
_user_table(t, self): [mapping.as_dbt_test(_sql(x)) for x in xs]
|
|
370
|
+
for t, xs in (c.suggested or {}).items()
|
|
371
|
+
}
|
|
372
|
+
if tr.status == "proved" and tr.test:
|
|
373
|
+
self.proved.add(tr.test)
|
|
374
|
+
if m.contract:
|
|
375
|
+
res.contract = self._contract(report, m, res)
|
|
376
|
+
|
|
377
|
+
def _contract(self, report: Any, m: Relation, res: ModelResult) -> list[ContractIssue]:
|
|
378
|
+
"""The declared columns and types against the proved output schema
|
|
379
|
+
(`Report.output`) and DuckDB's own types for the compiled SQL."""
|
|
380
|
+
issues: list[ContractIssue] = []
|
|
381
|
+
proved = {c: _duck_name(self.con, _ibis_to_duckdb(t)) for c, t in report.output.items()}
|
|
382
|
+
actual = {c: _duck_name(self.con, t) for c, t in res.duckdb_types.items()}
|
|
383
|
+
declared = {c.name: c.data_type for c in m.columns.values()}
|
|
384
|
+
for c in declared:
|
|
385
|
+
if c not in actual:
|
|
386
|
+
issues.append(ContractIssue(c, "declared, but the model has no such column"))
|
|
387
|
+
for c in actual:
|
|
388
|
+
if c not in declared:
|
|
389
|
+
issues.append(ContractIssue(c, "in the model's result, but not declared"))
|
|
390
|
+
for c, t in declared.items():
|
|
391
|
+
if c not in actual:
|
|
392
|
+
continue
|
|
393
|
+
if t is None:
|
|
394
|
+
issues.append(ContractIssue(c, "declared without a data_type"))
|
|
395
|
+
continue
|
|
396
|
+
want = _duck_name(self.con, t)
|
|
397
|
+
if want != actual[c]:
|
|
398
|
+
issues.append(ContractIssue(c, f"declared {t}, but DuckDB gives {actual[c]}"))
|
|
399
|
+
if proved.get(c) and proved[c] != actual[c]:
|
|
400
|
+
res.notes.append(
|
|
401
|
+
f"column {c}: sverify's plan has type {proved[c]}, and DuckDB's SQL gives "
|
|
402
|
+
f"{actual[c]}; the contract check uses DuckDB's"
|
|
403
|
+
)
|
|
404
|
+
return issues
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# ---- helpers ------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class _Unreadable(Exception):
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _input_names(relations: Iterable[Relation]) -> dict[str, str]:
|
|
415
|
+
"""A table name per relation, unique across the project: the model's or
|
|
416
|
+
seed's name, a source's table name, or `<source>__<table>` where two
|
|
417
|
+
collide."""
|
|
418
|
+
rels = list(relations)
|
|
419
|
+
counts: dict[str, int] = {}
|
|
420
|
+
for r in rels:
|
|
421
|
+
counts[r.name] = counts.get(r.name, 0) + 1
|
|
422
|
+
out = {}
|
|
423
|
+
for r in rels:
|
|
424
|
+
name = r.name
|
|
425
|
+
if counts[name] > 1 and r.resource_type == "source":
|
|
426
|
+
name = f"{r.source_name}__{r.name}"
|
|
427
|
+
if counts[r.name] > 1 and r.resource_type in ("seed", "snapshot"):
|
|
428
|
+
name = f"{r.resource_type}__{r.name}"
|
|
429
|
+
out[r.unique_id] = name
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _parts(t: sg.exp.Table) -> tuple[str, ...]:
|
|
434
|
+
return tuple(p.lower() for p in (t.catalog, t.db, t.name) if p)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _rewrite(sql: str, inputs: dict[str, str]) -> str:
|
|
438
|
+
"""`sql` with each relation of `inputs` (dbt's `relation_name`) read by
|
|
439
|
+
its input name, keeping what the query calls it (its alias, else its
|
|
440
|
+
table name)."""
|
|
441
|
+
try:
|
|
442
|
+
tree = sg.parse_one(sql, read="duckdb")
|
|
443
|
+
except sg.errors.ParseError as e:
|
|
444
|
+
raise _Unreadable(f"the compiled SQL does not parse: {_first_line(e)}") from e
|
|
445
|
+
wanted = {}
|
|
446
|
+
for rel, name in inputs.items():
|
|
447
|
+
t = sg.exp.to_table(rel, dialect="duckdb")
|
|
448
|
+
wanted[_parts(t)] = name
|
|
449
|
+
ctes = {c.alias_or_name.lower() for c in tree.find_all(sg.exp.CTE)}
|
|
450
|
+
|
|
451
|
+
def find(t: sg.exp.Table) -> str | None:
|
|
452
|
+
parts = _parts(t)
|
|
453
|
+
if parts in wanted:
|
|
454
|
+
return wanted[parts]
|
|
455
|
+
hits = [n for p, n in wanted.items() if p[-len(parts) :] == parts]
|
|
456
|
+
return hits[0] if len(hits) == 1 else None
|
|
457
|
+
|
|
458
|
+
unknown = []
|
|
459
|
+
for t in list(tree.find_all(sg.exp.Table)):
|
|
460
|
+
if not isinstance(t.this, sg.exp.Identifier):
|
|
461
|
+
continue
|
|
462
|
+
if not t.db and not t.catalog and t.name.lower() in ctes:
|
|
463
|
+
continue
|
|
464
|
+
name = find(t)
|
|
465
|
+
if name is None:
|
|
466
|
+
unknown.append(t.sql("duckdb"))
|
|
467
|
+
continue
|
|
468
|
+
alias = t.alias or t.name
|
|
469
|
+
new = sg.exp.to_table(_q(name), dialect="duckdb")
|
|
470
|
+
if alias != name:
|
|
471
|
+
new.set("alias", sg.exp.TableAlias(this=sg.exp.to_identifier(alias)))
|
|
472
|
+
t.replace(new)
|
|
473
|
+
if unknown:
|
|
474
|
+
raise _Unreadable(
|
|
475
|
+
f"it reads {', '.join(dict.fromkeys(unknown))}, which is no ref or source of the "
|
|
476
|
+
"model (a hard-coded table name, or one a macro wrote)"
|
|
477
|
+
)
|
|
478
|
+
return tree.sql("duckdb")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _q(name: str) -> str:
|
|
482
|
+
return '"' + name.replace('"', '""') + '"'
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _first_line(e: BaseException) -> str:
|
|
486
|
+
lines = str(e).strip().splitlines()
|
|
487
|
+
return lines[0] if lines else type(e).__name__
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _refusal(e: BaseException) -> str:
|
|
491
|
+
"""sverify's refusal of a model, in the user's terms."""
|
|
492
|
+
msg = _first_line(e)
|
|
493
|
+
m = re.match(r"(.*?) is not supported(?: yet)?;?(.*)", msg)
|
|
494
|
+
if m:
|
|
495
|
+
what = m[1].strip()
|
|
496
|
+
if len(what) > 70:
|
|
497
|
+
what = what[:67] + "..."
|
|
498
|
+
rest = m[2].strip()
|
|
499
|
+
return f"sverify cannot read {what} yet" + (f" ({rest})" if rest else "")
|
|
500
|
+
return f"sverify cannot check it: {msg}"
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
_WHY = {
|
|
504
|
+
"observation": "proved only for some row orders DuckDB may pick",
|
|
505
|
+
"not_a_key": "the tests upstream do not make it a key",
|
|
506
|
+
"ties": "rows may tie in an ORDER BY, so DuckDB picks which rows it keeps",
|
|
507
|
+
"always_false": "every row fails it",
|
|
508
|
+
"may_be_false": "sverify cannot rule out a row that fails it, from the tests upstream",
|
|
509
|
+
"may_be_null": "sverify cannot rule out a row that fails it, from the tests upstream",
|
|
510
|
+
"not_proved_kind": "sverify checks this kind of condition on data only",
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _why(c: Any) -> str:
|
|
515
|
+
"""Why a condition is not proved, in a dbt user's terms
|
|
516
|
+
(`CheckedCondition.reason`; its `why` speaks of `sv.unique` and
|
|
517
|
+
`requires`)."""
|
|
518
|
+
return _WHY.get(c.reason, f"sverify only knows it is {c.value}")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _label(c: Any) -> str:
|
|
522
|
+
return c if isinstance(c, str) else c.label
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _sql(c: Any) -> str:
|
|
526
|
+
"""A suggested condition as SQL, as far as it can be."""
|
|
527
|
+
return str(c) if isinstance(c, str) else getattr(c, "label", str(c))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _user_table(name: str, checker: _Checker) -> str:
|
|
531
|
+
for uid, n in checker.names.items():
|
|
532
|
+
if n == name:
|
|
533
|
+
return checker.p.relations[uid].ref
|
|
534
|
+
return name
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _needed(report: Any, checker: _Checker, m: Relation) -> list[Needed]:
|
|
538
|
+
"""`Report.needs` as tests to add to the inputs."""
|
|
539
|
+
out = []
|
|
540
|
+
for n in report.needs:
|
|
541
|
+
issue = n.issue
|
|
542
|
+
out.append(
|
|
543
|
+
Needed(
|
|
544
|
+
on=_user_table(n.table, checker),
|
|
545
|
+
column=n.column,
|
|
546
|
+
test=mapping.dbt_test(n.column, n.op, n.value, n.sql),
|
|
547
|
+
because=(
|
|
548
|
+
f"{issue.kind} in `{issue.shown or issue.expr}` otherwise" if issue else None
|
|
549
|
+
),
|
|
550
|
+
condition=n.sql,
|
|
551
|
+
)
|
|
552
|
+
)
|
|
553
|
+
return out
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _ibis_to_duckdb(t: Any) -> str:
|
|
557
|
+
from ibis.backends.sql.datatypes import DuckDBType
|
|
558
|
+
|
|
559
|
+
return DuckDBType.to_string(t)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _duck_name(con: duckdb.DuckDBPyConnection, t: str) -> str:
|
|
563
|
+
"""DuckDB's own name of a type: `int` is INTEGER, `text` VARCHAR."""
|
|
564
|
+
try:
|
|
565
|
+
row = con.execute(f"SELECT typeof(CAST(NULL AS {t}))").fetchone()
|
|
566
|
+
return str(row[0]) if row else t.upper()
|
|
567
|
+
except duckdb.Error:
|
|
568
|
+
return t.upper()
|