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/program.py
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""One dbt model as one SQL Program, analyzed once per `is_incremental()` branch.
|
|
2
|
+
|
|
3
|
+
The Program puts the model's SQL first, as a FROM subquery of a Root Select,
|
|
4
|
+
on the Program's own first line, so a line of the model's SQL is the same line
|
|
5
|
+
of the Program. The model's tests follow as Assertion Markers on that Root
|
|
6
|
+
Select, one per line. Every relation the model reads (and every relation its
|
|
7
|
+
relationships tests reference) is declared after it: a model whose rows are
|
|
8
|
+
its query (a view, a table rebuilt each run) as a `CREATE VIEW` of its compiled
|
|
9
|
+
SQL with its own assertions blanked out, anything else (a source, seed,
|
|
10
|
+
snapshot, incremental model) as a table with unknown columns, or with a
|
|
11
|
+
`CREATE TABLE` when its columns are known completely. Tests of those relations
|
|
12
|
+
that are facts become Property Declarations, bound to the IR after linking.
|
|
13
|
+
|
|
14
|
+
Nothing here writes a marker the author did not: the generated markers live on
|
|
15
|
+
the generated Root Select only, so the Property Markers stay the one
|
|
16
|
+
user-facing contract.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from bisect import bisect_right
|
|
23
|
+
from collections.abc import Iterable, Mapping
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
|
|
26
|
+
import sqlglot
|
|
27
|
+
from sqlglot import exp
|
|
28
|
+
from sqlglot.errors import SqlglotError
|
|
29
|
+
from sqlglot.tokens import TokenType
|
|
30
|
+
|
|
31
|
+
from sqlassert import diagnostics as diag
|
|
32
|
+
from sqlassert import ir
|
|
33
|
+
from sqlassert_dbt.expectations import Expectation, PropertyKind
|
|
34
|
+
from sqlassert_dbt.jinja import Rendered, Unrenderable, has_incremental_branch, render
|
|
35
|
+
from sqlassert_dbt.manifest import Relation
|
|
36
|
+
from sqlassert.diagnostics import Diagnostic
|
|
37
|
+
from sqlassert.ir.convert import IrParser
|
|
38
|
+
from sqlassert.ir.model import LiteralValue
|
|
39
|
+
from sqlassert.properties import (
|
|
40
|
+
EnumeratedDomain, ForeignKey, NonNullColumn, NonNullColumns, NonNullUniqueSet, Property, ReferentialCoverage,
|
|
41
|
+
UniqueSet,
|
|
42
|
+
)
|
|
43
|
+
from sqlassert.python_engine import Engine
|
|
44
|
+
from sqlassert.reporting import Outcome, Report
|
|
45
|
+
from sqlassert.rules.domain import _domain
|
|
46
|
+
from sqlassert.rules.equality import _binary_text, _comparison_type, _identity_comparison
|
|
47
|
+
from sqlassert.rules.uniqueness import minimal_keys
|
|
48
|
+
from sqlassert.sql_parse import SqlParser, _marker_shapes
|
|
49
|
+
from sqlassert.suggest import MAX_ENUM_VALUES
|
|
50
|
+
|
|
51
|
+
# Materializations whose relation holds exactly the rows of the model's query
|
|
52
|
+
# when its children are built. An incremental model's table keeps rows of
|
|
53
|
+
# earlier runs, a snapshot's its history: those are tables, not their query.
|
|
54
|
+
QUERY_MATERIALIZATIONS = {"view", "table", "materialized_view", "dynamic_table"}
|
|
55
|
+
|
|
56
|
+
# Parse results that leave a relation's SQL unusable as a view body.
|
|
57
|
+
_UNPARSEABLE = {diag.SQL_PARSE_FAILED, diag.MULTIPLE_ROOT_SELECTS, diag.UNSUPPORTED_STATEMENT, diag.UNATTACHED_MARKER}
|
|
58
|
+
|
|
59
|
+
_CTE_NAME = re.compile(r"\s*(\S+)\s+as\s*\(", re.IGNORECASE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Body:
|
|
64
|
+
"""A model's SQL as analyzed, and where its lines came from.
|
|
65
|
+
|
|
66
|
+
`lines` holds, for each character, the line of the model file it was
|
|
67
|
+
copied from (0 where a tag produced it), when a rendering of the raw code
|
|
68
|
+
reproduced dbt's; None when none did, and markers are then found in the
|
|
69
|
+
raw code by their text. `injected` holds the spans dbt inserted for
|
|
70
|
+
ephemeral models: their findings belong to those models.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
text: str
|
|
74
|
+
raw_code: str
|
|
75
|
+
lines: tuple[int, ...] | None = None
|
|
76
|
+
injected: tuple[tuple[int, int], ...] = ()
|
|
77
|
+
|
|
78
|
+
def in_injected(self, offset: int) -> bool:
|
|
79
|
+
return any(start <= offset < end for start, end in self.injected)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Branches:
|
|
84
|
+
"""The bodies of every branch dbt may run; `unrendered` says why a branch is missing."""
|
|
85
|
+
|
|
86
|
+
bodies: tuple[Body, ...]
|
|
87
|
+
unrendered: str | None = None
|
|
88
|
+
warnings: tuple[str, ...] = ()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def branches(model: Relation, relations: Mapping[str, Relation]) -> Branches:
|
|
92
|
+
"""The compiled body, and for an incremental model the other branch too."""
|
|
93
|
+
compiled = _strip_end(model.compiled_code or "")
|
|
94
|
+
joined = ", ".join(cte.sql for cte in model.extra_ctes)
|
|
95
|
+
warnings: list[str] = []
|
|
96
|
+
original, cut = (compiled, None) if not joined else _uninject(compiled, joined)
|
|
97
|
+
if original is None:
|
|
98
|
+
warnings.append(
|
|
99
|
+
"dbt's inlined ephemeral models could not be located in its compiled code, so their markers are analyzed as this model's"
|
|
100
|
+
)
|
|
101
|
+
injected = () if cut is None or not joined else ((cut[0], cut[0] + len(joined)),)
|
|
102
|
+
two = model.materialized == "incremental" and has_incremental_branch(model.raw_code)
|
|
103
|
+
renderings: dict[bool, Rendered] = {}
|
|
104
|
+
failure: str | None = None
|
|
105
|
+
for incremental in (False, True) if two else (False,):
|
|
106
|
+
try:
|
|
107
|
+
renderings[incremental] = render(model.raw_code, incremental=incremental, resolve=_resolver(model, relations))
|
|
108
|
+
except Unrenderable as error:
|
|
109
|
+
failure = str(error)
|
|
110
|
+
taken = next(
|
|
111
|
+
(flag for flag, rendered in renderings.items() if original is not None and _strip_end(rendered.text) == original),
|
|
112
|
+
None,
|
|
113
|
+
)
|
|
114
|
+
lines = None
|
|
115
|
+
if taken is not None:
|
|
116
|
+
lines = _raw_lines(model.raw_code, _compiled_sources(renderings[taken], len(compiled), cut, len(joined)))
|
|
117
|
+
first = Body(compiled, model.raw_code, lines, injected)
|
|
118
|
+
if not two:
|
|
119
|
+
return Branches((first,), None, tuple(warnings))
|
|
120
|
+
if taken is None or (not taken) not in renderings:
|
|
121
|
+
reason = failure or "rendering the raw code did not reproduce dbt's compiled code"
|
|
122
|
+
return Branches((first,), f"the other is_incremental() branch was not analyzed: {reason}", tuple(warnings))
|
|
123
|
+
return Branches((first, _inject(renderings[not taken], joined, model.raw_code)), None, tuple(warnings))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _raw_lines(raw: str, sources: Iterable[int]) -> tuple[int, ...]:
|
|
127
|
+
"""Each raw offset as its 1-based line, -1 as 0."""
|
|
128
|
+
starts = [0] + [index + 1 for index, char in enumerate(raw) if char == "\n"]
|
|
129
|
+
return tuple(0 if source < 0 else bisect_right(starts, source) for source in sources)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _strip_end(sql: str) -> str:
|
|
133
|
+
"""Without trailing whitespace and semicolons, which the Program supplies."""
|
|
134
|
+
return re.sub(r"[\s;]*\Z", "", sql)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _uninject(compiled: str, joined: str) -> tuple[str | None, tuple[int, int] | None]:
|
|
138
|
+
"""The code dbt rendered before inlining ephemeral models, and the cut it made.
|
|
139
|
+
|
|
140
|
+
dbt inserts the joined CTE text right after the WITH (or RECURSIVE) of the
|
|
141
|
+
query, followed by a comma; with no WITH, it inserts `with` and the CTEs
|
|
142
|
+
before the first token. Returns the original and (offset of the CTE text,
|
|
143
|
+
characters of `with` or `,` added around it)."""
|
|
144
|
+
index = compiled.find(joined)
|
|
145
|
+
if index < 0 or compiled.find(joined, index + 1) >= 0:
|
|
146
|
+
return None, None
|
|
147
|
+
after = index + len(joined)
|
|
148
|
+
if compiled.startswith(",", after):
|
|
149
|
+
return compiled[:index] + compiled[after + 1:], (index, 0)
|
|
150
|
+
if compiled[max(0, index - 4):index] == "with":
|
|
151
|
+
return compiled[:index - 4] + compiled[after:], (index, 4)
|
|
152
|
+
return None, None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _compiled_sources(rendered: Rendered, length: int, cut: tuple[int, int] | None, injected: int) -> tuple[int, ...]:
|
|
156
|
+
"""Each compiled character's raw offset, through the cut dbt made."""
|
|
157
|
+
sources: list[int] = []
|
|
158
|
+
for offset in range(length):
|
|
159
|
+
if cut is None:
|
|
160
|
+
original = offset
|
|
161
|
+
else:
|
|
162
|
+
index, added = cut
|
|
163
|
+
if offset < index - added:
|
|
164
|
+
original = offset
|
|
165
|
+
elif offset < index + injected + (1 if added == 0 else 0):
|
|
166
|
+
original = -1
|
|
167
|
+
else:
|
|
168
|
+
original = offset - injected - (1 if added == 0 else 4)
|
|
169
|
+
raw = rendered.raw_offset(original) if original >= 0 else None
|
|
170
|
+
sources.append(-1 if raw is None else raw)
|
|
171
|
+
return tuple(sources)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _inject(rendered: Rendered, joined: str, raw_code: str) -> Body:
|
|
175
|
+
"""`rendered` with the ephemeral CTEs inlined as dbt would."""
|
|
176
|
+
text = _strip_end(rendered.text)
|
|
177
|
+
sources = list(rendered.sources[:len(text)])
|
|
178
|
+
if not joined:
|
|
179
|
+
return Body(text, raw_code, _raw_lines(raw_code, sources))
|
|
180
|
+
try:
|
|
181
|
+
tokens = sqlglot.tokenize(text)
|
|
182
|
+
except SqlglotError:
|
|
183
|
+
tokens = []
|
|
184
|
+
if tokens and tokens[0].token_type is TokenType.WITH:
|
|
185
|
+
end = tokens[1].end + 1 if len(tokens) > 1 and tokens[1].token_type is TokenType.RECURSIVE else tokens[0].end + 1
|
|
186
|
+
insert, at = joined + ",", end
|
|
187
|
+
else:
|
|
188
|
+
insert, at = "with" + joined + "\n", 0
|
|
189
|
+
return Body(
|
|
190
|
+
text[:at] + insert + text[at:], raw_code, _raw_lines(raw_code, sources[:at] + [-1] * len(insert) + sources[at:]),
|
|
191
|
+
((at, at + len(insert)),),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _resolver(model: Relation, relations: Mapping[str, Relation]):
|
|
196
|
+
from sqlassert_dbt.expectations import resolve
|
|
197
|
+
|
|
198
|
+
def resolved(function: str, arguments: tuple[object, ...], options: dict[str, object]) -> str:
|
|
199
|
+
if function == "this":
|
|
200
|
+
if model.relation_name is None:
|
|
201
|
+
raise Unrenderable("{{ this }} of a model with no relation")
|
|
202
|
+
return model.relation_name
|
|
203
|
+
found = resolve(function, tuple(str(argument) for argument in arguments), options, model.depends_on, relations)
|
|
204
|
+
if found is None:
|
|
205
|
+
raise Unrenderable(f"{function}{arguments} does not resolve among the model's dependencies")
|
|
206
|
+
target = relations[found]
|
|
207
|
+
if target.materialized == "ephemeral":
|
|
208
|
+
name = next(
|
|
209
|
+
(match.group(1) for cte in model.extra_ctes if cte.id == found and (match := _CTE_NAME.match(cte.sql))), None,
|
|
210
|
+
)
|
|
211
|
+
if name is None:
|
|
212
|
+
raise Unrenderable(f"the ephemeral model {target.name} is not inlined")
|
|
213
|
+
return name
|
|
214
|
+
if target.relation_name is None:
|
|
215
|
+
raise Unrenderable(f"{target.name} has no relation name")
|
|
216
|
+
return target.relation_name
|
|
217
|
+
|
|
218
|
+
return resolved
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Programs ---------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class TestResult:
|
|
226
|
+
proved: bool
|
|
227
|
+
explanation: str
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class MarkerResult:
|
|
232
|
+
"""One of the model's own markers in one branch. `line` is the body line;
|
|
233
|
+
`raw_line` the model file's line, when known; `place` its position among
|
|
234
|
+
the markers on its line."""
|
|
235
|
+
|
|
236
|
+
line: int
|
|
237
|
+
raw_line: int | None
|
|
238
|
+
place: int
|
|
239
|
+
marker: str
|
|
240
|
+
outcome: Outcome
|
|
241
|
+
explanation: str
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass(frozen=True)
|
|
245
|
+
class Problem:
|
|
246
|
+
"""A Diagnostic of the model's SQL, `line` in its body (None: no line), `raw_line` in its file."""
|
|
247
|
+
|
|
248
|
+
diagnostic: Diagnostic
|
|
249
|
+
warning: bool
|
|
250
|
+
line: int | None
|
|
251
|
+
raw_line: int | None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class Addition:
|
|
256
|
+
"""A Property proved of the model and tested by nothing: `kind` and its columns (and values)."""
|
|
257
|
+
|
|
258
|
+
kind: PropertyKind
|
|
259
|
+
columns: tuple[str, ...]
|
|
260
|
+
values: tuple[ir.LiteralValue, ...] = ()
|
|
261
|
+
explanation: str = ""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True)
|
|
265
|
+
class BranchResult:
|
|
266
|
+
tests: dict[str, TestResult] = field(default_factory=dict)
|
|
267
|
+
markers: tuple[MarkerResult, ...] = ()
|
|
268
|
+
problems: tuple[Problem, ...] = ()
|
|
269
|
+
additions: tuple[Addition, ...] = ()
|
|
270
|
+
# Relations reached that are tables with unknown columns.
|
|
271
|
+
undeclared: frozenset[str] = frozenset()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass
|
|
275
|
+
class Builder:
|
|
276
|
+
"""Builds and analyzes the Programs of one project. Bodies of other
|
|
277
|
+
relations are parsed once and reused for every model that reads them."""
|
|
278
|
+
|
|
279
|
+
relations: Mapping[str, Relation]
|
|
280
|
+
expectations: tuple[Expectation, ...]
|
|
281
|
+
dialect: str
|
|
282
|
+
# Whether the tests of what a model reads passed before it was built, as
|
|
283
|
+
# under `dbt build`. Plain `dbt run` runs no test, so by default only
|
|
284
|
+
# enforced contract constraints are facts.
|
|
285
|
+
trust_upstream_tests: bool = False
|
|
286
|
+
# The upstream tests that would have been facts had they been trusted.
|
|
287
|
+
untrusted: set[str] = field(default_factory=set)
|
|
288
|
+
_usable: dict[str, bool] = field(default_factory=dict)
|
|
289
|
+
|
|
290
|
+
def facts(self, node: str) -> list[Expectation]:
|
|
291
|
+
found = [e for e in self.expectations if e.node == node and e.is_fact]
|
|
292
|
+
if self.trust_upstream_tests:
|
|
293
|
+
return found
|
|
294
|
+
self.untrusted.update(e.unique_id for e in found if e.kind == "test")
|
|
295
|
+
return [e for e in found if e.kind == "constraint"]
|
|
296
|
+
|
|
297
|
+
def is_view(self, relation: Relation) -> bool:
|
|
298
|
+
"""Whether `relation` enters a Program as its query rather than as a table."""
|
|
299
|
+
if relation.resource_type != "model" or relation.language != "sql" or relation.compiled_code is None:
|
|
300
|
+
return False
|
|
301
|
+
if relation.materialized not in QUERY_MATERIALIZATIONS | {"ephemeral"}:
|
|
302
|
+
return False
|
|
303
|
+
if relation.unique_id not in self._usable:
|
|
304
|
+
parsed = SqlParser(self.dialect).parse(_blank_assertions(_strip_end(relation.compiled_code)))
|
|
305
|
+
self._usable[relation.unique_id] = parsed.root_select is not None and not any(
|
|
306
|
+
d.code in _UNPARSEABLE for d in parsed.diagnostics
|
|
307
|
+
)
|
|
308
|
+
return self._usable[relation.unique_id]
|
|
309
|
+
|
|
310
|
+
def reached(self, model: Relation, tests: Iterable[Expectation]) -> list[Relation]:
|
|
311
|
+
"""Every relation the model's Program needs: what it reads, through
|
|
312
|
+
views and ephemeral models, and what its tests reference."""
|
|
313
|
+
pending = [*model.depends_on, *(e.referenced for e in tests if e.referenced is not None)]
|
|
314
|
+
seen: dict[str, Relation] = {}
|
|
315
|
+
while pending:
|
|
316
|
+
node = pending.pop()
|
|
317
|
+
if node in seen or node == model.unique_id or node not in self.relations:
|
|
318
|
+
continue
|
|
319
|
+
relation = self.relations[node]
|
|
320
|
+
seen[node] = relation
|
|
321
|
+
if self.is_view(relation):
|
|
322
|
+
pending.extend(relation.depends_on)
|
|
323
|
+
return list(seen.values())
|
|
324
|
+
|
|
325
|
+
def analyze(self, model: Relation, body: Body, tests: tuple[Expectation, ...]) -> BranchResult:
|
|
326
|
+
reached = self.reached(model, tests)
|
|
327
|
+
body_lines = body.text.count("\n") + 1
|
|
328
|
+
lines = [f"select * from ({body.text}", f") as {_identifier(model.name, self.dialect)}"]
|
|
329
|
+
placed: dict[int, Expectation] = {}
|
|
330
|
+
unplaced: dict[str, TestResult] = {}
|
|
331
|
+
for expectation in tests:
|
|
332
|
+
marker = self._marker(expectation)
|
|
333
|
+
if marker is None:
|
|
334
|
+
unplaced[expectation.unique_id] = TestResult(False, "it references a relation with no name in the warehouse")
|
|
335
|
+
continue
|
|
336
|
+
placed[body_lines + 1 + len(placed) + 1] = expectation
|
|
337
|
+
lines.append(marker)
|
|
338
|
+
lines.append(";")
|
|
339
|
+
undeclared: set[str] = set()
|
|
340
|
+
for relation in reached:
|
|
341
|
+
statement = self._declaration(relation)
|
|
342
|
+
if statement is None and relation.materialized != "ephemeral":
|
|
343
|
+
undeclared.add(relation.unique_id)
|
|
344
|
+
if statement:
|
|
345
|
+
lines.append(statement)
|
|
346
|
+
sql = "\n".join(lines) + "\n"
|
|
347
|
+
|
|
348
|
+
ast = SqlParser(self.dialect).parse(sql)
|
|
349
|
+
conversion = IrParser(self.dialect).parse(ast)
|
|
350
|
+
program = conversion.program
|
|
351
|
+
tested = {line: e for line, e in placed.items()}
|
|
352
|
+
assertions = tuple(
|
|
353
|
+
_coverage(assertion) if assertion.origin.line in tested and isinstance(assertion.property, ForeignKey) else assertion
|
|
354
|
+
for assertion in program.assertions
|
|
355
|
+
)
|
|
356
|
+
declarations = (*program.declarations, *self._declarations(program, conversion.knowledge, model, reached))
|
|
357
|
+
program = replace(program, assertions=assertions, declarations=declarations)
|
|
358
|
+
diagnostics = ast.diagnostics + conversion.diagnostics
|
|
359
|
+
report = Engine().run(program, conversion.knowledge, diagnostics, conversion.warnings)
|
|
360
|
+
return BranchResult(
|
|
361
|
+
tests={**unplaced, **_tests(report, tested)},
|
|
362
|
+
markers=_markers(report, body, body_lines),
|
|
363
|
+
problems=_problems(report, body, body_lines, tested, [model, *reached], self.dialect),
|
|
364
|
+
additions=self._additions(program, conversion.knowledge, diagnostics, model),
|
|
365
|
+
undeclared=frozenset(undeclared),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _marker(self, expectation: Expectation) -> str | None:
|
|
369
|
+
columns = ", ".join(_identifier(column, self.dialect) for column in expectation.columns)
|
|
370
|
+
match expectation.asserted:
|
|
371
|
+
case "unique":
|
|
372
|
+
return f"/**ASSERT UNIQUE({columns})**/"
|
|
373
|
+
case "key":
|
|
374
|
+
return f"/**ASSERT NOT NULL UNIQUE({columns})**/"
|
|
375
|
+
case "not_null":
|
|
376
|
+
return f"/**ASSERT NOT NULL({columns})**/"
|
|
377
|
+
case "enum":
|
|
378
|
+
return f"/**ASSERT ENUM({columns} IN ({', '.join(value.sql() for value in expectation.values)}))**/"
|
|
379
|
+
case "coverage":
|
|
380
|
+
target = self.relations.get(expectation.referenced or "")
|
|
381
|
+
if target is None or target.relation_name is None:
|
|
382
|
+
return None
|
|
383
|
+
referenced = ", ".join(_identifier(column, self.dialect) for column in expectation.referenced_columns)
|
|
384
|
+
return f"/**ASSERT FOREIGN KEY({columns}) REFERENCES {target.relation_name}({referenced})**/"
|
|
385
|
+
case None:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
def _declaration(self, relation: Relation) -> str | None:
|
|
389
|
+
if relation.relation_name is None:
|
|
390
|
+
return None
|
|
391
|
+
if self.is_view(relation):
|
|
392
|
+
if relation.materialized == "ephemeral":
|
|
393
|
+
return None # dbt inlined it as a CTE wherever it is read.
|
|
394
|
+
body = _blank_assertions(_strip_end(relation.compiled_code or ""))
|
|
395
|
+
return f"create view {relation.relation_name} as\n{body}\n;"
|
|
396
|
+
columns = self._complete_columns(relation)
|
|
397
|
+
if columns is None:
|
|
398
|
+
return None
|
|
399
|
+
return f"create table {relation.relation_name} ({', '.join(columns)});"
|
|
400
|
+
|
|
401
|
+
def _complete_columns(self, relation: Relation) -> list[str] | None:
|
|
402
|
+
"""Column definitions known to be all of a table's columns, with their
|
|
403
|
+
types: an enforced contract's. Otherwise the columns stay unknown."""
|
|
404
|
+
if not relation.contract_enforced or not relation.columns:
|
|
405
|
+
return None
|
|
406
|
+
if any(column.data_type is None for column in relation.columns):
|
|
407
|
+
return None
|
|
408
|
+
definitions = []
|
|
409
|
+
for column in relation.columns:
|
|
410
|
+
try:
|
|
411
|
+
kind = exp.DataType.build(column.data_type or "", dialect=self.dialect)
|
|
412
|
+
except (SqlglotError, ValueError):
|
|
413
|
+
return None
|
|
414
|
+
definitions.append(f"{_identifier(column.name, self.dialect)} {kind.sql(dialect=self.dialect)}")
|
|
415
|
+
return definitions
|
|
416
|
+
|
|
417
|
+
def _declarations(
|
|
418
|
+
self, program: ir.Program, knowledge: tuple[Property, ...], model: Relation, reached: list[Relation],
|
|
419
|
+
) -> list[Property]:
|
|
420
|
+
"""The fact tests and constraints of every reached relation, as Properties of its IR."""
|
|
421
|
+
named = {
|
|
422
|
+
relation.name.lower(): relation for relation in program.named_relations
|
|
423
|
+
if relation.role in {ir.RelationRole.TABLE, ir.RelationRole.VIEW}
|
|
424
|
+
}
|
|
425
|
+
ctes: dict[str, list[ir.NamedRelation]] = {}
|
|
426
|
+
for relation in program.named_relations:
|
|
427
|
+
if relation.role is ir.RelationRole.CTE:
|
|
428
|
+
ctes.setdefault(relation.name.lower(), []).append(relation)
|
|
429
|
+
|
|
430
|
+
def relations_of(node: str) -> list[ir.NamedRelation]:
|
|
431
|
+
relation = self.relations[node]
|
|
432
|
+
if relation.materialized == "ephemeral":
|
|
433
|
+
names = {
|
|
434
|
+
match.group(1).lower()
|
|
435
|
+
for source in (model, *reached) for cte in source.extra_ctes
|
|
436
|
+
if cte.id == node and (match := _CTE_NAME.match(cte.sql))
|
|
437
|
+
}
|
|
438
|
+
return [r for name in names for r in ctes.get(name, [])]
|
|
439
|
+
canonical = _canonical(relation.relation_name, self.dialect)
|
|
440
|
+
return [named[canonical]] if canonical in named else []
|
|
441
|
+
|
|
442
|
+
declared: list[Property] = []
|
|
443
|
+
for relation in reached:
|
|
444
|
+
for expectation in self.facts(relation.unique_id):
|
|
445
|
+
for target in relations_of(relation.unique_id):
|
|
446
|
+
columns = _columns(target, expectation.columns)
|
|
447
|
+
if columns is None:
|
|
448
|
+
continue # A column nothing reads; its facts cannot matter.
|
|
449
|
+
match expectation.declared:
|
|
450
|
+
case "unique":
|
|
451
|
+
declared.append(UniqueSet(columns=frozenset(columns)))
|
|
452
|
+
case "key":
|
|
453
|
+
declared.append(NonNullUniqueSet(columns=frozenset(columns)))
|
|
454
|
+
declared.extend(NonNullColumn(column=column) for column in columns)
|
|
455
|
+
case "not_null":
|
|
456
|
+
declared.extend(NonNullColumn(column=column) for column in columns)
|
|
457
|
+
case "enum":
|
|
458
|
+
with Engine().session(program, knowledge):
|
|
459
|
+
identical = _holds_its_literals(columns[0], expectation.values, self.dialect)
|
|
460
|
+
if identical:
|
|
461
|
+
declared.append(EnumeratedDomain(column=columns[0], values=frozenset(expectation.values)))
|
|
462
|
+
case "coverage":
|
|
463
|
+
referenced = [
|
|
464
|
+
found for other in relations_of(expectation.referenced or "")
|
|
465
|
+
if (found := _columns(other, expectation.referenced_columns)) is not None
|
|
466
|
+
] if expectation.referenced in self.relations else []
|
|
467
|
+
declared.extend(
|
|
468
|
+
ReferentialCoverage(columns=columns, referenced_columns=found) for found in referenced
|
|
469
|
+
)
|
|
470
|
+
case None:
|
|
471
|
+
pass
|
|
472
|
+
return declared
|
|
473
|
+
|
|
474
|
+
def _additions(
|
|
475
|
+
self, program: ir.Program, knowledge: tuple[Property, ...], diagnostics: tuple[Diagnostic, ...], model: Relation,
|
|
476
|
+
) -> tuple[Addition, ...]:
|
|
477
|
+
"""Unique Sets, Not Null columns and Enumerated Domains proved of the model."""
|
|
478
|
+
root = program.root
|
|
479
|
+
if root is None or any(d.code in _UNPARSEABLE | {diag.DUPLICATE_DECLARATION} for d in diagnostics):
|
|
480
|
+
return ()
|
|
481
|
+
names = [column.name.lower() for column in root.output_columns]
|
|
482
|
+
columns = tuple(column for column in root.output_columns if names.count(column.name.lower()) == 1)
|
|
483
|
+
found: list[Addition] = []
|
|
484
|
+
with Engine().session(program, knowledge) as arena:
|
|
485
|
+
non_null = {
|
|
486
|
+
column: arena.prove(NonNullColumns(columns=frozenset((column,))))
|
|
487
|
+
for column in columns
|
|
488
|
+
}
|
|
489
|
+
for key in minimal_keys(root):
|
|
490
|
+
if not set(key) <= set(columns):
|
|
491
|
+
continue
|
|
492
|
+
evidence = arena.prove(UniqueSet(columns=frozenset(key)))
|
|
493
|
+
if not evidence.value:
|
|
494
|
+
continue
|
|
495
|
+
strict = all(non_null[column].value for column in key)
|
|
496
|
+
found.append(Addition("key" if strict else "unique", tuple(c.name for c in key), (), evidence.explain()))
|
|
497
|
+
for column, evidence in non_null.items():
|
|
498
|
+
if evidence.value:
|
|
499
|
+
found.append(Addition("not_null", (column.name,), (), evidence.explain()))
|
|
500
|
+
for column in columns:
|
|
501
|
+
if isinstance(column.scalar_expr, ir.Constant):
|
|
502
|
+
continue
|
|
503
|
+
domain = _domain(column).value
|
|
504
|
+
if domain and len(domain) <= MAX_ENUM_VALUES and len({value.type for value in domain}) == 1:
|
|
505
|
+
evidence = arena.prove(EnumeratedDomain(column=column, values=domain))
|
|
506
|
+
if evidence.value:
|
|
507
|
+
values = tuple(sorted(domain, key=lambda value: value.text))
|
|
508
|
+
found.append(Addition("enum", (column.name,), values, evidence.explain()))
|
|
509
|
+
return tuple(found)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _blank_assertions(sql: str) -> str:
|
|
513
|
+
"""`sql` with its Assertion Markers blanked: another model's assertions are
|
|
514
|
+
checked when that model is analyzed, not wherever it is read."""
|
|
515
|
+
for match in reversed(_marker_shapes(sql)):
|
|
516
|
+
if re.sub(r"\s+", " ", match.group(0)).upper().startswith("/**ASSERT "):
|
|
517
|
+
sql = sql[:match.start()] + re.sub(r"[^\n]", " ", match.group(0)) + sql[match.end():]
|
|
518
|
+
return sql
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _identifier(name: str, dialect: str) -> str:
|
|
522
|
+
return exp.to_identifier(name).sql(dialect=dialect)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _canonical(relation_name: str | None, dialect: str) -> str:
|
|
526
|
+
"""How the IR names a relation written as `relation_name`."""
|
|
527
|
+
if relation_name is None:
|
|
528
|
+
return ""
|
|
529
|
+
try:
|
|
530
|
+
table = exp.to_table(relation_name, dialect=dialect)
|
|
531
|
+
except SqlglotError:
|
|
532
|
+
return relation_name.lower()
|
|
533
|
+
return ".".join(part for part in (table.catalog, table.db, table.name) if part).lower()
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _columns(relation: ir.NamedRelation, names: tuple[str, ...]) -> tuple[ir.OutputColumn, ...] | None:
|
|
537
|
+
found = []
|
|
538
|
+
for name in names:
|
|
539
|
+
matching = [column for column in relation.output_columns if column.name.lower() == name.lower()]
|
|
540
|
+
if len(matching) != 1:
|
|
541
|
+
return None
|
|
542
|
+
found.append(matching[0])
|
|
543
|
+
return tuple(found)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _coverage(assertion: ir.Assertion) -> ir.Assertion:
|
|
547
|
+
"""A relationships test checks Referential Coverage only, never the
|
|
548
|
+
referenced Unique Set a Foreign Key Assertion also requires."""
|
|
549
|
+
property = assertion.property
|
|
550
|
+
assert isinstance(property, ForeignKey)
|
|
551
|
+
coverage = ReferentialCoverage(columns=property.columns, referenced_columns=property.referenced_columns)
|
|
552
|
+
return ir.Assertion(origin=assertion.origin, property=coverage)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _tests(report: Report, tested: Mapping[int, Expectation]) -> dict[str, TestResult]:
|
|
556
|
+
results: dict[str, TestResult] = {}
|
|
557
|
+
for assertion in report.assertions:
|
|
558
|
+
expectation = tested.get(assertion.origin.line or 0)
|
|
559
|
+
if expectation is not None:
|
|
560
|
+
explanation = assertion.evidence.explain() if assertion.evidence is not None else ""
|
|
561
|
+
results[expectation.unique_id] = TestResult(assertion.outcome is Outcome.PROVED, explanation)
|
|
562
|
+
for diagnostic in (*report.diagnostics, *report.warnings):
|
|
563
|
+
line = diagnostic.origin.line if diagnostic.origin is not None else None
|
|
564
|
+
expectation = tested.get(line or 0)
|
|
565
|
+
if expectation is not None and expectation.unique_id not in results:
|
|
566
|
+
results[expectation.unique_id] = TestResult(False, f"{diagnostic.code}: {diagnostic.message}")
|
|
567
|
+
for line, expectation in tested.items():
|
|
568
|
+
results.setdefault(expectation.unique_id, TestResult(False, "it was not analyzed"))
|
|
569
|
+
return results
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _holds_its_literals(column: ir.OutputColumn, values: Iterable[LiteralValue], dialect: str) -> bool:
|
|
573
|
+
"""Whether `column IN (values)` passing means each value of `column` is one
|
|
574
|
+
of `values` as written: the comparison casts neither side and equates no
|
|
575
|
+
two distinct values. An integer column passes `IN ('01')` with 1, a
|
|
576
|
+
decimal one `IN (1)` with 1.00; of an unknown type nothing is known."""
|
|
577
|
+
declared = _comparison_type(column).value
|
|
578
|
+
if declared is None or not _identity_comparison(column).value:
|
|
579
|
+
return False
|
|
580
|
+
if _binary_text(column).value:
|
|
581
|
+
return all(value.type == "string" for value in values)
|
|
582
|
+
kind = exp.DataType.build(declared, dialect=dialect).this
|
|
583
|
+
if kind in exp.DataType.INTEGER_TYPES:
|
|
584
|
+
return all(value.type == "numeric" and value.text.lstrip("-").isdigit() for value in values)
|
|
585
|
+
if kind == exp.DataType.Type.BOOLEAN:
|
|
586
|
+
return all(value.type == "boolean" for value in values)
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _markers(report: Report, body: Body, body_lines: int) -> tuple[MarkerResult, ...]:
|
|
591
|
+
found = []
|
|
592
|
+
for assertion in report.assertions:
|
|
593
|
+
span = assertion.origin.span
|
|
594
|
+
if span is None or span.line > body_lines:
|
|
595
|
+
continue
|
|
596
|
+
start = _line_start(body, span.line)
|
|
597
|
+
offset = start + span.column - 1
|
|
598
|
+
if body.in_injected(offset):
|
|
599
|
+
continue
|
|
600
|
+
end = body.text.find("\n", start) % (len(body.text) + 1) # -1: the last line runs to the end.
|
|
601
|
+
on_line = [m.start() for m in _marker_shapes(body.text) if start <= m.start() < end]
|
|
602
|
+
found.append(MarkerResult(
|
|
603
|
+
span.line, raw_line_of(body, span.line, offset), on_line.index(offset) if offset in on_line else 0,
|
|
604
|
+
_marker_at(body, offset), assertion.outcome,
|
|
605
|
+
assertion.evidence.explain() if assertion.evidence is not None else "",
|
|
606
|
+
))
|
|
607
|
+
return tuple(found)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _problems(
|
|
611
|
+
report: Report, body: Body, body_lines: int, tested: Mapping[int, Expectation], reached: list[Relation], dialect: str,
|
|
612
|
+
) -> tuple[Problem, ...]:
|
|
613
|
+
"""The Diagnostics about the model's own SQL; those about another relation
|
|
614
|
+
are reported when that relation is analyzed, and a table with unknown
|
|
615
|
+
columns is reported once for the project (the model's own, read through
|
|
616
|
+
`{{ this }}`, is its previous run and unknown by nature)."""
|
|
617
|
+
reached_names = {_canonical(relation.relation_name, dialect) for relation in reached}
|
|
618
|
+
found = []
|
|
619
|
+
for diagnostic, warning in [*((d, False) for d in report.diagnostics), *((d, True) for d in report.warnings)]:
|
|
620
|
+
line = diagnostic.origin.line if diagnostic.origin is not None else None
|
|
621
|
+
if diagnostic.code == diag.UNDECLARED_TABLE and any(
|
|
622
|
+
f"table {name} has" in diagnostic.message.lower() for name in reached_names
|
|
623
|
+
):
|
|
624
|
+
continue
|
|
625
|
+
if line is not None and (line > body_lines or line in tested or body.in_injected(_line_offset(body, line))):
|
|
626
|
+
continue
|
|
627
|
+
found.append(Problem(diagnostic, warning, line, raw_line_of(body, line) if line is not None else None))
|
|
628
|
+
return tuple(found)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _line_start(body: Body, line: int) -> int:
|
|
632
|
+
"""The offset where `line` of the body starts."""
|
|
633
|
+
start = 0
|
|
634
|
+
for _ in range(line - 1):
|
|
635
|
+
start = body.text.find("\n", start) + 1
|
|
636
|
+
if start == 0:
|
|
637
|
+
return len(body.text)
|
|
638
|
+
return start
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _line_offset(body: Body, line: int) -> int:
|
|
642
|
+
"""The offset where `line` of the body starts its first marker, or its first non-space."""
|
|
643
|
+
start = _line_start(body, line)
|
|
644
|
+
if start == len(body.text):
|
|
645
|
+
return start
|
|
646
|
+
end = body.text.find("\n", start)
|
|
647
|
+
end = len(body.text) if end < 0 else end
|
|
648
|
+
markers = [m for m in _marker_shapes(body.text) if start <= m.start() < end]
|
|
649
|
+
if markers:
|
|
650
|
+
return markers[0].start()
|
|
651
|
+
stripped = len(body.text[start:end]) - len(body.text[start:end].lstrip())
|
|
652
|
+
return start + stripped
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _marker_at(body: Body, offset: int) -> str:
|
|
656
|
+
match = next((m for m in _marker_shapes(body.text) if m.start() == offset), None)
|
|
657
|
+
return re.sub(r"\s+", " ", match.group(0)) if match else ""
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def raw_line_of(body: Body, line: int, offset: int | None = None) -> int | None:
|
|
661
|
+
"""The model file's line that `line` of the body was rendered from, when known.
|
|
662
|
+
|
|
663
|
+
Without a trusted rendering, a marker is found by its text: the k-th
|
|
664
|
+
occurrence in the body is the k-th in the raw code, when both have equally
|
|
665
|
+
many; any other line, by its text when that occurs once in each.
|
|
666
|
+
`offset` is the marker's, when `line` has more than one."""
|
|
667
|
+
offset = _line_offset(body, line) if offset is None else offset
|
|
668
|
+
if body.lines is not None:
|
|
669
|
+
found = body.lines[offset] if offset < len(body.lines) else 0
|
|
670
|
+
return found or None
|
|
671
|
+
marker = next((m for m in _marker_shapes(body.text) if m.start() == offset), None)
|
|
672
|
+
if marker is not None:
|
|
673
|
+
text = marker.group(0)
|
|
674
|
+
compiled = [m.start() for m in _marker_shapes(body.text) if m.group(0) == text and not body.in_injected(m.start())]
|
|
675
|
+
raw = [m.start() for m in _marker_shapes(body.raw_code) if m.group(0) == text]
|
|
676
|
+
if len(compiled) != len(raw) or offset not in compiled:
|
|
677
|
+
return None
|
|
678
|
+
return body.raw_code.count("\n", 0, raw[compiled.index(offset)]) + 1
|
|
679
|
+
end = body.text.find("\n", offset)
|
|
680
|
+
text = body.text[offset:len(body.text) if end < 0 else end].strip()
|
|
681
|
+
if not text or body.text.count(text) != 1 or body.raw_code.count(text) != 1:
|
|
682
|
+
return None
|
|
683
|
+
return body.raw_code.count("\n", 0, body.raw_code.index(text)) + 1
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
__all__ = ["Body", "Branches", "Builder", "BranchResult", "branches", "raw_line_of"]
|