dblect 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dblect/__init__.py +28 -0
- dblect/_version.py +1 -0
- dblect/adapters/__init__.py +40 -0
- dblect/adapters/builtin/__init__.py +7 -0
- dblect/adapters/builtin/bigquery.py +18 -0
- dblect/adapters/builtin/duckdb.py +24 -0
- dblect/adapters/builtin/postgres.py +17 -0
- dblect/adapters/builtin/redshift.py +17 -0
- dblect/adapters/builtin/snowflake.py +16 -0
- dblect/adapters/model.py +87 -0
- dblect/adapters/registry.py +108 -0
- dblect/analysis.py +125 -0
- dblect/audit/__init__.py +26 -0
- dblect/audit/sourcemap.py +177 -0
- dblect/audit/suppress.py +273 -0
- dblect/audit/walker.py +307 -0
- dblect/baseline.py +29 -0
- dblect/bootstrap/__init__.py +97 -0
- dblect/bootstrap/skill.md +290 -0
- dblect/check/__init__.py +59 -0
- dblect/check/coverage.py +132 -0
- dblect/check/findings.py +135 -0
- dblect/check/flags.py +79 -0
- dblect/check/incremental.py +122 -0
- dblect/check/run.py +713 -0
- dblect/check/worlds.py +126 -0
- dblect/cli/__init__.py +543 -0
- dblect/contracts/__init__.py +59 -0
- dblect/contracts/ast.py +213 -0
- dblect/contracts/compile.py +374 -0
- dblect/contracts/decorator.py +80 -0
- dblect/contracts/proxy.py +343 -0
- dblect/contracts/stubs.py +91 -0
- dblect/demo/__init__.py +20 -0
- dblect/demo/enums.py +65 -0
- dblect/demo/library.py +21 -0
- dblect/execution/__init__.py +5 -0
- dblect/execution/incremental.py +168 -0
- dblect/execution/project_env.py +69 -0
- dblect/execution/run.py +255 -0
- dblect/flatten/__init__.py +13 -0
- dblect/flatten/detector.py +69 -0
- dblect/lineage/__init__.py +41 -0
- dblect/lineage/builder.py +1123 -0
- dblect/lineage/facts/__init__.py +92 -0
- dblect/lineage/facts/grounding.py +190 -0
- dblect/lineage/facts/lattice.py +71 -0
- dblect/lineage/facts/model.py +170 -0
- dblect/lineage/facts/property.py +338 -0
- dblect/lineage/facts/registry.py +115 -0
- dblect/lineage/graph.py +262 -0
- dblect/lineage/predicate.py +521 -0
- dblect/lineage/properties/__init__.py +84 -0
- dblect/lineage/properties/activation.py +43 -0
- dblect/lineage/properties/aggregation_depth.py +71 -0
- dblect/lineage/properties/array_nonemptiness.py +207 -0
- dblect/lineage/properties/domain_type.py +709 -0
- dblect/lineage/properties/functional_dependency.py +540 -0
- dblect/lineage/properties/nullability.py +644 -0
- dblect/lineage/properties/predicate_flow.py +316 -0
- dblect/lineage/properties/uniqueness.py +1252 -0
- dblect/lineage/properties/where_provenance.py +57 -0
- dblect/lineage/property.py +466 -0
- dblect/lineage/semiring.py +110 -0
- dblect/loader.py +130 -0
- dblect/manifest/__init__.py +42 -0
- dblect/manifest/catalog.py +63 -0
- dblect/manifest/dag.py +155 -0
- dblect/manifest/parse.py +732 -0
- dblect/nullability/__init__.py +23 -0
- dblect/nullability/detector.py +502 -0
- dblect/py.typed +0 -0
- dblect/report.py +492 -0
- dblect/sarif.py +418 -0
- dblect/severity.py +152 -0
- dblect/snapshot/__init__.py +18 -0
- dblect/snapshot/detector.py +142 -0
- dblect/sql/__init__.py +101 -0
- dblect/sql/_sqlglot.py +447 -0
- dblect/sql/aggregates.py +181 -0
- dblect/sql/findings.py +100 -0
- dblect/sql/guards.py +261 -0
- dblect/sql/parse.py +166 -0
- dblect/sql/patterns.py +889 -0
- dblect/sql/vocab.py +240 -0
- dblect/templating.py +83 -0
- dblect/types/__init__.py +106 -0
- dblect/types/bridge.py +732 -0
- dblect/types/contract.py +325 -0
- dblect/types/domain.py +270 -0
- dblect/types/enums.py +28 -0
- dblect/types/errors.py +15 -0
- dblect/types/scalars.py +148 -0
- dblect/uniqueness/__init__.py +24 -0
- dblect/uniqueness/detector.py +1070 -0
- dblect/varinf/__init__.py +52 -0
- dblect/varinf/usage.py +224 -0
- dblect/varinf/walker.py +343 -0
- dblect-0.1.0.dist-info/METADATA +443 -0
- dblect-0.1.0.dist-info/RECORD +103 -0
- dblect-0.1.0.dist-info/WHEEL +4 -0
- dblect-0.1.0.dist-info/entry_points.txt +2 -0
- dblect-0.1.0.dist-info/licenses/LICENSE +201 -0
dblect/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""dblect: semantic correctness framework for dbt analytics pipelines.
|
|
2
|
+
|
|
3
|
+
The authored surface a project writes against: domain types and model contracts
|
|
4
|
+
(also under ``dblect.types``), and the contract/proxy layer (``contract``,
|
|
5
|
+
``models``) for relating columns across a model's rows and across models. More
|
|
6
|
+
surfaces (flags, the CLI) land as they are built.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dblect._version import __version__
|
|
10
|
+
from dblect.contracts import contract, models
|
|
11
|
+
from dblect.types import (
|
|
12
|
+
DomainType,
|
|
13
|
+
Field,
|
|
14
|
+
ForeignKey,
|
|
15
|
+
ModelContract,
|
|
16
|
+
PrimaryKey,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DomainType",
|
|
21
|
+
"Field",
|
|
22
|
+
"ForeignKey",
|
|
23
|
+
"ModelContract",
|
|
24
|
+
"PrimaryKey",
|
|
25
|
+
"__version__",
|
|
26
|
+
"contract",
|
|
27
|
+
"models",
|
|
28
|
+
]
|
dblect/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Target adapters: one :class:`AdapterProfile` per dbt adapter, behind a registry.
|
|
2
|
+
|
|
3
|
+
A dbt project compiles against one adapter (duckdb, snowflake, bigquery, ...), and
|
|
4
|
+
that single choice fixes everything dblect reasons about the target: which sqlglot
|
|
5
|
+
dialect parses its compiled SQL, whether the warehouse enforces PRIMARY KEY /
|
|
6
|
+
UNIQUE and NOT NULL on write, and which incremental strategy runs when a model
|
|
7
|
+
leaves ``incremental_strategy`` unset. :class:`AdapterProfile` gathers those facets
|
|
8
|
+
into one value so a run reads a single coherent target.
|
|
9
|
+
|
|
10
|
+
Adding a warehouse is self-contained: a module that builds an ``AdapterProfile``
|
|
11
|
+
and calls :func:`register`. The built-ins under :mod:`dblect.adapters.builtin`
|
|
12
|
+
are auto-discovered, so this package never enumerates them.
|
|
13
|
+
|
|
14
|
+
An adapter is **validated** when dblect's detectors have been exercised against its
|
|
15
|
+
SQL end-to-end (today, only duckdb). The others carry the runtime semantics dbt's
|
|
16
|
+
adapter docs describe and route through the matching sqlglot dialect once the
|
|
17
|
+
operator opts in via ``--dialect``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dblect.adapters.model import DEDUP_STRATEGIES, AdapterProfile, IncrementalStrategy
|
|
23
|
+
from dblect.adapters.registry import (
|
|
24
|
+
UnvalidatedAdapterError,
|
|
25
|
+
profile_for_adapter,
|
|
26
|
+
register,
|
|
27
|
+
resolve_profile,
|
|
28
|
+
validated_adapters,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DEDUP_STRATEGIES",
|
|
33
|
+
"AdapterProfile",
|
|
34
|
+
"IncrementalStrategy",
|
|
35
|
+
"UnvalidatedAdapterError",
|
|
36
|
+
"profile_for_adapter",
|
|
37
|
+
"register",
|
|
38
|
+
"resolve_profile",
|
|
39
|
+
"validated_adapters",
|
|
40
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Built-in adapter profiles, one module per warehouse.
|
|
2
|
+
|
|
3
|
+
Each module builds an :class:`~dblect.adapters.model.AdapterProfile` and calls
|
|
4
|
+
:func:`~dblect.adapters.registry.register`. The registry discovers every module in
|
|
5
|
+
this package automatically, so adding a warehouse is a new file here and nothing
|
|
6
|
+
else.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dblect.adapters import AdapterProfile, IncrementalStrategy, register
|
|
4
|
+
from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
|
|
5
|
+
|
|
6
|
+
register(
|
|
7
|
+
AdapterProfile(
|
|
8
|
+
adapter_type="bigquery",
|
|
9
|
+
sqlglot_dialect="bigquery",
|
|
10
|
+
validated=True,
|
|
11
|
+
# NOT NULL is enforced (REQUIRED mode). PRIMARY KEY / FOREIGN KEY constraints
|
|
12
|
+
# exist but are advisory (unenforced), so keys are not enforced.
|
|
13
|
+
not_null_enforced=True,
|
|
14
|
+
key_enforced=False,
|
|
15
|
+
default_incremental_strategy=IncrementalStrategy.MERGE,
|
|
16
|
+
non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
|
|
17
|
+
)
|
|
18
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dblect.adapters import AdapterProfile, register
|
|
4
|
+
from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
|
|
5
|
+
|
|
6
|
+
# `txid_current()` and `nextval()` arrive as `exp.Anonymous`. `random()`, `uuid()`,
|
|
7
|
+
# `today()` and `gen_random_uuid()` are usually normalised to a typed node (so
|
|
8
|
+
# already caught dialect-neutrally) and are listed for robustness, so a sqlglot
|
|
9
|
+
# version that leaves them anonymous still fires.
|
|
10
|
+
_DUCKDB_NON_DETERMINISTIC_BUILTINS = PORTABLE_NON_DETERMINISTIC_BUILTINS | frozenset(
|
|
11
|
+
{"random", "uuid", "txid_current", "nextval", "today", "gen_random_uuid"}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
register(
|
|
15
|
+
AdapterProfile(
|
|
16
|
+
adapter_type="duckdb",
|
|
17
|
+
sqlglot_dialect="duckdb",
|
|
18
|
+
validated=True,
|
|
19
|
+
not_null_enforced=True,
|
|
20
|
+
key_enforced=True,
|
|
21
|
+
default_incremental_strategy=None, # left unset pending validation
|
|
22
|
+
non_deterministic_builtins=_DUCKDB_NON_DETERMINISTIC_BUILTINS,
|
|
23
|
+
)
|
|
24
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dblect.adapters import AdapterProfile, IncrementalStrategy, register
|
|
4
|
+
from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
|
|
5
|
+
|
|
6
|
+
register(
|
|
7
|
+
AdapterProfile(
|
|
8
|
+
adapter_type="postgres",
|
|
9
|
+
sqlglot_dialect="postgres",
|
|
10
|
+
validated=False,
|
|
11
|
+
not_null_enforced=True,
|
|
12
|
+
key_enforced=True,
|
|
13
|
+
# dbt-postgres defaults to delete+insert (not merge) once a unique_key is set
|
|
14
|
+
default_incremental_strategy=IncrementalStrategy.DELETE_INSERT,
|
|
15
|
+
non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
|
|
16
|
+
)
|
|
17
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dblect.adapters import AdapterProfile, IncrementalStrategy, register
|
|
4
|
+
from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
|
|
5
|
+
|
|
6
|
+
register(
|
|
7
|
+
AdapterProfile(
|
|
8
|
+
adapter_type="redshift",
|
|
9
|
+
sqlglot_dialect="redshift",
|
|
10
|
+
validated=False,
|
|
11
|
+
not_null_enforced=True,
|
|
12
|
+
key_enforced=False,
|
|
13
|
+
# dbt-redshift, like Postgres, defaults to delete+insert (not merge) with a unique_key
|
|
14
|
+
default_incremental_strategy=IncrementalStrategy.DELETE_INSERT,
|
|
15
|
+
non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
|
|
16
|
+
)
|
|
17
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dblect.adapters import AdapterProfile, IncrementalStrategy, register
|
|
4
|
+
from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
|
|
5
|
+
|
|
6
|
+
register(
|
|
7
|
+
AdapterProfile(
|
|
8
|
+
adapter_type="snowflake",
|
|
9
|
+
sqlglot_dialect="snowflake",
|
|
10
|
+
validated=False,
|
|
11
|
+
not_null_enforced=True,
|
|
12
|
+
key_enforced=False,
|
|
13
|
+
default_incremental_strategy=IncrementalStrategy.MERGE,
|
|
14
|
+
non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
|
|
15
|
+
)
|
|
16
|
+
)
|
dblect/adapters/model.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The value types describing one target adapter: :class:`AdapterProfile` and the
|
|
2
|
+
incremental strategies it can default to.
|
|
3
|
+
|
|
4
|
+
These carry no per-adapter data themselves; the concrete profiles live in
|
|
5
|
+
self-contained modules under :mod:`dblect.adapters.builtin` and register
|
|
6
|
+
themselves with the registry, so adding a warehouse never edits this file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IncrementalStrategy(StrEnum):
|
|
16
|
+
"""The incremental materialization strategies dbt ships.
|
|
17
|
+
|
|
18
|
+
A custom strategy (a project-defined macro) is outside this closed set;
|
|
19
|
+
:meth:`parse` maps an unrecognized value to ``None`` so a caller reads it as
|
|
20
|
+
"no known dedup guarantee" rather than guessing.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
APPEND = "append"
|
|
24
|
+
MERGE = "merge"
|
|
25
|
+
DELETE_INSERT = "delete+insert"
|
|
26
|
+
INSERT_OVERWRITE = "insert_overwrite"
|
|
27
|
+
MICROBATCH = "microbatch"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def parse(cls, raw: str | None) -> IncrementalStrategy | None:
|
|
31
|
+
"""The strategy a config string names, or ``None`` when unset or custom."""
|
|
32
|
+
if raw is None:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
return cls(raw.strip().lower())
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Strategies whose write deduplicates on ``unique_key``: ``merge`` updates the
|
|
41
|
+
# matching row and ``delete+insert`` removes then reinserts, so each key value
|
|
42
|
+
# lands once. ``append`` inserts unconditionally and ``insert_overwrite`` replaces
|
|
43
|
+
# whole partitions, so neither enforces the key.
|
|
44
|
+
DEDUP_STRATEGIES: frozenset[IncrementalStrategy] = frozenset(
|
|
45
|
+
{IncrementalStrategy.MERGE, IncrementalStrategy.DELETE_INSERT}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class AdapterProfile:
|
|
51
|
+
"""Everything dblect's analysis needs to know about one target adapter.
|
|
52
|
+
|
|
53
|
+
``adapter_type`` is the effective target's dbt name (the manifest's, or the one
|
|
54
|
+
an override selects); ``sqlglot_dialect`` parses its compiled SQL. The two
|
|
55
|
+
enforcement flags are descriptive provenance, read by the unenforced-constraint
|
|
56
|
+
findings and never by fact resolution. ``default_incremental_strategy`` is the
|
|
57
|
+
strategy in force when a model leaves ``incremental_strategy`` unset, or
|
|
58
|
+
``None`` where dblect does not know the adapter's default to deduplicate.
|
|
59
|
+
``non_deterministic_builtins`` is the complete name set (the portable baseline
|
|
60
|
+
plus this adapter's own builtins) the non-determinism detector matches anonymous
|
|
61
|
+
function calls against; a consumer reads it whole and unions nothing.
|
|
62
|
+
``duplicate_safe_aggregate_builtins`` is the same shape for the duplicate-sensitivity
|
|
63
|
+
predicate: names of UDF aggregates this warehouse exposes (parsed as anonymous
|
|
64
|
+
calls) whose result a duplicated row does not change, so a fan-out into them is
|
|
65
|
+
harmless. It defaults to empty, since most duplicate-safe aggregates already carry a
|
|
66
|
+
dedicated sqlglot type; the few that arrive anonymous are declared here.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
adapter_type: str
|
|
70
|
+
sqlglot_dialect: str
|
|
71
|
+
validated: bool
|
|
72
|
+
not_null_enforced: bool
|
|
73
|
+
key_enforced: bool
|
|
74
|
+
default_incremental_strategy: IncrementalStrategy | None
|
|
75
|
+
non_deterministic_builtins: frozenset[str]
|
|
76
|
+
duplicate_safe_aggregate_builtins: frozenset[str] = frozenset()
|
|
77
|
+
|
|
78
|
+
def effective_strategy(self, declared: str | None) -> IncrementalStrategy | None:
|
|
79
|
+
"""The strategy in force for a model: the declared one when set (``None`` if
|
|
80
|
+
that is a custom strategy dblect does not recognize, so no default is
|
|
81
|
+
assumed for a model that did choose a strategy), else this adapter's
|
|
82
|
+
default. The default branch is meaningful only when a ``unique_key`` is
|
|
83
|
+
present, which the config discoverer checks before consulting it.
|
|
84
|
+
"""
|
|
85
|
+
if declared is not None:
|
|
86
|
+
return IncrementalStrategy.parse(declared)
|
|
87
|
+
return self.default_incremental_strategy
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The adapter registry: registration, auto-discovery, and resolution.
|
|
2
|
+
|
|
3
|
+
Built-in profiles live one per module under :mod:`dblect.adapters.builtin` and are
|
|
4
|
+
auto-discovered on first lookup, so nothing here enumerates them. See the package
|
|
5
|
+
docstring for how the pieces fit together.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import pkgutil
|
|
12
|
+
|
|
13
|
+
from dblect.adapters.model import AdapterProfile
|
|
14
|
+
from dblect.sql import PORTABLE_NON_DETERMINISTIC_BUILTINS
|
|
15
|
+
|
|
16
|
+
_PROFILES: dict[str, AdapterProfile] = {}
|
|
17
|
+
_loaded = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(profile: AdapterProfile) -> AdapterProfile:
|
|
21
|
+
"""Register ``profile`` under its (case-folded) adapter name and return it, so a
|
|
22
|
+
module can both register and keep a reference: ``SNOWFLAKE = register(...)``.
|
|
23
|
+
|
|
24
|
+
A later registration of the same name wins, which lets a host refine or replace
|
|
25
|
+
a built-in profile without editing it.
|
|
26
|
+
"""
|
|
27
|
+
_PROFILES[profile.adapter_type.strip().lower()] = profile
|
|
28
|
+
return profile
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ensure_loaded() -> None:
|
|
32
|
+
"""Import the built-in adapter modules once, so their ``register`` calls run.
|
|
33
|
+
|
|
34
|
+
Discovery walks the :mod:`dblect.adapters.builtin` package rather than naming
|
|
35
|
+
its modules, so a new built-in is picked up without being listed here. The flag
|
|
36
|
+
is set before importing so a module that triggers a lookup during its own
|
|
37
|
+
import does not recurse.
|
|
38
|
+
"""
|
|
39
|
+
global _loaded
|
|
40
|
+
if _loaded:
|
|
41
|
+
return
|
|
42
|
+
_loaded = True
|
|
43
|
+
from dblect.adapters import builtin
|
|
44
|
+
|
|
45
|
+
for info in pkgutil.iter_modules(builtin.__path__, builtin.__name__ + "."):
|
|
46
|
+
importlib.import_module(info.name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _conservative(adapter_type: str, *, sqlglot_dialect: str | None = None) -> AdapterProfile:
|
|
50
|
+
"""The profile for an adapter dblect has no specific knowledge of: NOT NULL
|
|
51
|
+
enforced (true on essentially every warehouse), PRIMARY KEY / UNIQUE advisory,
|
|
52
|
+
no known dedup default (so an unset incremental strategy claims no key), and
|
|
53
|
+
only the portable non-determinism baseline."""
|
|
54
|
+
return AdapterProfile(
|
|
55
|
+
adapter_type=adapter_type,
|
|
56
|
+
sqlglot_dialect=sqlglot_dialect if sqlglot_dialect is not None else adapter_type,
|
|
57
|
+
validated=False,
|
|
58
|
+
not_null_enforced=True,
|
|
59
|
+
key_enforced=False,
|
|
60
|
+
default_incremental_strategy=None,
|
|
61
|
+
non_deterministic_builtins=PORTABLE_NON_DETERMINISTIC_BUILTINS,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def profile_for_adapter(adapter_type: str) -> AdapterProfile:
|
|
66
|
+
"""The capability profile for a dbt adapter by name.
|
|
67
|
+
|
|
68
|
+
An adapter no module has registered gets a conservative profile, never an
|
|
69
|
+
error: this is the semantics lookup, distinct from the parsing-validation gate
|
|
70
|
+
in :func:`resolve_profile`.
|
|
71
|
+
"""
|
|
72
|
+
_ensure_loaded()
|
|
73
|
+
return _PROFILES.get(adapter_type.strip().lower()) or _conservative(adapter_type)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validated_adapters() -> frozenset[str]:
|
|
77
|
+
"""The names of registered adapters dblect has validated end-to-end."""
|
|
78
|
+
_ensure_loaded()
|
|
79
|
+
return frozenset(name for name, profile in _PROFILES.items() if profile.validated)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class UnvalidatedAdapterError(ValueError):
|
|
83
|
+
"""The manifest's adapter is not in dblect's validated set and no ``--dialect``
|
|
84
|
+
override is in effect. Carries the adapter name so the CLI can build an
|
|
85
|
+
actionable message."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, adapter_type: str) -> None:
|
|
88
|
+
super().__init__(
|
|
89
|
+
f"adapter `{adapter_type}` is not in dblect's validated set "
|
|
90
|
+
f"({sorted(validated_adapters())})"
|
|
91
|
+
)
|
|
92
|
+
self.adapter_type = adapter_type
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_profile(*, adapter_type: str, explicit_dialect: str | None) -> AdapterProfile:
|
|
96
|
+
"""The single target profile for a run, or raise :class:`UnvalidatedAdapterError`.
|
|
97
|
+
|
|
98
|
+
An ``explicit_dialect`` override names the target wholesale (its grammar and its
|
|
99
|
+
runtime semantics together), so the two cannot drift apart; passing it is the
|
|
100
|
+
operator's acknowledgment of a best-effort, possibly unvalidated
|
|
101
|
+
interpretation. Without an override the manifest's adapter must be validated.
|
|
102
|
+
"""
|
|
103
|
+
if explicit_dialect is not None:
|
|
104
|
+
return profile_for_adapter(explicit_dialect)
|
|
105
|
+
profile = profile_for_adapter(adapter_type)
|
|
106
|
+
if not profile.validated:
|
|
107
|
+
raise UnvalidatedAdapterError(adapter_type)
|
|
108
|
+
return profile
|
dblect/analysis.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""One analysis door: run every detector family over a manifest, return every
|
|
2
|
+
finding under one sealed type.
|
|
3
|
+
|
|
4
|
+
Two families surface findings today. :func:`~dblect.check.run.run_check` reports
|
|
5
|
+
the declaration-level ones (contract resolution, domain-type contradictions across
|
|
6
|
+
the DAG, not-well-typed aggregations), located by model, column, and contract.
|
|
7
|
+
:func:`~dblect.audit.run_audit` reports the SQL-structural ones (join fan-out,
|
|
8
|
+
window order, the nullability hazards, the rest), located by a span in one compiled
|
|
9
|
+
statement. The two stay distinct in representation and altitude; issue #107 weighs
|
|
10
|
+
whether to merge the representations as well.
|
|
11
|
+
|
|
12
|
+
What this module removes is the obligation to *know* there are two families. Asking
|
|
13
|
+
for "the findings of a manifest" should not require remembering to call both
|
|
14
|
+
producers and merge them: a consumer that threads findings (the incremental-worlds
|
|
15
|
+
cross-world diff, and the world axes still to come) calls :func:`analyze` once and
|
|
16
|
+
gets them all. Adding a third family is a change here, not in every consumer, and
|
|
17
|
+
:data:`AnalysisFinding` is sealed so a ``match`` over it with ``assert_never`` turns
|
|
18
|
+
a forgotten family into a type error rather than a silent coverage gap. That gap is
|
|
19
|
+
not hypothetical: the incremental check first threaded only the declaration-level
|
|
20
|
+
family, so the structural detectors (the very hazard it exists to catch) were absent
|
|
21
|
+
until the omission was noticed by hand.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Hashable
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import assert_never
|
|
29
|
+
|
|
30
|
+
from dblect.adapters import AdapterProfile
|
|
31
|
+
from dblect.audit import AuditReport, LocatedFinding, run_audit
|
|
32
|
+
from dblect.check.findings import CheckFinding, CheckReport
|
|
33
|
+
from dblect.check.run import build_check_graphs, run_check
|
|
34
|
+
from dblect.manifest import Manifest
|
|
35
|
+
from dblect.sql import parse_manifest_models
|
|
36
|
+
from dblect.types import ContractRegistry
|
|
37
|
+
|
|
38
|
+
# The sealed set of findings any analysis surfaces: one member per detector family.
|
|
39
|
+
# A ``match`` over this union closed by ``assert_never`` is exhaustiveness-checked,
|
|
40
|
+
# so adding a family without handling it everywhere is a type error, not a quiet
|
|
41
|
+
# blind spot.
|
|
42
|
+
AnalysisFinding = CheckFinding | LocatedFinding
|
|
43
|
+
|
|
44
|
+
# A finding's identity across two compilations of the same project: enough to say
|
|
45
|
+
# "the same issue in both worlds" while ignoring what drifts between compiled SQLs.
|
|
46
|
+
FindingIdentity = tuple[Hashable, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cross_world_identity(finding: AnalysisFinding) -> FindingIdentity:
|
|
50
|
+
"""The stable cross-world identity of ``finding``: where it lands and what it is,
|
|
51
|
+
without the message or line span that differ between two compilations. A
|
|
52
|
+
declaration-level finding keys on kind/model/column/contract; a structural one on
|
|
53
|
+
kind/model and the rendered offending snippet. A snippet present in one world only
|
|
54
|
+
(a steady-state-only join) has no match in the other, so it surfaces as varying.
|
|
55
|
+
"""
|
|
56
|
+
match finding:
|
|
57
|
+
case CheckFinding():
|
|
58
|
+
return (
|
|
59
|
+
"check",
|
|
60
|
+
finding.kind,
|
|
61
|
+
finding.model_unique_id,
|
|
62
|
+
finding.column,
|
|
63
|
+
finding.contract,
|
|
64
|
+
)
|
|
65
|
+
case LocatedFinding():
|
|
66
|
+
inner = finding.finding
|
|
67
|
+
return ("audit", inner.kind, finding.model_unique_id, inner.sql_snippet)
|
|
68
|
+
assert_never(finding)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class AnalysisReport:
|
|
73
|
+
"""Every finding for one manifest, from every detector family, plus each family's
|
|
74
|
+
own report so a caller that needs the family-specific extras (coverage blocks,
|
|
75
|
+
suppressed directives) still has them. ``findings`` is the merged, sealed view the
|
|
76
|
+
finding-threading consumers read; the rest is there when an altitude matters."""
|
|
77
|
+
|
|
78
|
+
findings: tuple[AnalysisFinding, ...]
|
|
79
|
+
check: CheckReport
|
|
80
|
+
audit: AuditReport
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def analyze(
|
|
84
|
+
manifest: Manifest,
|
|
85
|
+
profile: AdapterProfile,
|
|
86
|
+
*,
|
|
87
|
+
registry: ContractRegistry | None = None,
|
|
88
|
+
resolution_floor: float | None = None,
|
|
89
|
+
) -> AnalysisReport:
|
|
90
|
+
"""Run every detector family over ``manifest`` and return every finding.
|
|
91
|
+
|
|
92
|
+
``profile`` is the resolved target whose dialect parses every model, and
|
|
93
|
+
``registry`` the contracts to resolve (defaulting to the active one), the same
|
|
94
|
+
inputs :func:`~dblect.check.run.run_check` takes. ``resolution_floor`` is forwarded
|
|
95
|
+
to it unchanged. The merged ``findings`` carry both families so a consumer never
|
|
96
|
+
has to enumerate the families itself.
|
|
97
|
+
|
|
98
|
+
The two families share one parse and one lineage-graph build (the run's dominant cost),
|
|
99
|
+
threaded into both. The shared build stamps the parsed trees in place and ``propagate``
|
|
100
|
+
never mutates them, so both families read the same resolution they would have built
|
|
101
|
+
themselves. ``run_check`` takes ``graphs`` rather than ``registry`` here, since ``graphs``
|
|
102
|
+
already carries the resolved contracts.
|
|
103
|
+
|
|
104
|
+
The build's resolved ``determines`` facts are also threaded into the structural audit so
|
|
105
|
+
join-fanout grounds key coverage through functional dependencies (a declared ``wiki_id
|
|
106
|
+
determines wiki_name`` lets a join on the determinant cover a key carrying the dependent).
|
|
107
|
+
The declaration family already reads these off the shared build; this hands the same facts
|
|
108
|
+
to the structural one.
|
|
109
|
+
"""
|
|
110
|
+
parsed, trees = parse_manifest_models(manifest, dialect=profile.sqlglot_dialect)
|
|
111
|
+
graphs = build_check_graphs(manifest, profile, registry=registry, trees=trees)
|
|
112
|
+
check = run_check(manifest, profile, resolution_floor=resolution_floor, graphs=graphs)
|
|
113
|
+
audit = run_audit(
|
|
114
|
+
manifest,
|
|
115
|
+
profile,
|
|
116
|
+
parsed=parsed,
|
|
117
|
+
column_graph=graphs.column_build.graph,
|
|
118
|
+
relation_graph=graphs.relation_build.graph,
|
|
119
|
+
fd_facts=graphs.resolved.fd_facts,
|
|
120
|
+
)
|
|
121
|
+
return AnalysisReport(
|
|
122
|
+
findings=(*check.findings, *audit.findings),
|
|
123
|
+
check=check,
|
|
124
|
+
audit=audit,
|
|
125
|
+
)
|
dblect/audit/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Audit pipeline: static detectors, replay-determinism, heuristic invariants."""
|
|
2
|
+
|
|
3
|
+
from dblect.audit.sourcemap import SourceSpan, SpanBasis
|
|
4
|
+
from dblect.audit.suppress import SuppressionDirective
|
|
5
|
+
from dblect.audit.walker import (
|
|
6
|
+
DEFAULT_DETECTORS,
|
|
7
|
+
AuditReport,
|
|
8
|
+
Detector,
|
|
9
|
+
LocatedFinding,
|
|
10
|
+
SkippedModel,
|
|
11
|
+
SuppressedFinding,
|
|
12
|
+
run_audit,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_DETECTORS",
|
|
17
|
+
"AuditReport",
|
|
18
|
+
"Detector",
|
|
19
|
+
"LocatedFinding",
|
|
20
|
+
"SkippedModel",
|
|
21
|
+
"SourceSpan",
|
|
22
|
+
"SpanBasis",
|
|
23
|
+
"SuppressedFinding",
|
|
24
|
+
"SuppressionDirective",
|
|
25
|
+
"run_audit",
|
|
26
|
+
]
|