Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e2c721b
Strengthen MetricFlow ingestion into SLayer (DEV-1595)
ZmeiGorynych Jun 24, 2026
8665c2b
Address PR #202 review: filtered special aggs, derived input filters,…
ZmeiGorynych Jun 25, 2026
f3719db
Address PR #202 round 2: multi-hop filter reachability + Sonar comple…
ZmeiGorynych Jun 25, 2026
13b197a
Flatten _reachable_entity_names to clear last Sonar S3776
ZmeiGorynych Jun 25, 2026
2e5a2cd
Address Codex re-review: multi-hop filter + unmaterialized derived/ra…
ZmeiGorynych Jun 25, 2026
efb0778
Codex re-review round 2: robust dangling-reference post-pass + filter…
ZmeiGorynych Jun 25, 2026
9944723
Codex re-review round 3: clean-fail ambiguous multi-owner cross-model…
ZmeiGorynych Jun 25, 2026
851d2a8
Codex re-review round 4: intersect referenced-metric filter in push-down
ZmeiGorynych Jun 25, 2026
01a34e3
Codex re-review round 5: don't resurrect clean-failed metrics via pus…
ZmeiGorynych Jun 25, 2026
a2593cb
Codex re-review round 6: honor measure-level filter when resolving si…
ZmeiGorynych Jun 25, 2026
1fc0029
Reduce _resolve_input_to_leaf_filtered complexity (Sonar S3776)
ZmeiGorynych Jun 25, 2026
e4953f7
Add dbt MetricFlow -> SLayer demo notebook
ZmeiGorynych Jun 29, 2026
a982846
Address PR #202 review on the MetricFlow demo
ZmeiGorynych Jun 30, 2026
3639a6b
Handle missing-git OSError in _checkout_is_valid too
ZmeiGorynych Jun 30, 2026
f0568f7
Merge origin/main into egor/dev-1595
ZmeiGorynych Jun 30, 2026
73fc946
Stash conversion-metric funnel details in meta
ZmeiGorynych Jul 1, 2026
e3dff79
Resolve metric-ref funnel sources; refactor for complexity
ZmeiGorynych Jul 1, 2026
db3585b
Make dbt->SLayer conversion an explicit notebook cell; commit outputs
ZmeiGorynych Jul 1, 2026
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
2 changes: 1 addition & 1 deletion .claude/skills/slayer-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ measures:
formula: "amount:sum / *:count"
```

Aggregation is specified at query time with **colon syntax**: `"amount:sum"`, `"amount:avg"`, `"*:count"`. A bare-name reference like `{"formula": "aov"}` resolves to the saved `ModelMeasure` formula on the model. Built-in aggregations: `sum`, `avg`, `min`, `max`, `count`, `count_distinct`, `first`, `last`, `weighted_avg`, `median`, `percentile`, `stddev_samp`, `stddev_pop`, `var_samp`, `var_pop`, `corr`, `covar_samp`, `covar_pop`. The two-column ones (`corr`, `covar_samp`, `covar_pop`) take the second column as a named param: `price:corr(other=quantity)`.
Aggregation is specified at query time with **colon syntax**: `"amount:sum"`, `"amount:avg"`, `"*:count"`. A bare-name reference like `{"formula": "aov"}` resolves to the saved `ModelMeasure` formula on the model. Built-in aggregations: `sum`, `avg`, `min`, `max`, `count`, `count_distinct`, `count_distinct_approx`, `first`, `last`, `weighted_avg`, `median`, `percentile`, `stddev_samp`, `stddev_pop`, `var_samp`, `var_pop`, `corr`, `covar_samp`, `covar_pop`. `count_distinct_approx` is dialect-aware (native approximate-distinct where available, exact `COUNT(DISTINCT)` fallback otherwise). The two-column ones (`corr`, `covar_samp`, `covar_pop`) take the second column as a named param: `price:corr(other=quantity)`.

## Data Types

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/slayer-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Each entry in `measures` is either a bare formula string or a `{"formula": ...,
]
```

Built-in aggregations: `sum`, `avg`, `min`, `max`, `count`, `count_distinct`, `first`, `last`, `weighted_avg`, `median`, `percentile`, `stddev_samp`, `stddev_pop`, `var_samp`, `var_pop`, `corr`, `covar_samp`, `covar_pop`. Two-column `corr`/`covar_samp`/`covar_pop` take the second column as a named param: `price:corr(other=quantity)`. `sum` and `avg` accept an optional trailing-window: `revenue:sum(window='30d')`.
Built-in aggregations: `sum`, `avg`, `min`, `max`, `count`, `count_distinct`, `count_distinct_approx`, `first`, `last`, `weighted_avg`, `median`, `percentile`, `stddev_samp`, `stddev_pop`, `var_samp`, `var_pop`, `corr`, `covar_samp`, `covar_pop`. `count_distinct_approx` is dialect-aware (native approximate-distinct where available, exact `COUNT(DISTINCT)` fallback otherwise). Two-column `corr`/`covar_samp`/`covar_pop` take the second column as a named param: `price:corr(other=quantity)`. `sum` and `avg` accept an optional trailing-window: `revenue:sum(window='30d')`.

`*:count` is always available — no column definition needed. `col:count` counts non-nulls.

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,6 @@ docs/examples/jaffle_data/jaffle-data/
docs/examples/jaffle_data/jaffle_shop.duckdb
docs/examples/jaffle_data/demo/
docs/examples/jaffle_data/slayer_models/

# Auto-generated by the dbt MetricFlow demo (clone + DuckDB + converted models)
docs/examples/11_dbt_metricflow/.cache/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ poetry run ruff check slayer/ tests/
- **Run-by-name execution**: `engine.execute(str, variables=..., dry_run=..., explain=...)` and `execute_sync(str, ...)` run the stored backing query for a query-backed model. Errors surface as `Model '<name>' not found` or `Model '<name>' is not query-backed; pass a SlayerQuery with source_model='<name>'.`. Variable precedence (highest first): runtime kwarg > stage > outer query > model defaults. The `variables=` kwarg works uniformly for str, dict, SlayerQuery, and list inputs. Runtime kwargs are merged into the available variable set; extra keys not referenced by any `{var}` placeholder simply remain unused. `dry_run`/`explain` are engine kwargs (not query fields) and apply to every input shape. Surfaced via REST `POST /query` with `{"name": "...", "variables": {...}}`, MCP `query` tool with `variables=`, CLI `slayer query <model_name> --variables k=v`.
- **Unified columns** (v2): `SlayerModel.columns: List[Column]` replaces v1's separate `dimensions` and `measures`. A `Column` carries name, sql, type (`DataType`), `primary_key`, `description`, `label`, `hidden`, `format`, `allowed_aggregations` (whitelist), `filter` (CASE-WHEN at aggregation time), `meta`. What a column is "used as" (group-by dim vs aggregation source) is decided per query.
- **Measures are named formulas**: `SlayerModel.measures: List[ModelMeasure]` is a library of saved formulas of shape `{formula, name, label, description}`. Same shape as the inline `SlayerQuery.measures` entries. Queries can reference them by bare name (`{formula: "aov"}`) or expand them inline.
- **Aggregations are query-time**: specified via **colon syntax** in formulas — `"revenue:sum"`, `"*:count"`, `"price:weighted_avg(weight=quantity)"`, `"price:corr(other=quantity)"`. Built-in aggregations: sum, avg, min, max, count, count_distinct, first, last, weighted_avg, median, percentile, stddev_samp, stddev_pop, var_samp, var_pop, corr, covar_samp, covar_pop. Custom aggregations defined at model level in `aggregations` list.
- **Aggregations are query-time**: specified via **colon syntax** in formulas — `"revenue:sum"`, `"*:count"`, `"price:weighted_avg(weight=quantity)"`, `"price:corr(other=quantity)"`. Built-in aggregations: sum, avg, min, max, count, count_distinct, count_distinct_approx, first, last, weighted_avg, median, percentile, stddev_samp, stddev_pop, var_samp, var_pop, corr, covar_samp, covar_pop. Custom aggregations defined at model level in `aggregations` list. **`count_distinct_approx`** (DEV-1595, aliases `approx_count_distinct` / `countdistinctapprox`) is dialect-aware: it emits the DB-native approximate-distinct (`approx_count_distinct` on DuckDB/Spark/Databricks, `uniq` on ClickHouse, `APPROX_COUNT_DISTINCT` on BigQuery/Snowflake/T-SQL/Oracle, `approx_distinct` on Trino/Presto, `APPROXIMATE COUNT(DISTINCT)` on Redshift) and falls back to exact `COUNT(DISTINCT)` where there's no native function (Postgres/SQLite/MySQL) — exact is *more* accurate, consistent with the no-approximate-SQL rule. Per-dialect mapping lives in `SqlDialect.build_approx_count_distinct` (`slayer/sql/dialects/`); eligible on every type and on PK columns, like `count_distinct`.
- **`*:count`** for COUNT(*) — `*` means "all rows", `count` is just a regular aggregation. `col:count` = COUNT(col) for non-nulls.
- Columns can have `allowed_aggregations` whitelist — validated at model creation and query time. Primary-key columns are always restricted to `count`/`count_distinct` regardless of type. Default eligibility per data type lives in `slayer/core/enums.py:DEFAULT_AGGREGATIONS_BY_TYPE`.
- Auto-ingestion emits one `Column` per non-joined column. PK columns get `primary_key=True`. Columns named "count" rename to "count_col" to avoid clashing with `*:count`.
Expand Down
10 changes: 5 additions & 5 deletions docs/concepts/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ A column with no explicit `allowed_aggregations` whitelist gets a default set ba

| Type | Default eligible aggregations |
|------|-------------------------------|
| `number` | sum, avg, min, max, count, count_distinct, median, weighted_avg, percentile, first, last, stddev_samp, stddev_pop, var_samp, var_pop, corr, covar_samp, covar_pop |
| `string` | count, count_distinct, first, last, min, max |
| `boolean` | count, count_distinct, sum, min, max, first, last |
| `date` / `time` | count, count_distinct, first, last, min, max |
| `number` | sum, avg, min, max, count, count_distinct, count_distinct_approx, median, weighted_avg, percentile, first, last, stddev_samp, stddev_pop, var_samp, var_pop, corr, covar_samp, covar_pop |
| `string` | count, count_distinct, count_distinct_approx, first, last, min, max |
| `boolean` | count, count_distinct, count_distinct_approx, sum, min, max, first, last |
| `date` / `time` | count, count_distinct, count_distinct_approx, first, last, min, max |

Primary-key columns are always restricted to `count` / `count_distinct` regardless of type. When `allowed_aggregations` is set, every entry must already be eligible under the type-default map (or be a custom aggregation defined on this model); violations are caught at model construction time, so query-time validation is a single membership check.
`count_distinct_approx` is dialect-aware: it emits the database-native approximate-distinct function where one exists and falls back to an exact `COUNT(DISTINCT)` where it does not (Postgres / SQLite / MySQL). Primary-key columns are always restricted to `count` / `count_distinct` / `count_distinct_approx` regardless of type. When `allowed_aggregations` is set, every entry must already be eligible under the type-default map (or be a custom aggregation defined on this model); violations are caught at model construction time, so query-time validation is a single membership check.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Filtered columns

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/terminology.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Key terms used throughout SLayer documentation and code.

**Measure (named formula)** — A saved formula stored on a model (`SlayerModel.measures: List[ModelMeasure]`). Shape `{formula, name, label, description}` — same as a query's inline `measures` entry. Queries reference saved measures by bare name in any formula context (`{"formula": "aov"}`).

**Aggregation** — How a column is rolled up. Built-in aggregations: `sum`, `avg`, `min`, `max`, `count`, `count_distinct`, `first`, `last`, `weighted_avg`, `median`, `percentile`, `stddev_samp`, `stddev_pop`, `var_samp`, `var_pop`, `corr`, `covar_samp`, `covar_pop`. Custom aggregations can be defined at model level. Applied at query time via colon syntax: `revenue:sum`, `*:count`, `price:weighted_avg(weight=quantity)`, `price:corr(other=quantity)`.
**Aggregation** — How a column is rolled up. Built-in aggregations: `sum`, `avg`, `min`, `max`, `count`, `count_distinct`, `count_distinct_approx`, `first`, `last`, `weighted_avg`, `median`, `percentile`, `stddev_samp`, `stddev_pop`, `var_samp`, `var_pop`, `corr`, `covar_samp`, `covar_pop`. Custom aggregations can be defined at model level. Applied at query time via colon syntax: `revenue:sum`, `*:count`, `price:weighted_avg(weight=quantity)`, `price:corr(other=quantity)`.

**Join** — A LEFT JOIN relationship between two models. Defined by a target model name and join key pairs (from the model's own foreign keys). Each model only stores direct joins — multi-hop paths like `customers.regions.name` are resolved at query time by walking each intermediate model's own joins.

Expand Down
22 changes: 21 additions & 1 deletion docs/database-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ Oracle.
## Aggregation support

Most aggregations (`sum`, `avg`, `min`, `max`, `count`, `count_distinct`,
`first`, `last`, `weighted_avg`) work on every supported database.
`count_distinct_approx`, `first`, `last`, `weighted_avg`) work on every
supported database. `count_distinct_approx` is dialect-aware (see
[below](#count_distinct_approx-by-dialect)) but always available — it falls
back to an exact `COUNT(DISTINCT)` where there's no native function.
`median`, `percentile`, the variance/stddev family (`stddev_samp`,
`stddev_pop`, `var_samp`, `var_pop`), and the paired statistics
(`corr`, `covar_samp`, `covar_pop`) need dialect-specific handling
Expand All @@ -55,6 +58,23 @@ because no standard syntax works everywhere:
| SQL Server (T-SQL) | **no** | **no** | yes | yes (decomposed) | `MEDIAN` doesn't exist and T-SQL's `PERCENTILE_CONT` is window-only (no `WITHIN GROUP` aggregate form) — SLayer raises `NotImplementedError`. Native `STDEV`/`STDEVP`/`VAR`/`VARP` (slayer renames the canonical `STDDEV_*`/`VAR_*` names at emit time). `CORR`/`COVAR_*` use the same variance-decomposition formula as MySQL (`cov(x,y) = (var(x+y) − var(x) − var(y)) / 2`, `corr = cov / (stddev(x) · stddev(y))`). |
| BigQuery | **no** | **no** | yes | yes | BigQuery has no `MEDIAN` aggregate, and its `PERCENTILE_CONT` is analytic-only (no `WITHIN GROUP` syntax) — the base class emit `PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY x)` fails at runtime. If you need percentile on BigQuery, define a custom `Aggregation` using `APPROX_QUANTILES(x, 100)[OFFSET(N)]`. Native `STDDEV_SAMP`/`STDDEV_POP`/`VAR_SAMP`/`VAR_POP`/`CORR`/`COVAR_SAMP`/`COVAR_POP` (sqlglot may emit `VARIANCE` for `var_samp`). |

### `count_distinct_approx` by dialect

`count_distinct_approx` emits each database's native approximate-distinct
function where one exists, and falls back to an **exact** `COUNT(DISTINCT)`
where it does not. The fallback is exact (more accurate, never approximate),
so results are always at least as precise as requested. The per-dialect
mapping lives in `SqlDialect.build_approx_count_distinct`.

| Engine | Emitted SQL |
|---|---|
| DuckDB / Spark / Databricks | `approx_count_distinct(x)` |
| ClickHouse | `uniq(x)` |
| BigQuery / Snowflake / SQL Server (T-SQL) / Oracle | `APPROX_COUNT_DISTINCT(x)` |
| Trino / Presto | `approx_distinct(x)` |
| Redshift | `APPROXIMATE COUNT(DISTINCT x)` |
| Postgres / SQLite / MySQL | `COUNT(DISTINCT x)` (exact fallback) |

### SQLite caveats

SQLite has a much smaller built-in math/stat catalog than the other supported
Expand Down
60 changes: 48 additions & 12 deletions docs/dbt/dbt_import.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,17 +136,53 @@ Nothing to add — the underlying measure is already directly queryable.

All three fold into a `ModelMeasure` on the source semantic model. Inputs are referenced by **bare ModelMeasure name**, so the formula parser resolves them locally:

- **Derived**: `formula: "metric_a + metric_b"`
- **Ratio**: `formula: "numerator / denominator"`
- **Cumulative (unbounded)**: `formula: "cumsum(measure_name)"`

#### Unconverted metrics

Some dbt metrics cannot be expressed as a `ModelMeasure`. They are reported in `ConversionResult.unconverted_metrics` and printed with an `UNCONVERTED` tag. Categories:

- **Cumulative with window or grain_to_date**: SLayer's `cumsum` is unbounded.
- **Conversion metrics**: entity-based sequential event tracking is not supported.
- **Transform-name shadowing**: a dbt measure or metric named after a SLayer transform (`cumsum`, `lag`, `lead`, `change`, `change_pct`, `time_shift`, `rank`, `percent_rank`, `dense_rank`, `ntile`, `first`, `last`) is rejected — using it bare in a formula would shadow the transform.
- **Derived**: `formula: "metric_a + metric_b"`. An `offset_window` on a single-aggregate input (a measure or simple metric) lowers to a `time_shift`: a `1 month` offset becomes `time_shift(metric_a, -1, 'month')`.
- **Ratio**: `formula: "numerator / nullif(denominator, 0)"` — the denominator is NULL-guarded to prevent divide-by-zero.
- **Cumulative (unbounded)**: `formula: "cumsum(measure_name)"`.

#### Supported mappings

Every legal dbt construct that reaches the importer is either represented exactly or [failed cleanly](#clean-fail-and-unsupported). Represented exactly:

| dbt construct | SLayer representation |
| --- | --- |
| Measure `agg: sum/avg/min/max/count/count_distinct/median` | `ModelMeasure` `col:<agg>` |
| Measure `agg: percentile` (continuous) | `col:percentile(p=<value>)` |
| Measure `agg: count_distinct_approx` | `col:count_distinct_approx` (dialect-aware) |
| Measure `agg: sum_boolean` | `Column.sql = "CASE WHEN (<expr>) THEN 1 ELSE 0 END"`, type `INT`, `col:sum` |
| Metric-level / per-input `filter` | pushed down into a leaf `Column.filter` (CASE-inside-aggregate) |
| Filter as string **or** list (`WhereFilterIntersection`) | AND-joined into one filter |
| Ratio metric | `num / nullif(den, 0)` |
| Derived metric | `ModelMeasure` formula over inputs |
| Derived input `offset_window` (single aggregate) | `time_shift(input, -<count>, '<grain>')` (plural grains normalized) |
| Unbounded cumulative | `cumsum(measure)` |
| `config.meta`, semantic-model `label` | carried onto the corresponding entity's `meta` |

#### Clean-fail and unsupported

Constructs that cannot be expressed exactly are **failed cleanly** — never converted to approximate or wrong SQL. Each is routed to the conversion report with a category, severity, and documented workaround, and the raw construct is stashed into the owning entity's `meta` so nothing is silently lost.

| dbt construct | Why | Workaround |
| --- | --- | --- |
| Cumulative `window` (rolling) | Query-grain-dependent re-aggregation | Use `cumsum(measure)` for an unbounded total |
| Cumulative `grain_to_date` | Reset-at-grain can't bake into a saved measure | `cumsum(measure)` + put the grain dimension in the query |
| Cumulative `period_agg` ≠ `first` | Only the default running total is exact | Use the default `period_agg` |
| Derived input `offset_to_grain` | No truncate-to-grain shift transform | Use `cumsum(...)` + grain dimension |
| `offset_window` on a ratio/derived input | Multi-aggregate offset isn't exactly expressible | Restructure as a multi-stage `source_queries` model |
| Non-standard granularity (e.g. `fortnight`) | Not a SLayer granularity | Use day/week/month/quarter/year |
| `non_additive_dimension` (semi-additive) | Not exactly expressible | `balance:last(<time>)` / `first(...)`, or a multi-stage query |
| Discrete / approximate percentile flags | Only continuous-exact `PERCENTILE_CONT` is supported | Drop `use_discrete_percentile` / `use_approximate_percentile` |
| Conversion metrics (funnel) | Sequential-event SQL unsupported | Express the funnel as a multi-stage query |
| `join_to_timespine` / `fill_nulls_with` | No time-spine gap-filling | Remove the gap-fill request |
| Measure-less simple metric (`metric_aggregation_params`) | Unsupported shape | Define an explicit measure |
| Cross-model filter on an unreachable model | No join from the source model | Add the required join, or filter locally |
| Transform-name shadowing | A measure/metric named after a transform (`cumsum`, `lag`, `lead`, `change`, `change_pct`, `time_shift`, `rank`, `percent_rank`, `dense_rank`, `ntile`, `first`, `last`) would shadow it | Rename the dbt measure/metric |

Percentile/median measures on a dialect that lacks them (MySQL / T-SQL) import fine but get an **info** caveat — pass `target_dialect=` to surface it at import time.

#### Conversion report

`ConversionResult.render_report()` groups every clean-fail and caveat by category, each with the entity, severity (`unconverted` / `dropped` / `info`), reason, and workaround. `slayer import-dbt` prints this report plus a final tally (`N models, M unconverted, K dropped`).

## Filter Syntax Conversion

Expand All @@ -168,7 +204,7 @@ The converter resolves these to plain SLayer filter strings:

The converter produces:
1. **Model YAML files** (or rows in SQLite storage) — one per dbt semantic model. Every metric folds into a `ModelMeasure` on its source model.
2. **Console report** — summary of models imported, unconverted metrics, and warnings.
2. **Console report** — the [conversion report](#conversion-report) grouped by category, plus a final tally (`N models, M unconverted, K dropped`).

## Regular dbt Models (Hidden Import)

Expand Down
Loading
Loading