sqlassert-dbt 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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"]