Skip to content

0.4.3: Datasource-scoped model storage, query generator fixes, and 30x faster unit tests

Choose a tag to compare

@ZmeiGorynych ZmeiGorynych released this 05 May 12:14
· 413 commits to main since this release
6ab0c51

SLayer 0.4.3 Release Notes

A bug-fix and quality release: thirteen PRs since 0.4.2 covering storage, the query planner, SQL generation, MCP introspection, and a large drop in test-suite wall time.

Datasource-scoped model storage

Models are now keyed by (data_source, name) instead of bare name, so two datasources sharing a table name no longer collide silently. The schema bumps from v3 to v4, with an automatic migration that moves YAML files from models/<name>.yaml into models/<data_source>/<name>.yaml and rebuilds the SQLite models table with a composite primary key. A new data_source argument flows through engine.execute, the MCP create_model / edit_model / inspect_model / delete_model tools, the HTTP /models endpoints (?data_source=), and SlayerClient. Bare-name lookups that match more than one datasource raise the new AmbiguousModelError (HTTP 409). New datasource-priority APIs -- set_datasource_priority / get_datasource_priority, GET/PUT /datasources/priority, and the slayer datasources priority CLI -- let you disambiguate without passing data_source on every call.

Recursive expansion of derived columns

A Column.sql may now reference any other column on the same model or on a joined model -- including derived columns whose own sql is itself an expression. Chains like "A.bar / B.foo_normalized" (where foo_normalized is itself derived) used to fail at execute with no such column; expansion now happens at enrichment time and applies uniformly to dimensions, time dimensions, measures, cross-model measures, and filters. Reference cycles raise a clear error.

Auto-join discovery for filter-only cross-table refs

When a filter references a bare-named local derived column whose Column.sql itself crosses a join (e.g., filters=["is_eu = 1"] where is_eu.sql reads customers.region), the planner now walks the column's reference chain, discovers the implied joins, and adds them. Previously the column also had to appear in dimensions to force the join.

Window functions in filters

Filter expressions that resolve to SQL containing OVER (...) no longer crash with near "OVER": syntax error. A model column whose sql contains a window function is auto-promoted to a computed column on the inner SELECT and referenced via an outer post-filter wrap, so it can appear in a query filter naturally. Raw OVER text inside a ModelMeasure.formula or an inline query filter is rejected at construction or parse time with an actionable error pointing at the rank() / first() / last() / lag() / lead() transforms.

Multi-stage query joins form a DAG

Non-final named stages in source_queries can now declare joins.target_model against prior named sibling stages, so a multi-stage query forms a true DAG instead of being limited to "final stage joins anything, inner stages join nothing". Forward references and self-references surface as clear errors instead of the generic Model 'X' not found.

Honor user-supplied measure names on inner stages

{"formula": "amount:sum", "name": "rev"} on an inner SlayerQuery stage now emits the alias rev instead of the canonical amount_sum. Downstream stages that reference the renamed measure no longer error or surface NULLs.

SQLite json_extract() semantics fix

sqlglot's default SQLite generator was emitting JSONExtract as col -> '$.path', which in SQLite returns the JSON-quoted form ('"Owned"' with literal quotes). Equality and CASE-WHEN matches against bare-string literals silently produced all-NULL or 0 -- wrong answers, no error. The generator now rewrites every JSONExtract to JSON_EXTRACT(...) on SQLite while leaving Postgres / DuckDB / MySQL paths and JSONExtractScalar (->>) untouched.

Preserve log10(x) / log2(x) literal form

sqlglot was canonicalizing log10(x) and log2(x) into LOG(10, x) / LOG(2, x), which broke benchmark agents reading back inspect_model.last_sql and triggered runtime errors on dialects without 2-arg LOG(b, x) (Oracle in some configs, embedded SQLite builds without log). The generator now rewrites the canonicalized form back to log10(...) / log2(...) on every dialect that natively supports the alias. A strict-error log2 UDF for SQLite is registered alongside the existing _log10 for parity with Postgres semantics.

__aggN__ placeholder leak fix

When a measure formula wrapped a colon-syntax aggregation reference inside a non-transform SQL call (nullif, coalesce, ...) and combined it with another aggregation in arithmetic, the embedded __aggN__ placeholder leaked all the way to emitted SQL, crashing execution with no such column: __aggN__. The formula AST walker now recurses into non-transform Call args and keywords so the placeholder is registered correctly.

Multi-hop derived-column qualification regression tests

Six regression tests pin the contract that derived Column.sql reached via a multi-hop join path emits correctly qualified SQL across dim refs, cross-model measure aggregations, filters, time dimensions, the verbatim solar-panels reproduction from the original issue, and the diamond-join arm-isolation case. The bug itself was incidentally fixed by the derived-column expansion work above; the tests pin the fix so it cannot silently regress.

meta field surfaced in inspect_model

The meta field on ModelMeasure and Aggregation (added in 0.4.2) round-tripped through storage but never appeared in inspect_model output, so callers concluded the value had been dropped. Markdown columns / measures / aggregations tables now include a meta cell (compact JSON, pruned when no entity in the section uses it); the model header gets a **meta:** bullet; the JSON form emits meta unconditionally on every entity.

Unit suite ~30x faster

_retry_with_backoff was retrying every OperationalError, including deterministic ones like no such table, syntax errors, and permission denied -- adding a 3-second backoff to every such failure. A new _is_transient_db_error filter narrows retries to DisconnectionError plus a known-transient DBAPI signal allowlist (database is locked, deadlock, lost connection, broken pipe, server closed, connection refused/reset). Combined with a deferred dbt.cli.main import (saving ~4s of CLI startup) and session-scope MCP and FastAPI test fixtures, the unit suite drops from ~243s to ~8.3s. --cov is also dropped from the default pytest addopts so coverage is now opt-in via pytest --cov=slayer --cov-report=term-missing.

Integration suite ~85s faster

Module-scope Postgres and DuckDB fixtures plus a cached Jaffle Shop DuckDB shared across tests cut the integration-suite wall time without weakening per-test isolation guarantees.