ripple-sql 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.
- ripple/__init__.py +31 -0
- ripple/answer.py +473 -0
- ripple/answer_page.py +214 -0
- ripple/cache.py +80 -0
- ripple/ci.py +422 -0
- ripple/ci_signature.py +374 -0
- ripple/cli.py +733 -0
- ripple/doctor.py +225 -0
- ripple/engine/__init__.py +111 -0
- ripple/engine/budget.py +86 -0
- ripple/engine/column_lineage.py +112 -0
- ripple/engine/column_ref.py +818 -0
- ripple/engine/cte_tracing.py +1309 -0
- ripple/engine/dependencies.py +466 -0
- ripple/engine/dialect.py +132 -0
- ripple/engine/dispatch.py +12 -0
- ripple/engine/extraction.py +27 -0
- ripple/engine/jinja.py +282 -0
- ripple/engine/json_sources.py +241 -0
- ripple/engine/macro_source.py +127 -0
- ripple/engine/pipeline.py +265 -0
- ripple/engine/preprocess.py +174 -0
- ripple/engine/safe_gen.py +21 -0
- ripple/engine/schema_qualification.py +151 -0
- ripple/engine/scope.py +488 -0
- ripple/engine/select_sources.py +1038 -0
- ripple/engine/sql_script.py +729 -0
- ripple/engine/statement.py +449 -0
- ripple/engine/tech_debt.py +169 -0
- ripple/engine/tsql_catalog.py +83 -0
- ripple/engine/tsql_scalar_vars.py +248 -0
- ripple/engine/tsql_tvf.py +653 -0
- ripple/engine/tsql_xml.py +97 -0
- ripple/engine/types.py +167 -0
- ripple/engine/unused_deps.py +555 -0
- ripple/engine/validation.py +158 -0
- ripple/graph.py +1499 -0
- ripple/home.py +232 -0
- ripple/loaders/__init__.py +7 -0
- ripple/loaders/dbt.py +359 -0
- ripple/loaders/dbt_config.py +339 -0
- ripple/loaders/identity.py +328 -0
- ripple/loaders/sidecar.py +65 -0
- ripple/loaders/sqldir.py +262 -0
- ripple/loaders/types.py +197 -0
- ripple/lookml.py +163 -0
- ripple/mcp_server.py +600 -0
- ripple/names.py +40 -0
- ripple/project.py +167 -0
- ripple/py.typed +0 -0
- ripple/render.py +426 -0
- ripple/render_shims.py +209 -0
- ripple/schemas.py +155 -0
- ripple/semantic.py +232 -0
- ripple/server.py +184 -0
- ripple/sourcefiles.py +64 -0
- ripple/star_resolution.py +100 -0
- ripple/static/answer.css +146 -0
- ripple/static/answer.html +358 -0
- ripple/static/answer_twin.js +299 -0
- ripple/static/explore.js +133 -0
- ripple/usage/__init__.py +18 -0
- ripple/usage/cli.py +78 -0
- ripple/usage/collect.py +315 -0
- ripple/usage/discover.py +190 -0
- ripple/usage/ingest.py +414 -0
- ripple/usage/report.py +131 -0
- ripple_sql-0.1.0.dist-info/METADATA +285 -0
- ripple_sql-0.1.0.dist-info/RECORD +72 -0
- ripple_sql-0.1.0.dist-info/WHEEL +4 -0
- ripple_sql-0.1.0.dist-info/entry_points.txt +3 -0
- ripple_sql-0.1.0.dist-info/licenses/LICENSE +202 -0
ripple/project.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Project discovery and loading.
|
|
2
|
+
|
|
3
|
+
Finds what kind of SQL project we're in and yields models with parseable SQL.
|
|
4
|
+
Three modes, best evidence first:
|
|
5
|
+
|
|
6
|
+
1. dbt with a compiled manifest (target/manifest.json): exact compiled SQL,
|
|
7
|
+
explicit dependency edges, adapter-declared dialect.
|
|
8
|
+
2. dbt raw (no manifest): models/**/*.sql with lightweight Jinja conversion.
|
|
9
|
+
ref('x') becomes x, source('a','b') becomes a__b.
|
|
10
|
+
3. Plain SQL directory: every .sql file is a model named by its stem.
|
|
11
|
+
|
|
12
|
+
Models from mode 2 and 3 carry lower baseline trust than mode 1; the graph
|
|
13
|
+
layer folds that into per-edge confidence.
|
|
14
|
+
|
|
15
|
+
This module is the public API and the mode dispatcher; the loaders package
|
|
16
|
+
does the work.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from ripple.loaders.dbt import _load_one_dbt, resolve_dbt_dialect
|
|
24
|
+
from ripple.loaders.dbt_config import _dbt_owned_dirs
|
|
25
|
+
from ripple.loaders.identity import _apply_ingested_schemas, _qualify_collisions
|
|
26
|
+
from ripple.loaders.sqldir import _load_sql_dir
|
|
27
|
+
from ripple.loaders.types import (
|
|
28
|
+
ADAPTER_TO_DIALECT,
|
|
29
|
+
DEFAULT_DIALECT,
|
|
30
|
+
IGNORE_DIRS,
|
|
31
|
+
Model,
|
|
32
|
+
Project,
|
|
33
|
+
_find_dbt_projects,
|
|
34
|
+
find_project_root,
|
|
35
|
+
relative_posix,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ADAPTER_TO_DIALECT",
|
|
40
|
+
"DEFAULT_DIALECT",
|
|
41
|
+
"IGNORE_DIRS",
|
|
42
|
+
"Model",
|
|
43
|
+
"Project",
|
|
44
|
+
"find_project_root",
|
|
45
|
+
"load_project",
|
|
46
|
+
"resolve_dbt_dialect",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_project(path: str | Path = ".", dialect: str | None = None) -> Project:
|
|
51
|
+
start = Path(path).resolve()
|
|
52
|
+
root = find_project_root(start)
|
|
53
|
+
# If we're standing above several dbt projects (a monorepo), gather them
|
|
54
|
+
# all. find_project_root only walks UP to the nearest one, so scan DOWN.
|
|
55
|
+
scan_root = root if (root / "dbt_project.yml").exists() else start
|
|
56
|
+
dbt_dirs = _find_dbt_projects(scan_root)
|
|
57
|
+
|
|
58
|
+
if len(dbt_dirs) > 1:
|
|
59
|
+
project = _load_monorepo(scan_root, dbt_dirs, dialect)
|
|
60
|
+
elif len(dbt_dirs) == 1:
|
|
61
|
+
project = _load_one_dbt(dbt_dirs[0], dialect)
|
|
62
|
+
elif (root / "dbt_project.yml").exists():
|
|
63
|
+
project = _load_one_dbt(root, dialect)
|
|
64
|
+
else:
|
|
65
|
+
project = _load_sql_dir(start, dialect)
|
|
66
|
+
if not project.models and dbt_dirs:
|
|
67
|
+
project = _sql_beside_dbt(start, dbt_dirs, dialect) or project
|
|
68
|
+
elif project.models and dbt_dirs:
|
|
69
|
+
# a repo can hold raw build SQL beside its dbt project(s), even
|
|
70
|
+
# inside a project dir, beside its models/ (nyc cbbr, holdout
|
|
71
|
+
# round 4). Only the directories dbt actually owns are pruned;
|
|
72
|
+
# pruning whole project dirs made cbbr/sql invisible.
|
|
73
|
+
owned = [d for pdir in dbt_dirs for d in _dbt_owned_dirs(pdir)]
|
|
74
|
+
# the dbt project's declared warehouse is the best evidence for the
|
|
75
|
+
# SQL beside it; sniffing those files alone reported "Assuming
|
|
76
|
+
# postgres" inside Snowflake's own tasty_bytes demo
|
|
77
|
+
inherited = None if project.dialect_assumed else project.dialect
|
|
78
|
+
stray = _load_sql_dir(scan_root, dialect or inherited, exclude=owned)
|
|
79
|
+
if stray.models:
|
|
80
|
+
project.models.extend(stray.models)
|
|
81
|
+
project.warnings.extend(w for w in stray.warnings if not w.startswith("No .sql files"))
|
|
82
|
+
project.warnings.append(
|
|
83
|
+
f"{len(stray.models)} plain-SQL models outside the dbt "
|
|
84
|
+
f"project{'s' if len(dbt_dirs) != 1 else ''} were loaded as well."
|
|
85
|
+
)
|
|
86
|
+
_qualify_collisions(project)
|
|
87
|
+
_apply_ingested_schemas(project, extra_roots=(scan_root, start))
|
|
88
|
+
from ripple.lookml import load_lookml_downstream
|
|
89
|
+
from ripple.semantic import load_downstream
|
|
90
|
+
|
|
91
|
+
downstream = []
|
|
92
|
+
if project.mode != "sql-dir":
|
|
93
|
+
downstream += load_downstream(project.root, {m.name for m in project.models})
|
|
94
|
+
downstream += load_lookml_downstream(project.root) # Looker, where the extra is installed
|
|
95
|
+
project.downstream = downstream
|
|
96
|
+
return project
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _sql_beside_dbt(start: Path, dbt_dirs: list[Path], dialect: str | None) -> Project | None:
|
|
100
|
+
"""The repo's other SQL, when the dbt projects define no models.
|
|
101
|
+
|
|
102
|
+
dagster-open-platform ships a dbt project of macros, tests and snapshots
|
|
103
|
+
with no models/ directory, and fifteen plain .sql files beside it. Reporting
|
|
104
|
+
zero there was true of the dbt project and useless about the repo.
|
|
105
|
+
|
|
106
|
+
Deliberately narrow. It runs only when the dbt side found nothing, so a repo
|
|
107
|
+
that already resolves cannot start counting its migrations as models. The
|
|
108
|
+
dbt directories are excluded, since their macros are Jinja fragments rather
|
|
109
|
+
than models.
|
|
110
|
+
"""
|
|
111
|
+
project = _load_sql_dir(start, dialect, exclude=dbt_dirs)
|
|
112
|
+
if not project.models:
|
|
113
|
+
return None
|
|
114
|
+
project.warnings.insert(
|
|
115
|
+
0,
|
|
116
|
+
"The dbt project here defines no models, so this is the rest of the repo's SQL instead.",
|
|
117
|
+
)
|
|
118
|
+
return project
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_monorepo(root: Path, project_dirs: list[Path], dialect: str | None) -> Project:
|
|
122
|
+
"""Union of several dbt projects, each parsed in its own dialect.
|
|
123
|
+
|
|
124
|
+
Cross-project references stay unresolved and degrade to review_required in
|
|
125
|
+
the graph layer, which is honest: one warehouse rarely reads another's
|
|
126
|
+
tables directly, and where it does we don't pretend to know the columns.
|
|
127
|
+
"""
|
|
128
|
+
from collections import Counter
|
|
129
|
+
|
|
130
|
+
all_models: list[Model] = []
|
|
131
|
+
all_sources: list[Model] = []
|
|
132
|
+
macro_sources: list = []
|
|
133
|
+
inner_warnings: list[str] = []
|
|
134
|
+
island_dialects: dict[str, str] = {}
|
|
135
|
+
merged_vars: dict = {}
|
|
136
|
+
|
|
137
|
+
for pdir in project_dirs:
|
|
138
|
+
sub = _load_one_dbt(pdir, dialect)
|
|
139
|
+
for model in sub.models:
|
|
140
|
+
model.dialect = sub.dialect
|
|
141
|
+
model.jinja_vars = sub.jinja_vars
|
|
142
|
+
all_models.extend(sub.models)
|
|
143
|
+
all_sources.extend(sub.sources)
|
|
144
|
+
macro_sources.extend(sub.macro_sources)
|
|
145
|
+
inner_warnings.extend(sub.warnings)
|
|
146
|
+
merged_vars.update(sub.jinja_vars)
|
|
147
|
+
label = relative_posix(pdir, root) or pdir.name
|
|
148
|
+
island_dialects[label] = sub.dialect
|
|
149
|
+
|
|
150
|
+
dialect_counts = Counter(m.dialect for m in all_models if m.dialect)
|
|
151
|
+
primary = dialect or (dialect_counts.most_common(1)[0][0] if dialect_counts else "snowflake")
|
|
152
|
+
listing = ", ".join(f"{p} ({d})" for p, d in sorted(island_dialects.items()))
|
|
153
|
+
warnings = [
|
|
154
|
+
f"Found {len(project_dirs)} dbt projects, each parsed in its own dialect: {listing}."
|
|
155
|
+
] + inner_warnings
|
|
156
|
+
|
|
157
|
+
return Project(
|
|
158
|
+
root=root,
|
|
159
|
+
dialect=primary,
|
|
160
|
+
mode="dbt-monorepo",
|
|
161
|
+
models=all_models,
|
|
162
|
+
sources=all_sources,
|
|
163
|
+
warnings=warnings,
|
|
164
|
+
macro_sources=macro_sources,
|
|
165
|
+
package_name=next((pkg for pkg, _ in macro_sources if pkg), None),
|
|
166
|
+
jinja_vars=merged_vars,
|
|
167
|
+
)
|
ripple/py.typed
ADDED
|
File without changes
|
ripple/render.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Tier-b Jinja: really render dbt models instead of regex-stripping them.
|
|
2
|
+
|
|
3
|
+
Three tiers of evidence for a model's SQL (best first):
|
|
4
|
+
a. compiled SQL from target/manifest.json (exact, handled in project.py)
|
|
5
|
+
b. jinja2 render with a real macro registry (this module): control flow
|
|
6
|
+
executes, the project's own macros expand, package-namespaced calls
|
|
7
|
+
like {{ ga4.unnest_key(...) }} resolve, and adapter.dispatch finds
|
|
8
|
+
default__/<dialect>__ implementations
|
|
9
|
+
c. the regex converter in engine/jinja.py (last resort)
|
|
10
|
+
|
|
11
|
+
A render that fails falls through to tier c. A model that still won't parse
|
|
12
|
+
is reported honestly, never guessed at.
|
|
13
|
+
|
|
14
|
+
The registry design mirrors sqlmesh's dbt loader: macros are registered as
|
|
15
|
+
late-bound wrappers before anything compiles, so compile order never
|
|
16
|
+
matters; unknown macros degrade to NULL (parseable, no fake lineage).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as _datetime
|
|
22
|
+
import itertools as _itertools
|
|
23
|
+
import re
|
|
24
|
+
import re as _re_mod
|
|
25
|
+
|
|
26
|
+
from jinja2 import BaseLoader, ChainableUndefined, Environment
|
|
27
|
+
from jinja2.utils import missing as _missing
|
|
28
|
+
|
|
29
|
+
from ripple.render_shims import BUILTIN_PACKAGE_MACROS as _BUILTIN_PACKAGE_MACROS
|
|
30
|
+
|
|
31
|
+
_IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Anything(ChainableUndefined):
|
|
35
|
+
"""Undefined that survives calls, attribute access, and iteration.
|
|
36
|
+
|
|
37
|
+
Unknown macros render as NULL: '{{ unknown_macro(x) }} as col' becomes
|
|
38
|
+
'NULL as col', which parses, keeps the column, and carries no fake
|
|
39
|
+
lineage. The engine's honesty labels take it from there.
|
|
40
|
+
|
|
41
|
+
A pure undefined name/attribute chain that was never called
|
|
42
|
+
({{params.dataset_name_raw}}, Airflow-templated dbt) is different: it
|
|
43
|
+
names something outside the render's knowledge, and the round-trip
|
|
44
|
+
convention cites it as written. Such a chain stringifies back to its
|
|
45
|
+
own {{...}} spelling; the relation-position rewrite in preprocess
|
|
46
|
+
carries it into the emitted table name. Calls, and attribute misses on
|
|
47
|
+
DEFINED objects, still render NULL.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__slots__ = ("_chain",)
|
|
51
|
+
|
|
52
|
+
def __init__(self, *args, **kwargs):
|
|
53
|
+
super().__init__(*args, **kwargs)
|
|
54
|
+
name = self._undefined_name
|
|
55
|
+
self._chain = (
|
|
56
|
+
name
|
|
57
|
+
if self._undefined_obj is _missing and isinstance(name, str) and _IDENT.match(name)
|
|
58
|
+
else None
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __getattr__(self, name):
|
|
62
|
+
if name.startswith("_"):
|
|
63
|
+
raise AttributeError(name)
|
|
64
|
+
if self._chain and _IDENT.match(name):
|
|
65
|
+
out = _Anything(name=name)
|
|
66
|
+
out._chain = f"{self._chain}.{name}"
|
|
67
|
+
return out
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __getitem__(self, key):
|
|
71
|
+
return self.__getattr__(key) if isinstance(key, str) else self
|
|
72
|
+
|
|
73
|
+
def __call__(self, *args, **kwargs):
|
|
74
|
+
called = _Anything(name=self._undefined_name)
|
|
75
|
+
called._chain = None
|
|
76
|
+
return called
|
|
77
|
+
|
|
78
|
+
def __iter__(self):
|
|
79
|
+
return iter(())
|
|
80
|
+
|
|
81
|
+
def __str__(self):
|
|
82
|
+
if self._chain:
|
|
83
|
+
return "{{" + self._chain + "}}"
|
|
84
|
+
return "NULL"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ref(*args, **kwargs) -> str:
|
|
88
|
+
# ref('model') or ref('package', 'model')
|
|
89
|
+
return str(args[-1]) if args else "unknown_ref"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _source(source_name: str, table_name: str, **kwargs) -> str:
|
|
93
|
+
return f"{source_name}__{table_name}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _VarValue(str):
|
|
97
|
+
"""Missing-var stub: renders as the quoted var name, survives range()/int()."""
|
|
98
|
+
|
|
99
|
+
__slots__ = ()
|
|
100
|
+
|
|
101
|
+
def __index__(self):
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
def __int__(self):
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
def __getitem__(self, item):
|
|
108
|
+
return _VarValue(self.strip("'"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_MISSING = object()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _make_var(declared: dict | None = None):
|
|
115
|
+
"""dbt precedence minus CLI --vars: project vars, call default, name stub."""
|
|
116
|
+
declared = declared or {}
|
|
117
|
+
|
|
118
|
+
def _var(name: str, default=_MISSING):
|
|
119
|
+
if name in declared:
|
|
120
|
+
return declared[name]
|
|
121
|
+
# var('x', none) must return None (dbt semantics): no-default is a sentinel
|
|
122
|
+
return default if default is not _MISSING else _VarValue(f"'{name}'")
|
|
123
|
+
|
|
124
|
+
return _var
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _config(*args, **kwargs) -> str:
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _MacroReturn(Exception):
|
|
132
|
+
"""dbt's return(): control flow out of a macro, caught at invocation."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, value=None):
|
|
135
|
+
super().__init__()
|
|
136
|
+
self.value = value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _return(value=None):
|
|
140
|
+
raise _MacroReturn(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _returnable(macro):
|
|
144
|
+
"""A macro call evaluates to its return() value; body output otherwise."""
|
|
145
|
+
|
|
146
|
+
def call(*args, **kwargs):
|
|
147
|
+
try:
|
|
148
|
+
return macro(*args, **kwargs)
|
|
149
|
+
except _MacroReturn as ret:
|
|
150
|
+
return ret.value
|
|
151
|
+
|
|
152
|
+
return call
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class _MacroRegistry:
|
|
156
|
+
"""(package, name) -> compiled jinja macro. Shared and mutable: late binding."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, root_package: str | None, dialect: str):
|
|
159
|
+
self.root_package = root_package
|
|
160
|
+
self.dialect = dialect
|
|
161
|
+
self.macros: dict[tuple[str | None, str], object] = {}
|
|
162
|
+
self.package_order: list[str | None] = []
|
|
163
|
+
|
|
164
|
+
def register(self, package: str | None, name: str, macro) -> None:
|
|
165
|
+
self.macros[(package, name)] = macro
|
|
166
|
+
if package not in self.package_order:
|
|
167
|
+
self.package_order.append(package)
|
|
168
|
+
|
|
169
|
+
def lookup(self, name: str, package: str | None = None):
|
|
170
|
+
"""dbt search order: explicit package, else root project, then others."""
|
|
171
|
+
if package is not None:
|
|
172
|
+
return self.macros.get((package, name))
|
|
173
|
+
for pkg in [self.root_package] + [p for p in self.package_order if p != self.root_package]:
|
|
174
|
+
hit = self.macros.get((pkg, name))
|
|
175
|
+
if hit is not None:
|
|
176
|
+
return hit
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
def dispatch(self, macro_name: str, macro_namespace: str | None = None):
|
|
180
|
+
for candidate in (f"{self.dialect}__{macro_name}", f"default__{macro_name}"):
|
|
181
|
+
hit = self.lookup(candidate, None)
|
|
182
|
+
if hit is None and macro_namespace:
|
|
183
|
+
hit = self.lookup(candidate, macro_namespace)
|
|
184
|
+
if hit is not None:
|
|
185
|
+
return hit
|
|
186
|
+
return _Anything(name=macro_name)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _LateBound:
|
|
190
|
+
"""Callable placeholder registered before compile; resolves at call time."""
|
|
191
|
+
|
|
192
|
+
__slots__ = ("_registry", "_package", "_name")
|
|
193
|
+
|
|
194
|
+
def __init__(self, registry: _MacroRegistry, package: str | None, name: str):
|
|
195
|
+
self._registry = registry
|
|
196
|
+
self._package = package
|
|
197
|
+
self._name = name
|
|
198
|
+
|
|
199
|
+
def __call__(self, *args, **kwargs):
|
|
200
|
+
macro = self._registry.lookup(self._name, self._package)
|
|
201
|
+
if macro is None:
|
|
202
|
+
return _Anything(name=self._name)
|
|
203
|
+
return macro(*args, **kwargs)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _MacroNamespace:
|
|
207
|
+
"""{{ pkg.macro(...) }} resolves through the registry; unknown attrs -> NULL."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, registry: _MacroRegistry | None, package: str, var_fn=None):
|
|
210
|
+
self._registry = registry
|
|
211
|
+
self._package = package
|
|
212
|
+
self._var_fn = var_fn
|
|
213
|
+
|
|
214
|
+
def __getattr__(self, name: str):
|
|
215
|
+
if name.startswith("__"):
|
|
216
|
+
raise AttributeError(name)
|
|
217
|
+
if self._registry is not None and self._registry.lookup(name, self._package) is not None:
|
|
218
|
+
return _LateBound(self._registry, self._package, name)
|
|
219
|
+
builtin = _BUILTIN_PACKAGE_MACROS.get((self._package, name))
|
|
220
|
+
if builtin is not None:
|
|
221
|
+
if getattr(builtin, "wants_var", False) and self._var_fn is not None:
|
|
222
|
+
import functools
|
|
223
|
+
|
|
224
|
+
return functools.partial(builtin, _var=self._var_fn)
|
|
225
|
+
return builtin
|
|
226
|
+
return _Anything(name=f"{self._package}.{name}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _AdapterNoOp:
|
|
230
|
+
"""Unmodeled adapter surface: calls return '', attribute chains keep
|
|
231
|
+
no-opping, and str() is empty so expression position never leaks a
|
|
232
|
+
function repr into SQL."""
|
|
233
|
+
|
|
234
|
+
__slots__ = ()
|
|
235
|
+
|
|
236
|
+
def __call__(self, *args, **kwargs):
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
def __str__(self):
|
|
240
|
+
return ""
|
|
241
|
+
|
|
242
|
+
def __getattr__(self, name):
|
|
243
|
+
if name.startswith("__"):
|
|
244
|
+
raise AttributeError(name)
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_ADAPTER_NOOP = _AdapterNoOp()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _Adapter:
|
|
252
|
+
"""adapter.dispatch resolves against the registry; everything else no-ops."""
|
|
253
|
+
|
|
254
|
+
def __init__(self, registry: _MacroRegistry | None = None):
|
|
255
|
+
self._registry = registry
|
|
256
|
+
|
|
257
|
+
def dispatch(self, macro_name, macro_namespace=None):
|
|
258
|
+
if self._registry is not None:
|
|
259
|
+
return self._registry.dispatch(macro_name, macro_namespace)
|
|
260
|
+
return _Anything(name=str(macro_name))
|
|
261
|
+
|
|
262
|
+
def quote(self, name):
|
|
263
|
+
# identity, not '""': ground truth cites unquoted spellings, and a
|
|
264
|
+
# no-op '' poisons comparisons like elementary's edr_quote_column
|
|
265
|
+
# (cycle-11 review, F5)
|
|
266
|
+
return name
|
|
267
|
+
|
|
268
|
+
def __getattr__(self, name):
|
|
269
|
+
return _ADAPTER_NOOP
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _Modules:
|
|
273
|
+
datetime = _datetime
|
|
274
|
+
re = _re_mod
|
|
275
|
+
itertools = _itertools
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_SET_BLOCK_RE = re.compile(r"{%-?\s*(call|materialization|test)\b.*?{%-?\s*end\1\s*-?%}", re.S)
|
|
279
|
+
_MACRO_DEF_RE = re.compile(r"{%-?\s*macro\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
280
|
+
_MACRO_DEF_SUB_RE = re.compile(r"({%-?\s*macro\s+)([A-Za-z_][A-Za-z0-9_]*)(\s*\()")
|
|
281
|
+
# Definition names are mangled before compile so no call site can reach the
|
|
282
|
+
# raw jinja macro: a same-file sibling (or recursive) call would bypass the
|
|
283
|
+
# registry wrapper and its _MacroReturn would unwind through the CALLER's
|
|
284
|
+
# remainder into the caller's wrapper, returning the inner macro's value from
|
|
285
|
+
# the outer macro (cycle-11 review, F1: shopify max_columns came out 24, not
|
|
286
|
+
# 9966). With the rename every call resolves late through the registry, so
|
|
287
|
+
# each invocation catches only its own return(). Must not start with "_":
|
|
288
|
+
# jinja does not export underscore names from template modules.
|
|
289
|
+
_MACRO_DEF_PREFIX = "ripple_macro_def__"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def build_environment(
|
|
293
|
+
model_name: str,
|
|
294
|
+
registry: _MacroRegistry | None = None,
|
|
295
|
+
project_vars: dict | None = None,
|
|
296
|
+
) -> Environment:
|
|
297
|
+
env = Environment(
|
|
298
|
+
loader=BaseLoader(),
|
|
299
|
+
undefined=_Anything,
|
|
300
|
+
extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols"],
|
|
301
|
+
)
|
|
302
|
+
dialect = registry.dialect if registry else "snowflake"
|
|
303
|
+
env.globals.update(
|
|
304
|
+
{
|
|
305
|
+
"ref": _ref,
|
|
306
|
+
"source": _source,
|
|
307
|
+
"var": _make_var(project_vars),
|
|
308
|
+
"config": _config,
|
|
309
|
+
"this": model_name,
|
|
310
|
+
"target": {"name": "prod", "schema": "analytics", "database": "prod", "type": dialect},
|
|
311
|
+
# True keeps the incremental WHERE, which proves columns that a
|
|
312
|
+
# SELECT * chain from an undeclared source would otherwise hide
|
|
313
|
+
"is_incremental": lambda: True,
|
|
314
|
+
"should_full_refresh": lambda: False,
|
|
315
|
+
"execute": False,
|
|
316
|
+
"adapter": _Adapter(registry),
|
|
317
|
+
"return": _return,
|
|
318
|
+
"run_query": lambda *a, **k: [],
|
|
319
|
+
"log": lambda *a, **k: "",
|
|
320
|
+
"exceptions": _Anything(name="exceptions"),
|
|
321
|
+
"modules": _Modules(),
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
# vendored macros still win: the namespace checks the registry first
|
|
325
|
+
for pkg in {p for p, _ in _BUILTIN_PACKAGE_MACROS}:
|
|
326
|
+
env.globals.setdefault(pkg, _MacroNamespace(registry, pkg, var_fn=_make_var(project_vars)))
|
|
327
|
+
return env
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def load_project_macros(env: Environment, macro_pairs, registry: _MacroRegistry) -> int:
|
|
331
|
+
"""Register a dbt project's macros, package-attributed and late-bound."""
|
|
332
|
+
# pass 0: learn every macro name; wire wrappers + namespaces before compiling
|
|
333
|
+
names_by_pkg: dict[str | None, set[str]] = {}
|
|
334
|
+
for pkg, source in macro_pairs:
|
|
335
|
+
for name in _MACRO_DEF_RE.findall(source):
|
|
336
|
+
names_by_pkg.setdefault(pkg, set()).add(name)
|
|
337
|
+
for pkg, names in names_by_pkg.items():
|
|
338
|
+
if pkg is not None:
|
|
339
|
+
env.globals.setdefault(pkg, _MacroNamespace(registry, pkg))
|
|
340
|
+
for name in names:
|
|
341
|
+
env.globals.setdefault(name, _LateBound(registry, None, name))
|
|
342
|
+
|
|
343
|
+
# pass 1: compile once; real macros land in the shared registry.
|
|
344
|
+
# Sibling names resolve through module vars bound to the OWN package
|
|
345
|
+
# first (dbt's search order), then the bare-name globals from pass 0.
|
|
346
|
+
loaded = 0
|
|
347
|
+
for pkg, source in macro_pairs:
|
|
348
|
+
if "{% macro" not in source and "{%- macro" not in source:
|
|
349
|
+
continue
|
|
350
|
+
mangled = _MACRO_DEF_SUB_RE.sub(rf"\g<1>{_MACRO_DEF_PREFIX}\g<2>\g<3>", source)
|
|
351
|
+
try:
|
|
352
|
+
template = env.from_string(_SET_BLOCK_RE.sub("", mangled))
|
|
353
|
+
module = template.make_module(
|
|
354
|
+
vars={name: _LateBound(registry, pkg, name) for name in names_by_pkg.get(pkg, ())}
|
|
355
|
+
)
|
|
356
|
+
except Exception:
|
|
357
|
+
continue
|
|
358
|
+
for name in dir(module):
|
|
359
|
+
if name.startswith(_MACRO_DEF_PREFIX):
|
|
360
|
+
real_name = name[len(_MACRO_DEF_PREFIX) :]
|
|
361
|
+
elif name.startswith("_"):
|
|
362
|
+
continue
|
|
363
|
+
else:
|
|
364
|
+
real_name = name
|
|
365
|
+
attr = getattr(module, name)
|
|
366
|
+
if callable(attr):
|
|
367
|
+
registry.register(pkg, real_name, _returnable(attr))
|
|
368
|
+
loaded += 1
|
|
369
|
+
return loaded
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def build_project_environment(
|
|
373
|
+
macro_sources=None,
|
|
374
|
+
root_package: str | None = None,
|
|
375
|
+
dialect: str = "snowflake",
|
|
376
|
+
project_vars: dict | None = None,
|
|
377
|
+
) -> Environment:
|
|
378
|
+
"""Environment with the project's macros loaded.
|
|
379
|
+
|
|
380
|
+
macro_sources: list of (package_name | None, source_text). Bare strings
|
|
381
|
+
are accepted and treated as root-package.
|
|
382
|
+
"""
|
|
383
|
+
registry = _MacroRegistry(root_package, dialect)
|
|
384
|
+
env = build_environment(model_name="this_model", registry=registry, project_vars=project_vars)
|
|
385
|
+
pairs = []
|
|
386
|
+
for item in macro_sources or []:
|
|
387
|
+
if isinstance(item, str):
|
|
388
|
+
pairs.append((root_package, item))
|
|
389
|
+
else:
|
|
390
|
+
pairs.append(item)
|
|
391
|
+
if pairs:
|
|
392
|
+
load_project_macros(env, pairs, registry)
|
|
393
|
+
return env
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def render_dbt_sql(
|
|
397
|
+
sql: str,
|
|
398
|
+
model_name: str,
|
|
399
|
+
env: Environment | None = None,
|
|
400
|
+
jinja_vars: dict | None = None,
|
|
401
|
+
) -> str:
|
|
402
|
+
"""Render a dbt model's Jinja to plain SQL. Raises on failure."""
|
|
403
|
+
trimmed = _SET_BLOCK_RE.sub("", sql)
|
|
404
|
+
if env is None:
|
|
405
|
+
env = build_environment(model_name, project_vars=jinja_vars)
|
|
406
|
+
context = {"this": model_name}
|
|
407
|
+
if jinja_vars:
|
|
408
|
+
# monorepo shares one env; each island's models still see their own vars
|
|
409
|
+
context["var"] = _make_var(jinja_vars)
|
|
410
|
+
rendered = env.from_string(trimmed).render(**context)
|
|
411
|
+
# a var value can itself be jinja ("{{ ref('sessions') }}" in
|
|
412
|
+
# dbt_project.yml); re-render bounded, then give up honestly
|
|
413
|
+
for _ in range(3):
|
|
414
|
+
if "{{" not in rendered and "{%" not in rendered:
|
|
415
|
+
break
|
|
416
|
+
try:
|
|
417
|
+
again = env.from_string(rendered).render(**context)
|
|
418
|
+
except Exception:
|
|
419
|
+
break
|
|
420
|
+
if again == rendered:
|
|
421
|
+
break
|
|
422
|
+
rendered = again
|
|
423
|
+
# a render that eats the whole model is a failure, not a success
|
|
424
|
+
if not re.search(r"\bselect\b", rendered, re.I):
|
|
425
|
+
raise ValueError("render produced no SELECT")
|
|
426
|
+
return rendered
|