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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""dbt integration: check a dbt project's tests and Property Markers from its manifest, without dbt.
|
|
2
|
+
|
|
3
|
+
`analyze_project(project_dir)` reads `target/manifest.json` and returns a
|
|
4
|
+
`DbtReport`: which tests the SQL proves (skippable), which it does not, which
|
|
5
|
+
proved Properties no test states, and the outcome of every marker written in
|
|
6
|
+
a model file, mapped back to that file's lines. `sqlassert-dbt` is its command
|
|
7
|
+
line (`__main__.py`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sqlassert_dbt.project import SUPPORTED_DIALECTS, ProjectError, analyze_project
|
|
11
|
+
from sqlassert_dbt.report import (
|
|
12
|
+
DbtReport, Location, MarkerOutcome, Problem, Status, TestOutcome, Untested,
|
|
13
|
+
exit_code, format_text,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SUPPORTED_DIALECTS", "DbtReport", "Location", "MarkerOutcome", "Problem",
|
|
18
|
+
"ProjectError", "Status", "TestOutcome", "Untested", "analyze_project", "exit_code", "format_text",
|
|
19
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
`sqlassert-dbt check [PROJECT]` checks a compiled dbt project's tests and
|
|
4
|
+
markers from its `target/manifest.json`, with `sqlassert check`'s exit codes.
|
|
5
|
+
|
|
6
|
+
`sqlassert-dbt build|test [PROJECT] [-- DBT_ARGS]` runs dbt with the markers
|
|
7
|
+
checked too: `dbt compile`, then the check, then `dbt build` (or `dbt test`)
|
|
8
|
+
with every test the SQL proves excluded. dbt runs the tests of what a model
|
|
9
|
+
reads in the same invocation, so they are facts, unless DBT_ARGS select nodes
|
|
10
|
+
(a selection may leave an upstream test out).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from sqlassert.check import EXIT_FINDINGS, EXIT_OK, EXIT_USAGE
|
|
21
|
+
from sqlassert_dbt.project import ProjectError, analyze_project
|
|
22
|
+
from sqlassert_dbt.report import DbtReport, exit_code, format_text
|
|
23
|
+
|
|
24
|
+
# The dbt flags that narrow which nodes run: under any of them, the tests of
|
|
25
|
+
# what a model reads may not run, so they are no facts.
|
|
26
|
+
SELECTION_FLAGS = frozenset({"-s", "--select", "-m", "--models", "--model", "--exclude", "--selector", "--resource-type", "--resource-types"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_dbt(arguments: list[str]) -> int:
|
|
30
|
+
"""Run dbt from PATH with `arguments`; its exit status."""
|
|
31
|
+
try:
|
|
32
|
+
return subprocess.run(["dbt", *arguments], check=False).returncode
|
|
33
|
+
except FileNotFoundError:
|
|
34
|
+
print("sqlassert-dbt: dbt is not on PATH", file=sys.stderr)
|
|
35
|
+
return EXIT_USAGE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
parser = argparse.ArgumentParser(prog="sqlassert-dbt")
|
|
40
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
check = commands.add_parser(
|
|
42
|
+
"check",
|
|
43
|
+
help="check the tests and markers of a compiled project, without dbt",
|
|
44
|
+
description="Check a dbt project's tests and markers from its target/manifest.json (run `dbt compile` first). "
|
|
45
|
+
f"Exit status: {EXIT_OK} every marker PROVED, {EXIT_FINDINGS} a marker UNKNOWN or a diagnostic reported, "
|
|
46
|
+
f"{EXIT_USAGE} the manifest cannot be read.",
|
|
47
|
+
)
|
|
48
|
+
_common(check)
|
|
49
|
+
check.add_argument("--manifest", help="manifest.json to read (default: PROJECT/target/manifest.json)")
|
|
50
|
+
check.add_argument(
|
|
51
|
+
"--trust-upstream-tests", action="store_true",
|
|
52
|
+
help="take the passing tests of what each model reads as facts about it: true when they run first, as in `dbt build`",
|
|
53
|
+
)
|
|
54
|
+
for name, verb in (("build", "dbt build"), ("test", "dbt test")):
|
|
55
|
+
command = commands.add_parser(
|
|
56
|
+
name,
|
|
57
|
+
help=f"`dbt compile`, check the markers, then `{verb}` without the tests the SQL proves",
|
|
58
|
+
description=f"Run `dbt compile`, check the project, then `{verb}` excluding every test the SQL proves. "
|
|
59
|
+
f"Arguments after -- go to dbt. Exit status: dbt's when it fails, else {EXIT_FINDINGS} when a marker is "
|
|
60
|
+
f"UNKNOWN or a diagnostic is reported, else {EXIT_OK}.",
|
|
61
|
+
)
|
|
62
|
+
_common(command)
|
|
63
|
+
command.add_argument("dbt_arguments", nargs=argparse.REMAINDER, help="-- then arguments for dbt")
|
|
64
|
+
arguments = parser.parse_args(argv)
|
|
65
|
+
project = Path(arguments.project)
|
|
66
|
+
|
|
67
|
+
if arguments.command == "check":
|
|
68
|
+
report = _analyze(project, arguments.manifest, arguments.dialect, arguments.trust_upstream_tests)
|
|
69
|
+
if report is None:
|
|
70
|
+
return EXIT_USAGE
|
|
71
|
+
sys.stdout.write(format_text(report, explain=arguments.explain))
|
|
72
|
+
return exit_code(report, require_proved=arguments.require_proved)
|
|
73
|
+
|
|
74
|
+
passed = [argument for argument in arguments.dbt_arguments if argument != "--"]
|
|
75
|
+
located = [*passed, "--project-dir", str(project)]
|
|
76
|
+
compiled = run_dbt(["compile", *located])
|
|
77
|
+
if compiled != 0:
|
|
78
|
+
return compiled
|
|
79
|
+
selecting = any(argument.split("=")[0] in SELECTION_FLAGS for argument in passed)
|
|
80
|
+
report = _analyze(project, None, arguments.dialect, not selecting)
|
|
81
|
+
if report is None:
|
|
82
|
+
return EXIT_USAGE
|
|
83
|
+
sys.stdout.write(format_text(report, explain=arguments.explain))
|
|
84
|
+
proved = proved_tests(report)
|
|
85
|
+
ran = run_dbt([arguments.command, *located, *(["--exclude", *proved] if proved else [])])
|
|
86
|
+
return ran if ran != 0 else exit_code(report, require_proved=arguments.require_proved)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _common(command: argparse.ArgumentParser) -> None:
|
|
90
|
+
command.add_argument("project", nargs="?", default=".", help="dbt project directory (default: .)")
|
|
91
|
+
command.add_argument("--dialect", help="analyze under this dialect instead of the adapter's")
|
|
92
|
+
command.add_argument("--explain", action="store_true", help="print each proof and failed proof")
|
|
93
|
+
command.add_argument(
|
|
94
|
+
"--require-proved", action="store_true",
|
|
95
|
+
help=f"exit {EXIT_FINDINGS} when a dbt test is not proved from the SQL, too, not only a marker",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def proved_tests(report: DbtReport) -> list[str]:
|
|
100
|
+
"""The names of the tests the SQL proves, to pass dbt as `--exclude`."""
|
|
101
|
+
return sorted({test.name for test in report.tests if test.skippable})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _analyze(project: Path, manifest: str | None, dialect: str | None, trust: bool) -> DbtReport | None:
|
|
105
|
+
try:
|
|
106
|
+
return analyze_project(
|
|
107
|
+
project, manifest_path=Path(manifest) if manifest else None, dialect=dialect, trust_upstream_tests=trust,
|
|
108
|
+
)
|
|
109
|
+
except ProjectError as error:
|
|
110
|
+
print(f"sqlassert-dbt: {error}", file=sys.stderr)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
sys.exit(main())
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Map dbt data tests and model contract constraints onto Properties.
|
|
2
|
+
|
|
3
|
+
An Expectation is one test or constraint read as a Property of the relation it
|
|
4
|
+
is attached to. It is used twice: as an Assertion about its own model (can
|
|
5
|
+
analysis prove the test passes?) and, for every model downstream, as a
|
|
6
|
+
Property Declaration (the test ran and passed before they were built). The
|
|
7
|
+
two can differ: `dbt_utils.unique_combination_of_columns` counts NULL equal
|
|
8
|
+
to NULL, so it is proved by a Non-Null Unique Set but declares only a Unique
|
|
9
|
+
Set; a test with a `where:` config is proved by the Property on every row but
|
|
10
|
+
declares nothing, since it checked only some rows.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Iterator, Mapping
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
import sqlglot
|
|
22
|
+
from sqlglot.errors import SqlglotError
|
|
23
|
+
|
|
24
|
+
from sqlassert_dbt.manifest import Constraint, Manifest, Relation, Test
|
|
25
|
+
from sqlassert.ir.model import LiteralValue
|
|
26
|
+
from sqlassert.sql_parse import literal_value
|
|
27
|
+
|
|
28
|
+
# How an Expectation enters analysis: "unique" a Unique Set, "key" a Non-Null
|
|
29
|
+
# Unique Set, "not_null" Not Null, "enum" an Enumerated Domain, "coverage"
|
|
30
|
+
# Referential Coverage (a dbt relationships test checks no Unique Set on its target).
|
|
31
|
+
type PropertyKind = Literal["unique", "key", "not_null", "enum", "coverage"]
|
|
32
|
+
|
|
33
|
+
# Constraint types each adapter enforces when a contracted table is built
|
|
34
|
+
# (dbt's per-adapter `CONSTRAINT_SUPPORT`). Every other adapter enforces
|
|
35
|
+
# NOT NULL only; their keys are informational. Constraints never apply to views.
|
|
36
|
+
_ENFORCED_CONSTRAINTS = {
|
|
37
|
+
"postgres": frozenset({"not_null", "unique", "primary_key", "foreign_key"}),
|
|
38
|
+
"duckdb": frozenset({"not_null", "unique", "primary_key", "foreign_key"}),
|
|
39
|
+
}
|
|
40
|
+
_NOT_NULL_ONLY = frozenset({"not_null"})
|
|
41
|
+
_CONSTRAINED_MATERIALIZATIONS = {"table", "incremental"}
|
|
42
|
+
|
|
43
|
+
_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_$]*")
|
|
44
|
+
_QUOTED = re.compile(r'"([^"]+)"|`([^`]+)`|\[([^\]]+)\]')
|
|
45
|
+
_CALL = re.compile(r"\b(ref|source)\s*\(.*?\)", re.DOTALL)
|
|
46
|
+
_THRESHOLD = re.compile(r"\s*(!=|<>|>=|<=|==|=|>|<)\s*(\d+)\s*")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Expectation:
|
|
51
|
+
"""One test or constraint as a Property of relation `node`.
|
|
52
|
+
|
|
53
|
+
`asserted` is the Property whose proof proves it passes, None when none
|
|
54
|
+
does (`unprovable` says why). `declared` is the Property it guarantees
|
|
55
|
+
downstream once it passed, None when it is no fact (`not_a_fact` says why).
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
unique_id: str
|
|
59
|
+
name: str
|
|
60
|
+
kind: Literal["test", "constraint"]
|
|
61
|
+
node: str
|
|
62
|
+
path: str
|
|
63
|
+
asserted: PropertyKind | None
|
|
64
|
+
declared: PropertyKind | None
|
|
65
|
+
columns: tuple[str, ...] = ()
|
|
66
|
+
values: tuple[LiteralValue, ...] = ()
|
|
67
|
+
referenced: str | None = None
|
|
68
|
+
referenced_columns: tuple[str, ...] = ()
|
|
69
|
+
unprovable: str | None = None
|
|
70
|
+
not_a_fact: str | None = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_fact(self) -> bool:
|
|
74
|
+
return self.declared is not None and self.not_a_fact is None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def expectations(manifest: Manifest) -> Iterator[Expectation]:
|
|
78
|
+
for test in manifest.tests.values():
|
|
79
|
+
expectation = _test(test, manifest.relations)
|
|
80
|
+
if expectation is not None:
|
|
81
|
+
yield expectation
|
|
82
|
+
for relation in manifest.relations.values():
|
|
83
|
+
yield from _constraints(relation, manifest)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _test(test: Test, relations: Mapping[str, Relation]) -> Expectation | None:
|
|
87
|
+
node = _attached(test, relations)
|
|
88
|
+
if node is None:
|
|
89
|
+
return None # A test of nothing analysis knows: a seed-less singular test, say.
|
|
90
|
+
base = Expectation(test.unique_id, test.name, "test", node, test.original_file_path, asserted=None, declared=None)
|
|
91
|
+
weakness = _weakness(test)
|
|
92
|
+
unprovable = _threshold_unprovable(test)
|
|
93
|
+
name = test.test_name if test.namespace is None else f"{test.namespace}.{test.test_name}"
|
|
94
|
+
match name:
|
|
95
|
+
case None:
|
|
96
|
+
return replace(base, asserted=None, declared=None, unprovable="a singular test states no Property")
|
|
97
|
+
case "unique" | "not_null" | "accepted_values" | "relationships":
|
|
98
|
+
column = _column(test.kwargs.get("column_name") or test.column_name)
|
|
99
|
+
if column is None:
|
|
100
|
+
return replace(
|
|
101
|
+
base,
|
|
102
|
+
unprovable=f"it tests the expression {test.kwargs.get('column_name') or test.column_name!r}, not a column",
|
|
103
|
+
)
|
|
104
|
+
case "dbt_utils.unique_combination_of_columns":
|
|
105
|
+
names = test.kwargs.get("combination_of_columns")
|
|
106
|
+
columns = tuple(_column(name) for name in names) if isinstance(names, list) and names else (None,)
|
|
107
|
+
if None in columns:
|
|
108
|
+
return replace(base, asserted=None, declared=None, unprovable="its columns are not all plain columns")
|
|
109
|
+
return replace(
|
|
110
|
+
base, asserted="key", declared="unique", columns=tuple(c for c in columns if c is not None),
|
|
111
|
+
unprovable=unprovable, not_a_fact=weakness,
|
|
112
|
+
)
|
|
113
|
+
case _:
|
|
114
|
+
return replace(base, asserted=None, declared=None, unprovable=f"the {name} test maps to no Property")
|
|
115
|
+
match name:
|
|
116
|
+
case "unique":
|
|
117
|
+
return replace(base, asserted="unique", declared="unique", columns=(column,), unprovable=unprovable, not_a_fact=weakness)
|
|
118
|
+
case "not_null":
|
|
119
|
+
return replace(base, asserted="not_null", declared="not_null", columns=(column,), unprovable=unprovable, not_a_fact=weakness)
|
|
120
|
+
case "accepted_values":
|
|
121
|
+
values = _accepted_values(test.kwargs.get("values"), test.kwargs.get("quote", True))
|
|
122
|
+
if values is None:
|
|
123
|
+
return replace(base, asserted=None, declared=None, unprovable="its values are not all string, numeric or boolean literals")
|
|
124
|
+
return replace(
|
|
125
|
+
base, asserted="enum", declared="enum", columns=(column,), values=values, unprovable=unprovable, not_a_fact=weakness,
|
|
126
|
+
)
|
|
127
|
+
case _:
|
|
128
|
+
target = _resolve_call(test.kwargs.get("to"), test.depends_on, relations)
|
|
129
|
+
field = _column(test.kwargs.get("field"))
|
|
130
|
+
if target is None or field is None:
|
|
131
|
+
return replace(base, asserted=None, declared=None, unprovable="its `to` or `field` cannot be resolved")
|
|
132
|
+
return replace(
|
|
133
|
+
base, asserted="coverage", declared="coverage", columns=(column,), referenced=target, referenced_columns=(field,),
|
|
134
|
+
unprovable=unprovable, not_a_fact=weakness,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _attached(test: Test, relations: Mapping[str, Relation]) -> str | None:
|
|
139
|
+
if test.attached_node in relations:
|
|
140
|
+
return test.attached_node
|
|
141
|
+
# A source test has no attached node; its model argument names the source.
|
|
142
|
+
attached = _resolve_call(test.kwargs.get("model"), test.depends_on, relations)
|
|
143
|
+
if attached is not None:
|
|
144
|
+
return attached
|
|
145
|
+
candidates = [node for node in test.depends_on if node in relations]
|
|
146
|
+
return candidates[0] if len(candidates) == 1 else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _weakness(test: Test) -> str | None:
|
|
150
|
+
"""Why a passing test is no fact about every row, or None when it is one."""
|
|
151
|
+
if test.severity != "error":
|
|
152
|
+
return f"its severity is {test.severity}, so a failure does not stop the build"
|
|
153
|
+
if test.where is not None:
|
|
154
|
+
return f"it checks only the rows where {test.where}"
|
|
155
|
+
if _normalized(test.fail_calc) != "count(*)" or not _fails_on_any_failure(test.error_if):
|
|
156
|
+
return f"its thresholds (fail_calc {test.fail_calc}, error_if {test.error_if}) tolerate failing rows"
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _threshold_unprovable(test: Test) -> str | None:
|
|
161
|
+
"""Why proving no row fails does not prove the test passes, or None."""
|
|
162
|
+
if _normalized(test.fail_calc) != "count(*)":
|
|
163
|
+
return f"it computes failures with fail_calc {test.fail_calc}"
|
|
164
|
+
for condition in (test.warn_if, test.error_if):
|
|
165
|
+
if _compare(0, condition) is not False:
|
|
166
|
+
return f"its threshold {condition} is not met by zero failing rows"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fails_on_any_failure(condition: str) -> bool:
|
|
171
|
+
"""Whether `condition` errors on one failing row and on every count above it."""
|
|
172
|
+
match = _THRESHOLD.fullmatch(condition)
|
|
173
|
+
return (
|
|
174
|
+
match is not None and match.group(1) in {"!=", "<>", ">", ">="}
|
|
175
|
+
and _compare(0, condition) is False and _compare(1, condition) is True
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _compare(failures: int, condition: str) -> bool | None:
|
|
180
|
+
match = _THRESHOLD.fullmatch(condition)
|
|
181
|
+
if match is None:
|
|
182
|
+
return None
|
|
183
|
+
operator, bound = match.group(1), int(match.group(2))
|
|
184
|
+
return {
|
|
185
|
+
"!=": failures != bound, "<>": failures != bound, ">": failures > bound, ">=": failures >= bound,
|
|
186
|
+
"<": failures < bound, "<=": failures <= bound, "=": failures == bound, "==": failures == bound,
|
|
187
|
+
}[operator]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _normalized(text: str) -> str:
|
|
191
|
+
return re.sub(r"\s+", "", text).lower()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _column(name: object) -> str | None:
|
|
195
|
+
"""A plain or quoted column name; None for an expression."""
|
|
196
|
+
if not isinstance(name, str):
|
|
197
|
+
return None
|
|
198
|
+
name = name.strip()
|
|
199
|
+
if _IDENTIFIER.fullmatch(name):
|
|
200
|
+
return name
|
|
201
|
+
quoted = _QUOTED.fullmatch(name)
|
|
202
|
+
return next((group for group in quoted.groups() if group), None) if quoted else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _accepted_values(values: object, quote: object) -> tuple[LiteralValue, ...] | None:
|
|
206
|
+
"""The literals dbt's accepted_values test compares with: each value quoted
|
|
207
|
+
as a string, or with `quote: false`, its text read as a SQL literal."""
|
|
208
|
+
if not isinstance(values, list) or not values:
|
|
209
|
+
return None
|
|
210
|
+
found: list[LiteralValue] = []
|
|
211
|
+
for value in values:
|
|
212
|
+
text = _jinja_text(value)
|
|
213
|
+
if quote is not False:
|
|
214
|
+
literal = LiteralValue(type="string", text=text)
|
|
215
|
+
else:
|
|
216
|
+
try:
|
|
217
|
+
literal = literal_value(sqlglot.parse_one(text))
|
|
218
|
+
except SqlglotError:
|
|
219
|
+
return None
|
|
220
|
+
if literal is None:
|
|
221
|
+
return None
|
|
222
|
+
if literal not in found:
|
|
223
|
+
found.append(literal)
|
|
224
|
+
return tuple(found)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _jinja_text(value: object) -> str:
|
|
228
|
+
"""How Jinja prints a YAML value inside `'{{ value }}'`."""
|
|
229
|
+
return str(value)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _resolve_call(text: object, depends_on: tuple[str, ...], relations: Mapping[str, Relation]) -> str | None:
|
|
233
|
+
"""The relation a `ref(...)` or `source(...)` call written in `text` names,
|
|
234
|
+
looked up among `depends_on` only: dbt resolved it there."""
|
|
235
|
+
if not isinstance(text, str):
|
|
236
|
+
return None
|
|
237
|
+
match = _CALL.search(text)
|
|
238
|
+
if match is None:
|
|
239
|
+
return None
|
|
240
|
+
try:
|
|
241
|
+
call = ast.parse(match.group(0), mode="eval").body
|
|
242
|
+
except SyntaxError:
|
|
243
|
+
return None
|
|
244
|
+
if not isinstance(call, ast.Call) or not all(isinstance(argument, ast.Constant) for argument in call.args):
|
|
245
|
+
return None
|
|
246
|
+
arguments = tuple(str(argument.value) for argument in call.args if isinstance(argument, ast.Constant))
|
|
247
|
+
options: dict[str, object] = {
|
|
248
|
+
keyword.arg: keyword.value.value for keyword in call.keywords
|
|
249
|
+
if keyword.arg is not None and isinstance(keyword.value, ast.Constant)
|
|
250
|
+
}
|
|
251
|
+
return resolve(match.group(1), arguments, options, depends_on, relations)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def resolve(
|
|
255
|
+
function: str, arguments: tuple[str, ...], options: Mapping[str, object], depends_on: tuple[str, ...],
|
|
256
|
+
relations: Mapping[str, Relation],
|
|
257
|
+
) -> str | None:
|
|
258
|
+
"""The unique id among `depends_on` a `ref` or `source` call resolves to, or None."""
|
|
259
|
+
candidates = [relations[node] for node in depends_on if node in relations]
|
|
260
|
+
match function, arguments:
|
|
261
|
+
case "source", (source_name, name):
|
|
262
|
+
found = [r for r in candidates if r.resource_type == "source" and r.source_name == source_name and r.name == name]
|
|
263
|
+
case "ref", (name,) | (_, name):
|
|
264
|
+
package = arguments[0] if len(arguments) == 2 else None
|
|
265
|
+
version = options.get("v", options.get("version"))
|
|
266
|
+
found = [
|
|
267
|
+
r for r in candidates
|
|
268
|
+
if r.resource_type != "source" and r.name == name and (package is None or r.package_name == package)
|
|
269
|
+
and (version is None or r.version == str(version))
|
|
270
|
+
]
|
|
271
|
+
case _:
|
|
272
|
+
return None
|
|
273
|
+
return found[0].unique_id if len(found) == 1 else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _constraints(relation: Relation, manifest: Manifest) -> Iterator[Expectation]:
|
|
277
|
+
enforced = _ENFORCED_CONSTRAINTS.get(manifest.adapter_type or "", _NOT_NULL_ONLY)
|
|
278
|
+
for index, constraint in enumerate(relation.constraints):
|
|
279
|
+
kind: PropertyKind
|
|
280
|
+
match constraint.type:
|
|
281
|
+
case "not_null":
|
|
282
|
+
kind = "not_null"
|
|
283
|
+
case "unique":
|
|
284
|
+
kind = "unique"
|
|
285
|
+
case "primary_key":
|
|
286
|
+
kind = "key"
|
|
287
|
+
case "foreign_key":
|
|
288
|
+
kind = "coverage"
|
|
289
|
+
case _:
|
|
290
|
+
continue # A check or custom constraint states no Property.
|
|
291
|
+
if not relation.contract_enforced:
|
|
292
|
+
not_a_fact = "the model's contract is not enforced"
|
|
293
|
+
elif relation.materialized not in _CONSTRAINED_MATERIALIZATIONS:
|
|
294
|
+
not_a_fact = f"constraints are not applied to a {relation.materialized} materialization"
|
|
295
|
+
elif constraint.type not in enforced:
|
|
296
|
+
not_a_fact = f"{manifest.adapter_type} does not enforce {constraint.type} constraints"
|
|
297
|
+
else:
|
|
298
|
+
not_a_fact = None
|
|
299
|
+
base = Expectation(
|
|
300
|
+
f"{relation.unique_id}.constraint.{index}",
|
|
301
|
+
f"{constraint.type} constraint on {relation.name}({', '.join(constraint.columns)})",
|
|
302
|
+
"constraint", relation.unique_id, relation.patch_path or relation.original_file_path, asserted=None, declared=None,
|
|
303
|
+
)
|
|
304
|
+
if not constraint.columns:
|
|
305
|
+
yield replace(base, asserted=None, declared=None, unprovable="it names no columns")
|
|
306
|
+
continue
|
|
307
|
+
if kind != "coverage":
|
|
308
|
+
yield replace(base, asserted=kind, declared=kind, columns=constraint.columns, not_a_fact=not_a_fact)
|
|
309
|
+
continue
|
|
310
|
+
target, target_columns = _foreign_key_target(constraint, relation, manifest)
|
|
311
|
+
if target is None or len(target_columns) != len(constraint.columns):
|
|
312
|
+
yield replace(base, asserted=None, declared=None, unprovable="its referenced relation or columns cannot be resolved")
|
|
313
|
+
continue
|
|
314
|
+
yield replace(
|
|
315
|
+
base, asserted=kind, declared=kind, columns=constraint.columns, referenced=target, referenced_columns=target_columns,
|
|
316
|
+
not_a_fact=not_a_fact,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _foreign_key_target(constraint: Constraint, relation: Relation, manifest: Manifest) -> tuple[str | None, tuple[str, ...]]:
|
|
321
|
+
if constraint.to is not None:
|
|
322
|
+
return _resolve_call(constraint.to, relation.depends_on, manifest.relations) or _resolve_call(
|
|
323
|
+
constraint.to, tuple(manifest.relations), manifest.relations
|
|
324
|
+
), constraint.to_columns
|
|
325
|
+
# An older foreign key's expression is raw SQL: `relation (column, ...)`.
|
|
326
|
+
match = re.fullmatch(r"\s*(.+?)\s*\(([^()]*)\)\s*", constraint.expression or "")
|
|
327
|
+
if match is None:
|
|
328
|
+
return None, ()
|
|
329
|
+
name = _canonical(match.group(1))
|
|
330
|
+
found = [r.unique_id for r in manifest.relations.values() if r.relation_name is not None and _canonical(r.relation_name) == name]
|
|
331
|
+
columns = tuple(column for part in match.group(2).split(",") if (column := _column(part)) is not None)
|
|
332
|
+
return (found[0] if len(found) == 1 else None), columns
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _canonical(name: str) -> str:
|
|
336
|
+
return re.sub(r'["`\[\]\s]', "", name).lower()
|
sqlassert_dbt/jinja.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Render the small Jinja subset most dbt models are written in, without dbt.
|
|
2
|
+
|
|
3
|
+
A model's compiled code is one rendering of its raw code: an incremental
|
|
4
|
+
model's `is_incremental()` branch is taken or not, and the manifest holds only
|
|
5
|
+
the one dbt rendered. To analyze the other, and to map a compiled line back to
|
|
6
|
+
the line of the model file, this module renders raw code itself.
|
|
7
|
+
|
|
8
|
+
Supported: data, `{# comments #}`, `{{ ref(...) }}`, `{{ source(...) }}`,
|
|
9
|
+
`{{ this }}`, `{{ config(...) }}` (which renders nothing), and
|
|
10
|
+
`{% if [not] is_incremental() %} ... {% else %} ... {% endif %}`, with
|
|
11
|
+
whitespace control. Anything else raises `Unrenderable`: a variable, a
|
|
12
|
+
filter, a loop or another macro could render anything, and guessing what
|
|
13
|
+
would be unsound. A caller trusts a rendering only after the same renderer
|
|
14
|
+
reproduced dbt's own compiled code for the branch dbt took.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
# Renders `ref`, `source` or `this` (with no arguments) from its literal arguments.
|
|
24
|
+
type Resolver = Callable[[str, tuple[object, ...], dict[str, object]], str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Unrenderable(Exception):
|
|
28
|
+
"""Raw code outside the supported subset."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Rendered:
|
|
33
|
+
"""Rendered text, and for each of its characters the raw-code offset it was copied from."""
|
|
34
|
+
|
|
35
|
+
text: str
|
|
36
|
+
sources: tuple[int, ...]
|
|
37
|
+
|
|
38
|
+
def raw_offset(self, offset: int) -> int | None:
|
|
39
|
+
"""The raw-code offset of rendered `offset`, or None past the end or
|
|
40
|
+
for text a tag produced (a resolved ref, say), which no raw line wrote."""
|
|
41
|
+
return self.sources[offset] if 0 <= offset < len(self.sources) and self.sources[offset] >= 0 else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_CLOSERS = {"{{": "}}", "{%": "%}", "{#": "#}"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_incremental_branch(raw: str) -> bool:
|
|
48
|
+
return "is_incremental" in raw
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def render(raw: str, *, incremental: bool, resolve: Resolver) -> Rendered:
|
|
52
|
+
text: list[str] = []
|
|
53
|
+
sources: list[int] = []
|
|
54
|
+
# Each open `if`: whether its current branch renders.
|
|
55
|
+
branches: list[bool] = []
|
|
56
|
+
strip_next = False
|
|
57
|
+
position = 0
|
|
58
|
+
while True:
|
|
59
|
+
start = _next_tag(raw, position)
|
|
60
|
+
data_end = len(raw) if start < 0 else start
|
|
61
|
+
opener = "" if start < 0 else raw[start:start + 2]
|
|
62
|
+
left_strip = start >= 0 and raw.startswith("-", start + 2)
|
|
63
|
+
data = range(position, data_end)
|
|
64
|
+
if strip_next:
|
|
65
|
+
data = range(_skip_space(raw, position, data_end), data_end)
|
|
66
|
+
if left_strip:
|
|
67
|
+
data = range(data.start, _trim_space(raw, data.start, data.stop))
|
|
68
|
+
if all(branches):
|
|
69
|
+
text.extend(raw[index] for index in data)
|
|
70
|
+
sources.extend(data)
|
|
71
|
+
if start < 0:
|
|
72
|
+
break
|
|
73
|
+
end = _tag_end(raw, start, _CLOSERS[opener])
|
|
74
|
+
body = raw[start + 2:end].removeprefix("-")
|
|
75
|
+
strip_next = body.endswith("-")
|
|
76
|
+
body = body.removesuffix("-").strip()
|
|
77
|
+
position = end + 2
|
|
78
|
+
match opener:
|
|
79
|
+
case "{#":
|
|
80
|
+
pass
|
|
81
|
+
case "{{":
|
|
82
|
+
if all(branches):
|
|
83
|
+
rendered = _expression(body, resolve)
|
|
84
|
+
text.extend(rendered)
|
|
85
|
+
sources.extend([-1] * len(rendered))
|
|
86
|
+
case _:
|
|
87
|
+
_statement(body, branches, incremental)
|
|
88
|
+
if branches:
|
|
89
|
+
raise Unrenderable("an {% if %} is never closed")
|
|
90
|
+
# Jinja drops a single trailing newline (`keep_trailing_newline` is off in dbt).
|
|
91
|
+
if text and text[-1] == "\n":
|
|
92
|
+
text.pop()
|
|
93
|
+
sources.pop()
|
|
94
|
+
return Rendered("".join(text), tuple(sources))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _next_tag(raw: str, position: int) -> int:
|
|
98
|
+
found = [index for opener in _CLOSERS if (index := raw.find(opener, position)) >= 0]
|
|
99
|
+
return min(found, default=-1)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _tag_end(raw: str, start: int, closer: str) -> int:
|
|
103
|
+
"""The offset of `closer` ending the tag opened at `start`, skipping quoted text."""
|
|
104
|
+
index, quote = start + 2, None
|
|
105
|
+
while index < len(raw):
|
|
106
|
+
char = raw[index]
|
|
107
|
+
if quote:
|
|
108
|
+
if char == "\\":
|
|
109
|
+
index += 2
|
|
110
|
+
continue
|
|
111
|
+
if char == quote:
|
|
112
|
+
quote = None
|
|
113
|
+
elif char in "'\"" and closer != "#}":
|
|
114
|
+
quote = char
|
|
115
|
+
elif raw.startswith(closer, index):
|
|
116
|
+
return index
|
|
117
|
+
index += 1
|
|
118
|
+
raise Unrenderable(f"a tag opened at offset {start} is never closed")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _skip_space(raw: str, start: int, stop: int) -> int:
|
|
122
|
+
while start < stop and raw[start].isspace():
|
|
123
|
+
start += 1
|
|
124
|
+
return start
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _trim_space(raw: str, start: int, stop: int) -> int:
|
|
128
|
+
while stop > start and raw[stop - 1].isspace():
|
|
129
|
+
stop -= 1
|
|
130
|
+
return stop
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _expression(body: str, resolve: Resolver) -> str:
|
|
134
|
+
try:
|
|
135
|
+
tree = ast.parse(body, mode="eval").body
|
|
136
|
+
except SyntaxError as error:
|
|
137
|
+
raise Unrenderable(f"{{{{ {body} }}}} is not a supported expression") from error
|
|
138
|
+
match tree:
|
|
139
|
+
case ast.Name(id="this"):
|
|
140
|
+
return resolve("this", (), {})
|
|
141
|
+
case ast.Call(func=ast.Name(id="config")):
|
|
142
|
+
return ""
|
|
143
|
+
case ast.Call(func=ast.Name(id="ref" | "source" as name), args=args, keywords=keywords):
|
|
144
|
+
values = tuple(_literal(argument, body) for argument in args)
|
|
145
|
+
options = {keyword.arg: _literal(keyword.value, body) for keyword in keywords if keyword.arg is not None}
|
|
146
|
+
if len(options) != len(keywords):
|
|
147
|
+
raise Unrenderable(f"{{{{ {body} }}}} passes **arguments")
|
|
148
|
+
return resolve(name, values, options)
|
|
149
|
+
case _:
|
|
150
|
+
raise Unrenderable(f"{{{{ {body} }}}} is not a ref, source, this or config")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _literal(node: ast.expr, body: str) -> object:
|
|
154
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (str, int, float)):
|
|
155
|
+
return node.value
|
|
156
|
+
raise Unrenderable(f"{{{{ {body} }}}} has an argument that is not a literal")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _statement(body: str, branches: list[bool], incremental: bool) -> None:
|
|
160
|
+
keyword, _, rest = body.partition(" ")
|
|
161
|
+
match keyword:
|
|
162
|
+
case "if":
|
|
163
|
+
branches.append(_condition(rest.strip(), incremental))
|
|
164
|
+
case "else" if branches and not rest.strip():
|
|
165
|
+
branches[-1] = not branches[-1]
|
|
166
|
+
case "endif" if branches and not rest.strip():
|
|
167
|
+
branches.pop()
|
|
168
|
+
case _:
|
|
169
|
+
raise Unrenderable(f"{{% {body} %}} is not an is_incremental() branch")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _condition(text: str, incremental: bool) -> bool:
|
|
173
|
+
try:
|
|
174
|
+
tree = ast.parse(text, mode="eval").body
|
|
175
|
+
except SyntaxError as error:
|
|
176
|
+
raise Unrenderable(f"{{% if {text} %}} is not a supported condition") from error
|
|
177
|
+
match tree:
|
|
178
|
+
case ast.Call(func=ast.Name(id="is_incremental"), args=[], keywords=[]):
|
|
179
|
+
return incremental
|
|
180
|
+
case ast.UnaryOp(op=ast.Not(), operand=ast.Call(func=ast.Name(id="is_incremental"), args=[], keywords=[])):
|
|
181
|
+
return not incremental
|
|
182
|
+
case _:
|
|
183
|
+
raise Unrenderable(f"{{% if {text} %}} is not is_incremental()")
|