Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions crates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ stages run natively:

The shipped tier covers discovery and rendering: declaration files and scopes, model config,
reference extraction, the model loop, macro calls and the macro-call store, and attachments. SQL
analysis, contracts, lineage, SQL-test glue and project assembly are still `preview`. When a
native render stage fails, the compile runs the Python stage again so the error is exactly
Python's; native error messages replace that re-run later. Because the macro-call store is
shipped, the [macro determinism contract](../website/src/content/docs/docs/concepts/macros.mdx)
applies to every default compile; `SQLBUILD_COMPILER_ENGINE=python` runs every macro call each
time.
analysis, contracts, lineage, SQL-test glue and project assembly are still `preview`. A failing
render raises its first error directly, as the Python stage would, and runs each macro call at
most once; a divergence the bridge detects is reported as a native stage mismatch. Because the
macro-call store is shipped, the
[macro determinism contract](../website/src/content/docs/docs/concepts/macros.mdx) applies to
every default compile; `SQLBUILD_COMPILER_ENGINE=python` runs every macro call each time.

Each native stage declares its tier once, in `NATIVE_STAGE_TIERS` in
`src/sqlbuild/compiler/frontier/constants.py`. A stage moves from `preview` to `shipped` by
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
'def cents(expression: str) -> str:\n """Convert to cents."""\n'
' return f"({expression} * 100)"\n'
)
_EXPLODE_MACRO: str = (
'\n\ndef explode(expression: str) -> str:\n """Fail while rendering."""\n'
' raise ValueError(f"cannot render {expression}")\n'
)
_ORDERS_FOR: str = (
"FUNCTION (\n"
' description "Orders for one customer",\n'
Expand Down Expand Up @@ -93,6 +97,51 @@ def _macro_cases() -> tuple[FailureCase, ...]:
},
),
),
failure_case(
name="macro-raises-after-a-call",
expected_code="P001",
expected_message="failed: cannot render status",
files=_staging_columns(
columns='@cents("amount") AS amount, @explode("status") AS status',
files={_STAGING_MACROS: _CENTS_MACRO + _EXPLODE_MACRO},
),
),
failure_case(
name="macro-arguments-do-not-parse",
expected_code="P001",
expected_message="could not be parsed",
files=_staging_columns(
columns='amount, status, @cents("amount",,) AS cents',
files={_STAGING_MACROS: _CENTS_MACRO},
),
),
failure_case(
name="macro-private-helper",
expected_code="P001",
expected_message="Unknown macro '@_scaled'",
files=_staging_columns(
columns='amount, status, @_scaled("amount") AS scaled',
files={
_STAGING_MACROS: _CENTS_MACRO
+ "\n\ndef _scaled(expression: str) -> str:\n return expression\n"
},
),
),
failure_case(
name="macro-first-of-two-failing-models",
expected_code="P001",
expected_message="failed: cannot render total",
files={
**_staging_columns(
columns='amount, @explode("status") AS status',
files={_STAGING_MACROS: _CENTS_MACRO + _EXPLODE_MACRO},
),
"macros/explode.py": _EXPLODE_MACRO.replace("def explode", "def blow_up"),
FAILURE_MART_PATH: FAILURE_BASE_MART.replace(
"SUM(amount)", 'SUM(@blow_up("total"))'
),
},
),
failure_case(
name="macro-output-calls-macro",
expected_code="P001",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ SQLBuild relies on this contract to reuse macro results:
- [Compile reuse](../cli/compile.md) replays an unchanged compile without running macros again.
- A compile may run each distinct macro call once and reuse its result for identical calls
elsewhere in the project.
- A compile that fails stops at its first error. It does not run the project's macros a second
time to report that error.
- Macro call results are kept between compiles under `target/cache/` by default, so editing a
model does not run its unchanged macro calls again. They are recomputed when
macro or other Python files, configuration, declarations, seeds, sources, variables, the target,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,7 @@ def native_model_validation(
def native_validation_error(
*, error: _native.NativeConfigError, values: dict[str, object]
) -> Exception:
"""Return a native validator error; validators read expanded SQL, so the bridge decides."""
"""Return a native validator error, unmarked because validators read macro-expanded SQL."""

return native_config_error(error=error, bridge_independent=False, values=values)

Expand Down
23 changes: 23 additions & 0 deletions src/sqlbuild/compiler/macro_bridge/_helpers/splicing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Splice macro outputs in Python for text the native splice cannot read."""

from __future__ import annotations


def splice_text(
*, sql: str, bounds: list[tuple[int, int]], outputs: list[str]
) -> tuple[str, list[tuple[int, int, int, int]]]:
"""Replace each `(start, end)` span with its output, as `splice_macro_calls` does."""

parts: list[str] = []
spans: list[tuple[int, int, int, int]] = []
cursor: int = 0
length: int = 0
for (start, end), output in zip(bounds, outputs, strict=True):
parts.append(sql[cursor:start])
length += start - cursor
parts.append(output)
spans.append((start, end, length, length + len(output)))
length += len(output)
cursor = end
parts.append(sql[cursor:])
return "".join(parts), spans
94 changes: 60 additions & 34 deletions src/sqlbuild/compiler/macro_bridge/_helpers/store_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,29 @@
from sqlbuild.compiler.scopes.models import DeclarationIdentity
from sqlbuild.python_nodes.models import SqlResourceRef

_UNSTABLE_KEY_ERRORS: tuple[type[Exception], ...] = (
UncacheableValueError,
UnicodeEncodeError,
RecursionError,
)

def macro_store_token(*, loaded: LoadedMacro, identity: DeclarationIdentity | None) -> str:
"""Name a macro by its name, file, declaration identity and exact source."""

return _key_text(
[
loaded.name,
loaded.relative_path.as_posix(),
_canonical(identity),
_native.content_digest([loaded.raw_source]),
]
)
def macro_store_token(*, loaded: LoadedMacro, identity: DeclarationIdentity | None) -> str | None:
"""Name a macro by its name, file, identity and exact source, or None when it has no key."""

try:
return _encodable(
_key_text(
[
loaded.name,
loaded.relative_path.as_posix(),
_canonical(identity),
_native.content_digest([loaded.raw_source]),
]
)
)
except _UNSTABLE_KEY_ERRORS:
return None


def context_store_token(
Expand All @@ -41,41 +52,49 @@ def context_store_token(
"""Digest the whole context a macro may read, or None when a value has no stable text."""

try:
text: str = _key_text(
return _native.content_digest(
[
_canonical_context(macro_context),
None
if declarations is None
else [
_canonical(declarations.constants),
_canonical(declarations.enums),
sorted(declarations.inaccessible_constants),
sorted(declarations.inaccessible_enums),
],
_key_text(
[
_canonical_context(macro_context),
None
if declarations is None
else [
_canonical(declarations.constants),
_canonical(declarations.enums),
sorted(declarations.inaccessible_constants),
sorted(declarations.inaccessible_enums),
],
]
)
]
)
except UncacheableValueError:
except _UNSTABLE_KEY_ERRORS:
return None
return _native.content_digest([text])


def call_class_store_text(
*,
macro_tokens: tuple[str, ...],
context_token: str | None,
prior_relations: tuple[SqlResourceRef, ...] | None,
) -> str:
"""Text of one persistent call class: its macros, context and relations passed so far."""

return _key_text(
[
list(macro_tokens),
context_token,
None
if prior_relations is None
else [[relation.kind.value, relation.name] for relation in prior_relations],
]
)
) -> str | None:
"""Text of one persistent call class, or None when a relation name has no UTF-8 text."""

try:
return _encodable(
_key_text(
[
list(macro_tokens),
context_token,
None
if prior_relations is None
else [[relation.kind.value, relation.name] for relation in prior_relations],
]
)
)
except UnicodeEncodeError:
return None


def _canonical_context(macro_context: MacroContext) -> object:
Expand Down Expand Up @@ -141,3 +160,10 @@ def _type_name(kind: type) -> str:

def _key_text(value: object) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))


def _encodable(text: str) -> str:
"""Return `text`, raising `UnicodeEncodeError` when the native store cannot hold it."""

_ = text.encode("utf-8")
return text
61 changes: 34 additions & 27 deletions src/sqlbuild/compiler/macro_bridge/classes/macro_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
LoadedMacro,
MacroContext,
)
from sqlbuild.compiler.macro_bridge._helpers.splicing import splice_text
from sqlbuild.compiler.macro_bridge._helpers.store_environment import (
digest_module_files,
module_digests_metadata,
Expand Down Expand Up @@ -51,7 +52,6 @@ def __init__(self, *, python_version: tuple[int, int], unicode_version: str) ->
self._module_digests: dict[str, str] = {}
self._observed_modules: dict[str, object] = {}
self._project_inputs: tuple[Path, list[str], str] = (Path(), [], "")
self._scanned: bool = False

def attach_store(self, *, cache_dir: Path, project_dir: Path, model_paths: list[str]) -> None:
"""Reuse call results stored by earlier compiles of the same code and project files."""
Expand Down Expand Up @@ -134,10 +134,12 @@ def _observe_modules(self) -> bool:
def scan(self, sql: str) -> tuple[MacroCallSite, ...] | None:
"""Return the top-level call sites of `sql`, or None when Python must expand it."""

self._scanned = True
rows: list[tuple[int, int, str, list[str], bool]] | None = _native.scan_macro_call_sites(
sql, self._python_version, self._unicode_version
)
try:
rows: list[tuple[int, int, str, list[str], bool]] | None = (
_native.scan_macro_call_sites(sql, self._python_version, self._unicode_version)
)
except UnicodeEncodeError:
return None
if rows is None:
return None
return tuple(
Expand All @@ -151,12 +153,6 @@ def scan(self, sql: str) -> tuple[MacroCallSite, ...] | None:
for start, end, name, tree_names, typed_reference_text in rows
)

@property
def scanned(self) -> bool:
"""Whether any authored string has been scanned, so this compile left the Python path."""

return self._scanned

def call_class(
self,
*,
Expand Down Expand Up @@ -213,15 +209,17 @@ def lookup(
class_id: int | None = self._class_ids.get(class_key)
if class_id is None:
class_id = self._class_ids[class_key] = len(self._class_ids)
if self._store_path is not None and call_class.persistent:
self._memo.set_persistent_class(
class_id,
call_class_store_text(
macro_tokens=call_class.macro_store_tokens,
context_token=call_class.context_store_token,
prior_relations=prior_relations,
),
class_text: str | None = (
call_class_store_text(
macro_tokens=call_class.macro_store_tokens,
context_token=call_class.context_store_token,
prior_relations=prior_relations,
)
if self._store_path is not None and call_class.persistent
else None
)
if class_text is not None:
self._memo.set_persistent_class(class_id, class_text)
found: tuple[str, list[tuple[str, str]], list[tuple[int, str, str]]] | None = (
self._memo.lookup(class_id, call_text)
)
Expand Down Expand Up @@ -249,11 +247,18 @@ def record(

if self._store_trusted and len(sys.modules) != len(self._observed_modules):
_ = self._observe_modules()
self._memo.record(
class_id,
call_text,
(sql, [(relation.kind.value, relation.name) for relation in relations], list(events)),
)
try:
self._memo.record(
class_id,
call_text,
(
sql,
[(relation.kind.value, relation.name) for relation in relations],
list(events),
),
)
except UnicodeEncodeError:
return

def splice(
self, *, sql: str, sites: tuple[MacroCallSite, ...], outputs: list[str]
Expand All @@ -262,9 +267,11 @@ def splice(

rendered: str
spans: list[tuple[int, int, int, int]]
rendered, spans = _native.splice_macro_calls(
sql, [(site.start, site.end) for site in sites], outputs
)
bounds: list[tuple[int, int]] = [(site.start, site.end) for site in sites]
try:
rendered, spans = _native.splice_macro_calls(sql, bounds, outputs)
except UnicodeEncodeError:
rendered, spans = splice_text(sql=sql, bounds=bounds, outputs=outputs)
return rendered, tuple(
ExpansionSpan(
source_start=source_start,
Expand Down
Loading
Loading