Skip to content

Commit 947e33b

Browse files
committed
fix spider2 dbt templated source preflight
1 parent 0d31404 commit 947e33b

2 files changed

Lines changed: 245 additions & 1 deletion

File tree

src/razorback/benchmarks/spider2_dbt/preflight.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ def _resolve_db_path(
243243
_SOURCE_CALL_RE = re.compile(
244244
r"""\bsource\s*\(\s*(['"])(?P<source>[^'"]+)\1\s*,\s*(['"])(?P<table>[^'"]+)\3\s*\)"""
245245
)
246+
_JINJA_IF_ELSE_RE = re.compile(
247+
r"""\{%\s*if\s+(?P<condition>.*?)\s*%\}(?P<true>.*?)\{%\s*else\s*%\}(?P<false>.*?)\{%\s*endif\s*%\}""",
248+
re.DOTALL,
249+
)
250+
_JINJA_EXPR_RE = re.compile(r"""\{\{\s*(?P<expr>.*?)\s*\}\}""", re.DOTALL)
251+
_VAR_CALL_RE = re.compile(
252+
r"""^var\s*\(\s*(['"])(?P<name>[^'"]+)\1(?:\s*,\s*(?P<default>.*?))?\s*\)$""",
253+
re.DOTALL,
254+
)
246255

247256

248257
def _read_dbt_source_tables(workspace: Path) -> set[tuple[str, str]]:
@@ -267,6 +276,7 @@ def _read_dbt_source_tables(workspace: Path) -> set[tuple[str, str]]:
267276
return set()
268277

269278
referenced_sources = _read_referenced_source_names(workspace)
279+
render_context = _read_dbt_render_context(workspace, yaml)
270280
relations: set[tuple[str, str]] = set()
271281
for yaml_path in _iter_candidate_dbt_yaml_files(workspace):
272282
try:
@@ -276,6 +286,9 @@ def _read_dbt_source_tables(workspace: Path) -> set[tuple[str, str]]:
276286
for source in _iter_dicts(_as_list(_as_dict(document).get("sources"))):
277287
source_name = source.get("name")
278288
source_schema = source.get("schema") or source_name
289+
source_external_location = _as_dict(source.get("meta")).get(
290+
"external_location"
291+
)
279292
for table in _iter_dicts(_as_list(source.get("tables"))):
280293
table_name = table.get("name")
281294
if not (
@@ -288,9 +301,23 @@ def _read_dbt_source_tables(workspace: Path) -> set[tuple[str, str]]:
288301
source_key = (source_name.strip().lower(), table_name.strip().lower())
289302
if referenced_sources and source_key not in referenced_sources:
290303
continue
304+
table_external_location = _as_dict(table.get("meta")).get(
305+
"external_location"
306+
)
307+
if source_external_location or table_external_location:
308+
continue
291309
name = table.get("identifier") or table_name
292310
schema = table.get("schema") or source_schema
293-
if not (isinstance(schema, str) and schema.strip()):
311+
if not (
312+
isinstance(name, str)
313+
and name.strip()
314+
and isinstance(schema, str)
315+
and schema.strip()
316+
):
317+
continue
318+
name = _render_dbt_metadata_value(name, render_context)
319+
schema = _render_dbt_metadata_value(schema, render_context)
320+
if "{{" in name or "{%" in name or "{{" in schema or "{%" in schema:
294321
continue
295322
relations.add((schema.strip().lower(), name.strip().lower()))
296323
return relations
@@ -320,6 +347,141 @@ def _read_referenced_source_names(workspace: Path) -> set[tuple[str, str]]:
320347
return referenced
321348

322349

350+
def _read_dbt_render_context(workspace: Path, yaml_module: Any) -> dict[str, Any]:
351+
context: dict[str, Any] = {
352+
"vars": {},
353+
"target": {"schema": "main"},
354+
}
355+
356+
project_path = workspace / "dbt_project.yml"
357+
if project_path.is_file():
358+
try:
359+
document = yaml_module.safe_load(project_path.read_text())
360+
except Exception:
361+
document = None
362+
vars_value = _as_dict(_as_dict(document).get("vars"))
363+
context["vars"] = _flatten_dbt_vars(vars_value)
364+
365+
target_schema = _read_profiles_target_schema(workspace, yaml_module)
366+
if target_schema:
367+
context["target"]["schema"] = target_schema
368+
369+
return context
370+
371+
372+
def _flatten_dbt_vars(value: dict[str, Any]) -> dict[str, Any]:
373+
flattened: dict[str, Any] = {}
374+
for key, child in value.items():
375+
if not isinstance(key, str):
376+
continue
377+
flattened[key] = child
378+
379+
for child in value.values():
380+
if not isinstance(child, dict):
381+
continue
382+
for key, nested_child in child.items():
383+
if isinstance(key, str) and key not in flattened:
384+
flattened[key] = nested_child
385+
return flattened
386+
387+
388+
def _read_profiles_target_schema(workspace: Path, yaml_module: Any) -> str | None:
389+
for profiles_path in sorted(workspace.rglob("profiles.yml")):
390+
try:
391+
document = yaml_module.safe_load(profiles_path.read_text())
392+
except Exception:
393+
continue
394+
for profile in _iter_dicts(list(_as_dict(document).values())):
395+
outputs = {
396+
name: out
397+
for name, out in _as_dict(profile.get("outputs")).items()
398+
if isinstance(out, dict)
399+
}
400+
if not outputs:
401+
continue
402+
403+
target = profile.get("target")
404+
selected = None
405+
if isinstance(target, str) and target.strip() in outputs:
406+
selected = outputs[target.strip()]
407+
elif len(outputs) == 1:
408+
(selected,) = outputs.values()
409+
410+
schema = _as_dict(selected).get("schema")
411+
if isinstance(schema, str) and schema.strip():
412+
return schema.strip()
413+
return None
414+
415+
416+
def _render_dbt_metadata_value(value: str, context: dict[str, Any]) -> str:
417+
text = value
418+
while True:
419+
match = _JINJA_IF_ELSE_RE.search(text)
420+
if match is None:
421+
break
422+
replacement = (
423+
match.group("true")
424+
if _eval_dbt_condition(match.group("condition"), context)
425+
else match.group("false")
426+
)
427+
text = text[: match.start()] + replacement + text[match.end() :]
428+
429+
return _JINJA_EXPR_RE.sub(
430+
lambda match: _stringify_dbt_value(
431+
_eval_dbt_expr(match.group("expr"), context)
432+
),
433+
text,
434+
)
435+
436+
437+
def _eval_dbt_condition(expr: str, context: dict[str, Any]) -> bool:
438+
value = _eval_dbt_expr(expr, context)
439+
if isinstance(value, str):
440+
return value.lower() not in {"", "0", "false", "none", "null"}
441+
return bool(value)
442+
443+
444+
def _eval_dbt_expr(expr: str, context: dict[str, Any]) -> Any:
445+
parts = [part.strip() for part in expr.split("~")]
446+
if len(parts) > 1:
447+
return "".join(
448+
_stringify_dbt_value(_eval_dbt_expr(part, context)) for part in parts
449+
)
450+
451+
value = expr.strip()
452+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
453+
return value[1:-1]
454+
if value == "target.schema":
455+
return _as_dict(context.get("target")).get("schema", "main")
456+
if value.lower() == "true":
457+
return True
458+
if value.lower() == "false":
459+
return False
460+
461+
var_match = _VAR_CALL_RE.match(value)
462+
if var_match:
463+
var_name = var_match.group("name")
464+
vars_value = _as_dict(context.get("vars"))
465+
if var_name in vars_value:
466+
return vars_value[var_name]
467+
default = var_match.group("default")
468+
if default is not None:
469+
return _eval_dbt_expr(default, context)
470+
return ""
471+
472+
return value
473+
474+
475+
def _stringify_dbt_value(value: Any) -> str:
476+
if value is True:
477+
return "true"
478+
if value is False:
479+
return "false"
480+
if value is None:
481+
return ""
482+
return str(value)
483+
484+
323485
def _format_relations(relations: set[tuple[str, str]]) -> list[str]:
324486
return [f"{schema}.{table}" for schema, table in relations]
325487

tests/unit/test_spider2_dbt_workspace_preflight.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,88 @@ def test_unreadable_sql_file_is_skipped_for_source_reference_scan(
187187
assert result["status"] == "passed"
188188

189189

190+
def test_dbt_source_metadata_renders_target_schema_and_vars(tmp_path: Path) -> None:
191+
(tmp_path / "profiles.yml").write_text(
192+
"\n".join(
193+
[
194+
"example:",
195+
" target: dev",
196+
" outputs:",
197+
" dev:",
198+
" type: duckdb",
199+
" path: db.duckdb",
200+
" schema: warehouse",
201+
"",
202+
]
203+
)
204+
)
205+
(tmp_path / "dbt_project.yml").write_text(
206+
"\n".join(
207+
[
208+
"vars:",
209+
" seed_source: true",
210+
" package:",
211+
" raw_orders_identifier: raw_orders",
212+
"",
213+
]
214+
)
215+
)
216+
models = tmp_path / "models"
217+
models.mkdir()
218+
(models / "sources.yml").write_text(
219+
"\n".join(
220+
[
221+
"version: 2",
222+
"sources:",
223+
" - name: seeded",
224+
" schema: \"{% if var('seed_source') %}{{ target.schema ~ '_seeds' }}{% else %}{{ target.schema }}{% endif %}\"",
225+
" tables:",
226+
" - name: orders",
227+
' identifier: \'{{ var("raw_orders_identifier", "orders") }}\'',
228+
"",
229+
]
230+
)
231+
)
232+
(models / "uses_orders.sql").write_text(
233+
"select * from {{ source('seeded', 'orders') }}\n"
234+
)
235+
_write_duckdb_relations(tmp_path / "db.duckdb", {("warehouse_seeds", "raw_orders")})
236+
237+
result = preflight_spider2_workspace(task_id="t", workspace=tmp_path)
238+
239+
assert result["status"] == "passed"
240+
assert result["required_tables"] == ["warehouse_seeds.raw_orders"]
241+
242+
243+
def test_dbt_source_metadata_skips_external_location_sources(tmp_path: Path) -> None:
244+
models = tmp_path / "models"
245+
models.mkdir()
246+
(models / "sources.yml").write_text(
247+
"\n".join(
248+
[
249+
"version: 2",
250+
"sources:",
251+
" - name: external",
252+
" schema: raw",
253+
" meta:",
254+
" external_location: data/{identifier}.csv",
255+
" tables:",
256+
" - name: orders",
257+
"",
258+
]
259+
)
260+
)
261+
(models / "uses_orders.sql").write_text(
262+
"select * from {{ source('external', 'orders') }}\n"
263+
)
264+
_write_duckdb(tmp_path / "db.duckdb", {"already_present"})
265+
266+
result = preflight_spider2_workspace(task_id="t", workspace=tmp_path)
267+
268+
assert result["status"] == "passed"
269+
assert result["required_tables"] == []
270+
271+
190272
def test_preflight_cli_exits_nonzero_and_emits_json_payload(tmp_path: Path) -> None:
191273
completed = subprocess.run(
192274
[

0 commit comments

Comments
 (0)