specdbt 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.
specdbt/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,39 @@
1
+ """Execution adapter interface — the engine-agnostic boundary. Every concrete
2
+ adapter (FakeAdapter now; PolarsAdapter/DuckDBAdapter/DbtCoreAdapter later)
3
+ implements this and nothing above it needs to know which one is in use.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass
10
+
11
+ from specdbt.fixtures import Fixture
12
+
13
+
14
+ @dataclass
15
+ class ExecutionResult:
16
+ rows: list[dict]
17
+ row_count: int
18
+ raw: object = None
19
+
20
+ @classmethod
21
+ def of(cls, rows: list[dict], raw: object = None) -> ExecutionResult:
22
+ """Convenience constructor: row_count is derived from len(rows)."""
23
+ return cls(rows=rows, row_count=len(rows), raw=raw)
24
+
25
+
26
+ class ExecutionAdapter(ABC):
27
+ @abstractmethod
28
+ def run_model(self, model_name: str, fixtures: list[Fixture]) -> ExecutionResult:
29
+ """Run `model_name` with the given fixtures substituted for its
30
+ refs/sources, and return the resulting rows."""
31
+ raise NotImplementedError
32
+
33
+ @abstractmethod
34
+ def run_macro(self, macro_call: str, fixtures: list[Fixture]) -> ExecutionResult:
35
+ """Run `macro_call` -- a complete, real Jinja/SQL query string (not
36
+ just a macro call expression), with the given fixtures'
37
+ ref()/source() substituted for their ephemeral relations -- and
38
+ return the resulting rows."""
39
+ raise NotImplementedError
@@ -0,0 +1,111 @@
1
+ """Real execution against whatever dbt target a project's profile points at,
2
+ via dbtRunner -- the only concrete ExecutionAdapter that computes real
3
+ results instead of returning canned ones."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import uuid
8
+ from pathlib import Path
9
+
10
+ from dbt.cli.main import dbtRunner
11
+
12
+ from specdbt.adapters.base import ExecutionAdapter, ExecutionResult
13
+ from specdbt.adapters.prod_guard import ( # noqa: F401 -- re-exported for tests/test_dbt_adapter.py
14
+ ProdSchemaGuardError,
15
+ guard_against_prod_target,
16
+ )
17
+ from specdbt.dbt_integration.fixture_sql import render_fixture_ctas
18
+ from specdbt.dbt_integration.macro_file import (
19
+ delete_macro_file,
20
+ render_macro_file,
21
+ setup_macro_name,
22
+ teardown_macro_name,
23
+ write_macro_file,
24
+ )
25
+ from specdbt.dbt_integration.ref_substitution import substitute_fixture_refs
26
+ from specdbt.dbt_integration.target_catalog import resolve_target_catalog
27
+ from specdbt.fixtures import Fixture
28
+
29
+
30
+ class DbtInvocationError(RuntimeError):
31
+ """Raised when a dbtRunner.invoke() call fails."""
32
+
33
+
34
+ class ModelIntegrationTierNotImplementedError(NotImplementedError):
35
+ """Raised by run_model. The macro-file substitution mechanism only
36
+ works because a macro call's ref()/source() arguments are text
37
+ specdbt's own call site controls. A model's ref()s are inside its own
38
+ SQL file, which this mechanism never touches -- running it for real
39
+ would use whatever real state those refs already resolve to, not the
40
+ scenario's fixtures, silently producing wrong results."""
41
+
42
+
43
+ class DbtExecutionAdapter(ExecutionAdapter):
44
+ def __init__(
45
+ self,
46
+ project_dir: Path,
47
+ profiles_dir: Path,
48
+ *,
49
+ target: str | None = None,
50
+ allow_any_schema: bool = False,
51
+ keep_schema: bool = False,
52
+ ) -> None:
53
+ guard_against_prod_target(target, allow_any_schema)
54
+ self._project_dir = Path(project_dir)
55
+ self._profiles_dir = Path(profiles_dir)
56
+ self._target = target
57
+ self._keep_schema = keep_schema
58
+ self._runner = dbtRunner()
59
+
60
+ def run_model(self, model_name: str, fixtures: list[Fixture]) -> ExecutionResult:
61
+ raise ModelIntegrationTierNotImplementedError(
62
+ f"DbtExecutionAdapter.run_model({model_name!r}) is not "
63
+ "implemented -- model integration-tier testing goes through "
64
+ "FakeAdapter or the unit tier instead."
65
+ )
66
+
67
+ def run_macro(self, macro_call: str, fixtures: list[Fixture]) -> ExecutionResult:
68
+ database = resolve_target_catalog(self._project_dir, self._profiles_dir, self._target)
69
+ run_id = uuid.uuid4().hex
70
+ schema = f"specdbt_{run_id}"
71
+ fixture_names = {fixture.name for fixture in fixtures}
72
+ substituted_call = substitute_fixture_refs(
73
+ macro_call, schema, fixture_names, database=database
74
+ )
75
+ fixture_ctas = [
76
+ render_fixture_ctas(schema, fixture, database=database) for fixture in fixtures
77
+ ]
78
+ macro_text = render_macro_file(run_id, schema, fixture_ctas, database=database)
79
+ macro_path = write_macro_file(self._project_dir, run_id, macro_text)
80
+
81
+ try:
82
+ self._invoke(["run-operation", setup_macro_name(run_id)])
83
+ show_result = self._invoke(
84
+ ["show", "--inline", substituted_call, "--output", "json", "--limit", "-1"]
85
+ )
86
+ agate_table = show_result.result.results[0].agate_table
87
+ rows = [
88
+ dict(zip(agate_table.column_names, row, strict=True)) for row in agate_table.rows
89
+ ]
90
+ return ExecutionResult.of(rows)
91
+ finally:
92
+ if not self._keep_schema:
93
+ self._invoke(["run-operation", teardown_macro_name(run_id)])
94
+ delete_macro_file(macro_path)
95
+
96
+ def _invoke(self, args: list[str]):
97
+ full_args = [
98
+ *args,
99
+ "--project-dir",
100
+ str(self._project_dir),
101
+ "--profiles-dir",
102
+ str(self._profiles_dir),
103
+ "--quiet",
104
+ "--no-send-anonymous-usage-stats",
105
+ ]
106
+ if self._target:
107
+ full_args += ["--target", self._target]
108
+ result = self._runner.invoke(full_args)
109
+ if not result.success:
110
+ raise DbtInvocationError(f"dbt {args[0]} failed: {result.exception}")
111
+ return result
@@ -0,0 +1,38 @@
1
+ """Phase 0's only concrete adapter: returns pre-registered canned results,
2
+ never computes anything from the fixtures it's given. Proves the pipeline
3
+ plumbing; DbtExecutionAdapter (Phase 1) provides real correctness for macros.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from specdbt.adapters.base import ExecutionAdapter, ExecutionResult
9
+ from specdbt.fixtures import Fixture
10
+
11
+
12
+ class ModelNotRegisteredError(KeyError):
13
+ """Raised when run_model()/run_macro() is asked for a name with no
14
+ canned result registered."""
15
+
16
+
17
+ class FakeAdapter(ExecutionAdapter):
18
+ def __init__(self) -> None:
19
+ self._canned_results: dict[str, ExecutionResult] = {}
20
+
21
+ def register(self, name: str, result: ExecutionResult) -> None:
22
+ """Registers a canned result under `name` -- a model name
23
+ (run_model) or the exact macro-call string a scenario's When step
24
+ uses (run_macro). Same registry either way; FakeAdapter doesn't
25
+ distinguish between the two kinds of caller."""
26
+ self._canned_results[name] = result
27
+
28
+ def run_model(self, model_name: str, fixtures: list[Fixture]) -> ExecutionResult:
29
+ return self._lookup(model_name)
30
+
31
+ def run_macro(self, macro_call: str, fixtures: list[Fixture]) -> ExecutionResult:
32
+ return self._lookup(macro_call)
33
+
34
+ def _lookup(self, name: str) -> ExecutionResult:
35
+ try:
36
+ return self._canned_results[name]
37
+ except KeyError:
38
+ raise ModelNotRegisteredError(f"no canned result registered for {name!r}") from None
@@ -0,0 +1,22 @@
1
+ """Shared prod-schema heuristic guard -- used by every real-execution path
2
+ that touches a dbt target: DbtExecutionAdapter (macro/model integration
3
+ tier, ephemeral) and ModelUnitTestCompiler (model unit tier -- its
4
+ prebuild step writes real tables into the project's actually-configured
5
+ schema, not an ephemeral one, so it needs the same guard).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class ProdSchemaGuardError(RuntimeError):
12
+ """Raised when the configured target name looks like production and
13
+ allow_any_schema was not passed."""
14
+
15
+
16
+ def guard_against_prod_target(target: str | None, allow_any_schema: bool) -> None:
17
+ if target and "prod" in target.lower() and not allow_any_schema:
18
+ raise ProdSchemaGuardError(
19
+ f"target {target!r} looks like production -- refusing to run. "
20
+ "Pass allow_any_schema=True (CLI: --allow-any-schema) if this "
21
+ "is really what you want."
22
+ )
specdbt/ai/__init__.py ADDED
File without changes
specdbt/ai/stubs.py ADDED
@@ -0,0 +1,29 @@
1
+ """Typed placeholders for a future AI layer. Nothing here executes; this
2
+ only fixes the package shape now so that layer is additive later, not a
3
+ restructure."""
4
+
5
+ from __future__ import annotations
6
+
7
+ _NOT_YET = "AI features ship in Phase 3 -- see the roadmap doc."
8
+
9
+
10
+ class LLMClient:
11
+ """Placeholder for the provider-agnostic LLM client (Phase 3)."""
12
+
13
+ def complete(self, prompt: str) -> str:
14
+ raise NotImplementedError(_NOT_YET)
15
+
16
+
17
+ def generate_fixtures(model_sql: str, schema: dict[str, str], count: int = 3) -> list[dict]:
18
+ """Fixture synthesis (Phase 3, 03-ai-integration-plan.md §1)."""
19
+ raise NotImplementedError(_NOT_YET)
20
+
21
+
22
+ def scenario_from_text(description: str) -> str:
23
+ """Natural-language -> Gherkin (Phase 3, 03-ai-integration-plan.md §2)."""
24
+ raise NotImplementedError(_NOT_YET)
25
+
26
+
27
+ def explain_failure(fixture: dict, model_sql: str, diff: dict) -> str:
28
+ """Failure triage (Phase 3, 03-ai-integration-plan.md §4)."""
29
+ raise NotImplementedError(_NOT_YET)
specdbt/assertions.py ADDED
@@ -0,0 +1,145 @@
1
+ """Then-step assertion library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+
9
+ import polars as pl
10
+
11
+ from specdbt.adapters.base import ExecutionResult
12
+ from specdbt.typing_utils import coerce_scalar, rows_from_data_table
13
+
14
+
15
+ class AssertionFailure(AssertionError):
16
+ def __init__(self, message: str, expected: object = None, actual: object = None) -> None:
17
+ super().__init__(message)
18
+ self.expected = expected
19
+ self.actual = actual
20
+
21
+
22
+ class UnrecognizedStepError(ValueError):
23
+ """Raised when a Then/And/But step's text matches none of the known patterns."""
24
+
25
+
26
+ PRODUCES_ROWS_RE = re.compile(r'the "(.+)" should produce the following rows:$')
27
+ _ROW_COUNT_RE = re.compile(r'^"([^"]+)" should have (\d+) rows?$')
28
+ _NOT_NULL_RE = re.compile(r'^column "([^"]+)" in "([^"]+)" should not contain nulls$')
29
+ _UNIQUE_RE = re.compile(r'^column "([^"]+)" in "([^"]+)" should be unique$')
30
+ _ROW_FIELD_RE = re.compile(r'^the row for (\w+) "([^"]+)" should have (\w+) (.+)$')
31
+
32
+
33
+ @dataclass
34
+ class ThenContext:
35
+ """What a Then/And/But step needs: every named result produced so far in the
36
+ scenario, and the most recently produced one (for steps that don't name a
37
+ model explicitly, like "the row for X should have Y")."""
38
+
39
+ results: dict[str, ExecutionResult]
40
+ last_model: str | None
41
+
42
+
43
+ def evaluate_then_step(text: str, ctx: ThenContext, table: list[list[str]] | None = None) -> None:
44
+ """Raise AssertionFailure if the expectation doesn't hold, or
45
+ UnrecognizedStepError if the text matches no known pattern. None on
46
+ success. `table` is the step's data table, if it has one -- only the
47
+ row-table form (the canonical Then) uses it."""
48
+ if (m := PRODUCES_ROWS_RE.match(text)) is not None:
49
+ name = m.group(1)
50
+ if not table:
51
+ raise AssertionFailure(f"{text!r} requires a data table of expected rows")
52
+ result = _lookup(ctx, name)
53
+ header = table[0]
54
+ expected_rows = rows_from_data_table(table)
55
+ projected_actual_rows = [
56
+ {column: row.get(column) for column in header} for row in result.rows
57
+ ]
58
+ expected_counts = Counter(tuple(row[c] for c in header) for row in expected_rows)
59
+ actual_counts = Counter(tuple(row[c] for c in header) for row in projected_actual_rows)
60
+ if actual_counts != expected_counts:
61
+ expected_df = pl.DataFrame(expected_rows) if expected_rows else pl.DataFrame()
62
+ actual_df = (
63
+ pl.DataFrame(projected_actual_rows) if projected_actual_rows else pl.DataFrame()
64
+ )
65
+ raise AssertionFailure(
66
+ f'"{name}" produced different rows than expected (only columns '
67
+ f"{header} are compared; row order doesn't matter, row count "
68
+ f"does):\n"
69
+ f"--- expected ---\n{expected_df}\n"
70
+ f"--- actual (projected) ---\n{actual_df}",
71
+ expected=expected_rows,
72
+ actual=projected_actual_rows,
73
+ )
74
+ return
75
+
76
+ if (m := _ROW_COUNT_RE.match(text)) is not None:
77
+ model_name, expected_count = m.group(1), int(m.group(2))
78
+ result = _lookup(ctx, model_name)
79
+ if result.row_count != expected_count:
80
+ raise AssertionFailure(
81
+ f'expected "{model_name}" to have {expected_count} row(s), got {result.row_count}',
82
+ expected=expected_count,
83
+ actual=result.row_count,
84
+ )
85
+ return
86
+
87
+ if (m := _NOT_NULL_RE.match(text)) is not None:
88
+ column, model_name = m.group(1), m.group(2)
89
+ result = _lookup(ctx, model_name)
90
+ nulls = [row for row in result.rows if row.get(column) is None]
91
+ if nulls:
92
+ raise AssertionFailure(
93
+ f'expected column "{column}" in "{model_name}" to contain no nulls, '
94
+ f"found {len(nulls)}",
95
+ expected="no nulls",
96
+ actual=f"{len(nulls)} null row(s)",
97
+ )
98
+ return
99
+
100
+ if (m := _UNIQUE_RE.match(text)) is not None:
101
+ column, model_name = m.group(1), m.group(2)
102
+ result = _lookup(ctx, model_name)
103
+ values = [row.get(column) for row in result.rows]
104
+ duplicates = sorted({v for v in values if values.count(v) > 1}, key=str)
105
+ if duplicates:
106
+ raise AssertionFailure(
107
+ f'expected column "{column}" in "{model_name}" to be unique, '
108
+ f"found duplicate(s) {duplicates}",
109
+ expected="unique values",
110
+ actual=f"duplicates: {duplicates}",
111
+ )
112
+ return
113
+
114
+ if (m := _ROW_FIELD_RE.match(text)) is not None:
115
+ key_col, key_val_raw, field_name, raw_value = m.groups()
116
+ if ctx.last_model is None:
117
+ raise AssertionFailure(f"no model has run yet to check a row against: {text!r}")
118
+ result = _lookup(ctx, ctx.last_model)
119
+ key_val = coerce_scalar(key_val_raw)
120
+ matches = [row for row in result.rows if row.get(key_col) == key_val]
121
+ if not matches:
122
+ raise AssertionFailure(
123
+ f'no row found where {key_col} == {key_val_raw!r} in "{ctx.last_model}"',
124
+ expected=f"a row with {key_col}={key_val_raw!r}",
125
+ actual="no matching row",
126
+ )
127
+ expected_value = coerce_scalar(raw_value.strip('"'))
128
+ actual_value = matches[0].get(field_name)
129
+ if actual_value != expected_value:
130
+ raise AssertionFailure(
131
+ f"expected {field_name} {expected_value!r} for row {key_col}={key_val_raw!r}, "
132
+ f"got {actual_value!r}",
133
+ expected=expected_value,
134
+ actual=actual_value,
135
+ )
136
+ return
137
+
138
+ raise UnrecognizedStepError(f"no assertion pattern matches: {text!r}")
139
+
140
+
141
+ def _lookup(ctx: ThenContext, model_name: str) -> ExecutionResult:
142
+ try:
143
+ return ctx.results[model_name]
144
+ except KeyError:
145
+ raise AssertionFailure(f'model "{model_name}" has not run yet in this scenario') from None
specdbt/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ """specdbt command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from specdbt.adapters.base import ExecutionResult
12
+ from specdbt.adapters.dbt_adapter import DbtExecutionAdapter
13
+ from specdbt.adapters.fake_adapter import FakeAdapter
14
+ from specdbt.native_unit_tests.compiler import CompilerRegistry
15
+ from specdbt.native_unit_tests.model_unit_test_compiler import ModelUnitTestCompiler
16
+ from specdbt.reporter import render_feature_report, render_summary
17
+ from specdbt.runner import run_feature_file
18
+
19
+ _SCAFFOLD_FEATURE = """Feature: Example feature
20
+
21
+ Scenario: Replace this with a real scenario
22
+ Given the following rows in "example_source":
23
+ | id | value |
24
+ | 1 | hello |
25
+ When the "example_model" model runs
26
+ Then "example_model" should have 1 row
27
+ """
28
+
29
+ _SCAFFOLD_CANNED = '''"""Hand-coded canned result for example.feature (Phase 0)."""
30
+ from specdbt.adapters.base import ExecutionResult
31
+
32
+ CANNED_RESULTS = {
33
+ "example_model": ExecutionResult.of(rows=[{"id": 1, "value": "hello"}]),
34
+ }
35
+ '''
36
+
37
+
38
+ @click.group()
39
+ def cli() -> None:
40
+ """specdbt -- BDD-style Given/When/Then testing for dbt models."""
41
+
42
+
43
+ @cli.command()
44
+ @click.argument("directory", type=click.Path(path_type=Path), default=Path("features"))
45
+ def init(directory: Path) -> None:
46
+ """Scaffold DIRECTORY with one example .feature file and its canned result."""
47
+ directory.mkdir(parents=True, exist_ok=True)
48
+ example = directory / "example.feature"
49
+ canned = example.with_suffix(".canned.py")
50
+ if example.exists() or canned.exists():
51
+ raise click.ClickException(f"{example} already exists, not overwriting")
52
+ example.write_text(_SCAFFOLD_FEATURE)
53
+ canned.write_text(_SCAFFOLD_CANNED)
54
+ click.echo(f"created {example}")
55
+ click.echo(f"created {canned}")
56
+
57
+
58
+ def _load_canned_results(path: Path) -> dict[str, ExecutionResult]:
59
+ spec = importlib.util.spec_from_file_location(f"_specdbt_canned_{path.stem}", path)
60
+ if spec is None or spec.loader is None:
61
+ raise click.ClickException(f"could not load {path} as a Python module")
62
+ module = importlib.util.module_from_spec(spec)
63
+ spec.loader.exec_module(module)
64
+ try:
65
+ return module.CANNED_RESULTS
66
+ except AttributeError:
67
+ raise click.ClickException(f"{path} does not define CANNED_RESULTS") from None
68
+
69
+
70
+ @cli.command()
71
+ @click.argument("target", type=click.Path(path_type=Path, exists=True))
72
+ @click.option(
73
+ "--engine",
74
+ type=click.Choice(["fake", "dbt"]),
75
+ default="fake",
76
+ help="fake (default): FakeAdapter + co-located .canned.py. "
77
+ "dbt: DbtExecutionAdapter, real execution.",
78
+ )
79
+ @click.option("--project-dir", "project_dir", type=click.Path(path_type=Path, exists=True))
80
+ @click.option("--profiles-dir", "profiles_dir", type=click.Path(path_type=Path, exists=True))
81
+ @click.option("--target", "dbt_target", default=None)
82
+ @click.option("--allow-any-schema", is_flag=True, default=False)
83
+ @click.option("--keep-schema", is_flag=True, default=False)
84
+ def run(
85
+ target: Path,
86
+ engine: str,
87
+ project_dir: Path | None,
88
+ profiles_dir: Path | None,
89
+ dbt_target: str | None,
90
+ allow_any_schema: bool,
91
+ keep_schema: bool,
92
+ ) -> None:
93
+ """Parse and run the .feature file(s) under TARGET.
94
+
95
+ --engine fake (default): each FEATURE.feature file may have a co-located
96
+ FEATURE.canned.py exposing CANNED_RESULTS: dict[str, ExecutionResult],
97
+ pre-registered into a fresh FakeAdapter before that file's scenarios run.
98
+
99
+ --engine dbt: real execution via DbtExecutionAdapter against --project-dir
100
+ (required) and --profiles-dir (defaults to --project-dir).
101
+ """
102
+ paths = sorted(target.rglob("*.feature")) if target.is_dir() else [target]
103
+ if not paths:
104
+ raise click.ClickException(f"no .feature files found under {target}")
105
+
106
+ dbt_adapter: DbtExecutionAdapter | None = None
107
+ compiler_registry: CompilerRegistry | None = None
108
+ if engine == "dbt":
109
+ if project_dir is None:
110
+ raise click.ClickException("--project-dir is required with --engine dbt")
111
+ dbt_adapter = DbtExecutionAdapter(
112
+ project_dir=project_dir,
113
+ profiles_dir=profiles_dir or project_dir,
114
+ target=dbt_target,
115
+ allow_any_schema=allow_any_schema,
116
+ keep_schema=keep_schema,
117
+ )
118
+ compiler_registry = CompilerRegistry()
119
+ compiler_registry.register(
120
+ "model",
121
+ ModelUnitTestCompiler(
122
+ project_dir=project_dir,
123
+ profiles_dir=profiles_dir or project_dir,
124
+ target=dbt_target,
125
+ allow_any_schema=allow_any_schema,
126
+ ),
127
+ )
128
+
129
+ reports = []
130
+ for path in paths:
131
+ if dbt_adapter is not None:
132
+ adapter = dbt_adapter
133
+ else:
134
+ adapter = FakeAdapter()
135
+ canned_path = path.with_suffix(".canned.py")
136
+ if canned_path.exists():
137
+ for model_name, result in _load_canned_results(canned_path).items():
138
+ adapter.register(model_name, result)
139
+ reports.append(run_feature_file(path, adapter, compiler_registry))
140
+
141
+ for report in reports:
142
+ click.echo(render_feature_report(report))
143
+ click.echo(render_summary(reports))
144
+
145
+ if any(not scenario.passed for report in reports for scenario in report.scenarios):
146
+ sys.exit(1)
147
+
148
+
149
+ @cli.command()
150
+ @click.option("--from-model", "from_model", required=True)
151
+ @click.option("--fixtures", "fixtures_flag", is_flag=True, default=False)
152
+ def generate(from_model: str, fixtures_flag: bool) -> None:
153
+ """AI-assisted scenario/fixture generation (Phase 3 -- not implemented yet)."""
154
+ raise click.ClickException(
155
+ "`specdbt generate` ships in Phase 3 -- see the AI integration plan doc."
156
+ )
157
+
158
+
159
+ @cli.command(name="compile")
160
+ @click.argument("target", type=click.Path(path_type=Path, exists=True))
161
+ @click.option("--to", "to_format", type=click.Choice(["dbt-unit-tests"]), required=True)
162
+ def compile_(target: Path, to_format: str) -> None:
163
+ """Compile .feature scenarios to native dbt unit tests (Phase 2 -- not implemented yet)."""
164
+ raise click.ClickException("`specdbt compile` ships in Phase 2 -- see the roadmap doc.")
165
+
166
+
167
+ if __name__ == "__main__":
168
+ cli()
File without changes
@@ -0,0 +1,56 @@
1
+ """Render a Fixture as a CREATE TABLE ... AS SELECT ... UNION ALL statement,
2
+ for real execution against a dbt target.
3
+
4
+ Column types are Python-value-derived (the only information available --
5
+ many fixture names aren't real manifest nodes, so
6
+ adapter.get_columns_in_relation isn't usable for the common case), but made
7
+ explicit and adapter-dispatched via dbt.cast(...) + a per-column dbt type
8
+ macro, instead of relying on implicit VALUES-clause type inference --
9
+ which is not guaranteed identical across engines (an all-NULL column,
10
+ mixed int/float precision). dbt.cast, not dbt.safe_cast: some adapters
11
+ implement safe_cast as a silently-NULL-on-failure try_cast, wrong for a
12
+ testing framework, which should fail loudly on a type mismatch. The
13
+ `select ... union all select ...` shape matches dbt-core's own native
14
+ unit-test fixture generator, avoiding VALUES's cross-engine column-aliasing
15
+ and implicit-coercion quirks (spec: macro-tier adapter-dispatch design,
16
+ 2026-08-30).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from specdbt.dbt_integration.relation_expr import relation_expr
22
+ from specdbt.fixtures import Fixture
23
+ from specdbt.sql_literals import sql_literal_expr
24
+
25
+
26
+ def _dbt_type_macro(values: list) -> str:
27
+ if any(isinstance(v, float) for v in values):
28
+ return "dbt.type_float()"
29
+ if any(isinstance(v, str) for v in values):
30
+ return "dbt.type_string()"
31
+ if any(isinstance(v, int) and not isinstance(v, bool) for v in values):
32
+ return "dbt.type_bigint()"
33
+ if any(isinstance(v, bool) for v in values):
34
+ return "dbt.type_boolean()"
35
+ return "dbt.type_string()" # all-NULL column
36
+
37
+
38
+ def render_fixture_ctas(schema: str, fixture: Fixture, *, database: str | None = None) -> str:
39
+ """`fixture.rows` must be non-empty -- fixtures.build_fixture already
40
+ enforces this via FixtureBuildError. Columns come from the first row's
41
+ key order; all rows in one fixture are assumed to share the same
42
+ columns, matching how the Gherkin data table they came from is shaped."""
43
+ columns = list(fixture.rows[0].keys())
44
+ column_types = {col: _dbt_type_macro([row[col] for row in fixture.rows]) for col in columns}
45
+
46
+ select_rows = [
47
+ "select "
48
+ + ", ".join(
49
+ f"{{{{ dbt.cast({sql_literal_expr(row[col])}, {column_types[col]}) }}}} as {col}"
50
+ for col in columns
51
+ )
52
+ for row in fixture.rows
53
+ ]
54
+ body = "\nunion all\n".join(select_rows)
55
+ relation = relation_expr(schema=schema, identifier=fixture.name, database=database)
56
+ return f"create table {{{{ {relation} }}}} as (\n{body}\n)"