diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json index c9de491d0..a61032226 100644 --- a/.basedpyright/baseline.json +++ b/.basedpyright/baseline.json @@ -3243,40 +3243,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 36, - "endColumn": 48, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 32, - "endColumn": 44, - "lineCount": 1 - } - } - ], - "./tests/dialects/test_byte_equivalence.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 45, - "endColumn": 57, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 32, - "endColumn": 44, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -10769,14 +10735,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -11037,14 +10995,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -11053,14 +11003,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -11069,14 +11011,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -11157,54 +11091,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 40, - "endColumn": 52, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 40, - "endColumn": 52, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 40, - "endColumn": 52, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 40, - "endColumn": 52, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -11213,14 +11099,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -11781,14 +11659,6 @@ } ], "./tests/test_dev1733_order_only_transform_composite.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 34, - "endColumn": 46, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -12045,14 +11915,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 26, - "endColumn": 38, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -14137,14 +13999,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 49, - "endColumn": 61, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -16405,14 +16259,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 26, - "endColumn": 45, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -17595,14 +17441,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 34, - "endColumn": 46, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -18657,14 +18495,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 38, - "endColumn": 50, - "lineCount": 1 - } - }, { "code": "reportOptionalMemberAccess", "range": { @@ -18905,14 +18735,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 38, - "endColumn": 50, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -23083,30 +22905,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 49, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 59, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 37, - "endColumn": 59, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -23555,14 +23353,6 @@ "lineCount": 1 } }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 15, - "endColumn": 39, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -29563,14 +29353,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -30515,22 +30297,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 44, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 44, - "endColumn": 56, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -30539,22 +30305,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 44, - "endColumn": 56, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 44, - "endColumn": 56, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -32363,14 +32113,6 @@ "lineCount": 1 } }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 53, - "endColumn": 65, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { @@ -33834,24 +33576,6 @@ } } ], - "./tests/test_time_trunc_key.py": [ - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 24, - "endColumn": 28, - "lineCount": 1 - } - }, - { - "code": "reportAttributeAccessIssue", - "range": { - "startColumn": 24, - "endColumn": 35, - "lineCount": 1 - } - } - ], "./tests/test_transforms_planner.py": [ { "code": "reportArgumentType", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d699d874..937b4d21e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,7 +84,7 @@ Make changes with `/la:pr` in [Claude Code](https://docs.claude.com/en/docs/clau **Install the `la` plugin** at the version pinned in `pyproject.toml`, from inside Claude Code: ```text -/plugin marketplace add MotleyAI/living-architecture#v0.3.1 +/plugin marketplace add MotleyAI/living-architecture#v0.3.3 /plugin install la@living-architecture ``` diff --git a/architecture/semantics.arc42.md b/architecture/semantics.arc42.md index 7aa2ecadb..6d0e802b4 100644 --- a/architecture/semantics.arc42.md +++ b/architecture/semantics.arc42.md @@ -224,11 +224,16 @@ is the coercion from coarser to finer. 13. **Positions**: every query expression is a field or a measure; dimensions and measures return the value, filters mask on it, order sorts by it — the expression evaluates identically in every position, compiled by - construction as a hidden field/measure where not projected. + construction as a hidden field/measure where not projected. A column + reference is the one-leaf row-level expression: every position that takes + a column takes any row-level expression of its type, homed by Axiom 2.2. [enforced: test:tests/test_dev1865_value_parity.py] [enforced: test:tests/test_dev1865_stratification.py] [enforced: test:tests/test_dev1865_order.py] [enforced: test:tests/test_dev1976_dimension_values.py] + [enforced: test:tests/test_dev2119_expression_time_dimensions.py] + [enforced: test:tests/test_dev2119_partition_keys.py] + [enforced: test:tests/test_dev2119_ranked_expressions.py] 14. **Filters**: a field-typed filter masks population rows before any aggregation; a measure-typed filter masks result cells after all values are computed, never changing a surviving cell's values; valid-as-both @@ -243,13 +248,15 @@ is the coercion from coarser to finer. [enforced: test:tests/test_dev1935_boolean_lowering.py] 15. **Time spine**: the spine is the dataset of all instants, keyed by its instant. A dataset's axis — its declared default time column, else its - sole one — joins it to-one; a dataset reaches the spine by its nearest + sole one — joins it to-one; an expression is never an axis, so a spine + dimension over one is a type error; a dataset reaches the spine by its nearest axis on a to-one chain, equal distances failing loudly, and a path may begin or end at the spine but never cross it. The spine has no countable rows. A query with spine dimensions has population `spine × P`, P inferred per Axiom 12 from its other dimensions and field filters, and must bound the spine from below. [enforced: test:tests/test_dev2015_time_spine.py] + [enforced: test:tests/test_dev2119_time_spine.py] ## 3. Laws diff --git a/docs/concepts/formulas.md b/docs/concepts/formulas.md index 523bee8c7..ac6e235ae 100644 --- a/docs/concepts/formulas.md +++ b/docs/concepts/formulas.md @@ -69,6 +69,8 @@ An attached value — an aggregate or a grained transform *parameter* of a row-level aggregation (`weighted_avg(amount, weight=sum(amount, partition_by=region))`): it is attached into the input relation, so each row is weighted by its cell's value. +A column-valued parameter also takes a row-level expression, positionally or by name +(`weighted_avg(price, weight=quantity * 2)`, `corr(amount, quantity + 1)`). **Gates.** Per-column `allowed_aggregations` / primary-key / type-default gates apply to *columns*, not expressions — `sum(price * quantity)` succeeds @@ -177,7 +179,9 @@ share-of-parent shape (`SUM(revenue) OVER (PARTITION BY region)`): sums to 1.0 within each region. `partition_by` takes one query dimension, a list (`partition_by=[region, channel]`), a dotted path (`partition_by=customers.region`), or `[]` for the grand total (share of total). A time dimension partitions by its -truncated bucket. The coarser aggregate is computed over the rows passing the +truncated bucket. A key may also be a row-level expression (`partition_by=upper(city)`) or a +time dimension's name: an expression equal to a query computed dimension or time dimension +is that dimension. The coarser aggregate is computed over the rows passing the query's row-level filters — `having`-phase filters and pagination change which rows you see, never the parent total. NULL-valued partition dimensions keep their group. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index cf12f0cc8..1704ceaf5 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -290,7 +290,7 @@ columns: - {name: balance, sql: balance, type: number} ``` -`last(balance, updated_at)` gives the most recent balance per group; `first(balance, updated_at)` the earliest. When grouped by month, each month returns the latest (or earliest) record's balance in that month. If no time column is specified, ordering resolves via: query's `main_time_dimension` → first time/date dimension in the query → first time dimension in filters → model's `default_time_dimension`. Whichever column wins is an input of the aggregation: if it, or a derived definition it names, crosses a join hop that is not provably to-one from the aggregation's root, the query fails with the input-safety error exactly as an explicit ranking argument would. +`last(balance, updated_at)` gives the most recent balance per group; `first(balance, updated_at)` the earliest. When grouped by month, each month returns the latest (or earliest) record's balance in that month. If no time column is specified, ordering resolves via: query's `main_time_dimension` → first time/date dimension in the query → first time dimension in filters → model's `default_time_dimension`. Whichever column wins is an input of the aggregation: if it, or a derived definition it names, crosses a join hop that is not provably to-one from the aggregation's root, the query fails with the input-safety error exactly as an explicit ranking argument would. The value and the ranking key may each be a row-level expression (`first(amount - customers.discount)`, `last(balance, coalesce(updated_at, created_at))`), and a computed dimension typed DATE / TIMESTAMP counts as a time/date dimension in that order. Not to be confused with the [`last()` formula function](formulas.md#first-and-last-functions) — a window-function transform that broadcasts a value across all rows. Same name, different layer. @@ -453,6 +453,8 @@ Or use the saved result as a model in another query: A consumed query-backed model is spliced into the consuming query as stages whose names stay private to it, and one that references itself, directly or through other query-backed models, is rejected. +Joins declared on a saved query-backed model, keyed on its output columns, stay usable wherever the model is consumed; the backing source model's joins are not inherited. + ### Variables in model SQL `{variable}` substitution is not limited to query-level filters — the same mechanism injects caller-supplied values into a model's **raw-SQL (Mode A) surfaces**. This is the primitive for parameterizing hand-written model SQL (for example, representing a Cube `FILTER_PARAMS` cube as an `sql`-mode model whose filter bounds arrive as variables). diff --git a/docs/concepts/queries.md b/docs/concepts/queries.md index f24c575f7..e6c84958a 100644 --- a/docs/concepts/queries.md +++ b/docs/concepts/queries.md @@ -174,6 +174,8 @@ Emits roughly `SELECT orders.status, orders.amount FROM orders WHERE orders.amou A time dimension with a required granularity and an optional date range. Supports an optional `label` for human-readable output. To use a time column without truncation, add it as a regular dimension instead. The column may also be given under the key `column` as an alias of `dimension`; the object always serializes with `dimension`. +`dimension` may be any row-level expression typed DATE or TIMESTAMP (`"coalesce(shipped_at, created_at)"`, `"CAST(created_txt AS DATE)"`, also as `month(coalesce(shipped_at, created_at))`), and an optional `name` sets its result key and lets `main_time_dimension` and `partition_by=` refer to it (an unnamed expression is named like a computed dimension). + ```json { "dimension": "created_at", @@ -451,7 +453,7 @@ Use one of: Filters support `{variable_name}` placeholders, substituted from the query's `variables` dict. This keeps filter templates reusable and avoids string concatenation in client code. -The same placeholders work in measure formulas, computed-dimension expressions, `order` expressions and `date_range` bounds (`"measures": ["sum(amount) * {k} / 100"]`), and an unnamed entry is named from its template (`amount_sum_k_100`), so result keys never depend on the values; `{? ... ?}` blocks and placeholders in a time dimension's granularity or column are rejected. +The same placeholders work in measure formulas, computed- and time-dimension expressions, `order` expressions and `date_range` bounds (`"measures": ["sum(amount) * {k} / 100"]`), and an unnamed entry is named from its template (`amount_sum_k_100`), so result keys never depend on the values; `{? ... ?}` blocks and a placeholder naming a time dimension's granularity or standing for its whole dimension is rejected. ```json { diff --git a/docs/concepts/time.md b/docs/concepts/time.md index a57b52116..12434d7ad 100644 --- a/docs/concepts/time.md +++ b/docs/concepts/time.md @@ -101,6 +101,8 @@ The population is `time_spine × P` (P the population of the remaining dimension A stage of a multi-stage query, or a saved query, can be a spine query too; later stages then see one row per bucket. +An expression is never an axis: a time dimension whose expression reads `time_spine.timestamp` (`time_spine.timestamp + INTERVAL '1 day'`) is a typed error that names the remedy of bucketing the column itself, while any other expression time dimension is an ordinary dimension of P. + ## Custom granularities A datasource's `granularities` list defines named buckets `{name, base, multiple, origin}` — e.g. `{"name": "fiscal_year", "base": "month", "multiple": 12, "origin": "2024-04-01"}` or `{"name": "quarter_hour", "base": "minute", "multiple": 15}` — usable wherever a built-in granularity is (time dimensions, `fiscal_year(order_date)`, `Column.granularity`, transform units), each bucket starting at `origin + k × multiple × base`. diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/customers.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/customers.yaml index 79f23b019..cb2a1e9a9 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/customers.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/customers.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: customers sql_table: customers source_kind: table diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/items.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/items.yaml index caa382ebf..0cbe7bd23 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/items.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/items.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: items sql_table: items source_kind: table diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/orders.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/orders.yaml index d767bde11..97db4f291 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/orders.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/orders.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: orders sql_table: orders source_kind: table diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/products.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/products.yaml index c542b1bef..4374eeff0 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/products.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/products.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: products sql_table: products source_kind: table diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/stores.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/stores.yaml index a8faac726..ace2874ed 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/stores.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/stores.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: stores sql_table: stores source_kind: table diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/supplies.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/supplies.yaml index bdaf14802..62a495a6b 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/supplies.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/supplies.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: supplies sql_table: supplies source_kind: table diff --git a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/tweets.yaml b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/tweets.yaml index e9ac223d7..11a473f89 100644 --- a/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/tweets.yaml +++ b/docs/examples/09_lightning_talk/slayer_models/models/jaffle_shop/tweets.yaml @@ -1,4 +1,4 @@ -version: 16 +version: 17 name: tweets sql_table: tweets source_kind: table diff --git a/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/.openspec.yaml b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/.openspec.yaml new file mode 100644 index 000000000..dc36fb1bf --- /dev/null +++ b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/design.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/design.md new file mode 100644 index 000000000..45cf62bbc --- /dev/null +++ b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/design.md @@ -0,0 +1,19 @@ +## Context + +The public save path validates and persists joins after populating a query-backed model's output columns. Execution strips those joins from its placeholder, then creates a flat synthetic model from the final StageSchema. Product-hop support can turn the missing edge into a successful uncorrelated filter rather than a refusal. + +## Decisions + +1. Repair the saved-relation boundary. Preserve only the query-backed model's own declared joins; never inherit its backing source's join graph. +2. Reuse `model_from_stage_schema` to carry optional declared joins for saved final relation names in source, extension and sibling/target paths. Keep StageSchema unchanged. Validate joins against the saved relation's real identity so a legal target named `_stage` is not mistaken for a self-join. +3. Select ModelScope for a materialized saved relation by its identity in the query-backed model map, including relations with incoming reverse edges and no outgoing joins. Ordinary/private query stages retain flat StageSchema binding. +4. Keep demand-based splicing. Merely declaring or reaching an unread query-backed model must not plan it or manufacture a cycle. +5. Reuse the existing logical join-key and extension semantics. Test distinct/named extension joins. An extension join equal to one of its base model's stored joins in all but metadata and pair order is that stored edge, applied once, for every model kind; the runtime overlay and the schema-drift stage graph share this one rule. A join differing in target, name, join type, cardinality or pairs stays a separate edge. + +## Validation + +Record failing segmented-total tests before the implementation. Execute the repaired paths on SQLite and DuckDB using renamed physical/logical keys. Check root and target consumption, aggregate attribution, extension parity, private-stage scopes and cycles. Run the required unit, lint, type and architecture gates and the documented CI integration selection, then inspect current-head CI and review results. + +## Risks + +An incomplete root-only fix leaves target/sibling reads broken. Passing saved relationships to ordinary stages breaks stage isolation. Eager traversal of retained edges can introduce false cycles. Preserve those distinctions and use existing fixtures and regression suites. diff --git a/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/proposal.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/proposal.md new file mode 100644 index 000000000..1fb6cec52 --- /dev/null +++ b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/proposal.md @@ -0,0 +1,22 @@ +## Why + +A saved query-backed model's declared joins disappear when its final stage becomes a consuming relation. A customer-segment filter consequently returns all-customer revenue (150 instead of 120), because the missing relationship is treated as a product hop. The older outbound traversal gap is tracked as DEV-1918; this change fixes its wrong-result consequence reported in GitHub issue #507. + +## What Changes + +- Retain the saved relation's own joins through query-backed placeholder and relation materialization. +- Bind saved relations as model scopes, keeping ordinary query-stage outputs flat. +- Add executable SQLite/DuckDB tests for filtered totals, traversal and aggregate attribution. +- Preserve existing demand splicing, private stage scopes, cycle errors and extension behavior. +- Treat an extension join repeating a stored join of its base as that edge, for every model kind. + +## Capabilities + +### Modified Capabilities + +- `queries/query-backed-inline`: consuming a saved relation retains its own declared relationships. +- `models/join-traversal`: an extension join repeating a stored edge is that edge. + +## Impact + +A focused engine/IR fix with regression tests and a clarification in the models guide. No new dependency, persisted schema, public request field or normative architecture change. diff --git a/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/models/join-traversal/spec.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/models/join-traversal/spec.md new file mode 100644 index 000000000..4bf830856 --- /dev/null +++ b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/models/join-traversal/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: An extension join repeating a stored edge is that edge + +A query-level model extension's join that equals one of its base model's stored joins in every field except `description`, `meta` and pair order SHALL be that stored edge, applied once, for every model kind; an extension join differing in target, name, join type, cardinality or join pairs SHALL remain a separate edge. + +#### Scenario: Repeating a stored join in an extension +- **WHEN** an extension over a table-backed model repeats a stored join and a query reads through it +- **THEN** the query resolves over that single edge instead of failing as an ambiguous hop + +#### Scenario: A differing extension join stays parallel +- **WHEN** an extension adds an unnamed join to the same target with different join pairs +- **THEN** a bare hop to that target fails closed as ambiguous diff --git a/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md new file mode 100644 index 000000000..2096afe94 --- /dev/null +++ b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/specs/queries/query-backed-inline/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Saved relation relationships survive splicing + +A stored query-backed model consumed as a relation SHALL retain that model's own declared joins, with keys resolved against its final-stage output columns. Those relationships SHALL remain available when the relation is a source, a join target or an extended source. Ordinary query-stage outputs SHALL remain flat and SHALL NOT inherit joins from their backing source. + +#### Scenario: Joined segment filter on a saved relation +- **WHEN** a saved customer-total relation declares a customer-key join and a query filters to gold customers +- **THEN** only the related gold customers contribute, with the same values as an equivalent relationship declared solely on a query extension, on SQLite and DuckDB + +#### Scenario: Joined dimensions and aggregate attribution +- **WHEN** a query reads a dimension or aggregate through a saved relation's declared join +- **THEN** the relationship determines the rows and attribution instead of being treated as an unrelated dataset + +#### Scenario: A saved relation consumed through another model +- **WHEN** another model joins a saved query-backed relation and a query traverses onward through that relation's own join +- **THEN** the declared relationships remain available with logical key spelling and correct values + +#### Scenario: Extending a saved relation +- **WHEN** a query extension adds a distinct named join to a saved relation +- **THEN** both its stored joins and the extension's joins remain available + +#### Scenario: Repeating an existing saved relationship +- **WHEN** an extension over a saved query-backed relation repeats an exactly identical declared join +- **THEN** that edge is applied once, without introducing an ambiguous parallel path + +#### Scenario: Legal join targets do not collide with temporary identities +- **WHEN** a saved relation declares a join to a legal model named `_stage` +- **THEN** both unjoined and joined consumers execute without a false self-join error + +#### Scenario: Ordinary stage isolation +- **WHEN** an ordinary query stage is consumed by a later stage +- **THEN** only its projected output schema is visible and its backing source's joins are not inherited diff --git a/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/tasks.md b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/tasks.md new file mode 100644 index 000000000..38b37d05c --- /dev/null +++ b/openspec/changes/archive/2026-10-22-fix-query-backed-declared-joins/tasks.md @@ -0,0 +1,20 @@ +## 1. Plan and reproduce + +- [x] 1.1 Refresh current upstream and public issue/PR history; report the wrong-result behavior in #507, distinguishing the known DEV-1918 traversal gap. +- [x] 1.2 Review the relation-boundary design independently, preserving flat ordinary stages and demand splicing without a normative architecture edit. +- [x] 1.3 Record failing SQLite/DuckDB segmented-total regressions before production changes. + +## 2. Implement and document + +- [x] 2.1 Preserve declared joins through saved-relation placeholder, root/extension and sibling/target materialization. +- [x] 2.2 Verify logical-key traversal, aggregate attribution, extension compatibility, ordinary-stage isolation and cycle behavior. +- [x] 2.3 Clarify saved-relation joins in the user models guide. +- [x] 2.4 Treat an extension join repeating a stored join as that edge for every model kind, in the runtime overlay and the schema-drift stage graph. + +## 3. Validate and review + +- [x] 3.1 Pass the focused regressions and related query-backed scope/cycle tests. +- [x] 3.2 Run the full documented unit selection, retest environment/timing failures, and pass lint, type and architecture gates. +- [x] 3.3 Independently review the implementation and verify the final tree. + +Validation note: the local Python 3.14 unit run reported 31,377 passed, 99 skipped, 7 xfailed and three failures. All three passed isolated retries; the Poetry PATH was corrected for the affected subprocess test, and the timing-sensitive failure also reproduced on unchanged upstream. The documented integration selection passed with 656 passed, 36 skipped and 1 xfailed. CI validates the full suites on Python 3.11 and 3.14. diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/.openspec.yaml b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/.openspec.yaml new file mode 100644 index 000000000..dc36fb1bf --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/design.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/design.md new file mode 100644 index 000000000..93cabe52c --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/design.md @@ -0,0 +1,75 @@ +## Context + +See proposal.md for motivation. Every column-only position today is a separate `isinstance(…, ColumnKey | ColumnSqlKey)` gate: `TimeTruncKey.column` is typed as a column, and `binding.py` refuses expressions in `_bind_granularity_call`, `_bind_partition_keys`, `_fold_positional_agg_args` (ranking key), `_bind_agg` (first/last expression source) and `_bind_agg_arg` (parameters); `ranked_planner.explicit_ranking_time_arg` and the ranked kernel's renderers (`generator.py` `_ranked_value_expr`, `_ranked_scope_expr`) assume a column. `TimeDimension.dimension` is a `ColumnRef`, read by its `full_name` / `name` / `model` in about fifteen places. The dependency closure (`reference_closure.key_closure`), structural key equality and checker typing are already generic over key trees. This change stacks on DEV-2101 (`parse_expr` in `slayer/core/syntax.py`, `CastKey`, migration steps) and is implemented after it merges. + +## Goals / Non-Goals + +**Goals:** remove every column-only gate so each position reasons through the existing generic machinery; one representation of a time dimension's dimension. + +**Non-Goals:** deleting `ModelExtension.columns` or query-time Mode A (DEV-2021); new scalar functions (DEV-2120); a dataset axis over an expression; aggregate-bearing inline partition keys that are not a query dimension. + +## Decisions + +### D1. Positions + +All six column positions take a row-level expression: the time dimension (with the functional `gran(expr)` form in `dimensions`, `time_dimensions` and `order`), a granularity call's argument anywhere, `partition_by=` keys (aggregations and the rank family), the `first` / `last` ranking key, the `first` / `last` value (mixed sources included: picked at the ranked row, never the transform dispatch, which stays for a wholly attached operand), and column-valued aggregation parameters. Each former gate asks only whether the bound value is row-level (phase ROW, no aggregate / transform / attached value); anything else is a typed error naming the position. Alternative rejected (user ruling): only the three positions the issue lists — DEV-2021 would then lose `weight=` / `first` / `last` extension-column capability. + +### D2. One bucket key + +`TimeTruncKey.column` becomes `source: ValueKey` (any row-level key), renamed with `la:deterministic-refactor`; `column_leaf` / `column_path` callers move to the key's references. No new key kind (system P13). `explicit_ranking_time_arg` is kept, renamed `explicit_ranking_key`, returning any explicit row-level ranking argument, because `stages.py` `_assert_local_producer_inputs_safe` and `_checked_ranking_time_key` enforce Axiom 2.4 on it by closure for local and target-rooted producers. `_ranked_value_expr` and `_ranked_scope_expr` render through the row-expression renderer, registering every joined path of the expression's closure; a mixed source gets its row-attach substitution first. Truncation (`_raw_time_col_expr_for_planned`, `value_expr.py`) renders any row-level source. + +### D3. `TimeDimension.dimension` is Mode-B text + +Inputs coerce to text: a string, the `column` alias, the legacy `{"name", "model"}` object (→ `model.name`), and `gran(expr)` in `dimensions` / `time_dimensions` (the coercion parses with `parse_expr` and keeps the call's argument text). Dumps and the JSON schema use a string. Consumers of `full_name` / `name` / `model` (`unrelated.py` first hops, `model_reads.py` dimension texts, `schema_drift.py`, `population.py`, `time_spine.names_spine` / `is_spine_query`, `_dedupe_time_dimensions`, `_strip_time_dimensions`, `_merge_time_dimensions`, `ir/variables.py`, `facade/translator.py`, `rank_direction_migration.py`, `legacy_time_literals.py`) read the parsed expression's references (the walk computed dimensions use); the construction-time identity is the dimension text, the bind-time backstop the bound bucket. Alternative rejected (user ruling): a `ColumnRef | str` union — two shapes for one meaning, every consumer branching. + +### D4. Names + +`TimeDimension.name: str | None`. Unnamed column: today's result key. Unnamed expression: `auto_name_from_expression(text)`, result key model-prefixed, granularity suffix iff another projected time dimension shares the bound source. Named: the result key, never suffixed. Names are unique across dimensions and time dimensions (construction error). `main_time_dimension` matches a name, then (column time dimensions) full / leaf name; `partition_by=` resolves a time dimension's name through the same alias map computed dimensions use. + +### D5. Typing + +The checker types the expression (`temporal_type`, `CastKey.data_type`); not DATE / TIMESTAMP → `TimeDimensionColumnError` with the observed type; undetermined → the same error with the `CAST` remedy. Binding records each time dimension's checked type in a source → type map that `whole_periods_only` snapping (`_snap_whole_periods`) and `date_range` bound shaping read, instead of re-inferring through the column-type callback. + +### D6. Per-row consumers + +`date_range` builds its `TimePointCmpKey` on the bound source (no column assert); frame bounds (`time_bounds.is_frame_bound`, `stages.py` `_frame_bound_columns`) match by key identity on the source; `window=` kernels, time-ordered transforms (`generator.py` shift / `consecutive_periods` / `_in_transform_grain`) and the `first` / `last` collapse use the bucket key unchanged. At a stage boundary an expression time dimension emits a `StageColumn` carrying its checked temporal type and bucket granularity and no single-column provenance (label, format, respelling, default-axis); downstream it is a one-leaf column (`stages.py` `_source_column`, `_value_anchor_path`, `_leaf_variants`, `_surviving_default` return their no-column answer for it). + +### D7. Re-bucketing per bucketed leaf + +`check_time_dimension_column`'s re-bucketing branch runs once per bucketed temporal leaf of the source (a column carrying `Column.granularity`, a stage column with an upstream bucket, a sibling stand-in), naming that leaf. Alternative rejected (user ruling): an expression carries no granularity — silent fake precision, and wrapping a column in `CAST` would bypass the rule. + +### D8. Default ranking key + +`resolve_ranking_time_key` step 1 admits the first `dimensions` entry that is row-level and checker-typed DATE / TIMESTAMP (column or computed dimension), never a granularity bucket; step 2 reads the first time dimension's `source`. User ruling over keeping step 1 column-only: that would move the result change onto DEV-2021's migration path. Release note. + +### D9. Partition keys + +`_bind_partition_keys` accepts any row-level key; a key structurally equal to a query computed dimension or time-dimension bucket is that dimension (`pk in classes.dim_keys`, `dim_alias_map`), attach-carrying ones included; other aggregate-bearing keys are a typed error with the computed-dimension remedy. A raw key equal to a time dimension's source maps to its bucket (`_td_by_source`), keeping `_td_ambiguous_sources` for sources at several granularities. Attributability and input safety by closure (Axiom 8). + +### D10. Spine + +`is_spine_query` / `names_spine` read parsed references. A time dimension whose source reads `time_spine.timestamp` other than as the bare column is a typed error (`check_spine_plain_use` family); any other expression time dimension is an ordinary dimension of P. Alternative rejected (user ruling): rejecting every expression time dimension in a spine query — the same query with a fact column is legal. + +### D11. Remedies + +`binding.py`'s partition-key remedy and `slayer/memories/help_content/02_workflow.md`'s "inline extension column" advice point at the expression form. `SlayerModel`'s query-backed-measures remedy (a `ModelExtension` measure) stays: DEV-2021 removes only extension columns. + +### Approved harness edits (exact text, approved 2026-10-21) + +H1 `architecture/semantics.arc42.md`, end of Axiom 13 (before its tags): "A column reference is the one-leaf row-level expression: every position that takes a column takes any row-level expression of its type, homed by Axiom 2.2." + +H2 `architecture/semantics.arc42.md`, Axiom 15, after "joins it to-one;": "an expression is never an axis, so a spine dimension over one is a type error;" + +Each lands with an `enforced: test:…` tag naming this change's test file for it. + +## Risks / Trade-offs + +- [The `TimeTruncKey` widening touches every bucket consumer] → deterministic rename first, then the full suite; golden baselines must stay byte-identical for every pre-existing shape. +- [D8 changes `first` / `last` results for queries with a temporal computed dimension and no explicit ranking key] → release note; tests pin the new precedence. +- [D3 breaks Python attribute access and the object dump] → release note; the object form stays accepted on input; stored data migrates. +- [Expression `date_range` predicates are not sargable] → accepted: the author chose the expression. +- [DEV-2101 lands first] → `pr-implement` starts only once DEV-2101 is merged into this branch; tests needing `CAST` fail until then. + +## Migration Plan + +A stored-only step (SlayerQuery, SlayerModel `source_queries`, Memory; version numbers after DEV-2101's) rewrites object-form time dimensions to dotted text, recursing like `rank_direction_migration.py`. Rollback: an older SLayer cannot read the text form, as for any forward-written store. diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/proposal.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/proposal.md new file mode 100644 index 000000000..0ca31e316 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/proposal.md @@ -0,0 +1,37 @@ +## Why + +Several query positions take only a column reference — a time dimension, a granularity call's argument, `partition_by=` keys, the `first` / `last` ranking key and value, column-valued aggregation parameters — so an agent must declare a query-time Mode-A extension column to bucket, partition, rank or weight by a computed value. DEV-2021 deletes `ModelExtension.columns`; this change makes Mode-B row-level expressions legal in every column position first, so that deletion loses no capability. + +## What Changes + +- Rule: wherever a query takes a column reference it takes a row-level Mode-B expression of the same type, homed by semantics Axiom 2.2; a column is the one-leaf case. No new key kind: the time bucket stays one key whose source is any row-level value. +- `TimeDimension.dimension` accepts an expression (`coalesce(shipped_at, created_at)`, `CAST(created_txt AS DATE)`), also through the functional `gran(expr)` form in `dimensions`, `time_dimensions` and `order`. The expression must type DATE / TIMESTAMP (unknown fails closed); `date_range`, `whole_periods_only`, `window=`, the time-ordered transforms and the `first` / `last` collapse read its per-row value. Re-bucketing is judged per bucketed leaf. +- `TimeDimension` gains an optional `name` (default: the sanitised expression text; a column keeps today's result key); `main_time_dimension` and `partition_by=` may name a time dimension by it. +- A granularity call takes any row-level argument in every position (`month(coalesce(a, b))` in a filter, dimension or order key). +- `partition_by=` keys (aggregations and the rank family) accept row-level expressions; a key structurally equal to a query computed dimension or time-dimension bucket is that dimension. +- `first` / `last` accept an expression ranking key and an expression value (mixed sources included, picked at the ranked row); the default ranking key's "first DATE / TIMESTAMP dimension" step admits temporal expression dimensions (a result change for queries with a temporal computed dimension and no explicit ranking key). +- Column-valued aggregation parameters (`weight=`, `other=`, custom-aggregation column parameters) accept row-level expressions. +- A time dimension whose expression reads `time_spine.timestamp` other than as the bare column is a type error: an expression is never an axis. +- Error remedies pointing at an inline `ModelExtension` column point at the expression form. +- **BREAKING** `TimeDimension.dimension` is Mode-B text: it serialises as a string (formerly a `{"name", "model"}` object) and the schema advertises a string; the object form is still accepted on input and stored documents are migrated. Python callers reading `td.dimension.name` / `.model` break. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `queries/time-dimensions`: the dimension is an expression (text form, functional `gran(expr)`, granularity error surface, result keys and `name`, `main_time_dimension` by name, functional order key); expression time dimensions behave as a column in every time position; per-leaf re-bucketing. +- `queries/time-points`: a granularity call takes any row-level argument. +- `queries/time-spine`: a spine dimension over an expression is a type error. +- `queries/partitioned-aggregates`: expression partition keys; a mixed source under `first` / `last` picks at the ranked row. +- `aggregations/functional-form`: `first` / `last` over an expression value and ranking key; the default ranking key precedence. +- `aggregations/expression-aggregation`: `first` / `last` over expression sources and expression-valued parameters are accepted. +- `models/stored-upgrade`: stored time dimensions migrate from the object form to text. + +## Impact + +- Code: `slayer/core/keys.py` (`TimeTruncKey.column` → `source`, any row-level key), `slayer/core/query.py` (`TimeDimension.dimension` text, `name`, functional-form coercion, dedupe / strip / merge), `slayer/core/time_spine.py`, `slayer/engine/binding.py`, `bind_inputs.py`, `elaborate_env.py`, `ranked_planner.py`, `population.py`, `unrelated.py`, `model_reads.py`, `schema_drift.py`, `compile/stages.py`, `compile/spine.py`, `slayer/sql/generator.py`, `slayer/sql/render/value_expr.py`, `slayer/sql/naming.py`, `slayer/core/time_bounds.py`, `slayer/ir/variables.py`, `slayer/facade/translator.py`, `slayer/storage/migrations.py` + a new migration module, `slayer/storage/rank_direction_migration.py`, `slayer/storage/legacy_time_literals.py`. +- Depends on DEV-2101 (grammar, `CastKey`, migration steps it adds); implemented after it merges. +- Architecture: `semantics.arc42.md` Axioms 13 and 15 (approved text in design.md). +- Docs and agent surfaces: `docs/concepts/queries.md`, `formulas.md`, `time.md`, MCP `Field` descriptions, `slayer/memories/help_content/*`. diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/aggregations/expression-aggregation/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/aggregations/expression-aggregation/spec.md new file mode 100644 index 000000000..ba35922ca --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/aggregations/expression-aggregation/spec.md @@ -0,0 +1,176 @@ +## ADDED Requirements + +### Requirement: Column-valued parameters accept row-level expressions +Every aggregation parameter that takes a column — `weight=`, the second input +`other=` of `corr` / `covar_samp` / `covar_pop`, a custom aggregation's declared +parameter — SHALL accept a row-level Mode-B expression, positionally or by name, a +column being the one-leaf case. The expression SHALL be typed and homed by the rules +a column parameter follows (`queries/semantics` › Aggregation parameters are typed +by the home dataset's grain, semantics Axiom 2.4): a parameter whose leaves the +aggregation's dataset does not determine SHALL fail with the existing typed +parameter error naming the parameter, the grain and the remedy. An expression +parameter carrying an aggregate or transform SHALL keep the attached-parameter rules. + +#### Scenario: Expression weight +- **WHEN** a measure is written `weighted_avg(price, weight=quantity * 2)` and, + separately, `weighted_avg(price, quantity * 2)` +- **THEN** both equal `weighted_avg(price, weight=quantity)` by executed values on + SQLite and DuckDB, and the two spellings share one result key + +#### Scenario: Expression second input of corr +- **WHEN** a measure is written `corr(amount, quantity + 1)` +- **THEN** it equals `corr(amount, quantity)` by executed values + +#### Scenario: Cross-model expression parameter over a to-one hop +- **WHEN** a query rooted at `orders` selects + `weighted_avg(amount, weight=coalesce(customers.discount, 0))` +- **THEN** each order is weighted by its own customer's discount, by executed values + +#### Scenario: Undetermined expression parameter fails closed +- **WHEN** a query over `[region]` selects + `weighted_avg(sum(amount, partition_by=[city, region]), weight=id * 2)` +- **THEN** it fails at plan time with the typed parameter error naming `weight` + +## MODIFIED Requirements + +### Requirement: Expression source typing +The system SHALL type an aggregation's expression source by its leaves and +constituents alone, never by its spelling. A `first`/`last` aggregation over an +expression source SHALL pick the expression's value at the ranked row of each cell, +the source homed like any expression source. A numeric-only aggregation over a confidently non-numeric expression SHALL be +rejected at binding. An expression whose leaves no candidate home determines over +provably to-one hops SHALL fail with the existing input-safety error naming the leaf +and the fanning or unproven hop, and a leaf whose derived definition cannot be +analysed SHALL fail with the existing analyzability error naming the column — never +a multiplied or silently wrong value. A column carrying a column-level filter SHALL be +an ordinary derived operand (per `models/column-filters`). A transform nested in the +source SHALL be an attached constituent (per `queries/partitioned-aggregates` › +Re-aggregation consumes attached operands as datasets); a row-level leaf inside it +that is not a projected grain key SHALL be rejected per `queries/transforms` › +Non-shift transforms reject grain-refining row-level leaves. An aggregation source +consisting entirely of attached values is a re-aggregation and SHALL be accepted; a +source mixing row-level references with attached values is a row-grain aggregation +and SHALL be accepted (per `queries/semantics` › Row-grain aggregation sources), +a collapsing (`first`/`last`) transform constituent included (per +`queries/partitioned-aggregates` › Mixed sources carry the full expression-source +surface); an +attached (aggregate-valued) parameter on a row-level source SHALL be accepted when the +aggregation's operating grain determines it (per `queries/partitioned-aggregates` › +Attached parameters on row-level sources). An attached input — a source constituent +or a parameter — is opaque to the enclosing aggregation: it is compiled at its own +home in every `to_many_handling` mode and attached onto the aggregation's home rows +by its grain — well-defined only when the home determines every member of that +grain, else the typed determination error in every mode; the enclosing aggregation +never inspects the input's interior, and the input's own producer applies its own +rules (including the mode-aware explicit-partition-key rule) as any producer does; +the mode governs only the aggregation's own unattributable dimensions. Whether an aggregation +runs over rows or over an operand dataset's cells is decided by its source alone; +every attached input, in the source or in a parameter, is then attached by one +mechanism — into the input relation for a row-level source, as a constituent of the +operand dataset for an attached one. + +#### Scenario: Filtered-column operand accepted +- **WHEN** a measure is written `sum(q_amount - 1)` where `q_amount` is `amount` + carrying the column filter `product = 'Q'` +- **THEN** it executes as the sum over Q rows of `amount - 1` — the operand is + `CASE WHEN product = 'Q' THEN amount END` — by executed values, never the former + filtered-operand rejection + +#### Scenario: Grained transform in the source accepted +- **WHEN** a query over a month time dimension selects + `sum(cumsum(sum(amount, partition_by=[region, ordered_at])) - 1)` +- **THEN** each month carries the sum over regions of that region's running total + minus one per cell, by hand-computed values on SQLite and DuckDB, distinguishable + from the ungrained identity — never the former nested-transform rejection + +#### Scenario: Row leaf under a nested transform rejected +- **WHEN** a measure is written `sum(cumsum(weight) - 1)` with `weight` not a query + dimension +- **THEN** it fails at plan time with the typed row-leaf error naming the transform + and the aggregate-the-leaf remedy, citing no tracking issue + +#### Scenario: Fanning leaf fails closed +- **WHEN** an expression leaf is reachable from every candidate home only across a + fanning or unproven join hop +- **THEN** the query fails with the input-safety error naming the leaf and the hop, + never a multiplied value + +#### Scenario: Collapsing constituent mixed with a row leaf fails closed +- **WHEN** a query over a month time dimension selects + `sum(amount * last(sum(amount, partition_by=[region, ordered_at])))` +- **THEN** it executes with the collapsed value broadcast onto each row of its + partition (Jan 375 / Feb 825 / Mar 900 on the reference dataset), never a + multiplied value + +#### Scenario: Unanalysable derived leaf fails closed +- **WHEN** an expression leaf names a derived column whose definition no dialect can + parse +- **THEN** the query fails with the analyzability error naming the column + +#### Scenario: Ranked aggregation over an expression keeps its error +- **WHEN** a measure is written `first(amount - customers.discount)` +- **THEN** it executes over the `orders` rows, each cell carrying `amount` minus its + customer's discount on the cell's earliest row by the ranking key, by + hand-computed executed values on SQLite and DuckDB — never a cross-model error and + never wrong values + +#### Scenario: Fully attached source accepted +- **WHEN** a measure is written `avg(sum(amount, partition_by=[city, region]))` +- **THEN** it is accepted and compiles as a re-aggregation, not rejected by the + expression gate + +#### Scenario: Mixed row and attached source accepted +- **WHEN** a measure is written + `sum(quantity * avg(unit_price, partition_by=product))` +- **THEN** it is accepted and compiles at row grain — the attached value + broadcast per base row — not rejected by the expression gate + +#### Scenario: Attached parameter on a row-level source accepted +- **WHEN** a measure is written + `weighted_avg(customers.spend, weight=sum(amount, partition_by=customers.regions.name))` + rooted at `orders` under `to_many_handling: "associate"` +- **THEN** it is accepted and compiles with the parameter's value attached into + the aggregation's input relation — never the attached-parameter rejection — + and the same aggregation with a row-level parameter is unaffected + +#### Scenario: Attached parameter on a row-level source executes under broadcast +- **WHEN** a measure is written + `weighted_avg(customers.spend, weight=sum(amount, partition_by=customers.regions.name))` + rooted at `orders` under the default `broadcast` `to_many_handling` +- **THEN** it executes — the parameter's producer rooted at `orders`, attached per + customer row inside the `customers`-rooted producer — with values identical to + `associate` and `error` on every dimension the home determines, never the former + typed rejection naming the `associate` remedy + +#### Scenario: Attached parameter on a row-level source rejected +- **WHEN** a measure is written + `weighted_avg(customers.spend, weight=sum(customers.spend, partition_by=status))` + rooted at `orders`, in any `to_many_handling` mode, by an attributable + (`customers.tier`) or an unattributable (`status`) dimension — the aggregation's + home `customers` does not determine the parameter's grain member `status` +- **THEN** it fails with the typed parameter-determination error naming `weight` + (per `queries/partitioned-aggregates` › Undetermined attached parameter stays + rejected); what is rejected is the parameter's grain against the home, never the + mode or the columns the parameter reads + +#### Scenario: Windowed aggregation with an attached constituent +- **WHEN** a query rooted at `orders` over a month time dimension on + `customers.signup_at` selects + `sum(customers.spend * sum(amount, partition_by=customers.regions.name), window='1y')` +- **THEN** each signup-month bucket carries the trailing-window total, over the + customers signed up in the window, of spend times the region's order total — + the constituent rooted at `orders` and attached per customer row — identical + under every mode with no warning (10000, 25000, 28080, 33780 on the reference + dataset; the orphan order's NULL bucket NULL): the bucket is attributable, so + `associate` needs no association and the windowed combination is not refused + +#### Scenario: Mixed-source constituent homed toward the host executes in every mode +- **WHEN** a query rooted at `orders` selects + `sum(customers.spend * sum(amount, partition_by=customers.regions.name))` — a + row-grain source homed at `customers` whose constituent is homed at `orders` +- **THEN** the constituent's producer is rooted at `orders` and attached per + customer row; by `status` under the default mode the customers-rooted total + (33780 on the reference dataset) is broadcast to both cells with the usual + warning, and by `customers.tier` every mode returns identical hand-computed + values (gold 15300, silver 16600, bronze 1880) — never the input-safety error the + constituent's interior would raise from `customers` diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/aggregations/functional-form/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/aggregations/functional-form/spec.md new file mode 100644 index 000000000..ce9675e39 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/aggregations/functional-form/spec.md @@ -0,0 +1,144 @@ +## ADDED Requirements + +### Requirement: Default ranking key +A ranked `first` / `last` aggregation without an explicit ranking key SHALL rank by, +in order: the first entry of the query's `dimensions` (in projection order) that is +row-level and types DATE or TIMESTAMP — a column or a computed dimension, never a +granularity bucket, which ties every row of a cell; else the first time dimension's un-truncated source, column or +expression; else the model's default time dimension; else it SHALL fail with the +existing ranking-key resolution error. + +#### Scenario: A temporal computed dimension is the default ranking key +- **WHEN** a query groups by the computed dimension `coalesce(shipped_at, created_at)` + (typed TIMESTAMP) and a month time dimension on `created_at`, and selects + `last(amount)` +- **THEN** each cell's value is the `amount` of its row latest by the coalesced + timestamp, by executed values on SQLite and DuckDB + +#### Scenario: An expression time dimension's source is the default ranking key +- **WHEN** a query's only temporal dimension is the time dimension + `coalesce(shipped_at, created_at)` at `month` and it selects `last(amount)` +- **THEN** each month's value is the `amount` of its row latest by the un-truncated + coalesced timestamp + +### Requirement: An expression ranking key is judged by its leaves +An explicit or defaulted ranking key SHALL be determined by the aggregation's home +and never widen it (semantics Axiom 2.4), judged over every leaf the expression reads +and every dependency of a derived column it names, for local and cross-model +(target-rooted) producers alike: a key reading a leaf across a fanning or unproven +hop from the home SHALL fail with the existing ranked-input safety error naming that +leaf and the hop. Two ranked aggregations differing only in their ranking key SHALL +stay separate producers; equal keys, however spelled, SHALL share one. + +#### Scenario: Fanning leaf inside an expression ranking key +- **WHEN** a query rooted at `customers` selects + `last(spend, coalesce(orders.created_at, signup_ts))`, `customers → orders` + one-to-many +- **THEN** it fails at plan time with the ranked-input safety error naming + `orders.created_at` and the hop, never a value + +#### Scenario: Cross-model ranked aggregate with an expression key +- **WHEN** a query rooted at `orders` selects + `last(customers.spend, coalesce(customers.updated_at, customers.signup_ts), partition_by=region)` +- **THEN** it executes ranked inside the `customers`-rooted producer, by executed values + +## MODIFIED Requirements + +### Requirement: Ambiguous first and last names dispatch by argument shape +For `first` and `last` (both aggregation and transform names), the call SHALL +parse to one node and dispatch by the type of its bound first argument, which +binds exactly like any transform input: an argument that resolves to a row-level +value (a column, a literal, or an expression with a row-level leaf) SHALL be the +aggregation; an argument that resolves to an aggregate-valued operand — an +aggregation, a saved measure, or a grained transform, alone or composed through +arithmetic and scalar calls — SHALL be the transform over that series. The +dispatch SHALL NOT depend on the spelling of the argument, and SHALL NOT pre-empt +transform-input validation: an ill-formed transform inside the argument fails +with that transform's own error. + +#### Scenario: Aggregation reading +- **WHEN** a measure is written `last(balance)` or `last(balance, updated_at)` +- **THEN** it is the `last` aggregation — the latest `balance` ranked by the time + axis, or by `updated_at` + +#### Scenario: Transform reading +- **WHEN** a measure is written `last(sum(revenue))` +- **THEN** it is the `last` transform over the aggregated series + +#### Scenario: Saved-measure operand reads as the transform +- **WHEN** the model declares a measure `rev` with formula `sum(revenue)` and a + query measure is written `first(rev)` or `first(rev * 2)` +- **THEN** it is the `first` transform over the saved measure's series — the + same plan and executed values as `first(sum(revenue))` / `first(sum(revenue) * 2)` + on SQLite and DuckDB — never an unknown-reference error + +#### Scenario: Row-grain composite keeps the aggregation reading +- **WHEN** a measure is written `first(quantity * avg(unit_price, partition_by=product))` + or `first(1)` +- **THEN** it is the `first` aggregation, picking the row-level value at the ranked + row, by executed values — never the transform reading + +#### Scenario: Dispatch does not pre-empt transform-input validation +- **WHEN** a measure is written `first(cumsum(weight))` with `weight` an unprojected + row-level column +- **THEN** it fails at plan time with the grain-refining row-level-leaf error naming + `cumsum` — never the aggregation reading + +### Requirement: Positional parameters fold onto declared parameter order +An aggregation call MAY pass declared parameters positionally after its source +(or, for a re-aggregation, after its operand): positional values SHALL bind to +the aggregation's declared parameter order — the built-in registry +(`percentile` → `p`; `weighted_avg` → `weight`; `corr`/`covar_samp`/`covar_pop` +→ `other`) or a custom aggregation's `params` declaration order — yielding the +identical aggregation identity, SQL, results, and result keys as the named +spelling. Passing a parameter both positionally and by name, or more positional +values than declared parameters — any positional value at all on an aggregation +that declares none — SHALL fail with a clear error naming the rule. Ranked +`first`/`last` declare no parameters — they take at most one positional value, +their ranking key (resolved per › Default ranking key when omitted), which, when +given, SHALL be a row-level expression — a column being the one-leaf case — never a +literal-only expression or one carrying an aggregate, transform or attached value. After binding an +attached (aggregate- or transform-valued) parameter is therefore always a named +parameter. + +#### Scenario: Positional percentile equals named +- **WHEN** a measure is written `percentile(price, 0.9)` +- **THEN** SQL, results, and result keys are identical to `percentile(price, p=0.9)` + +#### Scenario: Positional parameter on a re-aggregation outer +- **WHEN** a measure is written + `percentile(sum(amount, partition_by=[city, region]), 0.9)` +- **THEN** it equals the `p=0.9` spelling by executed values + +#### Scenario: Custom aggregation binds positionals by declared order +- **WHEN** a model declares `wavg(weight)` and a measure is written `wavg(amount, id)` +- **THEN** it equals `wavg(amount, weight=id)` by executed values + +#### Scenario: Duplicate and excess positional parameters error +- **WHEN** `percentile(price, 0.9, p=0.5)` or `percentile(price, 0.9, 0.5)` is submitted +- **THEN** each fails with a clear error naming the duplicated parameter or the + declared-parameter count + +#### Scenario: Positional value on a parameterless aggregation errors +- **WHEN** `sum(amount, 1)` or `sum(customers.spend, sum(customers.spend, partition_by=status))` + is submitted, under any `to_many_handling` mode +- **THEN** each fails at bind with a clear error naming the aggregation and that it + takes no parameters — never an executed value + +#### Scenario: Invalid first/last ranking key errors +- **WHEN** `last(amount, sum(amount, partition_by=region))`, `last(amount, 1)` or + `last(amount, id, amount)` is submitted +- **THEN** each fails at bind with a clear error naming the ranking-key rule + +#### Scenario: Expression ranking key +- **WHEN** a measure is written `last(balance, coalesce(updated_at, created_at))` +- **THEN** it is the `balance` of the row latest by the coalesced timestamp, by + executed values on SQLite and DuckDB + +#### Scenario: Positional transform parameter equals named +- **WHEN** a measure is written + `weighted_avg(customers.spend, rank(sum(amount, partition_by=customers.regions.name), direction='desc'))` + rooted at `orders` +- **THEN** it binds to the identical aggregation identity as the `weight=rank(..., direction='desc')` + spelling and returns identical result keys and values + diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/models/stored-upgrade/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/models/stored-upgrade/spec.md new file mode 100644 index 000000000..e8520d237 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/models/stored-upgrade/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Stored time dimensions are upgraded to expression text + +On load, every time dimension of a stored model's `source_queries`, a saved query or a memory's bundled query written before this change, whose `dimension` (or `column`) is stored as a column-reference object `{"name": …, "model": …}`, SHALL be rewritten to the dotted text `model.name` (`name` when `model` is absent), recursively through nested stored queries, and re-saved in that form; the query SHALL return the rows it returned before. A time dimension already stored as text, and a client query without a version, SHALL NOT be rewritten. + +#### Scenario: Stored object form becomes text + +- **WHEN** a stored query-backed model's source query holds the time dimension `{"dimension": {"name": "created_at", "model": "customers"}, "granularity": "month"}` +- **THEN** it loads with `dimension` = `customers.created_at`, dumps it as that string, and returns the same rows as before + +#### Scenario: Memory-bundled query + +- **WHEN** a memory stored as YAML frontmatter or as a SQLite row bundles a query whose time dimension is stored in the object form +- **THEN** it loads with the dotted text and the bundled query returns the same rows + +#### Scenario: The released corpus still opens + +- **WHEN** the committed store written by `motley-slayer==0.10.2` is opened +- **THEN** every stored time dimension loads as text and every stored query returns its release's results diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/partitioned-aggregates/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/partitioned-aggregates/spec.md new file mode 100644 index 000000000..10f4e2576 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/partitioned-aggregates/spec.md @@ -0,0 +1,276 @@ +## ADDED Requirements + +### Requirement: Partition keys accept row-level expressions +A `partition_by=` key of an aggregation or a rank-family transform SHALL be any +row-level Mode-B expression — a column being the one-leaf case — or the name of a +query dimension, computed dimension or time dimension. An expression key +structurally equal to a query computed dimension's expression or to a time +dimension's bucket SHALL be that dimension, attach-carrying computed dimensions +included; an expression key equal to a projected time dimension's source SHALL be +its bucket, exactly as a column key is, and SHALL fail with the existing ambiguity +error when that source is projected at several granularities. Any other key +carrying an aggregate, transform or attached value SHALL fail with a typed error +naming the key and the remedy of declaring it as a computed dimension and naming it +in `partition_by=`. An expression key SHALL follow every rule a column key follows: +the combined-consumer rule (it is a query dimension iff it is one of the query's +dimension keys by structural identity), attributability from the aggregate's root +judged over every leaf the expression reads (per › Partition keys are attributable +from the aggregate's root), the finer-grain exemptions, and the rank family's +operand-grain membership; a producer grouping by it SHALL group by the expression's +per-row value. + +#### Scenario: Partition by an expression in a computed-dimension position +- **WHEN** a query over `[region, upper(city)]` selects + `sum(amount, partition_by=upper(city))` +- **THEN** each row carries its upper-cased city's total, by executed values on + SQLite and DuckDB, identical to declaring the computed dimension `ucity` and writing + `partition_by=ucity` + +#### Scenario: Expression key that is not a query dimension in a combined position +- **WHEN** a query over `[region]` selects `sum(amount, partition_by=upper(city))` as a + measure +- **THEN** it fails at plan time with the partition-key error naming `upper(city)` and + the remedy + +#### Scenario: Expression key under the finer-grain exemption +- **WHEN** a query over `[region]` selects + `avg(sum(amount, partition_by=[region, upper(city)]))` +- **THEN** each region carries the average of its upper-cased-city totals, by executed + values + +#### Scenario: Rank family partitioned by an expression +- **WHEN** a query over `[region, upper(city)]` selects + `rank(sum(amount), partition_by=upper(city), direction='desc')` +- **THEN** regions are ranked within each upper-cased city, by executed values + +#### Scenario: Expression key equal to a time dimension's source +- **WHEN** a query buckets `coalesce(shipped_at, created_at)` at `month`, groups by + `region`, and selects `sum(amount, partition_by=coalesce(shipped_at, created_at))` +- **THEN** the key is the month bucket and each row carries its month's total across + regions; projecting the same source also at `year` makes the key fail with the + ambiguity error + +#### Scenario: Joined leaf over an unproven hop errors +- **WHEN** an aggregate declares `partition_by=upper(r.name)` where `r` is reachable + only across a join of unproven arity, under `"broadcast"` or `"error"` mode +- **THEN** the query fails with the partition-key attributability error naming the + leaf, the hop and the remedy + +#### Scenario: Aggregate-bearing inline key rejected +- **WHEN** an aggregate declares + `partition_by=CASE WHEN sum(amount, partition_by=city) > 100 THEN 'hi' ELSE 'lo' END` + and no query computed dimension has that expression +- **THEN** it fails with the typed error naming the key and the computed-dimension + remedy; the same key equal to a query computed dimension's expression executes as + that dimension + +## MODIFIED Requirements + +### Requirement: Mixed sources carry the full expression-source surface +An aggregation source mixing row-level references with attached values SHALL +behave as a row-level expression source: everything legal for a plain +expression source is legal for it, and nothing more. Row leaves MAY be host-model +or joined-model columns, homed per `queries/semantics` › Home dataset of a +row-level aggregation source; an explicitly grained transform is an attached +constituent exactly like a partitioned aggregate, and so is a re-aggregation — an +aggregate over attached values, hand-written or produced by the `first`/`last` +collapse — evaluated at its own grain and broadcast per partition onto the source's +rows, an empty grain broadcasting its one value onto every row. The outer aggregation +SHALL support the plain scalar family, `count` (base rows with a non-null +operand value) and `count_distinct`, parametric and model-defined custom +aggregations — including multi-input built-ins and column-reference parameters +resolvable at row scope — and its own `partition_by=` and `window=`, each +resolved by the same rules as over a plain expression source. `first`/`last` +over the mixed expression SHALL be the ranked aggregation, picking the mixed +value at the ranked row of each cell. The shape SHALL be legal in measure, filter (typing as a +measure: pruning result rows without altering surviving values), and ORDER BY +positions, and as a computed dimension when grain-self-contained (the outer and +every inner aggregate explicitly grained). No producer or grouping step may +group by the row leaf or the attached value themselves — they feed the outer +aggregation only — and the mixed shape SHALL never be compiled through the +fully-attached carrier (whose cell-over-cell value differs). Row leaves keep +every existing expression-source restriction; attached constituents may be +cross-model, windowed, grained transforms, or themselves attached-input +aggregations. Discovery SHALL treat an aggregation that +owns attached inputs as opaque below its inputs (source and parameters): those +inputs belong to it — row-attached when it evaluates inline, owned by its own +producer when it is itself a producer answer — and are never discovered as +consumers of the enclosing level; its partition keys are not inputs and stay +visible to the enclosing level, so an attach-carrying computed dimension in +its `partition_by=` still gets the outer attach the grain join needs. + +#### Scenario: Outer partition_by over a mixed source +- **WHEN** a query over `[region, product]` selects + `sum(quantity * avg(unit_price, partition_by=product), partition_by=region)` +- **THEN** every row of a region carries that region's row-weighted total, + broadcast exactly as any explicit-grain partitioned measure, by executed + values + +#### Scenario: Outer window over a mixed source +- **WHEN** a query with a month time dimension selects + `sum(quantity * avg(unit_price, partition_by=product), window='90d')` +- **THEN** each bucket carries the trailing-90-day row-weighted total as of that + bucket, by executed values + +#### Scenario: Windowed constituent inside a mixed source +- **WHEN** a query with a month time dimension selects + `sum(qty * sum(revenue, window='90d'))` +- **THEN** the windowed inner is exactly one nested producer at the bucket + grain, row-attached into the outer aggregation's input relation — never an + attach of the enclosing level — and each bucket carries the hand-computed + row-weighted value, by executed values + +#### Scenario: Transform constituent inside a mixed source +- **WHEN** a query over `[region]` selects + `sum(quantity * rank(avg(unit_price, partition_by=product), direction='desc'))` +- **THEN** the transform is exactly one nested producer at its `(product)` grain, + row-attached into the outer aggregation's input relation, and each region carries + the hand-computed row-weighted value, by executed values — never the former + nested-transform rejection + +#### Scenario: Collapsing constituent mixed with a row leaf fails closed +- **WHEN** a query over a month time dimension selects + `sum(amount * last(sum(amount, partition_by=[region, ordered_at])))` +- **THEN** it no longer fails with the collapsing-transform error — it executes per the + next scenario; the former fail-closed pin is retired + +#### Scenario: Collapsing transform constituent inside a mixed source +- **WHEN** a query over a month time dimension selects `sum(amount * last(X))` with + `X = sum(amount, partition_by=[region, ordered_at])` +- **THEN** it executes with the hand-computed values Jan 375 / Feb 825 / Mar 900 on + SQLite and DuckDB: `last(X)` collapses to one value per region, broadcast onto each + row of that region, multiplied by the row's `amount` and summed per month — never + the former fail-closed collapse error + +#### Scenario: Hand-written re-aggregation constituent inside a mixed source +- **WHEN** a query over a month time dimension selects + `sum(amount * min(X, partition_by=region))` +- **THEN** it executes with hand-computed values on SQLite and DuckDB, the plan carries + exactly one producer for the re-aggregation at its `(region)` grain, row-attached on + `region` and never attached at the enclosing level, the emitted statement has one + flat `WITH`, scopes are closed, no placeholder leaks, and the query's row count is + unchanged — never the internal grain-cover assertion + +#### Scenario: Empty-grain re-aggregation constituent broadcasts one value +- **WHEN** a query over a month time dimension selects + `sum(amount * last(sum(amount, partition_by=ordered_at)))` +- **THEN** the collapsed constituent has an empty grain — one value — attached with no + join keys onto every row, and the query executes with hand-computed values on both + engines with unchanged cardinality, never an empty join predicate + +#### Scenario: Re-aggregation constituent as an attached parameter +- **WHEN** a query over a month time dimension selects + `weighted_avg(amount, weight=min(X, partition_by=region))` +- **THEN** the parameter is attached at its `(region)` grain through the same path as a + source constituent and the query executes with hand-computed values on both engines + +#### Scenario: Mixed re-aggregation root combined with a coarser measure +- **WHEN** a query over `[region]` with a month time dimension selects + `sum(amount * min(X, partition_by=region)) + sum(amount, partition_by=region)` +- **THEN** it executes with hand-computed values on both engines, the coarser term + broadcast across months exactly as any explicit-grain partitioned measure, with + unchanged cardinality + +#### Scenario: Mixed collapse in filter and order positions +- **WHEN** `sum(amount * last(X))` appears only in a filter or only as an ORDER BY target +- **THEN** the filter types as a measure — pruning result rows with surviving values + unchanged — and the order sorts by the same value the measure form returns + +#### Scenario: The mode axis is not bypassed by an attached re-aggregation +- **WHEN** a query selecting `sum(amount * min(X, partition_by=region))` also groups by + a dimension the source's home does not determine +- **THEN** under broadcast mode the value repeats across that dimension's cells with + the self-announcing warning, and under error mode the query fails with the mode + error — exactly as a mixed source with a plain attached aggregate + +#### Scenario: Joined-model row leaf inside a mixed source +- **WHEN** a query rooted at `orders` over `[status]` selects + `sum(customers.discount * avg(amount, partition_by=status))` +- **THEN** the source is homed at `orders`, the attached average is row-attached per + order, and each status carries the hand-computed value, by executed values + +#### Scenario: Parametric outer with a row-valued parameter +- **WHEN** a query selects + `wavg(quantity * avg(unit_price, partition_by=product), weight=qty)` (a + weighted average whose weight is a row column) +- **THEN** it executes with the hand-computed row-weighted average, never a + column-parameter rejection + +#### Scenario: Custom and multi-input aggregations over a mixed source +- **WHEN** a model-defined custom aggregation or a two-input built-in (e.g. + `corr(quantity * avg(unit_price, partition_by=product), qty)`) consumes a + mixed source +- **THEN** each executes with correct values through its own rendering path + +#### Scenario: Count semantics over a mixed source +- **WHEN** a query selects `count(quantity * avg(unit_price, partition_by=product))` +- **THEN** each cell counts its base rows whose operand value is non-null + +#### Scenario: Ranked aggregation over a mixed source keeps the expression error +- **WHEN** a query selects `first(quantity * avg(unit_price, partition_by=product))` +- **THEN** it is no longer an error: each cell carries `quantity` times its + product's average unit price on the cell's earliest row by the ranking key, by + hand-computed executed values on SQLite and DuckDB — never the transform + reading, a mixing error or wrong values + +#### Scenario: Filter and order positions +- **WHEN** the mixed measure appears only in a filter + (`sum(quantity * avg(unit_price, partition_by=product)) > 100`) or only as an + ORDER BY target +- **THEN** the filter types as a measure — pruning result rows with surviving + values unchanged — and the order sorts by the same value the measure form + returns + +#### Scenario: Grain-self-contained dimension position +- **WHEN** a query groups by a computed dimension banding + `sum(quantity * avg(unit_price, partition_by=product), partition_by=region)` +- **THEN** rows group by the band with correct executed values and unchanged + cardinality, and the plan carries one attach for the banded aggregation with + the inner `avg` nested inside its producer — no separate top-level attach for + the inner + +#### Scenario: Adding a mixed measure is cardinality-neutral +- **WHEN** any supported query runs with and without an additional mixed-source + measure +- **THEN** both runs return the same rows and identical values in all shared + columns, and the emitted SQL leaks no internal placeholder names + +#### Scenario: Never the fully-attached carrier +- **WHEN** the emitted SQL for a mixed-source measure is inspected +- **THEN** no relation groups by the attached value or the row leaf; the outer + aggregation consumes base rows with the attached value joined on the + constituent's complete grain + +#### Scenario: A producer-bound mixed aggregation owns its inners +- **WHEN** a mixed aggregation is itself a producer answer (it carries its own + `partition_by=`, `window=`, or is cross-model) and the plan is inspected +- **THEN** its inner constituents appear only inside that producer's plan, + never as attaches of the enclosing level + +#### Scenario: Partition keys stay visible through an opaque root +- **WHEN** a mixed aggregation declares `partition_by=` on an attach-carrying + computed dimension of the query +- **THEN** that dimension's own attach is still planned at the enclosing level + and the aggregation's producer joins back on it, with executed values equal + to the same query spelled with a plain dimension + +#### Scenario: One producer per distinct attached input +- **WHEN** the same attached aggregate appears twice — as a source constituent + and as a parameter of one aggregation, or as an input of two different + row-attach aggregations +- **THEN** the plan carries exactly one producer and one attach for it, every + occurrence substitutes to that attach, total routing holds after + substitution, and the executed values are correct + +#### Scenario: A re-aggregation used both standalone and as a mixed constituent is deferred +- **WHEN** one query selects both `min(X, partition_by=region)` on its own and + `sum(amount * min(X, partition_by=region))` — the same re-aggregation standalone + (combined phase) and as a mixed row-level constituent (row phase) — over `[region]` + and a month time dimension, in measure, filter, or order position +- **THEN** it is no longer deferred — never the former checker error — and executes on + every engine with the values each use has alone: the + standalone value is the region minimum broadcast onto every month cell (North 10, + South 5, NULL where the region has no non-null cell) and the mixed value is the + per-cell `amount` times that minimum, summed (North 100 / 200 / 300, South 25 / 75); + the emitted statement carries exactly one producer relation for the re-aggregation + and one for its nested operand, and adding either measure changes no other value diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-dimensions/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-dimensions/spec.md new file mode 100644 index 000000000..e9377bc53 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-dimensions/spec.md @@ -0,0 +1,323 @@ +## ADDED Requirements + +### Requirement: Time dimensions accept row-level expressions + +A time dimension's `dimension` SHALL be a row-level Mode-B expression — a bare or dotted column reference being the one-leaf case — bucketed at the requested granularity exactly as a column is. The expression SHALL be homed per `queries/semantics` › Home dataset (Axiom 2.2): its leaves MAY be host-model columns, columns reached over provably to-one hops, or columns of the current stage's schema; an expression whose leaves no candidate home determines SHALL fail with the existing input-safety error naming the leaf and the hop. The expression SHALL type DATE or TIMESTAMP; any other type, or a type that cannot be determined, SHALL fail before any SQL is generated with the existing temporal-column typed error naming the time dimension and the observed type, and, for an undetermined type, the remedy of wrapping the expression in `CAST(… AS TIMESTAMP)` or `CAST(… AS DATE)`. An aggregate, transform or attached value anywhere in the expression SHALL be rejected with a typed error stating that a time dimension is row-level. The population inference of `queries/population` SHALL read an expression time dimension's leaves as it reads a column's. + +#### Scenario: Coalesced timestamp groups by month +- **WHEN** a query on `orders` has `time_dimensions` = `[{"dimension": "coalesce(shipped_at, created_at)", "granularity": "month"}]` and a count measure +- **THEN** each row is one month of the per-row coalesced timestamp with the count of orders in it, by hand-computed executed values on SQLite and DuckDB + +#### Scenario: Cast text to a date +- **WHEN** a query buckets `CAST(created_txt AS DATE)` at `month`, `created_txt` a TEXT column holding ISO dates +- **THEN** it groups by the month of the cast value, by executed values on SQLite and DuckDB + +#### Scenario: Cross-model expression over a to-one hop +- **WHEN** a query rooted at `orders` buckets `coalesce(customers.signup_ts, created_at)` at `month`, `orders → customers` provably to-one +- **THEN** it executes over the `orders` rows, each order reading its own customer's value, by executed values + +#### Scenario: Expression leaf across a fanning hop fails closed +- **WHEN** a query rooted at `customers` buckets `coalesce(orders.shipped_at, signup_ts)` at `month`, `customers → orders` one-to-many +- **THEN** planning fails with the input-safety error naming `orders.shipped_at` and the hop, never a multiplied row set + +#### Scenario: Non-temporal expression rejected +- **WHEN** a query buckets `upper(status)` at `month` +- **THEN** planning fails with the temporal-column typed error naming the time dimension and the type TEXT, and no SQL is generated + +#### Scenario: Undetermined type fails closed with the cast remedy +- **WHEN** a query buckets an expression whose type cannot be determined at `month` +- **THEN** planning fails with the temporal-column typed error naming the remedy of an explicit `CAST` + +#### Scenario: Aggregate inside a time dimension rejected +- **WHEN** a query buckets `max(created_at, partition_by=status)` at `month` +- **THEN** planning fails with the typed error stating that a time dimension is row-level + +#### Scenario: Stage-scope expression +- **WHEN** an outer stage buckets `coalesce(shipped_at, created_at)` over an inner stage that projects both columns as plain dimensions +- **THEN** it groups by the month of the coalesced stage value, by executed values + +### Requirement: An expression time dimension behaves as a column in every time position + +Every rule stated for a time dimension's column SHALL apply to an expression time dimension's per-row value: its `date_range` SHALL restrict the rows whose value falls in the range and SHALL take date bounds when the expression types DATE and instant bounds when it types TIMESTAMP; `whole_periods_only` SHALL snap its bounds by the same rule; a frame bound written as a comparison of the expression (or `gran(expression)`) with a time point SHALL behave as a frame bound on that time dimension; `window=` aggregations, time-ordered transforms and the `first` / `last` collapse SHALL use it as their time axis; and a downstream stage SHALL see it as a temporal column carrying the bucket granularity, re-bucketable under the existing nesting rule. Its values, row counts and companion measures SHALL equal those of the same query over a column holding the expression's per-row values. + +#### Scenario: Date range on an expression time dimension +- **WHEN** a query buckets `coalesce(shipped_at, created_at)` at `month` with `date_range` `["2025-01-01", "2025-03-31"]` +- **THEN** only orders whose coalesced timestamp falls in the range are counted, and the result has the January–March buckets, by executed values + +#### Scenario: DATE-typed expression takes date bounds +- **WHEN** the time dimension is `CAST(created_at AS DATE)` with `date_range` `["2025-01", "2025-02"]` and `whole_periods_only` +- **THEN** the bounds are compared as dates and snap to whole periods exactly as for a DATE column, by executed values + +#### Scenario: Rolling window and change over an expression axis +- **WHEN** a query buckets `coalesce(shipped_at, created_at)` at `month` and selects `sum(amount, window='3m')` and `change(sum(amount))` +- **THEN** both read the months of the coalesced timestamp, including rows before the visible range for the window, by hand-computed executed values + +#### Scenario: Two-stage re-bucketing of an expression time dimension +- **WHEN** an inner stage buckets `coalesce(shipped_at, created_at)` at `month` with a revenue sum, and the outer stage buckets that output column at `year` and at `day` +- **THEN** the `year` query returns one row per year summing the monthly sums, and the `day` query fails with the typed re-bucketing error naming `month` and `day` + +#### Scenario: Equal to a column holding the per-row values +- **WHEN** a model declares a derived column `ts` with SQL `COALESCE(shipped_at, created_at)` and two queries bucket `ts` and `coalesce(shipped_at, created_at)` at `month` with the same measures +- **THEN** both return identical rows and values + +### Requirement: Time dimension names + +A time dimension SHALL accept an optional `name`. An unnamed column time dimension SHALL keep its existing result key. An unnamed expression time dimension SHALL be named by the same sanitising rule as an unnamed computed dimension, and its result key SHALL be that name prefixed by the query's model, extended with the granularity exactly when another projected time dimension shares its bound source. A named time dimension's result key SHALL be its name prefixed by the query's model, never extended with the granularity. Names SHALL be unique across a query's dimensions and time dimensions; a collision SHALL fail at construction naming it. A time dimension MAY be referred to by its name from `main_time_dimension` and from `partition_by=`, exactly as a computed dimension is by its name. + +#### Scenario: Unnamed expression time dimension's key +- **WHEN** a query on `orders` buckets `coalesce(shipped_at, created_at)` at `month` +- **THEN** its result key is `orders.coalesce_shipped_at_created_at` + +#### Scenario: Named time dimension +- **WHEN** the same time dimension carries `"name": "effective_at"` +- **THEN** its result key is `orders.effective_at`, and `main_time_dimension` `"effective_at"` selects it as the axis of a `cumsum` measure in a query that also buckets `created_at` + +#### Scenario: Named column time dimension +- **WHEN** a time dimension on `created_at` at `month` carries `"name": "m"` +- **THEN** its result key is `orders.m` and its values equal the unnamed form's + +#### Scenario: Partition by a time dimension's name +- **WHEN** a query buckets `coalesce(shipped_at, created_at)` at `month` named `effective_at`, groups by `region`, and selects `sum(amount, partition_by=effective_at)` +- **THEN** each row carries its month's total across regions, by executed values + +#### Scenario: Name collision rejected +- **WHEN** a query declares a computed dimension and a time dimension both named `m` +- **THEN** construction fails with an error naming `m` + +### Requirement: Re-bucketing is judged per bucketed leaf + +A time dimension over an expression SHALL obey the re-bucketing rule for every bucketed temporal column the expression reads — a column carrying a granularity (a query-backed model's cached bucket, a table-backed column declaring one, a sibling stage's stand-in) or a stage column its upstream stage bucketed: the requested granularity SHALL equal that leaf's granularity or be one it nests into, else planning SHALL fail with the existing typed re-bucketing error naming that leaf, both granularities and the remedy. Leaves carrying no granularity and non-temporal leaves SHALL impose nothing. + +#### Scenario: Cast of a month-bucketed column at day rejected +- **WHEN** a query on the query-backed `monthly` model (bucketing `created_at` at `month`) buckets `CAST(created_at AS DATE)` at `day` +- **THEN** planning fails with the typed re-bucketing error naming `created_at`, `month` and `day`, and no SQL is generated + +#### Scenario: Same and coarser granularities bind +- **WHEN** the same expression is bucketed at `month` and at `year` +- **THEN** each executes on SQLite and DuckDB with the monthly and yearly values + +#### Scenario: A bucketed leaf beside an unbucketed one still binds the rule +- **WHEN** a query buckets `coalesce(created_at, refunded_at)` at `day`, `created_at` bucketed at `month` and `refunded_at` unbucketed +- **THEN** planning fails with the typed re-bucketing error naming `created_at` + +## MODIFIED Requirements + +### Requirement: column is an accepted alias of dimension + +A time dimension SHALL accept its dimension under either the key `dimension` or the key +`column`, with identical coercion: a string holding a Mode-B row-level expression (a bare +or dotted column reference being the one-leaf case), or the legacy column-reference object +`{"name": …, "model": …}`, which SHALL be read as the dotted text `model.name`. Serialization +SHALL always emit `dimension` as a string, and the JSON schema SHALL advertise `dimension` as +a string property. + +#### Scenario: column key validates and dumps as dimension + +- **WHEN** a query is constructed with + `time_dimensions` = `[{"column": "created_at", "granularity": "month"}]` +- **THEN** the time dimension's dimension is `created_at`, and dumping the query emits + `{"dimension": "created_at", "granularity": "month"}` with no `column` key + +#### Scenario: dimension key still validates + +- **WHEN** a query is constructed with + `time_dimensions` = `[{"dimension": "created_at", "granularity": "month"}]` +- **THEN** it validates exactly as before, and constructing the object with the + `dimension` keyword still works + +#### Scenario: Legacy object form is read as text + +- **WHEN** a query is constructed with + `time_dimensions` = `[{"dimension": {"name": "created_at", "model": "customers"}, "granularity": "month"}]` +- **THEN** it equals the query with `"dimension": "customers.created_at"`, and dumps + `dimension` as the string `customers.created_at` + +#### Scenario: Schema advertises dimension + +- **WHEN** the query's JSON schema is generated +- **THEN** the time-dimension object's property is `dimension`, typed as a string, and no + `column` property is listed + +### Requirement: Functional granularity form in dimensions + +A string entry in `SlayerQuery.dimensions` that is a single call `gran(expr)` — where `gran` case-insensitively matches a built-in granularity (`second`, `minute`, `hour`, `day`, `week`, `week_sunday`, `month`, `quarter`, `year`) or a custom granularity defined on the query's datasource (`queries/custom-granularities`), and `expr` is a row-level expression (a bare or dotted column reference being the one-leaf case) — SHALL be exactly equivalent to an explicit `TimeDimension(dimension=expr, granularity=gran)`: rewritten entries leave `dimensions` and append to `time_dimensions` in order of appearance, after any explicit entries, and every downstream observable (generated SQL, result rows, result keys, projection order, grain, transform axes, `whole_periods_only`, `main_time_dimension` matching, canonical serialization) SHALL be identical to the explicitly written form. A built-in callee SHALL be rewritten at query construction; a callee that is not built-in SHALL be resolved at binding against the datasource's granularities and, when it names one, SHALL behave identically to the explicit form. + +#### Scenario: month(created_at) groups by month + +- WHEN a query has `dimensions=["month(created_at)"]` and a count measure +- THEN it produces the same SQL and results as the query with `time_dimensions=[{"dimension": "created_at", "granularity": "month"}]`, with result key `.created_at` + +#### Scenario: Functional form over an expression + +- WHEN a query has `dimensions=["month(coalesce(shipped_at, created_at))"]` and a count measure +- THEN it produces the same SQL and results as the query with `time_dimensions=[{"dimension": "coalesce(shipped_at, created_at)", "granularity": "month"}]` + +#### Scenario: every granularity value works + +- WHEN a dimension entry uses any of the nine built-in granularity values as the callee +- THEN the entry is rewritten to a time dimension with that granularity + +#### Scenario: datasource granularity callee + +- WHEN a query against a datasource defining `fiscal_year` has `dimensions=["fiscal_year(created_at)"]` and a count measure +- THEN it produces the same SQL and results as the query with `time_dimensions=[{"dimension": "created_at", "granularity": "fiscal_year"}]` + +#### Scenario: case-insensitive callee and dotted join path + +- WHEN a query has `dimensions=["MONTH(customers.created_at)"]` +- THEN it is equivalent to a time dimension on `customers.created_at` at `month` granularity + +#### Scenario: rewritten entries append after explicit time dimensions + +- WHEN a query has both explicit `time_dimensions` and functional entries in `dimensions` +- THEN the canonical query lists the explicit time dimensions first, then the rewritten ones in their order of appearance in `dimensions` + +#### Scenario: canonical serialization + +- WHEN a query with a functional dimension entry is serialized +- THEN the dump shows the entry as a `TimeDimension` under `time_dimensions`, not under `dimensions` + +#### Scenario: legacy-version query input + +- WHEN a query document at an older schema version contains a functional dimension entry +- THEN schema migrations run first and the entry is still rewritten + +### Requirement: Functional granularity form in time_dimensions entries + +A string entry in `SlayerQuery.time_dimensions` of the same `gran(expr)` shape SHALL coerce to the equivalent `TimeDimension`, whose granularity is resolved at binding when it is not built-in. A string entry that is not of the `gran(expr)` shape SHALL be rejected at construction with an error naming the functional form and the valid built-in granularities; a well-shaped entry whose callee names no built-in or datasource granularity SHALL fail at binding with the unknown-granularity error listing the built-in and the datasource's granularities; no default granularity is invented. The string form SHALL be accepted wherever queries enter the system (REST API, MCP `query` tool, stored queries). + +#### Scenario: functional string entry coerces + +- WHEN a query has `time_dimensions=["month(created_at)"]` +- THEN it is equivalent to `time_dimensions=[{"dimension": "created_at", "granularity": "month"}]` + +#### Scenario: functional string entry over an expression coerces + +- WHEN a query has `time_dimensions=["month(CAST(created_txt AS DATE))"]` +- THEN it is equivalent to `time_dimensions=[{"dimension": "CAST(created_txt AS DATE)", "granularity": "month"}]` + +#### Scenario: bare column string rejected with remedy + +- WHEN a query has `time_dimensions=["created_at"]` +- THEN construction fails with an error naming the `gran(expr)` form and the valid granularities + +#### Scenario: unknown callee fails at binding + +- WHEN a query has `time_dimensions=["mnth(created_at)"]` against a datasource defining `fiscal_year` +- THEN planning fails with the unknown-granularity error listing the nine built-in granularities and `fiscal_year` + +#### Scenario: string entries accepted at the API surfaces + +- WHEN a query with a string `time_dimensions` entry arrives via the REST API or the MCP `query` tool +- THEN it is accepted and behaves identically to the core form + +### Requirement: Granularity error surface in dimensions + +A dimension entry whose callee is a built-in granularity but whose shape is not a single row-level argument (zero or extra arguments, `*`, or keyword arguments) SHALL fail at construction with a typed error naming the required shape and the valid granularities; a datasource granularity callee of such a shape SHALL fail with the same error at binding. A single-argument entry whose argument does not type DATE or TIMESTAMP SHALL fail per › Time dimensions accept row-level expressions. A dimension entry of shape `name(col)` — a single bare or dotted column argument — whose callee is not a built-in or datasource granularity, not an allowlisted scalar function, not a transform, and not a builtin aggregation SHALL fail at binding with a typed error naming the valid built-in and datasource granularities and stating that a custom aggregation used as a dimension must carry `partition_by=`. All other dimension expressions SHALL keep their existing behaviour and error paths, including scalar-function and `partition_by=` aggregate computed dimensions remaining legal and bare builtin aggregates keeping their binding-time `partition_by=` error. + +#### Scenario: wrong-shape granularity call + +- WHEN a query has a dimension entry `month()`, `month(a, b)` or `month(*)` +- THEN construction fails with an error naming the required `gran(expr)` shape and the valid granularities + +#### Scenario: non-temporal argument fails as a time dimension + +- WHEN a query has a dimension entry `month(upper(x))` +- THEN planning fails with the temporal-column typed error naming the type TEXT, not a shape error + +#### Scenario: unknown single-column call names granularities + +- WHEN a query has a dimension entry `mnth(created_at)` +- THEN planning fails with an error naming the nine built-in granularities, any datasource granularities, and the `partition_by=` requirement for custom aggregations in dimensions + +#### Scenario: legal computed dimensions unaffected + +- WHEN a query has a dimension entry `upper(region)` or an aggregate expression carrying `partition_by=` +- THEN it binds and executes exactly as before this change + +#### Scenario: bare builtin aggregate keeps its error + +- WHEN a query has a dimension entry `sum(price)` +- THEN it fails at binding with the existing error stating aggregates in dimension expressions must declare `partition_by=` + +### Requirement: Result keys disambiguate same-column time dimensions + +When two or more projected unnamed time dimensions share the same bound source — a column or an expression — at different granularities, each of their result keys SHALL be the usual key with the granularity appended (`orders.created_at.month`, `orders.created_at.year`). An unnamed time dimension whose source no other projected time dimension shares SHALL keep its granularity-free key, and a named one SHALL never carry the suffix (› Time dimension names). Exact-duplicate time dimensions (same dimension text, granularity, date range, name and label) SHALL be deduplicated at construction; time dimensions sharing dimension text and granularity but differing in date range, name or label SHALL be rejected at construction. Time dimensions whose dimensions are spelled differently but resolve to the same bucket (the same bound time-truncation identity), disagreeing in date range, name or label, SHALL be rejected at binding — backstopping the construction-time text check for spellings that only prove equivalent once resolved. Such spellings that share their name (an unnamed one's derived from its template) and agree in date range and label SHALL be deduplicated at binding into one projected time dimension, as exact duplicates are at construction. + +#### Scenario: two granularities of one column + +- WHEN a query projects `month(created_at)` and `year(created_at)` +- THEN the result has two columns keyed `.created_at.month` and `.created_at.year` with correctly bucketed values + +#### Scenario: two granularities of one expression + +- WHEN a query projects `month(coalesce(shipped_at, created_at))` and `year(coalesce(shipped_at, created_at))` +- THEN the result has two columns keyed `.coalesce_shipped_at_created_at.month` and `.coalesce_shipped_at_created_at.year` + +#### Scenario: single time dimension keeps its key + +- WHEN a query projects a single time dimension on `created_at` +- THEN its result key stays `.created_at` with no granularity suffix + +#### Scenario: exact duplicates dedupe + +- WHEN a query lists `month(created_at)` twice (in either form) +- THEN the query behaves as if it were listed once + +#### Scenario: same column and granularity with differing metadata rejected + +- WHEN a query lists two time dimensions on the same dimension text and granularity that differ in date range or label +- THEN construction fails with an error naming the collision + +#### Scenario: equivalent spellings with differing metadata rejected at binding + +- WHEN two projected time dimensions name one source via different spellings (e.g. `coalesce(shipped_at, created_at)` and `COALESCE(shipped_at,created_at)`) that resolve to the same bucket but differ in date range or label +- THEN the query fails with an error naming the conflict + +#### Scenario: equivalent spellings with agreeing metadata dedupe at binding + +- WHEN two projected time dimensions name one source via different spellings (e.g. `coalesce(shipped_at, created_at)` and `COALESCE(shipped_at,created_at)`) at one granularity, agreeing in date range, name and label +- THEN the query behaves as if it were listed once, by executed values on SQLite and DuckDB + +### Requirement: main_time_dimension over same-column buckets fails closed + +`main_time_dimension` SHALL select a projected time dimension by its name (› Time dimension names), or, for a column time dimension, by the column's full or leaf name. When several projected time dimensions share a source at different granularities, a reference that matches them all cannot pick a bucket and SHALL be rejected as ambiguous, naming the per-granularity candidates (`month(created_at)`, `year(created_at)`). A `main_time_dimension` that resolves to exactly one projected time dimension (a lone time dimension, a distinct source, a full-name match, or a name match) SHALL continue to select it. + +#### Scenario: ambiguous bare-column main_time_dimension rejected + +- WHEN a query projects `month(created_at)` and `year(created_at)`, sets `main_time_dimension="created_at"`, and carries a time-ordered transform +- THEN the query fails with an ambiguity error naming `month(created_at)` and `year(created_at)` + +#### Scenario: main_time_dimension on a distinct column still resolves + +- WHEN a query projects time dimensions on two different columns and `main_time_dimension` names one of them +- THEN that time dimension is selected as the transform axis + +#### Scenario: main_time_dimension names an expression time dimension + +- WHEN a query projects `month(created_at)` and the time dimension `coalesce(shipped_at, created_at)` at `month` named `effective_at`, sets `main_time_dimension="effective_at"`, and selects `cumsum(count(*))` +- THEN the running count accumulates along the `effective_at` months, by executed values + +### Requirement: Functional granularity order key + +An order entry `gran(expr)` SHALL sort by the bucketed value of the query's time dimension on `expr` at granularity `gran` when that time dimension is projected, `expr` matching the time dimension's source by its bound identity. When no time dimension on `expr` is projected, or the projected granularity differs, the query SHALL fail with an error naming the remedy. A granularity call used inside a filter or another expression is a row-level expression (see `queries/time-points`). + +#### Scenario: order by projected bucket + +- WHEN a query projects `month(created_at)` (functional or explicit) and orders by `"month(created_at)"` +- THEN rows sort by the month bucket, identically to ordering by the time dimension's column name + +#### Scenario: order by a projected expression bucket + +- WHEN a query projects `month(coalesce(shipped_at, created_at))` and orders by `"month(coalesce(shipped_at, created_at))"` descending +- THEN rows sort by that month bucket descending + +#### Scenario: order without matching time dimension + +- WHEN a query orders by `"month(created_at)"` but projects no time dimension on `created_at`, or projects it at a different granularity +- THEN the query fails with an error naming the missing or mismatched time dimension and the remedy + +#### Scenario: granularity call in a filter is recognised + +- WHEN a query filter contains `month(created_at) >= '2024-01-01'` +- THEN the query executes and restricts rows to `created_at >= 2024-01-01` diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-points/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-points/spec.md new file mode 100644 index 000000000..46ec3be7b --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-points/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: A granularity call is a row-level expression in every position + +A call `gran(expr)` — `gran` a time granularity (case-insensitive) and `expr` a row-level expression typed DATE or TIMESTAMP (a bare or dotted column reference being the one-leaf case) — SHALL denote `expr` truncated to that granularity, with the same per-dialect bucketing as a time dimension, wherever a row-level expression is legal: filters, aggregation sources and parameters, computed-dimension sub-expressions and arithmetic. A whole `dimensions` or `time_dimensions` entry of that shape SHALL keep its time-dimension rewrite, and an order key of that shape SHALL keep its projected-time-dimension rule. A comparison between `gran(expr)` and a time point SHALL return exactly the rows of the equivalent bound on `expr`. A granularity callee with any other argument shape (`month()`, `month(a, b)`, `month(*)`, keyword arguments) SHALL fail with the typed error naming the required `gran(expr)` shape and the valid granularities; an argument that does not type DATE or TIMESTAMP SHALL fail with the date-operand type error naming the operand. + +#### Scenario: Granularity call in a filter + +- **WHEN** a filter is `month(created_at) >= '2024-03-15'` +- **THEN** it matches exactly the rows with `created_at >= 2024-04-01`, by executed values on SQLite and DuckDB + +#### Scenario: Granularity calls compared with each other + +- **WHEN** a filter is `month(shipped_at) = month(ordered_at)` +- **THEN** it matches the rows shipped in the month they were ordered + +#### Scenario: Granularity call inside a measure and a computed dimension + +- **WHEN** a query has the measure `count_distinct(month(created_at))` and the computed dimension `{"expression": "year(created_at)", "name": "y"}` +- **THEN** the measure counts distinct months and the dimension groups by year bucket, by executed values on SQLite and DuckDB + +#### Scenario: Granularity call over an expression + +- **WHEN** a filter is `month(coalesce(shipped_at, created_at)) >= '2024-03-15'` and a computed dimension is `{"expression": "year(CAST(created_txt AS DATE))", "name": "y"}` +- **THEN** the filter matches exactly the rows whose coalesced timestamp is at or after 2024-04-01 and the dimension groups by the year of the cast value, by executed values on SQLite and DuckDB + +#### Scenario: Wrong-shape granularity call in a filter + +- **WHEN** a filter contains `month()`, `month(a, b)` or `month(*)` +- **THEN** the query fails with the typed error naming the `gran(expr)` shape and the valid granularities + +#### Scenario: Non-temporal granularity argument + +- **WHEN** a filter contains `month(upper(x)) >= '2024-01-01'` +- **THEN** the query fails with the date-operand type error naming `upper(x)` + +#### Scenario: Granularity call against an instant + +- **WHEN** filters are `month(created_at) = '2025-03-15 10:00:00'` and `month(created_at) = '2025-03-01 00:00:00'` +- **THEN** the first matches no rows and the second matches the March rows diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-spine/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-spine/spec.md new file mode 100644 index 000000000..557b459ed --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/time-spine/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: An expression is never an axis + +A dataset's axis SHALL be a column, never an expression: a time dimension whose expression reads `time_spine.timestamp` other than as the whole bare column SHALL fail before any SQL is generated with a typed error naming the time dimension and the remedy of bucketing `time_spine.timestamp` itself. An expression time dimension that does not read the spine SHALL be an ordinary dimension of the spine query's P, exactly as a fact column's time dimension is. + +#### Scenario: Spine dimension over an expression rejected + +- **WHEN** a query buckets `time_spine.timestamp + INTERVAL '1 day'` at `month`, or `coalesce(time_spine.timestamp, orders.order_date)` at `month` +- **THEN** planning fails with the typed error naming the time dimension and the remedy, and no SQL is generated + +#### Scenario: A fact expression time dimension is a dimension of P + +- **WHEN** the two-fact Jan–Jun spine-month query also buckets `coalesce(orders.shipped_at, orders.order_date)` at `month` +- **THEN** it executes with population `spine × P`, P carrying that bucket, with the same values per (spine month, bucket) as the query with a column holding the coalesced value diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/variables/spec.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/variables/spec.md new file mode 100644 index 000000000..2e4be74e1 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/specs/queries/variables/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Time-dimension date ranges substitute variables; granularities and columns do not + +A time dimension's `date_range` bounds SHALL substitute `{var}` and SHALL then be validated as time points exactly like literal bounds. A placeholder inside a time dimension's expression SHALL substitute a literal exactly as in a computed dimension's expression, and an unnamed such time dimension SHALL take its name from the pre-substitution template. A placeholder in a time dimension's granularity, or one standing for its whole dimension, SHALL be rejected with an error stating that variables supply literal values and cannot name a granularity or a column. + +#### Scenario: Variable date range + +- **WHEN** a time dimension has `date_range: ["{start}", "{end}"]` with `variables={"start": "2024-01-01", "end": "2024-02-28"}` +- **THEN** rows are restricted to that range, by executed values on SQLite and DuckDB + +#### Scenario: Substituted bound is malformed + +- **WHEN** a time dimension has `date_range: ["{start}", null]` with `variables={"start": "2025/01/01"}` +- **THEN** execution fails with the typed date-range error listing the accepted time-point forms + +#### Scenario: Granularity or column placeholder + +- **WHEN** a time dimension has granularity `{g}`, or is written `"{g}(ordered_at)"`, or names column `{col}` +- **THEN** the query is rejected with the error stating variables cannot name a granularity or a column + +#### Scenario: Literal placeholder in a time-dimension expression + +- **WHEN** a time dimension is `month(coalesce(shipped_at, created_at + INTERVAL '{days}' DAY))` with `variables={"days": 30}` +- **THEN** rows with a NULL `shipped_at` are bucketed by `created_at` 30 days later, by executed values on SQLite and DuckDB, and its result key is derived from the template, not the value diff --git a/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/tasks.md b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/tasks.md new file mode 100644 index 000000000..6fb01a4a7 --- /dev/null +++ b/openspec/changes/dev-2119-row-level-expressions-in-every-column-position-time/tasks.md @@ -0,0 +1,63 @@ +## 1. Tracker records (pr-plan) + +- [x] 1.1 Plan approved (scope = all six column positions; dimension as text; default ranking key admits temporal expression dimensions; per-leaf re-bucketing; spine refusal only for expressions reading the spine) and Codex-reviewed; verify this change validates with `openspec validate --strict` + +## 2. Failing tests (pr-tests) + +Write tests from the specs of this change. Scenarios needing DEV-2101 forms (`CAST`, `INTERVAL`) fail for that reason too until DEV-2101 is merged into this branch; prefer `coalesce` / existing date functions where a scenario allows it. + +- [x] 2.1 `queries/time-dimensions` scenarios (expression time dimensions incl. cross-model, fanning leaf, non-temporal, undetermined type, aggregate inside, stage scope; every time position incl. `date_range`, `whole_periods_only` DATE vs TIMESTAMP with explicit / mirrored / synthesized bounds, `window=`, `change`, two-stage re-bucketing, parity with a derived column; names, result keys, `main_time_dimension` by name, `partition_by=` by name, collisions; per-leaf re-bucketing; text dump and schema; legacy object input; functional forms and order key), executed on SQLite and DuckDB; verify they fail +- [x] 2.2 `queries/time-points` granularity-call scenarios (expression argument in filter and computed dimension, non-temporal argument, wrong shapes); verify they fail where behaviour is new +- [x] 2.3 `queries/time-spine` scenarios (spine expression refused; fact expression time dimension in P); verify they fail +- [x] 2.4 `queries/partitioned-aggregates` expression partition-key scenarios (computed-dimension equivalence, combined-position error, finer-grain exemption, rank family, time-dimension source incl. the ambiguity error, unproven hop, aggregate-bearing key) and the mixed-source `first` scenario; verify they fail +- [x] 2.5 `aggregations/functional-form` scenarios (default ranking key incl. a temporal computed dimension and an expression time dimension's source; expression ranking key; fanning leaf in a ranking key for local and target-rooted producers; cross-model ranked aggregate with an expression key; separate vs shared producers by ranking key) and `aggregations/expression-aggregation` scenarios (`first` / `last` over expression and mixed sources, `first(1)`, expression parameters incl. positional, `corr`, cross-model, undetermined); verify they fail +- [x] 2.6 Ranked kernel coverage: expression value, expression ranking key, both together, joined leaves, attached constituent, windowed `first` / `last` with an expression key — executed values on SQLite and DuckDB; emission on every Tier-1 dialect; verify they fail +- [x] 2.7 `models/stored-upgrade` scenarios (object → text in source queries, saved queries and memories as YAML and SQLite rows; text untouched; unversioned client query untouched; 0.10.2 corpus); verify they fail +- [x] 2.8 Retarget, with the user's approval of each, the tests pinning refusals this change lifts, listing each in the commit message: `tests/test_time_points_exec.py:384` (`month(upper(code))` → date-operand type error), `tests/test_dev1883_errors.py:28` (`month(upper(x))` leaves the construction-time wrong shapes → binding temporal error), `tests/test_dev2015_time_dimensions.py:58` (`fiscal_year(upper(id))` → temporal error), `tests/test_dev1832_transform_source.py:300`, `tests/test_dev1903_first_last_dispatch.py:158` (incl. `first(1)`), `tests/test_dev1832_cross_model_exec.py:306`, `tests/test_dev1859_plan_structure.py:218` (`first` / `last` over an expression → executed values). Ranking-key error texts keep the substrings `ranks by a column` / `ranks by at most one column`, so `tests/test_agg_positional_params.py` and `tests/test_dev1946_transform_parameter.py` stay unchanged +- [x] 2.9 Codex review of the tests against this change + +## 3. Prerequisite + +- [x] 3.1 Merge DEV-2101 (merged to main, else its branch) into this branch; verify `slayer/core/syntax.py` and `CastKey` exist and the full unit suite is green before any implementation + +## 4. One bucket key + +- [x] 4.1 Rename `TimeTruncKey.column` → `source` with `la:deterministic-refactor`; verify `la-typecheck`, `dr-mock-lint` and the full unit suite +- [x] 4.2 Widen `source` to any row-level key; move `column_leaf` / `column_path` callers (`generator.py`, `stages.py`, `spine.py`, `bind_inputs.py`, `time_bounds.py`, `naming.py`, `ranked_planner.py`, `value_expr.py`) to the key's references; render truncation over any row-level source; verify golden baselines byte-identical + +## 5. Time dimensions + +- [x] 5.1 `TimeDimension.dimension` as text, legacy object / `column` alias coercion, `gran(expr)` functional forms, `name`, dedupe / strip / merge / variables / facade rebuilders, name uniqueness; verify 2.1 construction scenarios +- [x] 5.2 Binding of expression time dimensions (home, row-level check), checker typing with the source → type map, `date_range`, `whole_periods_only`, frame bounds, `window=` / transforms, result keys and names, `main_time_dimension` and `partition_by=` by name; verify 2.1 and 2.2 +- [x] 5.3 Every `full_name` / `name` / `model` consumer (`unrelated.py`, `model_reads.py`, `schema_drift.py`, `population.py`, `time_spine.py`) on parsed references; verify their suites +- [x] 5.4 Stage boundary: expression time dimensions emit a typed, bucketed `StageColumn` without single-column provenance; verify the two-stage scenarios +- [x] 5.5 Per-leaf re-bucketing; verify its scenarios +- [x] 5.6 Spine: refuse expressions reading `time_spine.timestamp`, admit other expression time dimensions into P; verify 2.3 +- [x] 5.7 Literal `{var}` placeholders in a time-dimension expression substitute as in a computed dimension and an unnamed one is named from its template; a whole-dimension or granularity placeholder stays refused; verify the `queries/variables` scenarios +- [x] 5.8 Equivalent time-dimension spellings that share their name and agree in date range and label dedupe at binding into one projected time dimension; verify the `queries/time-dimensions` dedupe-at-binding scenario + +## 6. Partition keys, ranking, parameters + +- [x] 6.1 `_bind_partition_keys` over row-level expressions, computed-dimension / bucket equivalence, source → bucket mapping with the ambiguity rule, aggregate-bearing key error with the computed-dimension remedy; verify 2.4 +- [x] 6.2 `explicit_ranking_key` (renamed from `explicit_ranking_time_arg`) over any row-level argument with both Axiom 2.4 safety paths; `_fold_positional_agg_args` ranking-key rule; default ranking key precedence; verify 2.5 ranking scenarios +- [x] 6.3 `first` / `last` over expression and mixed sources: lift the `_bind_agg` refusal; `_ranked_value_expr` / `_ranked_scope_expr` through the row-expression renderer with joined paths and row-attach substitution; verify 2.5 and 2.6 +- [x] 6.4 `_bind_agg_arg` over row-level expressions with parameter typing by the home grain; verify 2.5 parameter scenarios +- [x] 6.5 Rewrite the remedies pointing at an inline `ModelExtension` column (`binding.py` partition-key error, `help_content/02_workflow.md`); verify `grep` finds no such remedy + +## 7. Migration + +- [x] 7.1 Stored-only step (SlayerQuery, SlayerModel, Memory; versions after DEV-2101's) rewriting object-form time dimensions to text; verify 2.7 and the 0.10.2 corpus + +## 8. Architecture + +- [x] 8.1 Apply H1 and H2 exactly as in design.md with `enforced: test:` tags naming this change's tests; verify `la-arch-check` + +## 9. Docs and agent surfaces + +- [x] 9.1 `docs/concepts/queries.md` (TimeDimension text form and `name`, partition keys), `formulas.md` (`partition_by=`, `first` / `last` value and ranking key, expression parameters, default ranking key), `time.md` (spine and expressions); verify no doc shows the object form of a time dimension +- [x] 9.2 MCP `Field` descriptions (`TimeDimension`, `main_time_dimension`) and `slayer/memories/help_content/*`; verify `tests/test_mcp_surface_budget.py` and the documented-idioms tests +- [x] 9.3 Release-notes draft (untracked, never staged): the text dump of `TimeDimension.dimension`, Python attribute access, the default-ranking-key result change, the lifted refusals + +## 10. Gates + +- [x] 10.1 `poetry run pytest -m "not integration"`, the CI integration invocation, `poetry run ruff check slayer/ tests/`, `poetry run basedpyright` (no new errors vs baseline), `la-arch-check`; verify all green diff --git a/openspec/specs/models/join-traversal/spec.md b/openspec/specs/models/join-traversal/spec.md index e38ba9081..316c0ba0c 100644 --- a/openspec/specs/models/join-traversal/spec.md +++ b/openspec/specs/models/join-traversal/spec.md @@ -323,3 +323,15 @@ directed cycle SHALL fail closed when every candidate root is blocked by a fanni #### Scenario: Rootless query across a directed 3-cycle fails closed - **WHEN** a query without `source_model` selects `x.v`, `y.v` and `z.v` over `x → y → z → x` - **THEN** it fails with the no-viable-candidate error, and succeeds once `source_model` is given + +### Requirement: An extension join repeating a stored edge is that edge + +A query-level model extension's join that equals one of its base model's stored joins in every field except `description`, `meta` and pair order SHALL be that stored edge, applied once, for every model kind; an extension join differing in target, name, join type, cardinality or join pairs SHALL remain a separate edge. + +#### Scenario: Repeating a stored join in an extension +- **WHEN** an extension over a table-backed model repeats a stored join and a query reads through it +- **THEN** the query resolves over that single edge instead of failing as an ambiguous hop + +#### Scenario: A differing extension join stays parallel +- **WHEN** an extension adds an unnamed join to the same target with different join pairs +- **THEN** a bare hop to that target fails closed as ambiguous diff --git a/openspec/specs/queries/query-backed-inline/spec.md b/openspec/specs/queries/query-backed-inline/spec.md index c6ef3fadc..ee0a946ff 100644 --- a/openspec/specs/queries/query-backed-inline/spec.md +++ b/openspec/specs/queries/query-backed-inline/spec.md @@ -123,3 +123,35 @@ cycle. A planning failure unrelated to a cycle SHALL keep its own error. #### Scenario: Direct and transitive cycles - **WHEN** `A` consumes `A`, or `A` consumes `B` which consumes `A`, and a consumer of `A` executes, `A` runs by name, `A` is saved, or `A`'s column types are requested - **THEN** each raises the same cycle error naming the path + +### Requirement: Saved relation relationships survive splicing + +A stored query-backed model consumed as a relation SHALL retain that model's own declared joins, with keys resolved against its final-stage output columns. Those relationships SHALL remain available when the relation is a source, a join target or an extended source. Ordinary query-stage outputs SHALL remain flat and SHALL NOT inherit joins from their backing source. + +#### Scenario: Joined segment filter on a saved relation +- **WHEN** a saved customer-total relation declares a customer-key join and a query filters to gold customers +- **THEN** only the related gold customers contribute, with the same values as an equivalent relationship declared solely on a query extension, on SQLite and DuckDB + +#### Scenario: Joined dimensions and aggregate attribution +- **WHEN** a query reads a dimension or aggregate through a saved relation's declared join +- **THEN** the relationship determines the rows and attribution instead of being treated as an unrelated dataset + +#### Scenario: A saved relation consumed through another model +- **WHEN** another model joins a saved query-backed relation and a query traverses onward through that relation's own join +- **THEN** the declared relationships remain available with logical key spelling and correct values + +#### Scenario: Extending a saved relation +- **WHEN** a query extension adds a distinct named join to a saved relation +- **THEN** both its stored joins and the extension's joins remain available + +#### Scenario: Repeating an existing saved relationship +- **WHEN** an extension over a saved query-backed relation repeats an exactly identical declared join +- **THEN** that edge is applied once, without introducing an ambiguous parallel path + +#### Scenario: Legal join targets do not collide with temporary identities +- **WHEN** a saved relation declares a join to a legal model named `_stage` +- **THEN** both unjoined and joined consumers execute without a false self-join error + +#### Scenario: Ordinary stage isolation +- **WHEN** an ordinary query stage is consumed by a later stage +- **THEN** only its projected output schema is visible and its backing source's joins are not inherited diff --git a/poetry.lock b/poetry.lock index 4ab1a1c80..10a1ee37e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3554,14 +3554,14 @@ utils = ["numpydoc"] [[package]] name = "living-architecture" -version = "0.3.1" +version = "0.3.3" description = "Spec-driven development with an enforced living architecture, as a Claude Code plugin plus its CLI tools." optional = false python-versions = ">=3.11" groups = ["dev"] files = [ - {file = "living_architecture-0.3.1-py3-none-any.whl", hash = "sha256:d4b060d3a34912188cae4c7323b30e5083ee662020c55af74da456e59ae723ee"}, - {file = "living_architecture-0.3.1.tar.gz", hash = "sha256:4f0d7027ea3a6cf5b52285c86bd49f63c4f018ce6425e787c6fdcccfb45f062b"}, + {file = "living_architecture-0.3.3-py3-none-any.whl", hash = "sha256:bb70a3f4eee475465699c411cde7db438224c66f6ec6a94e944c5c45f5335359"}, + {file = "living_architecture-0.3.3.tar.gz", hash = "sha256:a523b19cc4083729f2469ed72802c0917bae4079e9799d734a8640b82cac590d"}, ] [package.dependencies] @@ -3569,7 +3569,7 @@ jsonschema = ">=4.18" pydantic = ">=2.6" pyyaml = ">=6" referencing = ">=0.28.4" -rope = ">=1.13" +rope = ">=1.15" [package.extras] verify = ["basedpyright (>=1.20)"] @@ -8297,4 +8297,4 @@ trino = ["trino"] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "bea59f700fb0ee40d6f20183dbe0341ac5238cb3fceaa945925dc8922864df8b" +content-hash = "04c3412d0d0c48a2590268d96672e025d614830673c05d1f25a998ff02144e4a" diff --git a/pyproject.toml b/pyproject.toml index 6547e3a00..8b20883cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "motley-slayer" -version = "1.4.0" +version = "1.4.1" description = "A lightweight, agent-first semantic layer for AI agents" requires-python = ">=3.11" license = "MIT" @@ -133,7 +133,7 @@ jpype1 = "^1.5.0" testcontainers = {extras = ["mysql", "clickhouse", "mssql", "trino"], version = "^4.0"} basedpyright = "^1.39.10" pytest-xdist = ">=3.6" -living-architecture = "0.3.1" +living-architecture = "0.3.3" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/slayer/core/errors.py b/slayer/core/errors.py index b5c9f1fb4..2c98fb45e 100644 --- a/slayer/core/errors.py +++ b/slayer/core/errors.py @@ -395,7 +395,7 @@ class UnknownGranularityError(QueryTypeError): class GranularityShapeError(QueryTypeError): - """A datasource-granularity call that is not a single column reference ``gran(col)``.""" + """A datasource-granularity call that does not take one row-level argument ``gran(expr)``.""" class TimeSpineError(QueryTypeError): @@ -881,10 +881,10 @@ class GranularityCallError(SlayerError, ValueError): @classmethod def wrong_shape(cls, entry: str) -> "GranularityCallError": - """A granularity callee with any argument shape other than one column reference.""" + """A granularity callee with any argument shape other than one row-level argument.""" return cls( - f"Granularity call {entry!r} must be a single column reference " - f"``gran(col)`` (e.g. ``month(created_at)``) for one of: " + f"Granularity call {entry!r} must take one row-level argument " + f"``gran(expr)`` (e.g. ``month(created_at)``) for one of: " f"{', '.join(g.value for g in TimeGranularity)}." ) diff --git a/slayer/core/join_edges.py b/slayer/core/join_edges.py index 7567408da..69ee07eb5 100644 --- a/slayer/core/join_edges.py +++ b/slayer/core/join_edges.py @@ -18,6 +18,7 @@ "relationship_signature", "resolve_join_ref", "resolve_join_refs", + "same_edge", "without_joins", ] @@ -58,6 +59,15 @@ def _pair_set(pairs: list[list[str]]) -> frozenset[tuple[str, str]]: return frozenset((p[0], p[1]) for p in pairs) +def same_edge(a: ModelJoin, b: ModelJoin) -> bool: + """Whether two declarations on one model are one edge: equal in all but metadata and pair order.""" + return ( + (a.target_model, a.name, a.join_type, a.cardinality) + == (b.target_model, b.name, b.join_type, b.cardinality) + and _pair_set(a.join_pairs) == _pair_set(b.join_pairs) + ) + + def _joins_to(model: SlayerModel, target: str) -> list[ModelJoin]: return [j for j in model.joins if j.target_model == target] diff --git a/slayer/core/keys.py b/slayer/core/keys.py index dd0ebeea3..507f6ca60 100644 --- a/slayer/core/keys.py +++ b/slayer/core/keys.py @@ -316,14 +316,13 @@ def map_children( class TimeTruncKey(_FrozenKey, frozen=True): - """Row-level reference to a time-truncated column, keyed by (column, granularity). + """A row-level value truncated to a granularity, keyed by (source, granularity). - ``column`` is a ``ColumnKey`` (base temporal column) or ``ColumnSqlKey`` - (derived). ``granularity`` is resolved: built-in, or a datasource definition. - Different granularities on the same column are distinct slots. + ``source`` is any row-level key, a column being the one-leaf case. + ``granularity`` is resolved: built-in, or a datasource definition. """ - column: ColumnKey | ColumnSqlKey + source: "ValueKey" granularity: Granularity @property @@ -331,23 +330,23 @@ def phase(self) -> Phase: return Phase.ROW def children(self) -> Tuple["ValueKey", ...]: - return (self.column,) + return (self.source,) def map_children( self, fn: Callable[["ValueKey"], "ValueKey"], ) -> "TimeTruncKey": m = _ChildMapper(fn) - column = m(self.column) - return self.model_copy(update={"column": column}) if m.changed else self + source = m(self.source) + return self.model_copy(update={"source": source}) if m.changed else self def column_leaf(col: ColumnKey | ColumnSqlKey) -> str: - """Leaf column name of a ``TimeTruncKey.column`` regardless of kind.""" + """Leaf column name of a ``TimeTruncKey.source`` regardless of kind.""" return getattr(col, "leaf", None) or getattr(col, "column_name") def column_path(col: ColumnKey | ColumnSqlKey) -> Tuple[str, ...]: - """Join path of a ``TimeTruncKey.column`` regardless of kind.""" + """Join path of a ``TimeTruncKey.source`` regardless of kind.""" return col.path @@ -405,15 +404,14 @@ def __eq__(self, other: object) -> bool: "ArithmeticKey", "ScalarCallKey", "LiteralKey", "AggregateKey", "TransformKey", "InKey", "TimePointCmpKey", "CastKey", ] -# Positional and kwarg arg values share one union: `last(created_at)` binds an -# identifier column, `weighted_avg(weight=qty)` a column, +# Positional and kwarg arg values share one union: `last(amount, created_at)` binds a +# ranking column, `weighted_avg(weight=qty * 2)` a row-level expression, # `weighted_avg(weight=count(id, partition_by=…))` an aggregate, and # `weighted_avg(weight=rank(sum(amount, partition_by=…), direction='desc'))` a grained transform — # all via `_bind_agg_arg`. _AggregateArgValue = Union[ ColumnKey, ColumnSqlKey, SqlFragmentKey, "AggregateKey", "TransformKey", - # A FILTER-masked row parameter. - "ScalarCallKey", + "ScalarCallKey", "ArithmeticKey", "CastKey", "InKey", TimeTruncKey, "LiteralKey", Decimal, str, bool, None, ] _AggregateKwargValue = _AggregateArgValue @@ -1189,7 +1187,7 @@ def substitute_value_keys( Pre-order match-before-recurse: a key equal to a ``mapping`` entry is replaced atomically (children never traversed, replacements never re-substituted); everything else routes through ``map_children`` (a - protocol-less kind raises). ``TimeTruncKey.column`` IS traversed. + protocol-less kind raises). ``TimeTruncKey.source`` IS traversed. """ # Scalars ride through untouched (ScalarCallKey args, AggregateKey kwargs). if key is None or isinstance(key, (Decimal, str, bool, int, float)): @@ -1285,7 +1283,7 @@ def temporal_type(key: object, *, column_type: ColumnTypeFn) -> Optional[DataTyp t = column_type(key) return t if t in TEMPORAL_TYPES else None if isinstance(key, TimeTruncKey): - return temporal_type(key.column, column_type=column_type) + return temporal_type(key.source, column_type=column_type) if isinstance(key, LiteralKey): key = key.value if isinstance(key, datetime): @@ -1721,7 +1719,7 @@ def source_row_leaves(source: ValueKey) -> List[ValueKey]: def _walk(k: ValueKey) -> None: if isinstance(k, (AggregateKey, TransformKey)): return - if isinstance(k, (ColumnKey, ColumnSqlKey, TimeTruncKey, StarKey)): + if isinstance(k, (ColumnKey, ColumnSqlKey, StarKey)): out.append(k) return for c in k.children(): @@ -1739,13 +1737,6 @@ def parameter_row_leaves(value) -> List[ValueKey]: return source_row_leaves(cast(ValueKey, value)) -def _leaf_join_path(k: ValueKey) -> Tuple[str, ...]: - """Join path of a row-level leaf key regardless of kind.""" - if isinstance(k, TimeTruncKey): - return column_path(k.column) - return getattr(k, "path", ()) - - def source_leaf_paths(source: ValueKey) -> List[Tuple[str, ...]]: """Join paths of the row-level column/star leaves of an aggregation source. @@ -1757,8 +1748,8 @@ def source_leaf_paths(source: ValueKey) -> List[Tuple[str, ...]]: def _walk(k: ValueKey) -> None: if isinstance(k, (AggregateKey, TransformKey)): return - if isinstance(k, (ColumnKey, ColumnSqlKey, TimeTruncKey, StarKey)): - out.append(_leaf_join_path(k)) + if isinstance(k, (ColumnKey, ColumnSqlKey, StarKey)): + out.append(k.path) return for c in k.children(): _walk(c) diff --git a/slayer/core/query.py b/slayer/core/query.py index a165ebe68..36c5e1381 100644 --- a/slayer/core/query.py +++ b/slayer/core/query.py @@ -36,7 +36,7 @@ ) from slayer.core.refs import auto_name_from_expression from slayer.core.time_points import TIME_POINT_FORMS, is_time_point_shape -from slayer.core.syntax import AggCall, DottedRef, Ref, canonical_measure_text, parse_expr +from slayer.core.syntax import AggCall, DottedRef, Ref, StarSource, canonical_measure_text, parse_expr from slayer.sql.window_detect import WINDOW_IN_FILTER_ERROR, has_window_function from slayer.storage.migrations import CURRENT_VERSIONS, migrate as _migrate_schema @@ -47,6 +47,7 @@ _NAME_PATTERN = re.compile(r"^[a-zA-Z_]\w*$", re.ASCII) _VAR_PATTERN = re.compile(r"\{\{|\}\}|\{([a-zA-Z_]\w*)\}|\{([^}]*)\}", re.ASCII) +_WHOLE_PLACEHOLDER_RE = re.compile(r"^\s*\{[a-zA-Z_]\w*\}\s*$", re.ASCII) # Leading callee of a whole-string single call ``name( ... )``; used only when # ``parse_expr`` rejects the shape (e.g. ``month()``) but the callee still names a @@ -78,9 +79,10 @@ def _single_col_of_source(node: AggCall) -> str | None: def call_callee(entry: str) -> tuple[Any, str | None]: - """``entry`` parsed (``None`` when unparseable) and its whole-call callee, if any.""" + """``entry`` parsed (``None`` when unparseable) and its whole-call callee, if any; placeholders + parse as probe literals, since only the shape is read.""" try: - node: Any = parse_expr(entry) + node: Any = parse_expr(render_probe_text(entry)) except Exception: node = None if isinstance(node, AggCall): @@ -96,27 +98,41 @@ def functional_call_column(node: Any) -> str | None: return None +def granularity_call_argument(node: Any, entry: str) -> str | None: + """The argument text of a single-argument call ``name(expr)``, else ``None``.""" + if not isinstance(node, AggCall) or node.args or node.kwargs or isinstance(node.source, StarSource): + return None + if not _WHOLE_CALL_RE.match(entry): + return _single_col_of_source(node) # the legacy ``col:gran`` spelling + text = entry.strip() + return text[text.index("(") + 1:-1].strip() + + +def _callee_granularity(callee: str) -> str: + return callee.lower() if callee.lower() in GRANULARITY_NAMES else callee + + def time_dimension_from_functional(entry: str, *, names: "frozenset[str] | set[str]") -> dict | None: - """A ``gran(col)`` string whose casefolded callee is in ``names`` → its ``TimeDimension`` + """A ``gran(expr)`` string whose casefolded callee is in ``names`` → its ``TimeDimension`` dict; another callee or a non-call → ``None``; a granularity callee of any other shape → raise.""" node, callee = call_callee(entry) if callee is None or callee.casefold() not in names: return None - col = functional_call_column(node) - if col is None: + arg = granularity_call_argument(node, entry) + if arg is None: _reject_call_placeholders(entry) raise GranularityCallError.wrong_shape(entry) - return {"dimension": col, "granularity": callee.lower() if callee.lower() in GRANULARITY_NAMES else callee} + return {"dimension": arg, "granularity": _callee_granularity(callee)} def granularity_call_parts(entry: str) -> tuple[str, str] | None: - """``(col, callee)`` for a well-formed single-column call string, else ``None`` (never raises); + """``(expr, callee)`` for a well-formed single-argument call string, else ``None`` (never raises); used to resolve a functional order key against projected time dimensions.""" node, callee = call_callee(entry) - col = functional_call_column(node) - if callee is None or col is None: + arg = granularity_call_argument(node, entry) + if callee is None or arg is None: return None - return col, (callee.lower() if callee.lower() in GRANULARITY_NAMES else callee) + return arg, _callee_granularity(callee) def _split_functional_dimensions(dims: "list | tuple") -> tuple[list, list]: @@ -144,14 +160,14 @@ def _coerce_time_dimension_entry(entry: Any) -> Any: if td is not None: return td node, callee = call_callee(entry) - col = functional_call_column(node) - if callee is None or col is None: + arg = granularity_call_argument(node, entry) + if callee is None or arg is None: raise GranularityCallError( f"Time dimension {entry!r} must be the functional form " - f"``gran(col)`` (e.g. ``month(created_at)``) for one of: " + f"``gran(expr)`` (e.g. ``month(created_at)``) for one of: " f"{_granularity_names()} or a datasource granularity; no default granularity is invented." ) - return {"dimension": col, "granularity": callee} + return {"dimension": arg, "granularity": callee} def _rewrite_functional_granularity(data: dict) -> dict: @@ -449,12 +465,13 @@ def _render_block_segments(filter_str: str, variables: dict, replace_fn) -> str: def query_variable_surfaces(query: "SlayerQuery") -> list[str]: """Every text of ``query`` that substitutes ``{var}``: filters, measure formulas, - computed-dimension expressions, order expressions and ``date_range`` bounds.""" + computed-dimension and time-dimension expressions, order expressions and ``date_range`` bounds.""" return [ *(query.filters or []), *(m.formula for m in query.measures or []), *(d.expression for d in query.dimensions or [] if isinstance(d, ComputedDimension)), *(o.raw_formula for o in query.order or [] if o.raw_formula), + *(td.dimension for td in query.time_dimensions or []), *(b for td in query.time_dimensions or [] for b in td.date_range or [] if b is not None), ] @@ -673,11 +690,24 @@ def _fill_name(self) -> "ComputedDimension": def computed_dimension_name(expression: str) -> str: """An unnamed computed dimension's name: its expression's canonical text, sanitised, so every spelling of one expression names it alike.""" + return auto_name_from_expression(_canonical_expression_text(expression)) + + +def _time_dimension_name_identity(td: "TimeDimension") -> tuple[str, tuple] | None: + """A named or expression time dimension's name and what it may repeat for; ``None`` for an unnamed column.""" + if td.name is None and td.column_ref is not None: + return None + text = _canonical_expression_text(td.template) + if td.name is not None: + return td.name, ("named", text, str(td.granularity)) + return computed_dimension_name(td.template), ("auto", text) + + +def _canonical_expression_text(expression: str) -> str: try: - text = canonical_measure_text(parse_expr(expression)) + return canonical_measure_text(parse_expr(expression)) except Exception: # noqa: BLE001 — an unparseable expression is reported at binding - text = expression - return auto_name_from_expression(text) + return expression def _coerce_column_ref(v: Any) -> Any: @@ -773,19 +803,44 @@ def _date_range_problem(date_range: list[str | None]) -> str | None: def _reject_time_dimension_placeholders(entry: Any, *, granularity: Any = None, column: Any = None) -> None: - """Variables supply literals: a ``{var}`` naming a time dimension's granularity or column is refused.""" - for what, value in (("granularity", granularity), ("column", column)): - if isinstance(value, str) and _bare_variable_names(value): + """Variables supply literals: a ``{var}`` naming a time dimension's granularity, or standing for its + whole dimension, is refused; one inside an expression is a literal.""" + for what, value, names_it in ( + ("granularity", granularity, lambda text: bool(_bare_variable_names(text))), + ("column", column, lambda text: _WHOLE_PLACEHOLDER_RE.match(text) is not None), + ): + if isinstance(value, str) and names_it(value): raise ValueError( f"Time dimension {entry!r}: variables supply literal values; they can't name a {what} ({value!r})." ) +def _legacy_column_ref_text(value: Any) -> Any: + """The dotted text of a legacy column-reference object (``{"name", "model"}`` or a ``ColumnRef``).""" + if isinstance(value, ColumnRef): + return value.full_name + if isinstance(value, dict) and isinstance(name := value.get("name"), str): + return f"{value['model']}.{name}" if value.get("model") else name + return value + + +def _coerce_time_dimension_text(value: Any) -> Any: + """A time dimension's dimension as Mode-B text; it must parse as an expression.""" + value = _legacy_column_ref_text(value) + if isinstance(value, str) and not _bare_variable_names(value): + try: + parse_expr(value) + except Exception as exc: + raise ValueError(f"Time dimension {value!r} is not a row-level expression: {exc}") from exc + return value + + class TimeDimension(BaseModel): """A time-bucketed group-by: ``dimension`` truncated to ``granularity``.""" - dimension: Annotated[ColumnRef, BeforeValidator(_coerce_column_ref)] = Field( + dimension: Annotated[str, BeforeValidator(_coerce_time_dimension_text)] = Field( validation_alias=AliasChoices("dimension", "column"), ) + name: str | None = None granularity: GranularitySpec = Field( description="A built-in granularity, or a custom granularity defined on the query's datasource.", ) @@ -801,21 +856,43 @@ class TimeDimension(BaseModel): ), ) label: str | None = None + # Pre-substitution dimension; an unnamed expression is named from it. + _template: str | None = PrivateAttr(default=None) + + @property + def template(self) -> str: + """The dimension as written, before ``{var}`` substitution.""" + return self._template or self.dimension @model_validator(mode="before") @classmethod def _reject_placeholder_names(cls, data: Any) -> Any: if isinstance(data, dict): - _reject_time_dimension_placeholders( - data, granularity=data.get("granularity"), column=data.get("dimension", data.get("column")), - ) + dimension = data.get("dimension", data.get("column")) + _reject_time_dimension_placeholders(data, granularity=data.get("granularity"), column=dimension) + if isinstance(dimension, dict) and dimension.get("label") is not None and data.get("label") is None: + data = {**data, "label": dimension["label"]} return data + @property + def column_ref(self) -> ColumnRef | None: + """The dimension as a column reference when it is one, else ``None``.""" + return ColumnRef.from_string(self.dimension) if _is_valid_column_ref_name(self.dimension) else None + + @property + def effective_name(self) -> str | None: + """``name``, else an expression's sanitised template; ``None`` for an unnamed column.""" + if self.name is not None or self.column_ref is not None: + return self.name + return computed_dimension_name(self.template) + @model_validator(mode="after") def _check_date_range(self) -> "TimeDimension": + if self.name is not None: + _validate_model_name(self.name, "Time dimension") if self.date_range is not None and (problem := _date_range_problem(self.date_range)): raise ValueError( - f"time dimension {self.dimension.full_name!r}: date_range {self.date_range!r} {problem}." + f"time dimension {self.dimension!r}: date_range {self.date_range!r} {problem}." ) return self @@ -1039,19 +1116,21 @@ def _strip_column_ref(ref, model_name: str): return ref +def _model_prefix_pattern(model_name: str) -> re.Pattern: + """A leading ``.`` qualifier, never an inner hop of a longer path.""" + return re.compile(r"(? list[TimeDimension] | None: - """The prefix-stripped time dimensions, or ``None`` when nothing changed.""" + """The prefix-stripped column time dimensions (an expression keeps its text, as a computed + dimension does), or ``None`` when nothing changed.""" if not tds: return None - new_tds = [] - for td in tds: - stripped = _strip_column_ref(td.dimension, model_name) - if stripped is td.dimension: - new_tds.append(td) - else: - new_tds.append(TimeDimension( - dimension=stripped, granularity=td.granularity, date_range=td.date_range, label=td.label, - )) + new_tds = [ + td if td.column_ref is None or (stripped := _strip_column_ref(td.column_ref, model_name)) is td.column_ref + else td.model_copy(update={"dimension": stripped.full_name}) + for td in tds + ] return None if all(n is o for n, o in zip(new_tds, tds)) else new_tds @@ -1233,8 +1312,9 @@ def _validate_query_name(cls, v: str | None) -> str | None: json_schema_extra=_advertise_string_time_dimensions, description=( "Time-bucketed group-bys — one result row per bucket. Each entry is a " - "TimeDimension dict or the functional string gran(col), e.g. " - "\"month(created_at)\". Buckets without rows are absent. To return every bucket, " + "TimeDimension dict or the functional string gran(expr), e.g. " + "\"month(created_at)\"; the dimension may be any DATE/TIMESTAMP expression, with an " + "optional name. Buckets without rows are absent. To return every bucket, " "empty ones included, bucket time_spine.timestamp with a lower date_range bound and " "measure models by dotted path: " '{"dimension": "time_spine.timestamp", "granularity": "month", "date_range": "2025"} ' @@ -1245,7 +1325,7 @@ def _validate_query_name(cls, v: str | None) -> str | None: main_time_dimension: str | None = Field( default=None, description=( - "Name of the time dimension that time-ordered transforms (change, lag, ...) " + "Name (or column) of the time dimension that time-ordered transforms (change, lag, ...) " "key off; overrides auto-detection when the query has multiple time dimensions." ), ) @@ -1340,21 +1420,20 @@ def _validate_dsl_user_input(self) -> "SlayerQuery": @model_validator(mode="after") def _dedupe_time_dimensions(self) -> "SlayerQuery": - """Collapse exact-duplicate time dimensions; reject same column+granularity - differing in date range or label (their result keys would collide). Compares - on the source-model-prefix-stripped column so ``created_at`` and - ``orders.created_at`` (the same column) are treated as one.""" - if not self.time_dimensions: + """Collapse exact-duplicate time dimensions; reject same text+granularity differing + in date range, name or label. Compares source-model-prefix-stripped text, so + ``created_at`` and ``orders.created_at`` are one.""" + if not (tds := self.time_dimensions): return self model_name = self.source_model_name def _canon(td: TimeDimension) -> tuple: - col = _strip_column_ref(td.dimension, model_name) if model_name else td.dimension - return (col.full_name, td.granularity, tuple(td.date_range or ()), td.label) + text = _model_prefix_pattern(model_name).sub("", td.template) if model_name else td.template + return (text, td.granularity, tuple(td.date_range or ()), td.name, td.label) seen: dict[tuple[str, str], tuple] = {} result: list[TimeDimension] = [] - for td in self.time_dimensions: + for td in tds: canon = _canon(td) base = (canon[0], str(canon[1])) prior = seen.get(base) @@ -1363,14 +1442,35 @@ def _canon(td: TimeDimension) -> tuple: result.append(td) elif prior != canon: raise GranularityCallError( - f"Conflicting time dimensions on {td.dimension.full_name!r} at " - f"{td.granularity} granularity: same column and " - f"granularity must not differ in date range or label." + f"Conflicting time dimensions on {td.dimension!r} at " + f"{td.granularity} granularity: same dimension and " + f"granularity must not differ in date range, name or label." ) - if len(result) != len(self.time_dimensions): + if len(result) != len(tds): self.time_dimensions = result + self._check_unique_dimension_names() return self + def _check_unique_dimension_names(self) -> None: + """Time-dimension names, an unnamed expression's included, are unique across dimensions and + time dimensions; a name repeats only for its own expression — an auto-name at any granularity + (suffixes tell them apart), an explicit one at the same granularity — which binding dedupes or + rejects.""" + taken: set[str] = set() + for dim in self.dimensions or (): + if (name := dim.name if isinstance(dim, ComputedDimension) else dim.full_name) is not None: + taken.add(name) + identities: dict[str, tuple] = {} + for td in self.time_dimensions or (): + if (named := _time_dimension_name_identity(td)) is None: + continue + name, identity = named + if name in taken or identities.setdefault(name, identity) != identity: + raise ValueError( + f"Dimension name {name!r} is used twice; give the time dimension {td.dimension!r} a " + f"distinct `name`." + ) + def _validate_distinct_dimension_values(self) -> None: """Cheap, model-free rejection for ``distinct_dimension_values=False``: non-empty ``measures``, or both ``dimensions``/``time_dimensions`` empty. Deep filter/order @@ -1414,7 +1514,7 @@ def strip_source_model_prefix(self) -> "SlayerQuery": return self updates: dict[str, Any] = {} - pattern = re.compile(r"\b" + re.escape(model_name) + r"\.") + pattern = _model_prefix_pattern(model_name) if self.dimensions: new_dims = [_strip_column_ref(d, model_name) for d in self.dimensions] @@ -1510,6 +1610,10 @@ def _canonical_ref(ref: ColumnRef, model_name: str | None) -> ColumnRef: return _strip_column_ref(ref, model_name) if model_name else ref +def _canonical_text(text: str, model_name: str | None) -> str: + return _model_prefix_pattern(model_name).sub("", text) if model_name else text + + def _union( saved: list | None, refined: list | None, *, field: str, canon: Callable[[Any], Any], identity: Callable[[Any], tuple[str, str]], @@ -1572,15 +1676,15 @@ def _merge_attribute(*, saved: Any, refined: Any, key: str, attribute: str) -> A def _merge_time_dimensions( saved: list[TimeDimension] | None, refined: list[TimeDimension] | None, model_name: str | None, ) -> list[TimeDimension] | None: - """Per (column, granularity): append new keys, merge ``date_range`` / ``label`` of existing ones.""" + """Per (dimension, granularity): append new keys, merge ``date_range`` / ``name`` / ``label`` of existing ones.""" if not refined: return saved merged = list(saved or []) positions: dict[tuple[str, str], int] = {} for i, td in enumerate(merged): - positions.setdefault((_canonical_ref(td.dimension, model_name).full_name, granularity_key(td.granularity)), i) + positions.setdefault((_canonical_text(td.dimension, model_name), granularity_key(td.granularity)), i) for td in refined: - slot = (_canonical_ref(td.dimension, model_name).full_name, granularity_key(td.granularity)) + slot = (_canonical_text(td.dimension, model_name), granularity_key(td.granularity)) i = positions.get(slot) if i is None: positions[slot] = len(merged) @@ -1591,7 +1695,7 @@ def _merge_time_dimensions( attribute: _merge_attribute( saved=getattr(prior, attribute), refined=getattr(td, attribute), key=key, attribute=attribute, ) - for attribute in ("date_range", "label") + for attribute in ("date_range", "name", "label") } merged[i] = prior.model_copy(update=update) return merged diff --git a/slayer/core/refs.py b/slayer/core/refs.py index d077bb260..b72b5dcdf 100644 --- a/slayer/core/refs.py +++ b/slayer/core/refs.py @@ -84,7 +84,7 @@ def auto_name_from_expression(expression: str) -> str: ("column_name", attrgetter("column_name")), )), TimeTruncKey: ("TimeTruncKey", ( - ("column", attrgetter("column")), + ("column", attrgetter("source")), ("granularity", lambda k: str(k.granularity)), )), StarKey: ("StarKey", (("path", attrgetter("path")),)), @@ -195,7 +195,7 @@ def agg_signature_suffix( def _partition_key_display(key: Any) -> str: if isinstance(key, TimeTruncKey): - key = key.column + key = key.source if isinstance(key, ColumnKey): parts = [*key.path, key.leaf] elif isinstance(key, ColumnSqlKey): @@ -208,7 +208,7 @@ def _partition_key_display(key: Any) -> str: def dotted_key_display(pk: ValueKey) -> str: """Human-readable dotted path for a key in error messages.""" if isinstance(pk, TimeTruncKey): - return dotted_key_display(pk.column) + return dotted_key_display(pk.source) return key_display(pk) @@ -292,7 +292,7 @@ def key_display(key: ValueKey) -> str: if leaf is not None: return leaf if isinstance(key, TimeTruncKey): - return f"{key.granularity}({key_display(key.column)})" + return f"{key.granularity}({key_display(key.source)})" if isinstance(key, AggregateKey): return _aggregate_display(key) if isinstance(key, TransformKey): @@ -357,8 +357,8 @@ def agg_kwarg_canonical_str(value: Any) -> str: return _transform_key_canonical_str(value) if isinstance(value, SqlFragmentKey): return sql_fragment_digest(value) - if isinstance(value, ScalarCallKey): - # A FILTER-masked row parameter. + if isinstance(value, (ScalarCallKey, ArithmeticKey, CastKey, InKey, TimeTruncKey, LiteralKey)): + # A row-level expression parameter (a FILTER-masked one included). return auto_name_from_expression(key_display(value)) raise TypeError( f"AggregateKey kwarg value of type {type(value).__name__!r} " diff --git a/slayer/core/time_spine.py b/slayer/core/time_spine.py index 1e97d0703..a28bb33ea 100644 --- a/slayer/core/time_spine.py +++ b/slayer/core/time_spine.py @@ -89,9 +89,10 @@ def mentions_spine(text: str) -> bool: def is_spine_query(query: SlayerQuery) -> bool: """A query rooted at the spine, or reading it in a time dimension, dimension or filter.""" dims = [getattr(d, "expression", None) or getattr(d, "full_name", "") for d in query.dimensions or []] + tds = [td.dimension for td in query.time_dimensions or []] return query.source_model_name == TIME_SPINE_MODEL or any( - names_spine(td.dimension.full_name) for td in query.time_dimensions or [] - ) or any(mentions_spine(t) for t in [*dims, *(query.filters or [])]) + mentions_spine(t) for t in [*dims, *tds, *(query.filters or [])] + ) def query_host(model: SlayerModel, *, query: SlayerQuery) -> SlayerModel: diff --git a/slayer/engine/bind_inputs.py b/slayer/engine/bind_inputs.py index 59a1b5ee9..371bafa1d 100644 --- a/slayer/engine/bind_inputs.py +++ b/slayer/engine/bind_inputs.py @@ -11,19 +11,19 @@ from slayer.core.enums import BUILTIN_AGGREGATIONS, DataType, TimeGranularity, normalize_aggregation_name, TIME_TRANSFORMS from slayer.core.errors import ( AmbiguousJoinPathError, + SlayerError, AmbiguousReferenceError, CircularJoinPathError, GranularityCallError, UnknownReferenceError, ) from slayer.core.format import NumberFormat -from slayer.core.time_spine import is_spine, is_spine_query, names_spine +from slayer.core.time_spine import TIME_SPINE_COLUMN, TIME_SPINE_MODEL, is_spine, is_spine_query, mentions_spine from slayer.core.join_walker import resolve_hop, resolved_terminal, walk_terminal from slayer.core.keys import ( AggregateKey, ArithmeticKey, ColumnKey, - ColumnSqlKey, Grain, LiteralKey, Phase, @@ -37,6 +37,7 @@ attached_operand_keys, is_reaggregation_key, rewrite_rank_partition_keys, + source_anchor_path, walk_value_keys, window_kwarg_of, ) @@ -50,6 +51,7 @@ call_callee, computed_dimension_name, functional_call_column, + granularity_call_argument, granularity_call_parts, ) from slayer.core.refs import ( @@ -85,11 +87,16 @@ check_raw_rows_filter_measure_ref, check_raw_rows_order_measure_ref, check_time_dimension_column, + check_time_dimension_home, + check_time_dimension_row_level, + check_spine_time_dimension, + time_dimension_type, check_transform_inputs, check_transform_partition_keys_in_operand_grain, check_time_transforms_resolved, resolve_time_points, ) +from slayer.engine.home import row_expression_crossing from slayer.engine.join_safety import assert_partition_key_attributable from slayer.engine.key_metadata import ( measure_key_format_description, @@ -110,6 +117,7 @@ from slayer.ir.bound import ( GranularityConflict, BoundExpr, + BoundTimeDimension, BoundFilter, DeclaredMeasure, OrderSpec, @@ -307,6 +315,7 @@ def bind_query_inputs( # NOSONAR(S3776) — one cohesive bind pass. The stages check_spine_clash(clash=bundle.spine_clash and is_spine_query(query)) _check_column_granularities(bundle=bundle) query = _resolve_granularity_calls(query, bundle=bundle) + query = _dedupe_equivalent_time_dimensions(query, scope=scope, bundle=bundle) # Runs BEFORE binding so the targeted error wins over the binder's generic one. if query.distinct_dimension_values is False: @@ -349,6 +358,12 @@ def bind_query_inputs( # NOSONAR(S3776) — one cohesive bind pass. The stages and dm.public_name is not None and dm.public_name in _computed_names } + _, _td_alias_dms, _ = partition_declared_measures( + declared_measures=declared_measures, n_dims=n_dims, n_time_dimensions=n_tds, + ) + dim_alias_map.update(_time_dimension_aliases([ + (td, dm.bound, "") for td, dm in zip(query.time_dimensions or [], _td_alias_dms) + ])) # Filter list in WHERE order: date_range, model filters (Mode-A SQL), then user query filters. bound_filters: List[BoundFilter] = [] @@ -388,6 +403,7 @@ def bind_query_inputs( # NOSONAR(S3776) — one cohesive bind pass. The stages bound_filter_texts.append(f) order_specs = [] + _td_buckets = [(td, dm.bound.value_key) for td, dm in zip(query.time_dimensions or [], _td_alias_dms)] # Host spelling for the qualifier check below. _order_host_name = host_model_name(scope) for i, o in enumerate(query.order or []): @@ -403,17 +419,12 @@ def bind_query_inputs( # NOSONAR(S3776) — one cohesive bind pass. The stages ) if gran_parts is not None and _order_gran is not None: _col, _gran = gran_parts - matching_td = next( - ( - td for td in (query.time_dimensions or []) - if td.dimension.full_name == _col - and resolve_granularity(td.granularity, defined=bundle.granularities) == _order_gran - ), - None, + matching_td = _projected_bucket_td( + expr=_col, granularity=_order_gran, buckets=_td_buckets, scope=scope, bundle=bundle, ) if matching_td is None: check_spine_plain_use( - offender=o.raw_formula if names_spine(_col) or ( + offender=o.raw_formula if mentions_spine(_col) or ( isinstance(scope, ModelScope) and scope.source_model is not None and is_spine(scope.source_model) and "." not in _col ) else None, @@ -603,9 +614,9 @@ def bind_query_inputs( # NOSONAR(S3776) — one cohesive bind pass. The stages if not isinstance(vk, TimeTruncKey): continue # Ambiguous only if the same column already mapped to a DIFFERENT bucket. - if vk.column in _td_by_source and _td_by_source[vk.column] != vk: - _td_ambiguous_sources.add(vk.column) - _td_by_source[vk.column] = vk + if vk.source in _td_by_source and _td_by_source[vk.source] != vk: + _td_ambiguous_sources.add(vk.source) + _td_by_source[vk.source] = vk _td_key_set.add(vk) _available_dims = [dm.declared_name for dm in (*_dim_dms, *_td_dms)] @@ -1035,28 +1046,22 @@ def _guard_flatten(*, flat_name: str, origin: str) -> None: # names so their result keys disambiguate; a lone one keeps the # granularity-free key. bound_tds: List[Tuple[TimeDimension, BoundExpr, str]] = [] + td_types: List[DataType] = [] for i, td in enumerate(query.time_dimensions or []): with stale_spelling_position(f"time_dimensions[{i}]"): granularity = _td_granularity(td, bundle=bundle) btd = bind_time_dimension(td=td, scope=scope, bundle=bundle, granularity=granularity) - # The temporal / re-bucketing type rules are the checker's (P9). - check_time_dimension_column( - name=td.dimension.full_name, - column_type=btd.column_type, - upstream_granularity=btd.upstream_granularity, - requested_granularity=granularity, - ) - bound_tds.append( - (td, btd.bound, btd.bound.routed_dotted or td.dimension.full_name) - ) + td_types.append(_check_time_dimension(td=td, btd=btd, scope=scope, bundle=bundle, granularity=granularity)) + bound_tds.append((td, btd.bound, td.effective_name or btd.bound.routed_dotted or td.dimension)) _assert_equivalent_tds_agree(bound_tds) - _td_flat_counts = Counter(_flatten_dotted(canon) for _, _, canon in bound_tds) - for td, bound, canonical in bound_tds: + dim_alias_map.update(_time_dimension_aliases(bound_tds)) + _td_source_counts = Counter(_td_source(bound) for _, bound, _ in bound_tds) + for (td, bound, canonical), td_type in zip(bound_tds, td_types): base_flat = _flatten_dotted(canonical) assert isinstance(bound.value_key, TimeTruncKey) public = ( f"{base_flat}.{bound.value_key.granularity}" - if _td_flat_counts[base_flat] > 1 else base_flat + if td.name is None and _td_source_counts[bound.value_key.source] > 1 else base_flat ) _guard_flatten(flat_name=_flatten_dotted(public), origin=canonical) declared.append(DeclaredMeasure( @@ -1064,7 +1069,7 @@ def _guard_flatten(*, flat_name: str, origin: str) -> None: declared_name=public, public_name=public, label=td.label, - type=DataType.TIMESTAMP, + type=td_type, )) seen_measure_keys: Dict[str, Tuple[str, ValueKey, ModelMeasure]] = {} for i, m in enumerate(query.measures or []): @@ -1199,7 +1204,7 @@ def _td_granularity(td: TimeDimension, *, bundle: ResolvedSourceBundle) -> Granu """A time dimension's granularity resolved against the datasource.""" return check_granularity_known( name=str(td.granularity), granularity=resolve_granularity(td.granularity, defined=bundle.granularities), - defined=bundle.granularities, where=f"time dimension {td.dimension.full_name!r}", + defined=bundle.granularities, where=f"time dimension {td.dimension!r}", ) @@ -1222,7 +1227,7 @@ def _resolve_granularity_calls(query: SlayerQuery, *, bundle: ResolvedSourceBund moved: List[TimeDimension] = [] for item in query.dimensions or []: td = ( - _datasource_time_dimension(item.expression, bundle=bundle) + _datasource_time_dimension(item.expression, template=item.template, bundle=bundle) if isinstance(item, ComputedDimension) and item.name == computed_dimension_name(item.template) else None @@ -1238,15 +1243,39 @@ def _resolve_granularity_calls(query: SlayerQuery, *, bundle: ResolvedSourceBund }) -def _datasource_time_dimension(entry: str, *, bundle: ResolvedSourceBundle) -> Optional[TimeDimension]: +def _dedupe_equivalent_time_dimensions( + query: SlayerQuery, *, scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, +) -> SlayerQuery: + """Spellings of one time dimension (one bucket, name, date range and label) project once.""" + tds = query.time_dimensions or [] + if len(tds) < 2: + return query + seen: set = set() + kept: List[TimeDimension] = [] + for i, td in enumerate(tds): + with stale_spelling_position(f"time_dimensions[{i}]"): + granularity = _td_granularity(td, bundle=bundle) + bucket = bind_time_dimension(td=td, scope=scope, bundle=bundle, granularity=granularity).bound.value_key + identity = (bucket, td.effective_name, tuple(td.date_range or ()), td.label) + if identity not in seen: + seen.add(identity) + kept.append(td) + return query if len(kept) == len(tds) else query.model_copy(update={"time_dimensions": kept}) + + +def _datasource_time_dimension(entry: str, *, template: str, bundle: ResolvedSourceBundle) -> Optional[TimeDimension]: node, callee = call_callee(entry) if callee is None: return None column = functional_call_column(node) definition = bundle.granularities.get(granularity_key(callee)) if definition is not None: - check_granularity_call_shape(entry=entry, column=column) - return TimeDimension.model_validate({"dimension": column, "granularity": definition.name}) + argument = granularity_call_argument(node, entry) + check_granularity_call_shape(entry=entry, argument=argument) + td = TimeDimension.model_validate({"dimension": argument, "granularity": definition.name}) + if template != entry: + td._template = granularity_call_argument(call_callee(template)[0], template) + return td if column is not None and normalize_aggregation_name(callee) not in BUILTIN_AGGREGATIONS: check_dimension_call_known(entry=entry, defined=bundle.granularities) return None @@ -1258,14 +1287,11 @@ def _build_date_range_filter( scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, ) -> BoundFilter: - """A TimeDimension's ``date_range`` as one row-phase filter on its bare column: a single - point is ``col = P``; a pair is ``col >= lower`` and/or ``col <= upper`` (a null bound is open).""" - full = td.dimension.full_name - col_key = bind_expr(parsed=parse_expr(full), scope=scope, bundle=bundle).value_key - assert isinstance(col_key, (ColumnKey, ColumnSqlKey)), ( - f"date_range filter for TimeDimension {full!r} expected a " - f"column reference; got {type(col_key).__name__}." - ) + """A TimeDimension's ``date_range`` as one row-phase filter on its untruncated value: a single + point is ``v = P``; a pair is ``v >= lower`` and/or ``v <= upper`` (a null bound is open).""" + col_key = bind_time_dimension(td=td, scope=scope, bundle=bundle).bound.value_key + assert isinstance(col_key, TimeTruncKey) + col_key = col_key.source date_range = td.date_range assert date_range # the caller builds this only for a present date_range if len(date_range) == 1: @@ -1343,7 +1369,7 @@ def _snap_whole_periods( grans_by_column: Dict[ValueKey, List[Granularity]] = {} for td_key in td_keys: if isinstance(td_key, TimeTruncKey): - grans = grans_by_column.setdefault(td_key.column, []) + grans = grans_by_column.setdefault(td_key.source, []) if td_key.granularity not in grans: grans.append(td_key.granularity) conflicts = [ @@ -1391,23 +1417,98 @@ def _assert_equivalent_tds_agree( seen: Dict[ValueKey, TimeDimension] = {} for td, bound, _ in bound_tds: prior = seen.setdefault(bound.value_key, td) - if prior is not td and (prior.date_range, prior.label) != (td.date_range, td.label): + if prior is not td and (prior.date_range, prior.name, prior.label) != (td.date_range, td.name, td.label): raise GranularityCallError( - f"Conflicting time dimensions on {td.dimension.full_name!r} at " - f"{td.granularity} granularity: equivalent columns must not " - f"differ in date range or label." + f"Conflicting time dimensions on {td.dimension!r} at " + f"{td.granularity} granularity: equivalent spellings must not " + f"differ in date range, name or label." ) +def _projected_bucket_td( + *, expr: str, granularity: Granularity, buckets: List[Tuple[TimeDimension, ValueKey]], + scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, +) -> Optional[TimeDimension]: + """The projected time dimension bucketing ``expr`` at ``granularity``: its text, else its bound identity.""" + by_text = next((td for td, key in buckets if td.dimension == expr and _bucket_granularity(key) == granularity), None) + if by_text is not None: + return by_text + try: + source = bind_expr(parsed=parse_expr(expr), scope=scope, bundle=bundle).value_key + except SlayerError: + return None + wanted = TimeTruncKey(source=source, granularity=granularity) + return next((td for td, key in buckets if key == wanted), None) + + +def _bucket_granularity(key: ValueKey) -> Optional[Granularity]: + return key.granularity if isinstance(key, TimeTruncKey) else None + + +def _td_source(bound: BoundExpr) -> ValueKey: + assert isinstance(bound.value_key, TimeTruncKey) + return bound.value_key.source + + +def _time_dimension_aliases(bound_tds: List[Tuple[TimeDimension, BoundExpr, str]]) -> Dict[str, ValueKey]: + """Each time dimension's name (explicit, else an expression's sanitised text) → its bucket, + for ``partition_by=``; a name two buckets share resolves to neither.""" + named = [(td.effective_name, bound.value_key) for td, bound, _ in bound_tds if td.effective_name] + counts = Counter(name for name, _ in named) + return {name: key for name, key in named if counts[name] == 1} + + +def _reads_spine(source: ValueKey, *, scope: ModelScope | StageSchema) -> bool: + """Whether ``source`` reads ``time_spine.timestamp``.""" + host_is_spine = isinstance(scope, ModelScope) and scope.source_model is not None and is_spine(scope.source_model) + return any( + isinstance(k, ColumnKey) and k.leaf == TIME_SPINE_COLUMN + and (k.path[-1:] == (TIME_SPINE_MODEL,) or (not k.path and host_is_spine)) + for k in walk_value_keys(source) + ) + + +def _check_time_dimension( + *, td: TimeDimension, btd: BoundTimeDimension, scope: ModelScope | StageSchema, + bundle: ResolvedSourceBundle, granularity: Granularity, +) -> DataType: + """The checker's time-dimension rules (P9): row-level and homed (an expression), temporal, re-bucketing; its checked type.""" + column_type = btd.column_type + if btd.is_expression: + source = _td_source(btd.bound) + check_spine_time_dimension(offender=td.dimension if _reads_spine(source, scope=scope) else None) + check_time_dimension_row_level(name=td.dimension, source=source) + if isinstance(scope, ModelScope) and scope.source_model is not None: + check_time_dimension_home( + name=td.dimension, + crossing=row_expression_crossing(source=source, host_model=scope.source_model, bundle=bundle), + anchor=".".join((scope.source_model.name, *source_anchor_path(source))), + ) + column_type = time_dimension_type(source, column_type=scope_column_type(scope=scope, bundle=bundle)) + check_time_dimension_column( + name=td.dimension, + column_type=column_type, + requested_granularity=granularity, + bucketed_leaves=btd.bucketed_leaves, + is_expression=btd.is_expression, + ) + assert column_type is not None # the checker refuses a non-temporal one + return column_type + + def _named_td_matches( *, tds: List[TimeDimension], target: str, ) -> Tuple[List[TimeDimension], List[TimeDimension]]: - """(full-name matches, leaf matches) for ``target`` among ``tds``. Two same-column - buckets share a full_name, so full matches is a list, not a single TD.""" - full = [td for td in tds if td.dimension.full_name == target] + """(name or full-name matches, leaf matches) for ``target`` among ``tds``: a time + dimension's name, else a column's full or leaf name. Two same-column buckets share + a full name, so full matches is a list, not a single TD.""" + named = [td for td in tds if td.effective_name == target] + if named: + return named, [] + full = [td for td in tds if td.column_ref is not None and td.column_ref.full_name == target] if full: return full, [] - return [], [td for td in tds if td.dimension.name == target] + return [], [td for td in tds if td.column_ref is not None and td.column_ref.name == target] def _host_local_default_td( @@ -1416,7 +1517,7 @@ def _host_local_default_td( # The default points only at the host model; prefer a host-local TD over a same-leaf joined one. matches = [ td for td in tds - if td.dimension.model is None and td.dimension.name == default + if td.column_ref is not None and td.column_ref.model is None and td.column_ref.name == default ] # Same column at several granularities: the passive model default can't pick a # bucket. Resolve to None (not raise) so no-transform queries still run; a @@ -1448,7 +1549,7 @@ def _resolve_main_time_dimension( raise AmbiguousReferenceError( name=target, candidates=[ - f"{td.granularity}({td.dimension.full_name})" + f"{td.granularity}({td.dimension})" for td in full_matches ], ) @@ -1458,14 +1559,14 @@ def _resolve_main_time_dimension( # Multiple TDs share the leaf; force disambiguation via full_name. raise AmbiguousReferenceError( name=target, - candidates=[td.dimension.full_name for td in leaf_matches], + candidates=[td.dimension for td in leaf_matches], ) raise UnknownReferenceError( name=target, scope_kind="TimeDimension", scope_summary=( f"time_dimensions: " - f"{[td.dimension.full_name for td in tds]}" + f"{[td.dimension for td in tds]}" ), suggestion=None, ) diff --git a/slayer/engine/binding.py b/slayer/engine/binding.py index d12063122..3334f845f 100644 --- a/slayer/engine/binding.py +++ b/slayer/engine/binding.py @@ -8,7 +8,7 @@ from datetime import datetime from decimal import Decimal from functools import partial -from typing import Callable, Dict, Final, List, Mapping, Optional, Tuple, TypeGuard +from typing import Callable, Dict, Final, List, Mapping, Optional, Tuple, TypeGuard, cast from pydantic import BaseModel, ConfigDict @@ -36,7 +36,7 @@ from slayer.core.enums import RANK_FAMILY_TRANSFORMS from slayer.core.granularity import CustomGranularity, Granularity, resolve_granularity from slayer.core.refs import EXPRESSION_SOURCE_KINDS, key_display -from slayer.core.keys import source_anchor_path, window_kwarg_of, DATE_ADD_COUNT_ARG, DATE_OPERAND_ARGS, MIRRORED_TIME_POINT_OP, SCALAR_FUNCTIONS, cast_data_type, check_scalar_arity, type_date_values, AggregateKey, AggregateSource, ArithmeticKey, CastKey, ColumnKey, ColumnSqlKey, Grain, InKey, LiteralKey, ScalarCallKey, SqlFragmentKey, StarKey, TimePointCmpKey, TimePointOp, TimeTruncKey, TransformKey, ValueKey, boolean_valued, column_leaf, column_path, is_attached_source, normalize_scalar, prepend_value_key, temporal_type, walk_value_keys +from slayer.core.keys import source_anchor_path, window_kwarg_of, DATE_ADD_COUNT_ARG, DATE_OPERAND_ARGS, MIRRORED_TIME_POINT_OP, SCALAR_FUNCTIONS, cast_data_type, check_scalar_arity, type_date_values, AggregateKey, AggregateSource, ArithmeticKey, CastKey, ColumnKey, ColumnSqlKey, Grain, InKey, LiteralKey, Phase, ScalarCallKey, SqlFragmentKey, StarKey, TimePointCmpKey, TimePointOp, TimeTruncKey, TransformKey, VALUE_KEY_TYPES, ValueKey, boolean_valued, column_leaf, column_path, is_attached_source, normalize_scalar, prepend_value_key, temporal_type, walk_value_keys from slayer.core.join_walker import ( aggregation_owner, canonical_path, @@ -183,49 +183,34 @@ def bind_time_dimension( bundle: ResolvedSourceBundle, granularity: Optional[Granularity] = None, ) -> BoundTimeDimension: - """Bind a ``TimeDimension`` into a ``BoundTimeDimension``: a ``BoundExpr`` carrying a ``TimeTruncKey`` plus the column facts the checker judges (its type, its recorded bucket granularity — from a ``StageColumn`` or a model ``Column``). The column resolves like a Mode-B identifier ref against a ``ModelScope`` (joins) or a flat ``StageSchema``; the temporal / re-bucketing rules are the checker's (P9).""" - full = td.dimension.full_name - bound_col, column_type, upstream_granularity = _time_dimension_column_facts( - full, scope=scope, bundle=bundle, - ) + """Bind a ``TimeDimension`` into a ``BoundTimeDimension``: a ``BoundExpr`` carrying a ``TimeTruncKey`` plus the facts the checker judges (a column's type; every bucketed temporal leaf's recorded granularity — from a ``StageColumn`` or a model ``Column``). A column resolves like a Mode-B identifier ref against a ``ModelScope`` (joins) or a flat ``StageSchema``, any other text binds as a Mode-B expression; the row-level / temporal / re-bucketing rules are the checker's (P9).""" + text = td.dimension resolved = granularity or resolve_granularity(td.granularity, defined=bundle.granularities) if resolved is None: - raise ValueError(f"time dimension {full!r}: unknown granularity {td.granularity!r}") - time_key = TimeTruncKey(column=bound_col, granularity=resolved) - routed = _canonical_if_routed( - parsed=DottedRef(parts=tuple(full.split("."))) if "." in full else Ref(name=full), - value_key=bound_col, scope=scope, - ) + raise ValueError(f"time dimension {text!r}: unknown granularity {td.granularity!r}") + parsed = parse_expr(text) + if not isinstance(parsed, (Ref, DottedRef)): + source = bind_expr(parsed=parsed, scope=scope, bundle=bundle).value_key + return BoundTimeDimension( + bound=BoundExpr(value_key=TimeTruncKey(source=source, granularity=resolved)), + bucketed_leaves=_bucketed_leaves(source, scope=scope, bundle=bundle), + is_expression=True, + ) + bound_col = _resolve_column_ref(text, scope=scope, bundle=bundle) + column_type, upstream = _column_facts(bound_col, scope=scope, bundle=bundle) + routed = _canonical_if_routed(parsed=parsed, value_key=bound_col, scope=scope) return BoundTimeDimension( - bound=BoundExpr(value_key=time_key, routed_dotted=routed), + bound=BoundExpr(value_key=TimeTruncKey(source=bound_col, granularity=resolved), routed_dotted=routed), column_type=column_type, - upstream_granularity=upstream_granularity, + bucketed_leaves=() if upstream is None else ((text, upstream),), ) -def _time_dimension_column_facts( - full: str, - *, - scope: ModelScope | StageSchema, - bundle: ResolvedSourceBundle, -) -> Tuple[ColumnKey | ColumnSqlKey, Optional[DataType], Optional[Granularity]]: - """Resolve a time dimension's column against ``scope`` and read its facts — (bound column key, column type, recorded bucket granularity). Stage arm reads the flat ``StageColumn`` (dotted → illegal-scope, unknown → unknown-reference); model arm walks joins to the terminal ``Column`` and returns its ``granularity``, so a bucketed model column re-buckets under the same rule as a stage column.""" - if isinstance(scope, StageSchema): - if "." in full: - bound_col = _resolve_dotted(tuple(full.split(".")), scope=scope, bundle=bundle) - else: - bound_col = _resolve_ref(full, scope=scope, bundle=bundle) - assert isinstance(bound_col, ColumnKey) # a stage ref is always a flat ColumnKey - stage_col = scope.get(bound_col.leaf) - assert stage_col is not None # _resolve_ref already validated existence - upstream = ( - None if stage_col.granularity is None - else resolve_granularity(stage_col.granularity, defined=bundle.granularities) - ) - return bound_col, stage_col.type, upstream - - assert isinstance(scope, ModelScope) - if scope.source_model is None: +def _resolve_column_ref( + full: str, *, scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, +) -> ColumnKey | ColumnSqlKey: + """A time dimension's column against ``scope``: a stage ref is a flat ``StageColumn`` (dotted → illegal-scope, unknown → unknown-reference); a model ref walks joins.""" + if isinstance(scope, ModelScope) and scope.source_model is None: raise UnknownReferenceError( name=full, scope_kind="ModelScope", @@ -242,24 +227,50 @@ def _time_dimension_column_facts( f"TimeDimension {full!r} did not resolve to a column " f"reference (got {type(bound_col).__name__})." ) - terminal = _terminal_model_for_path( - path=column_path(bound_col), scope=scope, bundle=bundle, - ) + return bound_col + + +def _column_facts( + col: ColumnKey | ColumnSqlKey, *, scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, +) -> Tuple[Optional[DataType], Optional[Granularity]]: + """A column's (type, recorded bucket granularity): a flat ``StageColumn`` on a stage, else the terminal model ``Column`` its path reaches.""" + if isinstance(scope, StageSchema): + stage_col = scope.get(column_leaf(col)) + assert stage_col is not None # binding already validated existence + upstream = ( + None if stage_col.granularity is None + else resolve_granularity(stage_col.granularity, defined=bundle.granularities) + ) + return stage_col.type, upstream + terminal = _terminal_model_for_path(path=column_path(col), scope=scope, bundle=bundle) if terminal is None: # Defensive: _resolve_ref / _resolve_dotted would already have raised. + name = ".".join((*column_path(col), column_leaf(col))) raise UnknownReferenceError( - name=full, + name=name, scope_kind="ModelScope", - scope_summary=f"could not resolve terminal model for {full!r}", + scope_summary=f"could not resolve terminal model for {name!r}", suggestion=None, ) - col = next( - (c for c in terminal.columns if c.name == column_leaf(bound_col)), None, - ) + found = next((c for c in terminal.columns if c.name == column_leaf(col)), None) upstream = None - if col is not None and col.granularity is not None: - upstream = resolve_granularity(col.granularity, defined=bundle.granularities) - return bound_col, (col.type if col is not None else None), upstream + if found is not None and found.granularity is not None: + upstream = resolve_granularity(found.granularity, defined=bundle.granularities) + return (found.type if found is not None else None), upstream + + +def _bucketed_leaves( + source: ValueKey, *, scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, +) -> Tuple[Tuple[str, Granularity], ...]: + """Every temporal column leaf of ``source`` that carries a recorded bucket granularity, by its dotted name.""" + out: Dict[str, Granularity] = {} + for leaf in walk_value_keys(source): + if not isinstance(leaf, (ColumnKey, ColumnSqlKey)): + continue + column_type, upstream = _column_facts(leaf, scope=scope, bundle=bundle) + if upstream is not None and column_type in (DataType.DATE, DataType.TIMESTAMP): + out.setdefault(".".join((*column_path(leaf), column_leaf(leaf))), upstream) + return tuple(out.items()) def _canonical_if_routed( @@ -272,7 +283,7 @@ def _canonical_if_routed( its typed hop path (auto-routed or respelled) — or the canonical name of a stale flat ``Ref`` — else ``None``, so every already-canonical ref keeps a byte-identical result key.""" - key = value_key.column if isinstance(value_key, TimeTruncKey) else value_key + key = value_key.source if isinstance(value_key, TimeTruncKey) else value_key if not isinstance(key, (ColumnKey, ColumnSqlKey)): return None # saved measures canonicalize in bind_inputs._resolve_saved_measure_ref if isinstance(parsed, Ref): @@ -1011,18 +1022,23 @@ def _bind_partition_keys( pks.append(dim_alias_map[elem.name]) continue bound = _bind(parsed=elem, scope=scope, bundle=bundle, in_filter=False) - # A time bucket must be a query time dimension; the checker judges that. - if not isinstance(bound, (ColumnKey, ColumnSqlKey, TimeTruncKey)): + # Whether a key is a query dimension is the checker's; an aggregate-bearing one must already be one. + if _carries_attached_value(bound) and bound not in (dim_alias_map or {}).values(): raise PartitionKeyError( - summary=f"partition_by takes column references; '{key_display(bound)}' is an expression.", + summary=f"The partition_by key '{key_display(bound)}' carries an aggregate or transform.", location=f"{label} partition_by", - suggestion="Partition by a column: put a time bucket in time_dimensions, or compute " - "the expression as a column in an inner stage or an inline ModelExtension.", + suggestion="Declare it as a computed dimension of the query and name it in partition_by=.", ) pks.append(bound) return Grain.of(pks) +def _carries_attached_value(key: ValueKey) -> bool: + return isinstance(key, SqlFragmentKey) or any( + isinstance(k, (AggregateKey, TransformKey)) for k in walk_value_keys(key) + ) + + def _bind_expression_agg_source( parsed_source: ParsedExpr, *, agg: str, @@ -1230,13 +1246,15 @@ def _bind_granularity_call( parsed: AggCall, *, scope: ModelScope | StageSchema, bundle: ResolvedSourceBundle, granularity: Granularity, ) -> TimeTruncKey: - """``gran(col)`` as the row-level bucket of ``col``.""" - if parsed.args or parsed.kwargs or not isinstance(parsed.source, (Ref, DottedRef)): + """``gran(expr)`` as the row-level bucket of ``expr``.""" + if parsed.args or parsed.kwargs or isinstance(parsed.source, StarSource): raise GranularityCallError.wrong_shape(f"{parsed.agg}(...)") - column = _bind(parsed.source, scope=scope, bundle=bundle, in_filter=False) - if not isinstance(column, (ColumnKey, ColumnSqlKey)): + source = _bind(parsed.source, scope=scope, bundle=bundle, in_filter=False) + if source.phase is not Phase.ROW or isinstance(source, SqlFragmentKey) or any( + isinstance(k, (AggregateKey, TransformKey)) for k in walk_value_keys(source) + ): raise GranularityCallError.wrong_shape(f"{parsed.agg}(...)") - return TimeTruncKey(column=column, granularity=granularity) + return TimeTruncKey(source=source, granularity=granularity) def _bind_agg_source( @@ -1326,16 +1344,9 @@ def _bind_agg( args, kwargs = _fold_positional_agg_args( agg=effective_agg, source=source, bundle=bundle, args=args, kwargs=kwargs, ) - # Expression sources: order-sensitive first/last need a plain - # column (the ranked kernel can't rank an expression), and numeric-only - # aggregations are rejected when the expression is confidently non-numeric - # (per-column gates don't apply). + # Numeric-only aggregations over an expression source are rejected when it is + # confidently non-numeric (per-column gates don't apply). if isinstance(source, EXPRESSION_SOURCE_KINDS): - if effective_agg in ("first", "last"): - raise ValueError( - f"Aggregation {effective_agg!r} is not supported over an " - f"expression; use a plain column." - ) _reject_non_numeric_expression_agg( source=source, agg=effective_agg, scope=scope, bundle=bundle, ) @@ -1431,13 +1442,14 @@ def _fold_positional_agg_args( ) if len(args) != 1: raise ValueError( - f"Aggregation {agg!r} ranks by at most one column; got " + f"Aggregation {agg!r} ranks by at most one column or row-level expression; got " f"{len(args)} positional value(s)." ) - if not isinstance(args[0], (ColumnKey, ColumnSqlKey)): + if not _is_row_level_ranking_key(args[0]): raise ValueError( - f"Aggregation {agg!r} ranks by a column; got " - f"{type(args[0]).__name__} as its ranking key." + f"Aggregation {agg!r} ranks by a column or a row-level expression of columns; got " + f"{key_display(args[0]) if isinstance(args[0], VALUE_KEY_TYPES) else repr(args[0])} " + f"as its ranking key." ) return args, kwargs if len(args) > len(names): @@ -1457,6 +1469,13 @@ def _fold_positional_agg_args( return (), tuple(folded) +def _is_row_level_ranking_key(key: object) -> bool: + """A ranking key reads at least one column and no aggregate, transform or attached value.""" + return isinstance(key, VALUE_KEY_TYPES) and not _carries_attached_value(cast(ValueKey, key)) and any( + isinstance(k, (ColumnKey, ColumnSqlKey)) for k in walk_value_keys(cast(ValueKey, key)) + ) + + def _unknown_function_error(name: str, *, aggregations, bundle: ResolvedSourceBundle) -> UnknownFunctionError: """Unknown callable ``name``: the callable categories and a close match.""" granularities = sorted(GRANULARITY_NAMES | set(bundle.granularities)) @@ -1547,10 +1566,10 @@ def _bind_agg_arg( bundle: ResolvedSourceBundle, dim_alias_map: Optional[Dict[str, "ValueKey"]] = None, ): - """Bind one aggregation arg: identifiers → ``ColumnKey`` / ``ColumnSqlKey``, - a nested aggregate → ``AggregateKey`` (aggregate-valued parameter), a grained - transform → ``TransformKey`` at its result grain, literals → inline - scalar via ``normalize_scalar`` (stored inline, not as LiteralKey). + """Bind one aggregation arg: a nested aggregate → ``AggregateKey`` (aggregate-valued + parameter), a grained transform → ``TransformKey`` at its result grain, literals → inline + scalar via ``normalize_scalar`` (stored inline, not as LiteralKey), anything else as a + Mode-B expression (a column being the one-leaf case). ``dim_alias_map`` rides into a nested aggregate / transform so its ``partition_by=`` can name a computed dimension (as the outer aggregate's can).""" if isinstance(parsed, Literal): @@ -1565,13 +1584,7 @@ def _bind_agg_arg( return _bind_transform( parsed=parsed, scope=scope, bundle=bundle, dim_alias_map=dim_alias_map, ) - if isinstance(parsed, (Ref, DottedRef)): - return _bind(parsed, scope=scope, bundle=bundle, in_filter=False) - raise ValueError( - f"Aggregation argument of kind {type(parsed).__name__} is not " - f"supported. Pass a column reference, a scalar, a partitioned aggregate, " - f"or a grained transform." - ) + return _bind(parsed, scope=scope, bundle=bundle, in_filter=False, dim_alias_map=dim_alias_map) _NOT_SCALAR = object() # sentinel returned by _fold_to_scalar when the input isn't a literal-resolvable scalar diff --git a/slayer/engine/bundle_builder.py b/slayer/engine/bundle_builder.py index c4442f6f6..1ad41d04b 100644 --- a/slayer/engine/bundle_builder.py +++ b/slayer/engine/bundle_builder.py @@ -262,7 +262,7 @@ async def _split_query_backed( ) -> "Tuple[List[SlayerModel], Dict[str, SlayerModel]]": """Split ``models`` into referenced models and query-backed placeholders, closing over each placeholder's stage bases and each model's definition-named models (and - their components). Placeholders keep their stored form with joins dropped; on-chain + their components). Placeholders keep their stored form, including declared joins; on-chain names are never loaded.""" referenced: Dict[str, SlayerModel] = {} query_backed: Dict[str, SlayerModel] = {} @@ -279,7 +279,7 @@ async def _split_query_backed( storage=storage, data_source=data_source, failures=failures, ) if not is_spine(m)) continue - query_backed[model.name] = model.model_copy(update={"joins": []}) + query_backed[model.name] = model pending.extend(await _stage_base_components( model=model, skip=[*chain, *query_backed], known={*referenced, *query_backed}, storage=storage, data_source=data_source, failures=failures, diff --git a/slayer/engine/compile/projection.py b/slayer/engine/compile/projection.py index e5ef3f785..0958b9258 100644 --- a/slayer/engine/compile/projection.py +++ b/slayer/engine/compile/projection.py @@ -161,8 +161,9 @@ def _validate_alias_collisions( and public_name == key.column_name ) or ( isinstance(key, TimeTruncKey) - and column_path(key.column) == () - and public_name == column_leaf(key.column) + and isinstance(key.source, (ColumnKey, ColumnSqlKey)) + and column_path(key.source) == () + and public_name == column_leaf(key.source) ) # An unnamed ``(*)`` (StarKey) is exempt: its canonical alias (``_count``) # is a structural marker, not a column ref. An explicit user name still raises. @@ -516,7 +517,7 @@ def _canonical_name(key: ValueKey) -> str: # NOSONAR(S3776) — sequential isin return f"{prefix}{key.column_name}" if isinstance(key, TimeTruncKey): # Granularity lives in the SQL DATE_TRUNC, not the alias. - return _canonical_name(key.column) + return _canonical_name(key.source) if isinstance(key, AggregateKey): # Include args/kwargs so parametric aggregates over one column # (``percentile(revenue, p=0.5)`` vs ``p=0.95``) get distinct names. diff --git a/slayer/engine/compile/spine.py b/slayer/engine/compile/spine.py index 622dafd55..d0b29b2f5 100644 --- a/slayer/engine/compile/spine.py +++ b/slayer/engine/compile/spine.py @@ -9,8 +9,8 @@ from slayer.core.enums import UNIT_MONTHS, UNIT_SECONDS, TimeGranularity from slayer.core.granularity import Granularity, granularity_parts, nests_into, resolve_granularity from slayer.core.keys import ( - AggregateKey, ArithmeticKey, ColumnKey, LiteralKey, TimeTruncKey, ValueKey, source_anchor_path, split_top_level_and, - walk_value_keys, + AggregateKey, ArithmeticKey, ColumnKey, ColumnSqlKey, LiteralKey, TimeTruncKey, ValueKey, source_anchor_path, split_top_level_and, + column_leaf, walk_value_keys, ) from slayer.core.models import SlayerModel from slayer.core.refs import key_display @@ -51,7 +51,7 @@ def _mentions(key: ValueKey, spine: ColumnKey) -> bool: def _bare(key: ValueKey, spine: ColumnKey) -> bool: """``spine`` occurs outside a bucket of it.""" - if isinstance(key, TimeTruncKey) and key.column == spine: + if isinstance(key, TimeTruncKey) and key.source == spine: return False return key == spine or any(_bare(c, spine) for c in key.children()) @@ -140,7 +140,7 @@ def _checked_spine_time_dimensions( check_spine_plain_use(offender=dm.declared_name if _bare(key, spine) else None, position="measure") spine_tds = [ dm.bound.value_key for dm in prebound.declared_measures[n_dims:n_dims + n_tds] - if isinstance(dm.bound.value_key, TimeTruncKey) and dm.bound.value_key.column == spine + if isinstance(dm.bound.value_key, TimeTruncKey) and dm.bound.value_key.source == spine ] projected = set(spine_tds) for spec in prebound.order_specs: @@ -195,18 +195,21 @@ def check_axis_rebucket( ) -> None: """A spine bucket attributed through a fact's axis obeys that axis column's recorded granularity.""" spine = spine_column(host) - if not (isinstance(host_key, TimeTruncKey) and host_key.column == spine and isinstance(rerooted, TimeTruncKey)): + if not (isinstance(host_key, TimeTruncKey) and host_key.source == spine and isinstance(rerooted, TimeTruncKey)): + return + column = rerooted.source + if not isinstance(column, (ColumnKey, ColumnSqlKey)): return - column = rerooted.column owner = walk_key_path(model=root, path=tuple(column.path), bundle=bundle) - leaf = column.leaf if isinstance(column, ColumnKey) else column.column_name + leaf = column_leaf(column) col = owner.get_column(leaf) if owner is not None else None if col is None or col.granularity is None: return + name = ".".join((*column.path, leaf)) + upstream = resolve_granularity(col.granularity, defined=bundle.granularities) check_time_dimension_column( - name=".".join((*column.path, leaf)), column_type=col.type, - upstream_granularity=resolve_granularity(col.granularity, defined=bundle.granularities), - requested_granularity=rerooted.granularity, + name=name, column_type=col.type, requested_granularity=rerooted.granularity, + bucketed_leaves=() if upstream is None else ((name, upstream),), ) diff --git a/slayer/engine/compile/stages.py b/slayer/engine/compile/stages.py index a04d7bd52..4677e3547 100644 --- a/slayer/engine/compile/stages.py +++ b/slayer/engine/compile/stages.py @@ -134,7 +134,7 @@ ValueSlot, ) from slayer.engine.ranked_planner import ( - explicit_ranking_time_arg, + explicit_ranking_key, ordered_row_keys, resolve_ranking_time_key, ) @@ -178,17 +178,13 @@ ] -def _row_key_path(key: ValueKey) -> tuple: - if isinstance(key, TimeTruncKey): - return _row_key_path(key.column) - return tuple(getattr(key, "path", ())) - - def _order_wrap_source(key: ValueKey, *, dim_keys: frozenset) -> Optional[Tuple[AggregateSource, tuple]]: """(source, join path) of the MIN/MAX wrap a grouped ORDER BY row value needs, else ``None``.""" - if isinstance(key, (ColumnKey, ColumnSqlKey, TimeTruncKey)): - # A TimeTruncKey is not a legal aggregate source; wrap its underlying column (DATE_TRUNC is monotonic). - return (key.column if isinstance(key, TimeTruncKey) else key), _row_key_path(key) + if isinstance(key, TimeTruncKey): + # Not a legal aggregate source; wrap its untruncated source (DATE_TRUNC is monotonic). + return _order_wrap_source(key.source, dim_keys=dim_keys) + if isinstance(key, (ColumnKey, ColumnSqlKey)): + return key, key.path if not isinstance(key, EXPRESSION_SOURCE_KINDS) or any( isinstance(k, (AggregateKey, TransformKey)) or (isinstance(k, ColumnKey) and k.leaf.startswith(REGROUP_LEAF_PREFIX)) @@ -198,7 +194,7 @@ def _order_wrap_source(key: ValueKey, *, dim_keys: frozenset) -> Optional[Tuple[ off_grain = row_refs_off_grain(key, dim_keys) if not off_grain: return None - return key, next((p for p in map(_row_key_path, off_grain) if p), ()) + return key, next((p for p in map(source_anchor_path, off_grain) if p), ()) # Duration-windowed measures (``window='90d'``). @@ -282,7 +278,7 @@ def _stable_text(obj: Any) -> str: def _regroup_grain_name(pk: ValueKey) -> str: """A column's path/leaf spelling; any other key a key-derived identifier.""" if isinstance(pk, TimeTruncKey): - return f"{_regroup_grain_name(pk.column)}_{pk.granularity}" + return f"{_regroup_grain_name(pk.source)}_{pk.granularity}" if isinstance(pk, (ColumnKey, ColumnSqlKey)): return "__".join([*pk.path, column_leaf(pk)]) return f"grain_{hashlib.sha1(_stable_text(pk).encode()).hexdigest()[:8]}" @@ -531,18 +527,18 @@ def _first_unattributable_arg_leaf( host_model=host_model, host_name=host_name, ) if reason is not None: - leaf = column_leaf(arg.column if isinstance(arg, TimeTruncKey) else arg) + leaf = column_leaf(arg) return [(leaf if arg is value or name is None else name, reason)] return [] def _param_row_columns( agg: AggregateKey, -) -> Iterator[Tuple[Optional[str], object, ColumnKey | ColumnSqlKey | TimeTruncKey]]: +) -> Iterator[Tuple[Optional[str], object, ColumnKey | ColumnSqlKey]]: """``(parameter name or None, parameter value, column row leaf)`` per argument.""" for name, value in [*((None, a) for a in agg.args), *agg.kwargs]: for arg in parameter_row_leaves(value): - if isinstance(arg, (ColumnKey, ColumnSqlKey, TimeTruncKey)): + if isinstance(arg, (ColumnKey, ColumnSqlKey)): yield name, value, arg @@ -758,19 +754,21 @@ def _ranking_key_crossing( """The ``(leaf, hop)`` a ranked aggregate's ordering key crosses unsafely from ``root_model``, judged by its dependency closure (engine P10); ``None`` when every path is provably to-one. An unanalysable definition fails closed through - ``check_input_dependencies_analyzable`` naming the column (Axiom 2.8).""" - leaf = column_leaf(key) if isinstance(key, (ColumnKey, ColumnSqlKey)) else str(key) - closure = key_closure( - key=key, anchor_model=root_model, anchor_relation=root_model.name, bundle=bundle, - ) - if closure is None: - check_input_dependencies_analyzable(alias=alias, column=leaf) - closure = () - for path in closure: - if not safe_reachable( - root=root_model, path=path, models_by_name=bundle.models_by_name, - ): - return leaf, path[-1] + ``check_input_dependencies_analyzable`` naming the column (Axiom 2.8). An expression + key is judged per leaf, naming the crossing one.""" + for leaf_key in source_row_leaves(key) or [key]: + leaf = column_leaf(leaf_key) if isinstance(leaf_key, (ColumnKey, ColumnSqlKey)) else key_display(leaf_key) + closure = key_closure( + key=leaf_key, anchor_model=root_model, anchor_relation=root_model.name, bundle=bundle, + ) + if closure is None: + check_input_dependencies_analyzable(alias=alias, column=leaf) + closure = () + for path in closure: + if not safe_reachable( + root=root_model, path=path, models_by_name=bundle.models_by_name, + ): + return leaf, path[-1] return None @@ -791,7 +789,7 @@ def _safe(path: Tuple[str, ...]) -> bool: # Role: crossed ranking argument, explicitly named (a first/last ranking arg), # judged by its closure so a derived argument names itself (D1). ranked_crossings: List[Tuple[str, str]] = [] - ranking_arg = explicit_ranking_time_arg(agg) + ranking_arg = explicit_ranking_key(agg) if ranking_arg is not None: crossing = _ranking_key_crossing( key=ranking_arg, root_model=host_model, bundle=bundle, alias=alias, @@ -1190,7 +1188,7 @@ def _plan_shifted_attaches( } inherited = _frame_free_filters( prebound=prebound, filter_typings=filter_typings, - time_columns=frozenset(k.column for k in td_keys if isinstance(k, TimeTruncKey)), + time_columns=frozenset(k.source for k in td_keys if isinstance(k, TimeTruncKey)), ) producer_model = scope.source_model if isinstance(scope, ModelScope) else None kernel_root = producer_model or bundle.source_model @@ -1512,10 +1510,6 @@ def _reverse_hops( def _remap_ref_path(r: ValueKey, node_path: Tuple[str, ...]) -> ValueKey: - if isinstance(r, TimeTruncKey): - return r.model_copy(update={ - "column": r.column.model_copy(update={"path": node_path}), - }) return r.model_copy(update={"path": node_path}) @@ -1623,7 +1617,9 @@ def _operand_null(k: Any, *, h: Tuple[str, ...], srcs: _NullSources) -> bool: """Is value operand ``k`` NULL when hop ``h`` and its descendants are NULL? A leaf's null-source paths decide it; arithmetic over a null operand is null; a predicate is null iff UNKNOWN; a ScalarCall / Star is data-dependent.""" - if isinstance(k, (ColumnKey, ColumnSqlKey, TimeTruncKey, StarKey)): + if isinstance(k, TimeTruncKey): + return _operand_null(k.source, h=h, srcs=srcs) + if isinstance(k, (ColumnKey, ColumnSqlKey, StarKey)): return any(_is_prefix(h, p) for p in srcs.get(k, frozenset())) if isinstance(k, InKey) or ( isinstance(k, ArithmeticKey) and k.op.lower() in _PREDICATE_OPS @@ -1724,10 +1720,9 @@ def _conjunct_push_plan( srcs: Dict[ValueKey, "frozenset[Tuple[str, ...]]"] = {} host_node: Optional[Tuple[str, ...]] = None for r in walk_value_keys(cj): - if not isinstance(r, (ColumnKey, ColumnSqlKey, TimeTruncKey, StarKey)): + if not isinstance(r, (ColumnKey, ColumnSqlKey, StarKey)): continue - col = r.column if isinstance(r, TimeTruncKey) else r - hp = tuple(getattr(col, "path", ()) or ()) + hp = tuple(r.path) base_model, base_node, rel, host_node = _resolve_ref_anchor( hp, tp=tp, root_model=root_model, host_model=host_model, lookup=lookup, nodes=nodes, host_name=host_name, @@ -1738,7 +1733,7 @@ def _conjunct_push_plan( models_by_name=lookup, nodes=nodes, ) dep_terminals = _register_dep_hops( - col, node_path=node_path, host_model=host_model, lookup=lookup, + r, node_path=node_path, host_model=host_model, lookup=lookup, bundle=bundle, nodes=nodes, ) remapped = _remap_ref_path(r, node_path) @@ -1746,7 +1741,7 @@ def _conjunct_push_plan( # dependency terminal is — unless data-dependent (a star, a # non-propagating derived definition). if isinstance(r, StarKey) or not _ref_null_propagates( - col, host_model=host_model, models_by_name=lookup, bundle=bundle, + r, host_model=host_model, models_by_name=lookup, bundle=bundle, ): srcs[remapped] = frozenset() else: @@ -4359,7 +4354,7 @@ def _plan_empty_base_grain( def _frame_bound_columns(*, grain: Optional[List[SlotId]], registry) -> List[ValueKey]: """Raw columns of the grain's time dimensions — an explicit bound on one is a FRAME bound.""" keys = (registry.get(sid).key for sid in grain or ()) - return list(dict.fromkeys(k.column for k in keys if isinstance(k, TimeTruncKey))) + return list(dict.fromkeys(k.source for k in keys if isinstance(k, TimeTruncKey))) def _plan_src_row_filters( @@ -4425,7 +4420,7 @@ def _bucket_slots(slots: List[ValueSlot]): def _value_anchor_path(key: ValueKey) -> Tuple[str, ...]: """The join path a path-derived output's auto-name is prefixed with.""" if isinstance(key, TimeTruncKey): - key = key.column + key = key.source if isinstance(key, (ColumnKey, ColumnSqlKey, StarKey)): return tuple(key.path) if isinstance(key, AggregateKey): @@ -4488,7 +4483,7 @@ def _leaf_variants( """``rest`` first, then ``rest`` with its source column's name swapped for each of that column's respellings.""" if isinstance(key, TimeTruncKey): - key = key.column + key = key.source if isinstance(key, AggregateKey): key = key.source if not isinstance(key, (ColumnKey, ColumnSqlKey)): @@ -4552,7 +4547,7 @@ def _surviving_default(*, projection, columns: List[StageColumn], root: Optional wanted = ColumnKey(path=(), leaf=default) visible = [projection.registry.get(sid) for sid in projection.public_projection] for slot, column in zip((s for s in visible if not s.hidden), columns): - key = slot.key.column if isinstance(slot.key, TimeTruncKey) else slot.key + key = slot.key.source if isinstance(slot.key, TimeTruncKey) else slot.key if key == wanted: return column.name return None @@ -4565,7 +4560,7 @@ def _source_column( """The column a row-level stage output reads (a model column or an upstream stage column), for the metadata it carries downstream; ``None`` for anything else.""" if isinstance(key, TimeTruncKey): - key = key.column + key = key.source if not isinstance(key, (ColumnKey, ColumnSqlKey)): return None leaf = column_leaf(key) diff --git a/slayer/engine/elaborate_env.py b/slayer/engine/elaborate_env.py index 58353744c..c38fef261 100644 --- a/slayer/engine/elaborate_env.py +++ b/slayer/engine/elaborate_env.py @@ -73,6 +73,7 @@ regroup_root_grain, temporal_type, type_date_values, + column_leaf, value_arg_positions, source_anchor_path, transform_operand_grain, @@ -165,10 +166,10 @@ def _key_display(k: ValueKey) -> str: return f"{name} (partition_by)" if k.partition_keys is not None else name if isinstance(k, TransformKey): return f"{k.op}(...)" - if isinstance(k, (ColumnKey, ColumnSqlKey, TimeTruncKey)): - col = k.column if isinstance(k, TimeTruncKey) else k - leaf = getattr(col, "leaf", None) or getattr(col, "column_name", "?") - return ".".join((*col.path, leaf)) + if isinstance(k, TimeTruncKey): + return _key_display(k.source) if isinstance(k.source, (ColumnKey, ColumnSqlKey)) else key_display(k.source) + if isinstance(k, (ColumnKey, ColumnSqlKey)): + return ".".join((*k.path, column_leaf(k))) return type(k).__name__ @@ -868,7 +869,7 @@ def check_unrelated_row_read( """A row-level read of a dataset no join path reaches, outside an aggregation's inputs, raises.""" if reference is None: return - ref = key_display(reference.column if isinstance(reference, TimeTruncKey) else reference) + ref = key_display(reference.source if isinstance(reference, TimeTruncKey) else reference) raise UnrelatedDatasetError( summary=f"{ref} reads {dataset}, which no join path reaches from the population " f"{population}: it meets {population} as a product, so it is read only through aggregates.", @@ -929,6 +930,18 @@ def check_spine_plain_use(*, offender: Optional[str], position: str) -> None: ) +def check_spine_time_dimension(*, offender: Optional[str]) -> None: + """An expression is never an axis: a time dimension reads the spine column only as the bare column.""" + if offender is not None: + raise TimeSpineError( + summary=f"Time dimension `{offender}` reads time_spine.timestamp inside an expression; " + f"an expression is never an axis.", + location=f"time dimension {offender!r}", + suggestion="Bucket time_spine.timestamp itself (time_dimensions=[{dimension: time_spine.timestamp, " + "granularity: month, date_range: [...]}]) and compute other date logic from the fact's own column.", + ) + + def check_spine_filter(*, offender: Optional[str]) -> None: """A filter on the spine column must be a frame bound.""" if offender is not None: @@ -961,12 +974,12 @@ def check_granularity_known( return granularity -def check_granularity_call_shape(*, entry: str, column: Optional[str]) -> None: - """A datasource-granularity call must take exactly one column reference.""" - if column is None: +def check_granularity_call_shape(*, entry: str, argument: Optional[str]) -> None: + """A datasource-granularity call must take exactly one row-level argument.""" + if argument is None: raise GranularityShapeError( - summary=f"Granularity call {entry!r} must be a single column reference ``gran(col)``.", - suggestion="Pass one bare or dotted column, e.g. ``fiscal_year(created_at)``.", + summary=f"Granularity call {entry!r} must take one row-level argument ``gran(expr)``.", + suggestion="Pass one column or row-level expression, e.g. ``fiscal_year(created_at)``.", ) @@ -983,24 +996,27 @@ def check_time_dimension_column( *, name: str, column_type: Optional[DataType], - upstream_granularity: Optional[Granularity], requested_granularity: Granularity, + bucketed_leaves: Sequence[Tuple[str, Granularity]] = (), + is_expression: bool = False, ) -> None: - """A time dimension's column must be temporal (DATE / TIMESTAMP); a bucketed column — stage, query-backed cache, or hand-set ``Column.granularity`` — re-buckets only to the same or a nesting-coarser granularity (closure Axiom 9). One message for all three origins.""" + """A time dimension must be temporal (DATE / TIMESTAMP); every bucketed leaf — stage, query-backed cache, or hand-set ``Column.granularity`` — re-buckets only to the same or a nesting-coarser granularity (closure Axiom 9). One message for all three origins.""" if column_type not in (DataType.DATE, DataType.TIMESTAMP): + kind = "expression" if is_expression else "column" raise TimeDimensionColumnError( - summary=f"A time dimension must reference a temporal column " - f"(DATE / TIMESTAMP); got column type {column_type!r}.", + summary=f"A time dimension must reference a temporal {kind} " + f"(DATE / TIMESTAMP); got {kind} type {column_type!r}.", location=f"time dimension {name!r}", + suggestion="Wrap it in CAST(… AS TIMESTAMP) or CAST(… AS DATE)." + if is_expression and column_type is None else None, ) - if ( - upstream_granularity is not None - and requested_granularity != upstream_granularity - and not nests_into(upstream_granularity, requested_granularity) - ): + for leaf, upstream in bucketed_leaves: + if requested_granularity == upstream or nests_into(upstream, requested_granularity): + continue + column = "column" if leaf == name else f"column {leaf!r}" raise TimeDimensionColumnError( summary=f"Cannot re-bucket to '{requested_granularity}': the " - f"column is already bucketed at '{upstream_granularity}', which " + f"{column} is already bucketed at '{upstream}', which " f"does not nest into '{requested_granularity}'.", location=f"time dimension {name!r}", suggestion="Request the same or a nesting-coarser granularity, or " @@ -1008,6 +1024,37 @@ def check_time_dimension_column( ) +def check_time_dimension_row_level(*, name: str, source: ValueKey) -> None: + """A time dimension's expression is row-level: no aggregate, transform or attached value.""" + if any(isinstance(k, (AggregateKey, TransformKey)) for k in walk_value_keys(source)): + raise TimeDimensionColumnError( + summary="A time dimension is row-level; its expression reads an aggregate or transform.", + location=f"time dimension {name!r}", + suggestion="Bucket a column or a row-level expression of columns.", + ) + + +def time_dimension_type(source: ValueKey, *, column_type: ColumnTypeFn) -> Optional[DataType]: + """A time dimension expression's type: DATE / TIMESTAMP when provably temporal, else a provable other type, else ``None``.""" + return temporal_type(source, column_type=column_type) or _count_type(source, column_type=column_type) + + +def check_time_dimension_home(*, name: str, crossing: Optional[Tuple[str, Optional[str]]], anchor: str) -> None: + """A time dimension expression reads every leaf over provably to-one hops from its anchor (Axiom 2.2).""" + if crossing is None: + return + leaf, hop = crossing + if hop is None: + check_input_dependencies_analyzable(alias=name, column=leaf) + return + raise UnsafeJoinInputError( + summary=f"The time dimension reads {leaf}, which crosses an unproven or fanning join hop " + f"to {hop} from {anchor}: no dataset determines every column the expression reads.", + location=f"time dimension {name!r}", + suggestion=_JOIN_REMEDY, + ) + + # Allowlisted scalars whose result is never numeric. _TEXT_SCALARS = frozenset({ "lower", "upper", "trim", "ltrim", "rtrim", "replace", "substr", "substring", "concat", @@ -1109,7 +1156,7 @@ def check_date_operands(*, roots: Sequence[ValueKey], column_type: ColumnTypeFn) keys = [key for root in roots for key in walk_value_keys(root)] for key in keys: # innermost first: a ``gran(col)`` inside a date function names its column if isinstance(key, TimeTruncKey): - _require_temporal(str(key.granularity), key.column, column_type=column_type) + _require_temporal(str(key.granularity), key.source, column_type=column_type) for key in keys: if isinstance(key, ScalarCallKey) and key.name in DATE_OPERAND_ARGS: _check_date_call(key, column_type=column_type) @@ -1140,8 +1187,8 @@ def _time_operand( """``(bounded operand, its temporal type, bucket granularity)``: a direct ``gran(col)`` bounds ``col``; ISO literals in a conditional count as dates when that makes it temporal.""" if isinstance(operand, TimeTruncKey): - t = temporal_type(operand.column, column_type=column_type) - return operand.column, t, operand.granularity + t = temporal_type(operand.source, column_type=column_type) + return operand.source, t, operand.granularity t = temporal_type(operand, column_type=column_type) if t is None: typed = type_date_values(operand) @@ -1388,12 +1435,13 @@ def check_partition_key_resolves( ) if maps_to_bucket or lenient: return + is_column = isinstance(pk, (ColumnKey, ColumnSqlKey)) raise PartitionKeyError( - summary=f"The partition_by column '{dotted_key_display(pk)}' is not a " + summary=f"The partition_by {'column' if is_column else 'key'} '{dotted_key_display(pk)}' is not a " f"query dimension.", location=label, - suggestion=f"Add it to dimensions/time_dimensions, or choose one of: " - f"{', '.join(available_dims) or '(none)'}.", + suggestion=f"Add it to dimensions/time_dimensions{'' if is_column else ' (as a computed dimension)'}, " + f"or choose one of: {', '.join(available_dims) or '(none)'}.", ) diff --git a/slayer/engine/home.py b/slayer/engine/home.py index 7561c3182..87df11332 100644 --- a/slayer/engine/home.py +++ b/slayer/engine/home.py @@ -18,12 +18,16 @@ constituent_grain, operand_constituents, parameter_row_leaves, + reroot_value_key, source_anchor_path, source_leaf_paths, + source_row_leaves, walk_value_keys, ) from slayer.core.models import SlayerModel -from slayer.engine.join_safety import attributable_from_root, key_host_path +from slayer.core.refs import key_display +from slayer.engine.join_safety import attributable_from_root, key_host_path, safe_reachable +from slayer.engine.reference_closure import key_closure from slayer.ir.prebound import walk_key_path from slayer.ir.source_bundle import ResolvedSourceBundle @@ -109,15 +113,8 @@ def home_path_for( key=lambda p: (-len(p), p != anchor, p), ) for p in candidates: - model_at_p = walk_key_path(model=host_model, path=p, bundle=bundle) - if model_at_p is None: - continue - if all( - attributable_from_root( - host_path=q, target_path=p, root_model=model_at_p, - models_by_name=models_by_name, host_name=host_model.name, - ) - for q in input_paths + if _determines_all( + path=p, input_paths=input_paths, host_model=host_model, models_by_name=models_by_name, bundle=bundle, ): return p # No valid home: fall to the inputs' common prefix (never deeper than the @@ -150,3 +147,40 @@ def resolve_aggregate_homes( dim_keys=dim_keys, td_keys=td_keys, active_bucket=active_bucket, ) return homes + + +def _determines_all( + *, path: Path, input_paths: List[Path], host_model: SlayerModel, + models_by_name: Dict[str, SlayerModel], bundle: ResolvedSourceBundle, +) -> bool: + model_at_p = walk_key_path(model=host_model, path=path, bundle=bundle) + return model_at_p is not None and all( + attributable_from_root( + host_path=q, target_path=path, root_model=model_at_p, + models_by_name=models_by_name, host_name=host_model.name, + ) + for q in input_paths + ) + + +def row_expression_crossing( + *, source: ValueKey, host_model: SlayerModel, bundle: ResolvedSourceBundle, +) -> Optional[Tuple[str, Optional[str]]]: + """``(leaf, hop)`` of the first row leaf whose dependency closure crosses an unproven or + fanning hop from the expression's anchor (Axiom 2.2; ``hop`` ``None`` when the leaf's + definition cannot be analysed), else ``None``.""" + anchor = source_anchor_path(source) + root = walk_key_path(model=host_model, path=anchor, bundle=bundle) or host_model + for leaf in source_row_leaves(source): + closure = key_closure( + key=reroot_value_key(leaf, target_path=anchor), anchor_model=root, + anchor_relation=root.name, bundle=bundle, + ) + if closure is None: + return key_display(leaf), None + hop = next((p[-1] for p in closure if p and not safe_reachable( + root=root, path=p, models_by_name=bundle.models_by_name, + )), None) + if hop is not None: + return key_display(leaf), hop + return None diff --git a/slayer/engine/join_safety.py b/slayer/engine/join_safety.py index d3c84aaae..d31012f6c 100644 --- a/slayer/engine/join_safety.py +++ b/slayer/engine/join_safety.py @@ -6,7 +6,7 @@ from __future__ import annotations -from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar +from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar, cast from pydantic import BaseModel @@ -235,7 +235,7 @@ def _contradiction_finding(finding) -> JoinSafetyFinding: def key_host_path(key: ValueKey) -> Tuple[str, ...]: if isinstance(key, TimeTruncKey): - return tuple(getattr(key.column, "path", ()) or ()) + return source_anchor_path(key) return tuple(getattr(key, "path", ()) or ()) @@ -423,6 +423,8 @@ def _reroot_leaf_via_host( ) -> Optional[ValueKey]: if not isinstance(r, (ColumnKey, ColumnSqlKey, StarKey, TimeTruncKey)): return None + if isinstance(r, TimeTruncKey) and not isinstance(r.source, (ColumnKey, ColumnSqlKey)): + return None # an expression's leaves reroot one by one hp = key_host_path(r) if isinstance(r, ColumnKey) and _is_product_path(hp, host_name=host_name, models_by_name=models_by_name): route = home_spine_route(root_model=root_model, models_by_name=models_by_name) @@ -443,7 +445,7 @@ def _reroot_leaf_via_host( return None # resolved through the root's own join to the sibling if isinstance(r, TimeTruncKey): return r.model_copy(update={ - "column": r.column.model_copy(update={"path": via_host}), + "source": cast(ColumnKey | ColumnSqlKey, r.source).model_copy(update={"path": via_host}), }) return r.model_copy(update={"path": via_host}) @@ -460,8 +462,8 @@ def reroot_from_root( tp = tuple(target_path) mapping: Dict[ValueKey, ValueKey] = {} for r in walk_value_keys(key): - # The walk also yields TimeTruncKey.column, but substitute matches the - # whole TimeTruncKey pre-order, so the inner-column entry is inert. + # A column bucket reroots whole (substitute matches it pre-order, so its + # inner-column entry is inert); an expression bucket's leaves reroot alone. rerooted = _reroot_leaf_via_host( r, target_path=tp, root_model=root_model, models_by_name=models_by_name, host_name=host_name, @@ -687,7 +689,7 @@ def undetermined_witness( def _determination_operands(key: ValueKey) -> Optional[Iterable[ValueKey]]: """The operands whose determination decides ``key``'s, else ``None`` (opaque).""" if isinstance(key, TimeTruncKey): - return (key.column,) + return (key.source,) if isinstance(key, AggregateKey): return key.partition_keys if isinstance(key, (*SLOT_COMPOSITE_KINDS, SqlFragmentKey)): diff --git a/slayer/engine/key_metadata.py b/slayer/engine/key_metadata.py index fa31e9d1d..cbe5b507b 100644 --- a/slayer/engine/key_metadata.py +++ b/slayer/engine/key_metadata.py @@ -254,11 +254,11 @@ def dimension_key_metadata( """ if isinstance(key, CastKey): return key.data_type, None, None - if not isinstance(key, (ColumnKey, ColumnSqlKey, TimeTruncKey)): + inner = key.source if isinstance(key, TimeTruncKey) else key + if not isinstance(inner, (ColumnKey, ColumnSqlKey)): date_type = date_scalar_type(key, column_type=_model_types(model=model, bundle=bundle)) if date_type is not None: return date_type, None, None - inner = key.column if isinstance(key, TimeTruncKey) else key path = tuple(getattr(inner, "path", ()) or ()) leaf = getattr(inner, "leaf", None) or getattr(inner, "column_name", None) if leaf is None: diff --git a/slayer/engine/model_reads.py b/slayer/engine/model_reads.py index d1d2d05c6..74f0fc773 100644 --- a/slayer/engine/model_reads.py +++ b/slayer/engine/model_reads.py @@ -88,7 +88,7 @@ def _dimension_texts(query: SlayerQuery) -> Iterator[tuple[FieldKind, str]]: for dim in query.dimensions or []: yield ("formula", dim.expression) if isinstance(dim, ComputedDimension) else ("ref", dim.full_name) for td in query.time_dimensions or []: - yield "ref", td.dimension.full_name + yield "formula", td.dimension def query_reference_parts(query: SlayerQuery) -> list[tuple[str, ...]]: diff --git a/slayer/engine/plan.py b/slayer/engine/plan.py index 16dbc7f00..d205357af 100644 --- a/slayer/engine/plan.py +++ b/slayer/engine/plan.py @@ -131,6 +131,7 @@ def _stage_scope_and_bundle_unframed( if sib is not None and isinstance(src, ModelExtension): base = model_from_stage_schema( name=sib, schema=stage_schemas[sib], data_source=data_source, + joins=bundle.saved_joins(sib), ) overlaid = apply_extension_overlay(base, src) others = {n: s for n, s in stage_schemas.items() if n != sib} @@ -144,12 +145,15 @@ def _stage_scope_and_bundle_unframed( if isinstance(src, str) and src in stage_schemas: synth = model_from_stage_schema( name=src, schema=stage_schemas[src], data_source=data_source, + joins=bundle.saved_joins(src), ) others = {n: s for n, s in stage_schemas.items() if n != src} sb = stage_bundle_with_siblings( bundle=bundle, source_model=synth, sibling_schemas=others, data_source=data_source, ) + if src in bundle.query_backed: + return ModelScope(source_model=synth), sb return stage_schemas[src], sb # 3. Model-scoped: the stage's own resolved source model. diff --git a/slayer/engine/population.py b/slayer/engine/population.py index 22f8d4da8..8e03640ef 100644 --- a/slayer/engine/population.py +++ b/slayer/engine/population.py @@ -154,8 +154,8 @@ def _filter_refs( def _query_dimension_refs(query: SlayerQuery) -> list[str]: """Row-valued refs of the query's dimensions + time dimensions. - Computed dimensions contribute their row-valued and partition_by refs (never - refs inside an aggregation); plain and time dimensions contribute their name. + Computed and time dimensions contribute their row-valued and partition_by refs + (never refs inside an aggregation); plain dimensions contribute their name. """ refs: list[str] = [] for dim in query.dimensions or []: @@ -164,7 +164,7 @@ def _query_dimension_refs(query: SlayerQuery) -> list[str]: else: refs.append(dim.full_name) for td in query.time_dimensions or []: - refs.append(td.dimension.full_name) + refs.extend(_computed_dimension_refs(td.dimension)) return refs @@ -460,7 +460,7 @@ def spine_factor_out(query: SlayerQuery) -> SlayerQuery: if not mentions_spine(getattr(d, "expression", None) or getattr(d, "full_name", "")) ] or None, "time_dimensions": [ - td for td in query.time_dimensions or [] if not names_spine(td.dimension.full_name) + td for td in query.time_dimensions or [] if not mentions_spine(td.dimension) ] or None, "filters": [f for f in query.filters or [] if not _spine_only_filter(f)] or None, "measures": None, diff --git a/slayer/engine/ranked_planner.py b/slayer/engine/ranked_planner.py index 2083b8e27..6544f0c75 100644 --- a/slayer/engine/ranked_planner.py +++ b/slayer/engine/ranked_planner.py @@ -1,23 +1,25 @@ -"""Ranking-key resolution for ``first`` / ``last``: WHICH column a ranked -aggregate's ``ROW_NUMBER`` orders by, settled at plan time — the explicit time arg, -else the first DATE/TIMESTAMP row dimension, else the first time dimension's RAW -column (never the truncated bucket, which ties every row), else the model default. +"""Ranking-key resolution for ``first`` / ``last``: WHAT a ranked aggregate's +``ROW_NUMBER`` orders by, settled at plan time — the explicit ranking key, else the +first DATE/TIMESTAMP row-level dimension, else the first time dimension's untruncated +source (never the bucket, which ties every row), else the model default. A target-rooted producer passes its already-re-rooted aggregate, so it resolves host-style on its own root.""" from __future__ import annotations -from typing import List, Optional, Sequence +from typing import List, Optional, Sequence, cast -from slayer.core.enums import DataType, RANKED_AGGREGATIONS +from slayer.core.enums import RANKED_AGGREGATIONS from slayer.core.keys import ( + VALUE_KEY_TYPES, AggregateKey, - ColumnKey, - ColumnSqlKey, TimeTruncKey, + TransformKey, ValueKey, + temporal_type, + walk_value_keys, ) -from slayer.core.join_walker import resolved_terminal +from slayer.core.join_walker import model_column_type from slayer.core.models import SlayerModel from slayer.engine.reference_closure import column_default_key from slayer.ir.planned import ( @@ -27,20 +29,17 @@ from slayer.ir.source_bundle import ResolvedSourceBundle __all__ = [ - "explicit_ranking_time_arg", + "explicit_ranking_key", "ordered_row_keys", "resolve_ranking_time_key", ] -_TEMPORAL_TYPES = (DataType.DATE, DataType.TIMESTAMP) - - -def explicit_ranking_time_arg(key: AggregateKey) -> Optional[ValueKey]: - """The explicit positional ranking-time arg (first positional iff a column ref), or ``None``.""" +def explicit_ranking_key(key: AggregateKey) -> Optional[ValueKey]: + """The explicit ranking key (the first positional, a row-level expression), or ``None``.""" if key.agg not in RANKED_AGGREGATIONS: return None for arg in key.args: - return arg if isinstance(arg, (ColumnKey, ColumnSqlKey)) else None + return cast(ValueKey, arg) if isinstance(arg, VALUE_KEY_TYPES) else None return None @@ -50,17 +49,14 @@ def _temporal_row_dimension_key( source_model: SlayerModel, bundle: ResolvedSourceBundle, ) -> Optional[ValueKey]: - models_by_name = bundle.models_by_name + """The first row-level dimension typed DATE / TIMESTAMP — never a bucket, which ties a cell's rows.""" + column_type = model_column_type(model=source_model, models_by_name=bundle.models_by_name) for key in row_keys: - if not isinstance(key, ColumnKey): - continue - model = resolved_terminal( - root=source_model, path=key.path, models_by_name=models_by_name, - ) - if model is None: + if isinstance(key, TimeTruncKey) or any( + isinstance(k, (AggregateKey, TransformKey)) for k in walk_value_keys(key) + ): continue - col = next((c for c in model.columns if c.name == key.leaf), None) - if col is not None and col.type in _TEMPORAL_TYPES: + if temporal_type(key, column_type=column_type) is not None: return key return None @@ -68,10 +64,10 @@ def _temporal_row_dimension_key( def _time_dimension_raw_column( *, row_keys: Sequence[ValueKey], ) -> Optional[ValueKey]: - """The first time dimension's RAW column, un-truncated.""" + """The first time dimension's untruncated source.""" for key in row_keys: if isinstance(key, TimeTruncKey): - return key.column + return key.source return None @@ -82,12 +78,12 @@ def resolve_ranking_time_key( bundle: ResolvedSourceBundle, row_keys: Sequence[ValueKey] = (), ) -> ValueKey: - """The column a ranked aggregate's ``ROW_NUMBER`` orders by, in the producer's - root coordinates: explicit positional arg, else the first temporal row dimension, - else the first time dimension's raw column, else the model ``default_time_dimension`` + """What a ranked aggregate's ``ROW_NUMBER`` orders by, in the producer's root + coordinates: the explicit ranking key, else the first temporal row-level dimension, + else the first time dimension's untruncated source, else the model ``default_time_dimension`` (typed via ``column_default_key`` so a derived default's crossings close). A target-rooted producer passes its already-re-rooted aggregate, so this is host-style.""" - arg = explicit_ranking_time_arg(key) + arg = explicit_ranking_key(key) if arg is not None: return arg temporal = _temporal_row_dimension_key( diff --git a/slayer/engine/reference_closure.py b/slayer/engine/reference_closure.py index 8e3ab9ada..027fd3d8e 100644 --- a/slayer/engine/reference_closure.py +++ b/slayer/engine/reference_closure.py @@ -233,7 +233,7 @@ def _child_keys(node, *, descend_aggregates: bool = True) -> List: if isinstance(node, _LEAF_KINDS) or isinstance(node, _INLINE_SCALARS): return [] if isinstance(node, TimeTruncKey): - return [node.column] + return [node.source] if isinstance(node, AggregateKey): if not descend_aggregates: return [] diff --git a/slayer/engine/response_meta.py b/slayer/engine/response_meta.py index f036f0a4d..32fbf91b4 100644 --- a/slayer/engine/response_meta.py +++ b/slayer/engine/response_meta.py @@ -117,7 +117,7 @@ def _column_for_row_slot( """The source ``Column`` backing a ROW slot, for label / format lookup.""" key = slot.key if isinstance(key, TimeTruncKey): - key = key.column + key = key.source if isinstance(key, ColumnKey): model = _model_for_path(bundle=bundle, path=key.path) leaf = key.leaf diff --git a/slayer/engine/schema_drift.py b/slayer/engine/schema_drift.py index d23680277..2858611b2 100644 --- a/slayer/engine/schema_drift.py +++ b/slayer/engine/schema_drift.py @@ -68,6 +68,7 @@ from slayer.sql.client import SlayerSQLClient, build_sql_model_trial_query from slayer.sql.dialects import dialect_for_ds_type from slayer.sql.engine_factory import EngineCacheKey, _sql_client_cache_key +from slayer.ir.source_bundle import added_joins logger = logging.getLogger(__name__) @@ -769,6 +770,8 @@ def _build_stage_graph( extension_joins = _stage_source_joins(stage) extension_targets = {j.target_model for j in extension_joins} base = models_by_name.get(stage_source_name) if stage_source_name else None + if base is not None: + extension_joins = added_joins(base=base, joins=extension_joins) source = base.model_copy(update={"joins": [*base.joins, *extension_joins]}) if base is not None else None models = {**models_by_name, source.name: source} if source is not None else models_by_name roots = [source] if source is not None else [models[t] for t in sorted(extension_targets) if t in models] @@ -876,7 +879,7 @@ def _stage_refs(stage: SlayerQuery) -> list[str]: for ref in _measure_formula_refs(formula) ] refs += [getattr(d, "full_name", None) or str(d) for d in stage.dimensions or []] - refs += [td.dimension.full_name for td in stage.time_dimensions or []] + refs += [ref for td in stage.time_dimensions or [] for ref in _measure_formula_refs(td.dimension)] # Mode-B (DSL) filters: use the DSL parser so aggregations/transforms surface. refs += [col for f in stage.filters or [] for col in _filter_refs_dsl(f)] return refs diff --git a/slayer/engine/unrelated.py b/slayer/engine/unrelated.py index 4dc89d284..42a9305a1 100644 --- a/slayer/engine/unrelated.py +++ b/slayer/engine/unrelated.py @@ -63,7 +63,7 @@ def first_hop_names(query: SlayerQuery) -> set[str]: out.update(_parsed_heads(d.expression, as_filter=False) if isinstance(d, ComputedDimension) else _path_head(d.full_name)) for td in query.time_dimensions or []: - out.update(_path_head(td.dimension.full_name)) + out.update(_parsed_heads(td.dimension, as_filter=False)) for f in query.filters or []: out.update(_parsed_heads(f, as_filter=True)) for o in query.order or []: @@ -146,7 +146,7 @@ def _walk(self, node: ValueKey, *, inputs: bool) -> Iterator[Tuple[ValueKey, str elif isinstance(node, TransformKey): yield from self._walk_transform(node, inputs=inputs) elif isinstance(node, TimeTruncKey): - yield from self._walk(node.column, inputs=inputs) + yield from self._walk(node.source, inputs=inputs) elif isinstance(node, (ColumnKey, ColumnSqlKey, StarKey)): if not inputs: yield from ((node, dataset) for dataset in sorted(self.crossed(node))) @@ -189,7 +189,7 @@ def _row_leaves(node: ValueKey) -> Iterator[ValueKey]: if isinstance(node, (AggregateKey, TransformKey)): return if isinstance(node, TimeTruncKey): - yield from _row_leaves(node.column) + yield from _row_leaves(node.source) return if isinstance(node, (ColumnKey, ColumnSqlKey, StarKey)): yield node diff --git a/slayer/facade/translator.py b/slayer/facade/translator.py index 330c1dd0b..a368aab7b 100644 --- a/slayer/facade/translator.py +++ b/slayer/facade/translator.py @@ -2230,7 +2230,7 @@ def _record_time_grain( ) -> None: assert item.time_grain is not None and item.time_grain_underlying is not None dotted = item.time_grain_underlying.dimension_ref - td = TimeDimension.model_validate({"dimension": {"name": dotted}, "granularity": item.time_grain}) + td = TimeDimension.model_validate({"dimension": dotted, "granularity": item.time_grain}) plan.time_dims.append(td) plan.time_dim_by_name[dotted] = td plan.derived_dims.append(item.projected_name) diff --git a/slayer/ir/bound.py b/slayer/ir/bound.py index 2e395a8ba..25cad7947 100644 --- a/slayer/ir/bound.py +++ b/slayer/ir/bound.py @@ -45,16 +45,17 @@ def phase(self) -> Phase: class BoundTimeDimension(BaseModel): - """A bound time dimension: the wrapped ``BoundExpr`` (``bound``) plus the column - facts the checker judges — ``column_type`` and the recorded bucket - ``upstream_granularity`` (from a bucketed stage column or a model ``Column``'s - ``granularity``; ``None`` for an un-bucketed column).""" + """A bound time dimension: the wrapped ``BoundExpr`` (``bound``) plus the facts the + checker judges — a column's ``column_type`` (an expression is typed by the checker) + and ``(leaf, granularity)`` per temporal leaf a bucketed stage column or a model + ``Column``'s ``granularity`` records.""" model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) bound: BoundExpr column_type: Optional[DataType] = None - upstream_granularity: Optional[Granularity] = None + bucketed_leaves: Tuple[Tuple[str, Granularity], ...] = () + is_expression: bool = False def bound_filter_from_key(vk: ValueKey) -> BoundFilter: diff --git a/slayer/ir/source_bundle.py b/slayer/ir/source_bundle.py index 98e8bb172..935492569 100644 --- a/slayer/ir/source_bundle.py +++ b/slayer/ir/source_bundle.py @@ -11,8 +11,10 @@ from slayer.core.enums import DataType from slayer.core.granularity import CustomGranularity +from slayer.core.join_edges import same_edge from slayer.core.models import ( Column, + ModelJoin, SlayerModel, _check_column_measure_namespace, _check_join_keys, @@ -24,6 +26,7 @@ __all__ = [ "resolve_scope", "ResolvedSourceBundle", + "added_joins", "apply_extension_overlay", "as_extension_over_nonsibling", "follow_sibling_chain", @@ -50,7 +53,7 @@ class ResolvedSourceBundle(BaseModel): dialect: str # sqlglot dialect the query renders in # Minted stage identity → its user-facing spelling. stage_displays: Dict[str, StageDisplay] = Field(default_factory=dict) - # Stored query-backed models the statement may splice (stored form, joins dropped). + # Stored query-backed models the statement may splice, including result joins. query_backed: Dict[str, SlayerModel] = Field(default_factory=dict) # Query-backed models whose stages are being planned, outermost first. splice_chain: Tuple[str, ...] = () @@ -71,6 +74,11 @@ def get_referenced_model(self, name: str) -> Optional[SlayerModel]: return m return None + def saved_joins(self, name: str) -> List[ModelJoin]: + """The declared joins of stored query-backed model ``name``; none for any other stage.""" + saved = self.query_backed.get(name) + return list(saved.joins) if saved is not None else [] + def factor_free(self, model: SlayerModel) -> SlayerModel: """``model`` as stored: a population without its product hops.""" if model.unrelated_datasets: @@ -105,6 +113,11 @@ def models_by_name(self) -> Dict[str, SlayerModel]: return out +def added_joins(*, base: SlayerModel, joins: Optional[List[ModelJoin]]) -> List[ModelJoin]: + """Extension ``joins`` new to ``base``: one repeating a stored join is that edge.""" + return [j for j in joins or [] if not any(same_edge(j, s) for s in base.joins)] + + def apply_extension_overlay( base: SlayerModel, ext: ModelExtension ) -> SlayerModel: @@ -118,7 +131,7 @@ def apply_extension_overlay( update={ "columns": list(base.columns) + list(ext.columns or []), "measures": overlay_measures, - "joins": list(base.joins) + list(ext.joins or []), + "joins": list(base.joins) + added_joins(base=base, joins=ext.joins), } ) # model_copy runs no validators; re-run the namespace and join-key checks @@ -185,6 +198,7 @@ def model_from_stage_schema( sql: Optional[str] = None, column_sql: Optional[Dict[str, str]] = None, default_time_dimension: Optional[str] = None, + joins: Optional[List[ModelJoin]] = None, ) -> SlayerModel: """The model a stage's output is read as: over its CTE (``sql=None``) or over ``sql``. @@ -213,10 +227,14 @@ def model_from_stage_schema( for c in schema.columns ], ) + joins = joins or [] + # Joins attach after the rename (validators skip model_copy), so check their keys here. + _check_join_keys(model_name=name, columns=model.columns, joins=joins) # A minted stage identity carries the reserved prefix the name validator rejects. return model.model_copy(update={ "name": name, "sql_table": name if sql is None else None, "default_time_dimension": default_time_dimension or schema.default_time_dimension, + "joins": joins, }).with_spelling(schema.display_name if schema.display_name != name else None) @@ -237,7 +255,7 @@ def stage_bundle_with_siblings( """ synths = [ model_from_stage_schema( - name=n, schema=s, data_source=data_source + name=n, schema=s, data_source=data_source, joins=bundle.saved_joins(n), ) for n, s in sibling_schemas.items() ] diff --git a/slayer/ir/variables.py b/slayer/ir/variables.py index 3a2e11c4c..e534f4422 100644 --- a/slayer/ir/variables.py +++ b/slayer/ir/variables.py @@ -3,8 +3,8 @@ - :func:`merge_query_variables` collapses the four variable layers into the effective dict (runtime > stage > outer > model_defaults). - :func:`apply_variables_to_query` binds ``{var}`` as literals into a query's - filters, measure formulas, computed-dimension expressions and order - expressions, and substitutes its ``date_range`` bounds. + filters, measure formulas, computed- and time-dimension expressions and + order expressions, and substitutes its ``date_range`` bounds. - :func:`substitute_model_sql_surfaces` substitutes a model's four Mode-A surfaces (SQL regime) and binds its saved measure formulas. """ @@ -96,15 +96,17 @@ def _value(match: "re.Match[str]") -> str: def _substituted_time_dimension(td: TimeDimension, variables: Dict[str, Any]) -> TimeDimension: - if not td.date_range: - return td - bounds = [None if b is None else _date_range_bound(b, variables) for b in td.date_range] - if bounds == td.date_range: + dimension = bind_variables(td.dimension, variables) + bounds = td.date_range and [None if b is None else _date_range_bound(b, variables) for b in td.date_range] + if dimension == td.dimension and bounds == td.date_range: return td # Revalidated so a substituted bound is shape-checked like a literal one. - return TimeDimension.model_validate({ - "dimension": td.dimension, "granularity": td.granularity, "date_range": bounds, "label": td.label, + bound = TimeDimension.model_validate({ + "dimension": dimension, "granularity": td.granularity, "date_range": bounds, "name": td.name, + "label": td.label, }) + bound._template = td.template + return bound def apply_variables_to_query( diff --git a/slayer/memories/help_content/02_workflow.md b/slayer/memories/help_content/02_workflow.md index 47dd2ea96..9f933caad 100644 --- a/slayer/memories/help_content/02_workflow.md +++ b/slayer/memories/help_content/02_workflow.md @@ -12,7 +12,7 @@ Decompose the question into blocks first: every qualifier, projected column, filter, grouping, unit, rounding, and ordering hint is one block, and each must map to a named column, measure, filter or dimension. Never drop a qualifier because no entity matched: -search for it, else encode it as an expression or an inline extension column; reference +search for it, else encode it as an expression where it is used; reference already-encoded quantities by name rather than re-deriving them. Pin explicitly rather than guessing: which aggregation ("typical" is not automatically avg vs median), the grouping column and raw vs standardized labels, each aggregate's scope (all rows vs a diff --git a/slayer/memories/help_content/03_aggregations.md b/slayer/memories/help_content/03_aggregations.md index 9d9d7eabb..a2bae2330 100644 --- a/slayer/memories/help_content/03_aggregations.md +++ b/slayer/memories/help_content/03_aggregations.md @@ -12,7 +12,8 @@ filters, order, and computed dimensions. `var_samp`, `var_pop`, `corr(x, other=y)`, `covar_samp(x, other=y)`, `covar_pop(x, other=y)`, `first(x[, time_col])` / `last(x[, time_col])` (the earliest / latest record's value in each group), plus the model's custom aggregations -(`inspect` the model to list them). +(`inspect` the model to list them). Any column argument may be a row-level expression: +`weighted_avg(x, weight=qty * 2)`, `last(x, coalesce(updated_at, created_at))`. `name=value` is a keyword argument in an aggregation or transform but a comparison in a scalar function (`iif(status = 'paid', …)`); write `==` for a comparison in a keyword slot. @@ -43,7 +44,8 @@ suggested free name. `partition_by=` computes the aggregate at a coarser grain and repeats it on every row: one key, a list, a dotted path for a joined column, or `[]` for the grand total. Keys -are spelled exactly like the query's dimensions. In a measure, filter, order or +are spelled exactly like the query's dimensions, an expression key (`upper(city)`) being +the computed dimension with that expression. In a measure, filter, order or transform input every key must be one of the query's dimensions (a time dimension partitions by its bucket). The coarser total is computed over the rows that pass the row-level conditions; aggregate conditions and `limit` never change it. diff --git a/slayer/memories/help_content/05_time.md b/slayer/memories/help_content/05_time.md index 57e37d002..d0a231c3b 100644 --- a/slayer/memories/help_content/05_time.md +++ b/slayer/memories/help_content/05_time.md @@ -43,5 +43,5 @@ incomplete bucket. ## Granularity calls `month(created_at)` in `dimensions`, `time_dimensions`, filters or order buckets the -column: second, minute, hour, day, week (ISO, Monday), week_sunday, month, quarter, +column, or any DATE/TIMESTAMP expression (`month(coalesce(shipped_at, created_at))`): second, minute, hour, day, week (ISO, Monday), week_sunday, month, quarter, year, or a custom granularity defined on the datasource (fiscal years and the like). diff --git a/slayer/memories/resolver.py b/slayer/memories/resolver.py index a04869724..75adb6217 100644 --- a/slayer/memories/resolver.py +++ b/slayer/memories/resolver.py @@ -550,7 +550,7 @@ def _add(forms: Iterable[str]) -> None: # 2. every field the reads walk enumerates; aggregation arguments and the query's own names are not entities. local_names = {m.name for m in query.measures or [] if m.name} | { d.name for d in query.dimensions or [] if isinstance(d, ComputedDimension) and d.name - } + } | {td.name for td in query.time_dimensions or [] if td.name} for kind, text in query_field_texts(query): for token in _field_entity_tokens(kind, text): if token in local_names: diff --git a/slayer/sql/generator.py b/slayer/sql/generator.py index 589315316..9dd88bd8c 100644 --- a/slayer/sql/generator.py +++ b/slayer/sql/generator.py @@ -40,7 +40,7 @@ from slayer.core.enums import RANK_FAMILY_TRANSFORMS from slayer.core.granularity import CustomGranularity, Granularity, granularity_parts from slayer.core.time_spine import TIME_SPINE_COLUMN, is_spine -from slayer.core.keys import BOOL_CONNECTIVE_OPS, KIND_POLICY, REGROUP_LEAF_PREFIX, SLOT_COMPOSITE_KINDS, TEMPORAL_TYPES, VALUE_KEY_TYPES, AggregateKey, ArithmeticKey, CastKey, ColumnKey, ColumnSqlKey, ColumnTypeFn, InKey, LiteralKey, Phase, ScalarCallKey, SqlFragmentKey, StarKey, TimePointCmpKey, TimeTruncKey, TransformKey, aggregation_source_type, column_path, date_add_type, is_boolean_shaped, parameter_row_leaves, shift_offset_of, source_anchor_path, substitute_value_keys, temporal_type, walk_value_keys +from slayer.core.keys import BOOL_CONNECTIVE_OPS, KIND_POLICY, REGROUP_LEAF_PREFIX, SLOT_COMPOSITE_KINDS, TEMPORAL_TYPES, VALUE_KEY_TYPES, AggregateKey, ArithmeticKey, CastKey, ColumnKey, ColumnSqlKey, ColumnTypeFn, InKey, LiteralKey, Phase, ScalarCallKey, StarKey, TimePointCmpKey, TimeTruncKey, TransformKey, ValueKey, aggregation_source_type, column_path, date_add_type, is_boolean_shaped, parameter_row_leaves, shift_offset_of, source_anchor_path, substitute_value_keys, temporal_type, walk_value_keys from slayer.core.join_walker import ( aggregation_owner, follow, @@ -599,7 +599,11 @@ def _conjunction(parts: Sequence[Expression]) -> Optional[Expression]: # pyrigh return out # Parameter values rendered through a scope; scalars render as literals, markers never. -_ROW_PARAM_KINDS = (ColumnKey, ColumnSqlKey, SqlFragmentKey, ScalarCallKey) +def _is_row_param(value: object) -> bool: + """A row-valued aggregation parameter: a row-level key carrying no aggregate or transform.""" + return isinstance(value, VALUE_KEY_TYPES) and not any( + isinstance(k, (AggregateKey, TransformKey, StarKey)) for k in walk_value_keys(cast(ValueKey, value)) + ) # Shift units whose whole-unit offsets map each bucket start onto another, making the outer re-trunc a per-row no-op. _BUCKET_ALIGNED_SHIFT_UNITS: dict[str, frozenset[str]] = { @@ -2100,7 +2104,7 @@ def _resolve_agg_kwargs_for_key( resolved = { kname: ResolvedAggKwarg(value=scope.resolve(kval)) for kname, kval in getattr(key, "kwargs", ()) - if isinstance(kval, _ROW_PARAM_KINDS) + if _is_row_param(kval) } return resolved or None @@ -2215,8 +2219,8 @@ def _defer_composite(sid: str, slot, full_alias: str) -> None: group_by_keys.setdefault(sid, col_expr) _record_alias(sid, full_alias) elif isinstance(key, TimeTruncKey): - col_expr = self._raw_time_col_expr_for_planned( - time_column=key.column, + col_expr = derived_expr_by_sid.get(sid) or self._raw_time_col_expr_for_planned( + time_column=key.source, source_model=source_model, source_relation=source_relation, bundle=bundle, @@ -2456,9 +2460,8 @@ def _alias_of(sid: str) -> str: tslot = slots_by_id.get(sid) assert tslot is not None base_alias = _alias_of(sid) - src_scope.resolve(tslot.key.column) - raw = self._raw_time_col_expr_for_planned( - time_column=tslot.key.column, source_model=source_model, + raw = self._bucket_source_expr( + source=tslot.key.source, scope=src_scope, source_model=source_model, source_relation=source_relation, bundle=bundle, ) trunc = self._build_date_trunc(col_expr=raw, granularity=tslot.key.granularity) @@ -2471,9 +2474,8 @@ def _alias_of(sid: str) -> str: wtd_slot = slots_by_id.get(plan.window_time_dimension_slot_id) assert wtd_slot is not None wtd_alias = _alias_of(plan.window_time_dimension_slot_id) - src_scope.resolve(wtd_slot.key.column) - raw_time = self._raw_time_col_expr_for_planned( - time_column=wtd_slot.key.column, source_model=source_model, + raw_time = self._bucket_source_expr( + source=wtd_slot.key.source, scope=src_scope, source_model=source_model, source_relation=source_relation, bundle=bundle, ) src_cols.append(raw_time.copy().as_("_w_time")) @@ -2588,7 +2590,7 @@ def _alias_of(sid: str) -> str: # exclusive upper edge. # Calendar parts apply one by one in written order, clamping like date_add. assert wtd_slot is not None - time_type = src_scope.column_type(wtd_slot.key.column) + time_type = temporal_type(wtd_slot.key.source, column_type=src_scope.column_type) operand = DataType.DATE if time_type is DataType.DATE else DataType.TIMESTAMP unit, step, _ = granularity_parts(plan.window_granularity) bucket_end = self._date_offset(_base_col(wtd_alias), count=step, unit=unit, operand=operand) @@ -2715,49 +2717,73 @@ def _ranked_scope_expr( cast_derived: bool = True, ) -> Expression: """One value expression anchored in a ranked CTE's own scope.""" - - def _register(expr: Expression, path: Tuple[str, ...]) -> None: - if path: - scope.join_paths.add(path) - for p in self._joined_paths_in_sql( - sql_expr=expr, source_relation=root_relation, - source_model=root_model, bundle=bundle, - ): - scope.join_paths.add(p) - if isinstance(key, TimeTruncKey): - raw = self._raw_time_col_expr_for_planned( - time_column=key.column, source_model=root_model, - source_relation=root_relation, bundle=bundle, - ) - _register(raw, column_path(key.column)) + if isinstance(key.source, (ColumnKey, ColumnSqlKey)): + raw = self._raw_time_col_expr_for_planned( + time_column=key.source, source_model=root_model, + source_relation=root_relation, bundle=bundle, + ) + self._register_ranked_paths( + raw, column_path(key.source), root_model=root_model, root_relation=root_relation, + bundle=bundle, scope=scope, + ) + else: + raw = self._ranked_scope_expr( + key=key.source, root_model=root_model, root_relation=root_relation, + bundle=bundle, scope=scope, cast_derived=False, + ) return self._build_date_trunc(col_expr=raw, granularity=key.granularity) if isinstance(key, ColumnKey): expr = self._joined_or_local_dim_expr( path=key.path, leaf=key.leaf, source_model=root_model, source_relation=root_relation, bundle=bundle, ) - _register(expr, key.path) + self._register_ranked_paths( + expr, key.path, root_model=root_model, root_relation=root_relation, bundle=bundle, scope=scope, + ) return expr if isinstance(key, ColumnSqlKey): - expr = self._derived_column_expr( - key=key, source_model=root_model, - source_relation=root_relation, bundle=bundle, + return self._ranked_derived_column_expr( + key=key, root_model=root_model, root_relation=root_relation, bundle=bundle, scope=scope, + cast_derived=cast_derived, ) - if expr is None: - raise ValueError( - f"Derived column {key.column_name!r} on model " - f"{key.path[-1] if key.path else root_model.name!r} is not " - f"in the resolved source bundle.", - ) - _register(expr, key.path) - return expr if cast_derived else _strip_declared_cast(expr) + if isinstance(key, (*SLOT_COMPOSITE_KINDS, LiteralKey)): + return render_value_key(key=key, ctx=RenderContext(scope=scope, dialect=self._dialect)) raise NotImplementedError( f"Ranked CTE cannot anchor a {type(key).__name__} — the grain and " - f"the ranking column are columns, truncated time columns, or " - f"derived columns.", + f"the ranking key are row-level values.", ) + def _ranked_derived_column_expr( + self, *, key: ColumnSqlKey, root_model, root_relation: str, bundle, scope: ScopeFrame, cast_derived: bool, + ) -> Expression: + expr = self._derived_column_expr( + key=key, source_model=root_model, + source_relation=root_relation, bundle=bundle, + ) + if expr is None: + raise ValueError( + f"Derived column {key.column_name!r} on model " + f"{key.path[-1] if key.path else root_model.name!r} is not " + f"in the resolved source bundle.", + ) + self._register_ranked_paths( + expr, key.path, root_model=root_model, root_relation=root_relation, bundle=bundle, scope=scope, + ) + return expr if cast_derived else _strip_declared_cast(expr) + + def _register_ranked_paths( + self, expr: Expression, path: Tuple[str, ...], *, root_model, root_relation: str, bundle, scope: ScopeFrame, + ) -> None: + """Record the join paths ``expr`` reads (``path`` included) in the ranked CTE's scope.""" + if path: + scope.join_paths.add(path) + for p in self._joined_paths_in_sql( + sql_expr=expr, source_relation=root_relation, + source_model=root_model, bundle=bundle, + ): + scope.join_paths.add(p) + def _ranked_value_expr( self, *, key, root_model, root_relation: str, bundle, scope: ScopeFrame, ) -> Expression: @@ -2770,9 +2796,8 @@ def _ranked_value_expr( f"'*' — use 'count(*)' for COUNT(*)." ) if not isinstance(source, (ColumnKey, ColumnSqlKey)): - raise NotImplementedError( - f"AggregateKey source {type(source).__name__} not supported.", - ) + # An expression (a mixed one's attached values row-attached in ``scope``). + return render_value_key(key=source, ctx=RenderContext(scope=scope, dialect=self._dialect)) leaf = ( source.leaf if isinstance(source, ColumnKey) else source.column_name ) @@ -3360,9 +3385,8 @@ def _emit(slot, expr: Expression) -> None: for sid in (*plan.other_time_dimension_slot_ids, plan.window_time_dimension_slot_id): tslot = slots_by_id[sid] - scope.resolve(tslot.key.column) - raw = self._raw_time_col_expr_for_planned( - time_column=tslot.key.column, source_model=source_model, + raw = self._bucket_source_expr( + source=tslot.key.source, scope=scope, source_model=source_model, source_relation=source_relation, bundle=bundle, ) _emit(tslot, self._build_date_trunc(col_expr=raw, granularity=tslot.key.granularity)) @@ -4360,8 +4384,8 @@ def _add_row_slot(sid: str) -> None: key = slot.key if isinstance(key, ColumnKey): _add(key.path) - elif isinstance(key, TimeTruncKey): - _add(key.column.path) + elif isinstance(key, TimeTruncKey) and isinstance(key.source, (ColumnKey, ColumnSqlKey)): + _add(column_path(key.source)) for sid in base_render_order: _add_row_slot(sid) @@ -4519,7 +4543,7 @@ def _window_ordered(self, col: Expression, *, descending: bool = False) -> exp.O @staticmethod def _transform_grain_slot_ids(*, planned_query, slots_by_id, axis: object = None) -> List[str]: """The transform auto-grain: every grain dimension, and every grain time dimension off the axis's column.""" - axis_column = axis.column if isinstance(axis, TimeTruncKey) else None + axis_column = axis.source if isinstance(axis, TimeTruncKey) else None combined_placeholders = { sub.placeholder for plan in planned_query.regroup_attach_plans @@ -4538,7 +4562,7 @@ def _in_transform_grain(*, slot, axis_column: object) -> bool: """A row slot's grain membership: a dimension, or a time dimension off the axis's column.""" key = slot.key if isinstance(key, TimeTruncKey): - return axis_column is not None and key.column != axis_column + return axis_column is not None and key.source != axis_column return isinstance(key, (ColumnKey, ColumnSqlKey)) or slot.is_dimension def _render_window_transform_sql( # NOSONAR(S3776) — one per-op dispatch over the window-transform vocabulary, sharing the resolved measure / frame / partition state every arm reads. Each arm is one line; splitting the dispatch scatters that state without simplifying it. @@ -5215,12 +5239,18 @@ def _add(path: Tuple[str, ...]) -> None: if slot is None or slot.phase != Phase.ROW: continue key = slot.key - if isinstance(key, TimeTruncKey) and isinstance(key.column, ColumnSqlKey): + if isinstance(key, TimeTruncKey) and not isinstance(key.source, (ColumnKey, ColumnSqlKey)): + # The bucket's untruncated value, its joins registered before the FROM is built. + derived_expr_by_sid[sid] = render_value_key( + key=key.source, ctx=RenderContext(scope=scope, dialect=self._dialect), + ) + continue + if isinstance(key, TimeTruncKey) and isinstance(key.source, ColumnSqlKey): raw = self._raw_time_col_expr_for_planned( - time_column=key.column, source_model=source_model, + time_column=key.source, source_model=source_model, source_relation=source_relation, bundle=bundle, ) - _add(key.column.path) + _add(column_path(key.source)) for p in self._joined_paths_in_sql( sql_expr=raw, source_relation=source_relation, source_model=source_model, bundle=bundle, @@ -5370,10 +5400,22 @@ def _dim_column_expr_from_planned( type=col.type, ) + def _bucket_source_expr( + self, *, source: ValueKey, scope, source_model, source_relation: str, bundle, + ) -> Expression: + """Untruncated value of a bucket's ``source`` in ``scope``, registering the joins it reads.""" + if not isinstance(source, (ColumnKey, ColumnSqlKey)): + return render_value_key(key=source, ctx=RenderContext(scope=scope, dialect=self._dialect)) + scope.resolve(source) + return self._raw_time_col_expr_for_planned( + time_column=source, source_model=source_model, + source_relation=source_relation, bundle=bundle, + ) + def _raw_time_col_expr_for_planned( self, *, time_column, source_model, source_relation: str, bundle, ) -> Expression: - """Untruncated time expression for a ``TimeTruncKey.column``""" + """Untruncated value of a column bucket source.""" if isinstance(time_column, ColumnKey): return self._joined_or_local_dim_expr( @@ -5628,7 +5670,7 @@ def _agg_param_exprs( f"Aggregation {key.agg!r} parameter {name!r} is unbound text " f"{value!r}; a parameter binds at query binding, never as SQL text." ) - elif isinstance(value, _ROW_PARAM_KINDS): + elif _is_row_param(value): out[name] = ResolvedAggKwarg(value=scope.resolve(value)) elif isinstance(value, VALUE_KEY_TYPES): raise RuntimeError( @@ -6267,7 +6309,7 @@ def _host_base_order_ref( # NOSONAR(S3776) — per-key-kind resolution of ONE h # A hidden ROW column ordered in an UNGROUPED query emits a split . reference (the row is the # grain, so the bare ref is legal and Law 1 pulls the join in). key = slot.key - row_key = key.column if isinstance(key, TimeTruncKey) else key + row_key = key.source if isinstance(key, TimeTruncKey) else key if source_model is not None and isinstance(row_key, ColumnKey): return self._joined_or_local_dim_expr( path=row_key.path, leaf=row_key.leaf, diff --git a/slayer/sql/naming.py b/slayer/sql/naming.py index 170fbdf0d..df9bd21bc 100644 --- a/slayer/sql/naming.py +++ b/slayer/sql/naming.py @@ -182,8 +182,8 @@ def _joined_row_path(slot: "ValueSlot") -> Optional[Tuple[Tuple[str, ...], str]] path, leaf = key.path, key.leaf elif isinstance(key, ColumnSqlKey): path, leaf = key.path, key.column_name - elif isinstance(key, TimeTruncKey): - path, leaf = column_path(key.column), column_leaf(key.column) + elif isinstance(key, TimeTruncKey) and isinstance(key.source, (ColumnKey, ColumnSqlKey)): + path, leaf = column_path(key.source), column_leaf(key.source) else: return None return (path, leaf) if path else None diff --git a/slayer/sql/render/row_expr.py b/slayer/sql/render/row_expr.py index 6026a6cd1..ed9b66703 100644 --- a/slayer/sql/render/row_expr.py +++ b/slayer/sql/render/row_expr.py @@ -409,7 +409,7 @@ def _part(a: Any) -> Expression: if isinstance(key, ArithmeticKey): return _row_arithmetic(key=key, part=_part, dialect=dialect, column_type=column_type) if isinstance(key, TimeTruncKey): - return dialect.build_bucket(col_expr=_part(key.column), granularity=key.granularity) + return dialect.build_bucket(col_expr=_part(key.source), granularity=key.granularity) if isinstance(key, ScalarCallKey): return _row_scalar_call(key=key, part=_part, dialect=dialect, column_type=column_type) if isinstance(key, InKey): diff --git a/slayer/sql/render/value_expr.py b/slayer/sql/render/value_expr.py index 955e8bac2..29306d3c5 100644 --- a/slayer/sql/render/value_expr.py +++ b/slayer/sql/render/value_expr.py @@ -375,11 +375,13 @@ def render_value_key( # NOSONAR(S3776) — sequential dispatch over the closed return _literal(key.value, dialect=ctx.dialect) if isinstance(key, TimeTruncKey): - column = _require_scope(ctx, key).resolve( - key.column, consumer=ctx.consumer, + source = ( + _require_scope(ctx, key).resolve(key.source, consumer=ctx.consumer) + if isinstance(key.source, (ColumnKey, ColumnSqlKey)) + else render_value_key(key=key.source, ctx=ctx) ) # The dialect owns the per-backend wire form; a literal DATE_TRUNC would name a function SQLite lacks. - return ctx.dialect.build_bucket(col_expr=column, granularity=key.granularity) + return ctx.dialect.build_bucket(col_expr=source, granularity=key.granularity) if isinstance(key, ArithmeticKey): comparison = temporal_comparison(key) diff --git a/slayer/storage/migrations.py b/slayer/storage/migrations.py index c5adbbb83..f9dcd5eb8 100644 --- a/slayer/storage/migrations.py +++ b/slayer/storage/migrations.py @@ -16,10 +16,10 @@ # Per-entity current version. Bump independently when an entity's schema changes. CURRENT_VERSIONS: dict[str, int] = { - "SlayerModel": 16, - "SlayerQuery": 7, + "SlayerModel": 17, + "SlayerQuery": 8, "DatasourceConfig": 2, - "Memory": 5, + "Memory": 6, "Embedding": 1, } @@ -196,3 +196,4 @@ def migrate(entity: str, data: Any) -> Any: from slayer.storage import rank_direction_migration # noqa: E402, F401 # ALLOW(import-not-top): circular — migration modules import register_migration from here from slayer.storage import stored_repair_migration # noqa: E402, F401 # ALLOW(import-not-top): circular — migration modules import register_migration from here from slayer.storage import sql_string_migration # noqa: E402, F401 # ALLOW(import-not-top): circular — migration modules import register_migration from here +from slayer.storage import time_dimension_text_migration # noqa: E402, F401 # ALLOW(import-not-top): circular — migration modules import register_migration from here diff --git a/slayer/storage/time_dimension_text_migration.py b/slayer/storage/time_dimension_text_migration.py new file mode 100644 index 000000000..f1c77d046 --- /dev/null +++ b/slayer/storage/time_dimension_text_migration.py @@ -0,0 +1,38 @@ +"""SlayerModel v16 / SlayerQuery v7 / Memory v5: a stored time dimension's ``{"name", "model"}`` column object becomes its dotted text. + +Stored-only: a fresh payload is never rewritten. +""" + +from typing import Any + +from slayer.storage.migrations import register_migration +from slayer.storage.rank_direction_migration import rewrite_model_texts, rewrite_query_texts, stamp_memory_query + + +def _dotted(value: Any) -> Any: + if isinstance(value, dict) and isinstance(name := value.get("name"), str): + return f"{value['model']}.{name}" if value.get("model") else name + return value + + +def _text_time_dimension(item: Any) -> Any: + if not isinstance(item, dict): + return item + return {key: _dotted(value) if key in ("dimension", "column") else value for key, value in item.items()} + + +def rewrite_query_time_dimensions(data: dict) -> dict: + """Rewrite each object-form time dimension to text; mark an inline model as stored.""" + if isinstance(data.get("time_dimensions"), list): + data["time_dimensions"] = [_text_time_dimension(td) for td in data["time_dimensions"]] + return rewrite_query_texts(data, lambda text: text) + + +def mark_model_queries(data: dict) -> dict: + """Mark nested stored queries as stored.""" + return rewrite_model_texts(data, lambda text: text) + + +register_migration(entity="SlayerModel", source_version=16, stored_only=True)(mark_model_queries) +register_migration(entity="SlayerQuery", source_version=7, stored_only=True)(rewrite_query_time_dimensions) +register_migration(entity="Memory", source_version=5, stored_only=True)(stamp_memory_query) diff --git a/tests/_dev1471_fixtures.py b/tests/_dev1471_fixtures.py index 0eaee5ea8..69f8e8061 100644 --- a/tests/_dev1471_fixtures.py +++ b/tests/_dev1471_fixtures.py @@ -9,7 +9,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, DatasourceConfig, ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.storage.sqlite_conn import transaction from slayer.storage.yaml_storage import YAMLStorage @@ -85,7 +85,7 @@ def orders_table_spec(rows: list[tuple], *, extra_columns: Iterable[tuple[str, s # Inner stage: monthly revenue over ``orders.created_at`` (shared stage-axis fixture). MONTHLY_INNER = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) diff --git a/tests/_dev1739_fixtures.py b/tests/_dev1739_fixtures.py index 771935cf2..3c37438bf 100644 --- a/tests/_dev1739_fixtures.py +++ b/tests/_dev1739_fixtures.py @@ -142,7 +142,7 @@ async def gen(query: SlayerQuery, *, dialect: str = "duckdb", validate: bool = F def month_td(column: str = "ordered_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1750_fixtures.py b/tests/_dev1750_fixtures.py index ecee0ffa2..8bcec1bdf 100644 --- a/tests/_dev1750_fixtures.py +++ b/tests/_dev1750_fixtures.py @@ -140,7 +140,7 @@ async def gen(query: SlayerQuery, *, dialect: str = "duckdb") -> str: def month_td(column: str = "ordered_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1800_fixtures.py b/tests/_dev1800_fixtures.py index 1926d669b..eee8a19cf 100644 --- a/tests/_dev1800_fixtures.py +++ b/tests/_dev1800_fixtures.py @@ -152,7 +152,7 @@ def signup_td() -> List[TimeDimension]: """Month time dimension on the customer signup axis (the attributable axis). Dotted so it works whether the query is rooted at orders or customers.""" return [TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1832_fixtures.py b/tests/_dev1832_fixtures.py index 055ed063a..64a8f32a7 100644 --- a/tests/_dev1832_fixtures.py +++ b/tests/_dev1832_fixtures.py @@ -159,7 +159,7 @@ def monthly_multi_q(**kw) -> SlayerQuery: def month_td() -> List[TimeDimension]: - return [TimeDimension(dimension=ColumnRef(name="ordered_at"), + return [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/_dev1836_fixtures.py b/tests/_dev1836_fixtures.py index 29f5f2839..0be20b195 100644 --- a/tests/_dev1836_fixtures.py +++ b/tests/_dev1836_fixtures.py @@ -173,7 +173,7 @@ def q(**kw) -> SlayerQuery: def month_td(column: str = "ordered_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1838_fixtures.py b/tests/_dev1838_fixtures.py index 49a451320..7555623fd 100644 --- a/tests/_dev1838_fixtures.py +++ b/tests/_dev1838_fixtures.py @@ -215,7 +215,7 @@ def q(**kw) -> SlayerQuery: def month_td(column: str = "ordered_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1840_fixtures.py b/tests/_dev1840_fixtures.py index a56876885..ac5a6f6b6 100644 --- a/tests/_dev1840_fixtures.py +++ b/tests/_dev1840_fixtures.py @@ -282,7 +282,7 @@ def bundle(models: Optional[List[SlayerModel]] = None) -> ResolvedSourceBundle: def signup_month_td() -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1842_fixtures.py b/tests/_dev1842_fixtures.py index 883849af5..9ace6f7a0 100644 --- a/tests/_dev1842_fixtures.py +++ b/tests/_dev1842_fixtures.py @@ -14,7 +14,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from tests._engine_helpers import seeded_exec_engine @@ -102,7 +102,7 @@ def q(**kw): def month_td(column: str = "ordered_at"): return [TimeDimension( - dimension=ColumnRef(name=column), granularity=TimeGranularity.MONTH, + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1846_fixtures.py b/tests/_dev1846_fixtures.py index fa02fb427..cb73e3824 100644 --- a/tests/_dev1846_fixtures.py +++ b/tests/_dev1846_fixtures.py @@ -99,7 +99,7 @@ async def gen(query: SlayerQuery, *, dialect: str = "duckdb") -> str: def month_td(column: str = "ordered_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1871_raise_ledger.py b/tests/_dev1871_raise_ledger.py index 827f607bb..85c93ee7e 100644 --- a/tests/_dev1871_raise_ledger.py +++ b/tests/_dev1871_raise_ledger.py @@ -117,14 +117,28 @@ def _row(module, function, exc, message, category, family, user, owner, + SUGGEST + "…", category="checker", family="positions", user=True, owner="checker"), _row(module=_EE, function="check_time_dimension_column", exc="TimeDimensionColumnError", - message="A time dimension must reference a temporal column (DATE / TIMESTAMP); got column type …." - + AT + "time dimension …", + message="A time dimension must reference a temporal … (DATE / TIMESTAMP); got … type …." + + AT + "time dimension …" + SUGGEST + "…", category="checker", family="time-axis", user=True, owner="checker"), _row(module=_EE, function="check_time_dimension_column", exc="TimeDimensionColumnError", - message="Cannot re-bucket to '…': the column is already bucketed at '…', which does not nest into '…'." + message="Cannot re-bucket to '…': the … is already bucketed at '…', which does not nest into '…'." + AT + "time dimension …" + SUGGEST + "Request the same or a nesting-coarser granularity, or bucket the raw column instead.", category="checker", family="time-axis", user=True, owner="checker"), + _row(module=_EE, function="check_spine_time_dimension", exc="TimeSpineError", + message="Time dimension `…` reads time_spine.timestamp inside an expression; an expression is never an axis." + + AT + "time dimension …" + SUGGEST + "Bucket time_spine.timestamp itself (time_dimensions=[{dimension: " + "time_spine.timestamp, granularity: month, date_range: [...]}]) and compute other date logic from the " + "fact's own column.", + category="checker", family="time-axis", user=True, owner="checker"), + _row(module=_EE, function="check_time_dimension_row_level", exc="TimeDimensionColumnError", + message="A time dimension is row-level; its expression reads an aggregate or transform." + + AT + "time dimension …" + SUGGEST + "Bucket a column or a row-level expression of columns.", + category="checker", family="time-axis", user=True, owner="checker"), + _row(module=_EE, function="check_time_dimension_home", exc="UnsafeJoinInputError", + message="The time dimension reads …, which crosses an unproven or fanning join hop to … from …: no " + "dataset determines every column the expression reads." + AT + "time dimension …" + SUGGEST + "…", + category="checker", family="time-axis", user=True, owner="checker"), _row(module=_EE, function="_require_temporal", exc="DateOperandTypeError", message="…() needs a DATE or TIMESTAMP operand; `…` is not one." + SUGGEST + "Pass a column declared DATE / TIMESTAMP (set Column.type), min/max/first/last of one, " @@ -165,9 +179,9 @@ def _row(module, function, exc, message, category, family, user, owner, + SUGGEST + "Add it to time_dimensions, or choose one of: ….", category="checker", family="local-partitioned", user=True, owner="checker"), _row(module=_EE, function="check_partition_key_resolves", exc="PartitionKeyError", - message="The partition_by column '…' is not a query dimension." + message="The partition_by … '…' is not a query dimension." + AT + "…" - + SUGGEST + "Add it to dimensions/time_dimensions, or choose one of: ….", + + SUGGEST + "Add it to dimensions/time_dimensions…, or choose one of: ….", category="checker", family="local-partitioned", user=True, owner="checker"), _row(module=_EE, function="check_transform_partition_keys_in_operand_grain", exc="PartitionKeyError", message="The partition_by column '…' is not a member of the transform's operand grain (…); a rank-family transform partitions its operand's cells." @@ -369,7 +383,7 @@ def _row(module, function, exc, message, category, family, user, owner, message="Order key …(…) has no matching projected time dimension. Project a time_dimension on … at … granularity (e.g. …(…) in dimensions) to order by its bucket.", category="bind", family="time-axis", user=True, owner="bind"), _row(module=_BI, function="_assert_equivalent_tds_agree", exc="GranularityCallError", - message="Conflicting time dimensions on … at … granularity: equivalent columns must not differ in date range or label.", + message="Conflicting time dimensions on … at … granularity: equivalent spellings must not differ in date range, name or label.", category="bind", family="time-axis", user=True, owner="bind"), _row(module=_EE, function="type_position_conjunct", exc="PositionTypingError", message="This … expression is valid as neither a field nor a measure. Field typing failed: it references …, available only after aggregation. Measure typing failed: it references row-level …, not available at the query grain (not among the query dimensions)." @@ -396,7 +410,7 @@ def _row(module, function, exc, message, category, family, user, owner, message='…\n suggestion: For a time bucket use ``gran(col)``; a custom aggregation used as a dimension must carry ``partition_by=``.', category="checker", family='granularities', user=True, owner="checker"), _row(module=_EE, function='check_granularity_call_shape', exc='GranularityShapeError', - message='Granularity call … must be a single column reference ``gran(col)``.\n suggestion: Pass one bare or dotted column, e.g. ``fiscal_year(created_at)``.', + message='Granularity call … must take one row-level argument ``gran(expr)``.\n suggestion: Pass one column or row-level expression, e.g. ``fiscal_year(created_at)``.', category="checker", family='granularities', user=True, owner="checker"), _row(module=_EE, function='check_granularity_known', exc='UnknownGranularityError', message='…', diff --git a/tests/_dev1908_fixtures.py b/tests/_dev1908_fixtures.py index cf03bb5b5..7e8ecd9a6 100644 --- a/tests/_dev1908_fixtures.py +++ b/tests/_dev1908_fixtures.py @@ -217,7 +217,7 @@ def bundle1908() -> ResolvedSourceBundle: def signup_month_td() -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/_dev1915_fixtures.py b/tests/_dev1915_fixtures.py index c4be00178..4d6677e51 100644 --- a/tests/_dev1915_fixtures.py +++ b/tests/_dev1915_fixtures.py @@ -128,7 +128,7 @@ def q(**kw) -> SlayerQuery: def month_td(column: str = "created_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev1919_fixtures.py b/tests/_dev1919_fixtures.py index 1cd3c2239..05d49f68f 100644 --- a/tests/_dev1919_fixtures.py +++ b/tests/_dev1919_fixtures.py @@ -13,7 +13,7 @@ from slayer.core.enums import JoinCardinality, TimeGranularity from slayer.core.models import SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from tests._dev1840_fixtures import ( _CUSTOMERS_ROWS, @@ -95,7 +95,7 @@ def mode_q(mode: Optional[str], **kw) -> SlayerQuery: def ordered_month_td() -> List[TimeDimension]: - return [TimeDimension(dimension=ColumnRef(name="ordered_at"), + return [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/_dev1945_fixtures.py b/tests/_dev1945_fixtures.py index f41da7399..7248a3796 100644 --- a/tests/_dev1945_fixtures.py +++ b/tests/_dev1945_fixtures.py @@ -23,7 +23,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.ir.source_bundle import ResolvedSourceBundle from slayer.storage.sqlite_conn import transaction @@ -174,7 +174,7 @@ def orders_q(**kw) -> SlayerQuery: def month_td(column: str = "created_at") -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name=column), granularity=TimeGranularity.MONTH, + dimension=column, granularity=TimeGranularity.MONTH, )] diff --git a/tests/_dev2006_fixtures.py b/tests/_dev2006_fixtures.py index c5718cf3f..f051e4b7a 100644 --- a/tests/_dev2006_fixtures.py +++ b/tests/_dev2006_fixtures.py @@ -48,7 +48,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, ModelJoin, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.ir.source_bundle import ResolvedSourceBundle from slayer.storage.sqlite_conn import transaction @@ -116,7 +116,7 @@ def m(formula: str, name: str = "v") -> ModelMeasure: def month_td(column: str = "snapshot_date") -> List[TimeDimension]: - return [TimeDimension(dimension=ColumnRef(name=column), granularity=TimeGranularity.MONTH)] + return [TimeDimension(dimension=column, granularity=TimeGranularity.MONTH)] # --------------------------------------------------------------------------- # diff --git a/tests/_dev2046_fixtures.py b/tests/_dev2046_fixtures.py index ebb5b821b..117ad7645 100644 --- a/tests/_dev2046_fixtures.py +++ b/tests/_dev2046_fixtures.py @@ -30,7 +30,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.format import NumberFormat from slayer.core.models import Column, ModelJoin, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.storage.sqlite_conn import transaction @@ -87,7 +87,7 @@ def orders_q(**kw) -> SlayerQuery: def month_td() -> List[TimeDimension]: - return [TimeDimension(dimension=ColumnRef(name="ordered_at"), granularity=TimeGranularity.MONTH)] + return [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)] # --------------------------------------------------------------------------- # diff --git a/tests/_dev2062_fixtures.py b/tests/_dev2062_fixtures.py index 85056dd73..53c554925 100644 --- a/tests/_dev2062_fixtures.py +++ b/tests/_dev2062_fixtures.py @@ -30,7 +30,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, ModelJoin, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.storage.sqlite_conn import transaction @@ -93,7 +93,7 @@ def orders_q(**kw) -> SlayerQuery: def month_td(*, date_range: Optional[List[Optional[str]]] = None) -> List[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name="order_date"), granularity=TimeGranularity.MONTH, date_range=date_range, + dimension="order_date", granularity=TimeGranularity.MONTH, date_range=date_range, )] diff --git a/tests/_dev2119_fixtures.py b/tests/_dev2119_fixtures.py new file mode 100644 index 000000000..13674aa9c --- /dev/null +++ b/tests/_dev2119_fixtures.py @@ -0,0 +1,304 @@ +"""Shared fixtures for row-level expressions in every column position. + +Underscore-prefixed so pytest skips collection. ``orders → customers`` is proven +many-to-one (``customers.id`` is a primary key); ``accounts`` (the customers table) +→ ``orders`` is declared one-to-many; ``orders → regions`` (``region = code``, ``code`` +not a key) is unproven. + +orders (id cust region city product status amount quantity price created_at shipped_at refunded_at created_txt): + 1 1 EU Paris A paid 10 1 2 2025-01-05 10:00 2025-01-07 09:00 NULL '2025-01-05' + 2 1 EU Berlin B paid 20 2 3 2025-01-20 11:00 2025-02-02 08:00 2025-02-15 10:00 '2025-01-20' + 3 2 US NYC A pending 30 3 4 2025-02-10 12:00 NULL NULL '2025-02-10' + 4 2 US paris B paid 40 2 5 2025-02-25 09:00 2025-03-03 10:00 2025-03-10 09:00 '2025-02-25' + 5 3 EU berlin A void 50 4 1 2025-03-15 08:00 2025-04-01 12:00 NULL '2025-03-15' + 6 1 US NYC B paid 60 1 6 2024-12-28 15:00 2025-03-20 10:00 NULL '2024-12-28' +customers (id name region tier signup_ts updated_at discount spend): + 1 A North gold 2024-11-15 09:00 2025-01-10 0.1 100 + 2 B South silver NULL 2025-02-20 NULL 200 + 3 C North gold 2025-03-20 10:00 NULL 0.5 300 +accounts: the customers rows (id signup_ts updated_at spend); ``orders`` cannot reach it. +balances (id customer_id balance updated_at created_at): + 1 1 100 2025-01-01 2025-04-01 | 2 1 150 NULL 2025-03-01 | 3 1 120 2025-02-01 2025-01-15 + 4 2 50 2025-01-15 2025-01-10 | 5 2 70 NULL 2025-01-20 | 6 2 80 2025-01-18 2025-01-05 +regions (id code manager): 1 EU Ann | 2 US Bob | 3 EU Cy + +``EFF`` = ``coalesce(shipped_at, created_at)``; the derived column ``orders.eff`` holds it. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import AsyncGenerator, Iterable +from datetime import date, datetime +from typing import Any, Optional + +from slayer.core.enums import DataType, JoinCardinality +from slayer.core.models import Column, ModelJoin, ModelMeasure, SlayerModel +from slayer.core.query import SlayerQuery +from slayer.engine.query_engine import SlayerQueryEngine, SlayerResponse + +from tests._dev1737_fixtures import TableSpec, seed_backend +from tests._engine_helpers import _engine_generate, seeded_exec_engine +from tests._time_points_fixtures import PinnedClock + +BACKENDS = ["sqlite", "duckdb"] +TIER1 = ["sqlite", "postgres", "duckdb", "mysql", "clickhouse", "tsql", "trino", "snowflake", "bigquery"] +NOW = datetime(2026, 9, 29, 12, 0, 0) + +EFF = "coalesce(shipped_at, created_at)" +EFF_NAME = "coalesce_shipped_at_created_at" + +# --------------------------------------------------------------------------- # +# Tables +# --------------------------------------------------------------------------- # +ORDERS = TableSpec( + name="orders", + columns=[ + ("id", "INT"), ("customer_id", "INT"), ("region", "TEXT"), ("city", "TEXT"), ("product", "TEXT"), + ("status", "TEXT"), ("amount", "DOUBLE"), ("quantity", "INT"), ("price", "DOUBLE"), + ("created_at", "TIMESTAMP"), ("shipped_at", "TIMESTAMP"), ("refunded_at", "TIMESTAMP"), + ("created_txt", "TEXT"), + ], + rows=[ + (1, 1, "EU", "Paris", "A", "paid", 10.0, 1, 2.0, "2025-01-05 10:00:00", "2025-01-07 09:00:00", None, + "2025-01-05"), + (2, 1, "EU", "Berlin", "B", "paid", 20.0, 2, 3.0, "2025-01-20 11:00:00", "2025-02-02 08:00:00", + "2025-02-15 10:00:00", "2025-01-20"), + (3, 2, "US", "NYC", "A", "pending", 30.0, 3, 4.0, "2025-02-10 12:00:00", None, None, "2025-02-10"), + (4, 2, "US", "paris", "B", "paid", 40.0, 2, 5.0, "2025-02-25 09:00:00", "2025-03-03 10:00:00", + "2025-03-10 09:00:00", "2025-02-25"), + (5, 3, "EU", "berlin", "A", "void", 50.0, 4, 1.0, "2025-03-15 08:00:00", "2025-04-01 12:00:00", None, + "2025-03-15"), + (6, 1, "US", "NYC", "B", "paid", 60.0, 1, 6.0, "2024-12-28 15:00:00", "2025-03-20 10:00:00", None, + "2024-12-28"), + ], +) +CUSTOMERS = TableSpec( + name="customers", + columns=[ + ("id", "INT"), ("name", "TEXT"), ("region", "TEXT"), ("tier", "TEXT"), ("signup_ts", "TIMESTAMP"), + ("updated_at", "TIMESTAMP"), ("discount", "DOUBLE"), ("spend", "DOUBLE"), + ], + rows=[ + (1, "A", "North", "gold", "2024-11-15 09:00:00", "2025-01-10 00:00:00", 0.1, 100.0), + (2, "B", "South", "silver", None, "2025-02-20 00:00:00", None, 200.0), + (3, "C", "North", "gold", "2025-03-20 10:00:00", None, 0.5, 300.0), + ], +) +BALANCES = TableSpec( + name="balances", + columns=[("id", "INT"), ("customer_id", "INT"), ("balance", "DOUBLE"), ("updated_at", "TIMESTAMP"), + ("created_at", "TIMESTAMP")], + rows=[ + (1, 1, 100.0, "2025-01-01 00:00:00", "2025-04-01 00:00:00"), + (2, 1, 150.0, None, "2025-03-01 00:00:00"), + (3, 1, 120.0, "2025-02-01 00:00:00", "2025-01-15 00:00:00"), + (4, 2, 50.0, "2025-01-15 00:00:00", "2025-01-10 00:00:00"), + (5, 2, 70.0, None, "2025-01-20 00:00:00"), + (6, 2, 80.0, "2025-01-18 00:00:00", "2025-01-05 00:00:00"), + ], +) +REGIONS = TableSpec( + name="regions", + columns=[("id", "INT"), ("code", "TEXT"), ("manager", "TEXT")], + rows=[(1, "EU", "Ann"), (2, "US", "Bob"), (3, "EU", "Cy")], +) +TABLES = [ORDERS, CUSTOMERS, BALANCES, REGIONS] + +# --------------------------------------------------------------------------- # +# Oracles +# --------------------------------------------------------------------------- # +#: Per-order ``EFF`` (the coalesced timestamp) and ``amount``. +EFF_BY_ID = { + 1: "2025-01-07 09:00:00", 2: "2025-02-02 08:00:00", 3: "2025-02-10 12:00:00", + 4: "2025-03-03 10:00:00", 5: "2025-04-01 12:00:00", 6: "2025-03-20 10:00:00", +} +AMOUNT_BY_ID = {1: 10.0, 2: 20.0, 3: 30.0, 4: 40.0, 5: 50.0, 6: 60.0} +#: ``count(*)`` / ``sum(amount)`` per month of ``EFF``. +EFF_MONTH_COUNT = {"2025-01": 1, "2025-02": 2, "2025-03": 2, "2025-04": 1} +EFF_MONTH_SUM = {"2025-01": 10.0, "2025-02": 50.0, "2025-03": 100.0, "2025-04": 50.0} +#: ``count(*)`` per month of ``created_at`` (= of ``CAST(created_txt AS DATE)``). +CREATED_MONTH_COUNT = {"2024-12": 1, "2025-01": 2, "2025-02": 2, "2025-03": 1} + + +# --------------------------------------------------------------------------- # +# Models +# --------------------------------------------------------------------------- # +def orders_model() -> SlayerModel: + return SlayerModel( + name="orders", sql_table="orders", data_source="test", default_time_dimension="created_at", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="customer_id", type=DataType.INT), + Column(name="region", type=DataType.TEXT), + Column(name="city", type=DataType.TEXT), + Column(name="product", type=DataType.TEXT), + Column(name="status", type=DataType.TEXT), + Column(name="amount", type=DataType.DOUBLE), + Column(name="quantity", type=DataType.INT), + Column(name="price", type=DataType.DOUBLE), + Column(name="created_at", type=DataType.TIMESTAMP), + Column(name="shipped_at", type=DataType.TIMESTAMP), + Column(name="refunded_at", type=DataType.TIMESTAMP), + Column(name="created_txt", type=DataType.TEXT), + Column(name="eff", type=DataType.TIMESTAMP, sql="COALESCE(shipped_at, created_at)"), + ], + joins=[ + ModelJoin(target_model="customers", join_pairs=[["customer_id", "id"]]), + ModelJoin(target_model="regions", join_pairs=[["region", "code"]]), + ], + ) + + +def customers_model() -> SlayerModel: + return SlayerModel( + name="customers", sql_table="customers", data_source="test", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="name", type=DataType.TEXT), + Column(name="region", type=DataType.TEXT), + Column(name="tier", type=DataType.TEXT), + Column(name="signup_ts", type=DataType.TIMESTAMP), + Column(name="updated_at", type=DataType.TIMESTAMP), + Column(name="discount", type=DataType.DOUBLE), + Column(name="spend", type=DataType.DOUBLE), + ], + ) + + +def accounts_model() -> SlayerModel: + """The ``customers`` table with a declared one-to-many hop to ``orders``.""" + return SlayerModel( + name="accounts", sql_table="customers", data_source="test", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="signup_ts", type=DataType.TIMESTAMP), + Column(name="updated_at", type=DataType.TIMESTAMP), + Column(name="spend", type=DataType.DOUBLE), + ], + joins=[ModelJoin(target_model="orders", join_pairs=[["id", "customer_id"]], + cardinality=JoinCardinality.ONE_TO_MANY)], + ) + + +def balances_model() -> SlayerModel: + return SlayerModel( + name="balances", sql_table="balances", data_source="test", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="customer_id", type=DataType.INT), + Column(name="balance", type=DataType.DOUBLE), + Column(name="updated_at", type=DataType.TIMESTAMP), + Column(name="created_at", type=DataType.TIMESTAMP), + ], + ) + + +def regions_model() -> SlayerModel: + return SlayerModel( + name="regions", sql_table="regions", data_source="test", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="code", type=DataType.TEXT), + Column(name="manager", type=DataType.TEXT), + ], + ) + + +def monthly_model() -> SlayerModel: + """Query-backed: ``created_at`` cached at ``month``, ``refunded_at`` unbucketed.""" + return SlayerModel( + name="monthly", data_source="test", + source_queries=[SlayerQuery.model_validate({ + "source_model": "orders", "dimensions": ["refunded_at"], + "time_dimensions": [{"dimension": "created_at", "granularity": "month"}], + "measures": [{"formula": "sum(amount)", "name": "rev"}], + })], + ) + + +def dev2119_models() -> list[SlayerModel]: + return [orders_model(), customers_model(), accounts_model(), balances_model(), regions_model(), monthly_model()] + + +# --------------------------------------------------------------------------- # +# Engines +# --------------------------------------------------------------------------- # +@contextlib.asynccontextmanager +async def engine_for( + backend: str, *, models: Optional[list[SlayerModel]] = None, clock: Optional[PinnedClock] = None, + datasource_fields: Optional[dict[str, Any]] = None, +) -> AsyncGenerator[SlayerQueryEngine]: + """An executing engine over the seeded dataset on ``sqlite`` or ``duckdb``.""" + async with seeded_exec_engine( + dialect=backend, seed=lambda p: seed_backend(backend, p, TABLES), + models=dev2119_models() if models is None else models, + clock=clock if clock is not None else PinnedClock(NOW), datasource_fields=datasource_fields, + ) as (engine, _): + yield engine + + +async def emit(query: SlayerQuery | list[SlayerQuery | dict], *, dialect: str, + models: Optional[Iterable[SlayerModel]] = None) -> str: + """Dry-run SQL over the dev2119 models on ``dialect`` (asserts it parses there).""" + first, *rest = list(models) if models is not None else dev2119_models() + return await _engine_generate(query=query, model=first, extra_models=rest, dialect=dialect) + + +# --------------------------------------------------------------------------- # +# Queries and readers +# --------------------------------------------------------------------------- # +def q(**kw: Any) -> SlayerQuery: + return SlayerQuery.model_validate({"source_model": "orders", **kw}) + + +def m(formula: str, name: str = "v") -> ModelMeasure: + return ModelMeasure(formula=formula, name=name) + + +def td(dimension: str, granularity: str = "month", **extra: Any) -> dict[str, Any]: + return {"dimension": dimension, "granularity": granularity, **extra} + + +def num(value: Any) -> Optional[float]: + return None if value is None else float(value) + + +def bucket(value: Any, *, width: int = 7) -> Optional[str]: + """A bucket cell as ISO text cut to ``width`` (7 → ``YYYY-MM``, 4 → a year, 10 → a day).""" + if value is None: + return None + if isinstance(value, datetime): + text = value.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(value, date): + text = value.isoformat() + else: + text = str(value).replace("T", " ") + return text[:width] + + +def column(resp: SlayerResponse, name: str) -> str: + """The unique result key equal to ``name`` or ending in ``.name``.""" + hits = [k for k in resp.columns if k == name or k.endswith(f".{name}")] + assert len(hits) == 1, (name, resp.columns) + return hits[0] + + +def by_bucket(resp: SlayerResponse, key: str, value: str = "v", *, width: int = 7, + by: Iterable[str] = ()) -> dict[Any, Optional[float]]: + """``{bucket (or (by..., bucket)): value}``; asserts one row per key.""" + by = list(by) + out: dict[Any, Optional[float]] = {} + for row in resp.data: + b = bucket(row[key], width=width) + k = (*(row[d] for d in by), b) if by else b + assert k not in out, (k, resp.data) + out[k] = num(row[value]) + return out + + +def by_dims(resp: SlayerResponse, keys: Iterable[str], value: str = "orders.v") -> dict[tuple, Optional[float]]: + keys = list(keys) + out = {tuple(r[k] for k in keys): num(r[value]) for r in resp.data} + assert len(out) == len(resp.data), resp.data + return out diff --git a/tests/dialects/test_dev1879_alias_battery.py b/tests/dialects/test_dev1879_alias_battery.py index de347ef77..9321b520b 100644 --- a/tests/dialects/test_dev1879_alias_battery.py +++ b/tests/dialects/test_dev1879_alias_battery.py @@ -16,7 +16,7 @@ from slayer.core.enums import TimeGranularity from slayer.core.models import ModelMeasure -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from tests._dev1879_helpers import dry_response @@ -67,7 +67,7 @@ def _shape_query(shape: str) -> SlayerQuery: measures=[ModelMeasure(formula=_SHAPES[shape])], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ], @@ -134,7 +134,7 @@ async def test_bigquery_unnamed_formula_measures() -> None: ], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ], diff --git a/tests/dialects/test_generator_delegation.py b/tests/dialects/test_generator_delegation.py index 80d1e3f93..976e49acb 100644 --- a/tests/dialects/test_generator_delegation.py +++ b/tests/dialects/test_generator_delegation.py @@ -687,7 +687,7 @@ async def test_full_query_with_time_dim_valid_tsql( source_model="orders", measures=[ModelMeasure(formula="revenue:sum")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], ) @@ -703,7 +703,7 @@ async def test_calendar_time_shift_tsql_uses_dateadd( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ diff --git a/tests/dialects/test_multi_dialect_generation.py b/tests/dialects/test_multi_dialect_generation.py index 341f067b0..0a47c8763 100644 --- a/tests/dialects/test_multi_dialect_generation.py +++ b/tests/dialects/test_multi_dialect_generation.py @@ -97,7 +97,7 @@ async def test_date_trunc(self, dialect: str, orders_model: SlayerModel) -> None query = SlayerQuery( source_model="orders", measures=[ModelMeasure(formula="*:count")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], ) sql = await _generate(gen, query, orders_model) assert "COUNT(" in sql @@ -128,7 +128,7 @@ def _q(grain: TimeGranularity) -> SlayerQuery: source_model="orders", measures=[ModelMeasure(formula="*:count")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=grain + dimension="created_at", granularity=grain )], ) @@ -164,7 +164,7 @@ async def test_date_trunc_week_sunday_over_date_column(self, dialect: str) -> No source_model="orders", measures=[ModelMeasure(formula="*:count")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_on"), + dimension="ordered_on", granularity=TimeGranularity.WEEK_SUNDAY, )], ), @@ -202,7 +202,7 @@ async def test_date_trunc_casts_unknown_typed_time_dim(self, dialect: str) -> No SlayerQuery( source_model="orders", measures=[ModelMeasure(formula="*:count")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="ts"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="ts", granularity=TimeGranularity.MONTH)], ), model, ) @@ -216,7 +216,7 @@ async def test_date_trunc_casts_unknown_typed_time_dim(self, dialect: str) -> No SlayerQuery( source_model="orders", measures=[ModelMeasure(formula="*:count")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], ), model, ) @@ -232,7 +232,7 @@ async def test_calendar_time_shift(self, dialect: str, orders_model: SlayerModel gen = SQLGenerator(dialect=dialect) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="time_shift(revenue:sum, -1, 'year')", name="rev_prev_year")], ) sql = await _generate(gen, query, orders_model) @@ -264,7 +264,7 @@ async def test_window_measure_multi_unit_interval_dialect_correct( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), + TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="revenue:sum(window='1y2m3d')", @@ -301,7 +301,7 @@ async def test_window_measure_single_unit_interval_dialect_correct( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), + TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="revenue:sum(window='7d')", @@ -327,7 +327,7 @@ async def test_window_measure_tsql_uses_dateadd( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), + TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="revenue:sum(window='7d')", name="rev_w")], @@ -345,7 +345,7 @@ async def test_window_measure_tsql_multi_unit_uses_chained_dateadd( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), + TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="revenue:sum(window='1y2m3d')", name="rev_w")], diff --git a/tests/dialects/test_mysql.py b/tests/dialects/test_mysql.py index d8b664429..294f2c9fc 100644 --- a/tests/dialects/test_mysql.py +++ b/tests/dialects/test_mysql.py @@ -211,7 +211,7 @@ async def test_mysql_time_shift_inner_cte_uses_backticks_not_ansi_quotes() -> No q = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], diff --git a/tests/dialects/test_tsql.py b/tests/dialects/test_tsql.py index a5440d9c2..db785fe7a 100644 --- a/tests/dialects/test_tsql.py +++ b/tests/dialects/test_tsql.py @@ -556,7 +556,7 @@ async def test_tsql_time_shift_inner_cte_uses_mangled_brackets() -> None: q = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], diff --git a/tests/facade/test_translator.py b/tests/facade/test_translator.py index 448c30cee..7a721b2f3 100644 --- a/tests/facade/test_translator.py +++ b/tests/facade/test_translator.py @@ -997,7 +997,7 @@ def test_month_wrapper_creates_time_dimension(dialect) -> None: assert len(result.query.time_dimensions) == 1 td = result.query.time_dimensions[0] assert td.granularity == TimeGranularity.MONTH - assert td.dimension.full_name == "ordered_at" + assert td.dimension == "ordered_at" def test_date_trunc_creates_time_dimension(dialect) -> None: @@ -1022,7 +1022,7 @@ def test_cast_wrapped_time_trunc_creates_time_dimension(dialect) -> None: assert len(result.query.time_dimensions) == 1 td = result.query.time_dimensions[0] assert td.granularity == TimeGranularity.MONTH - assert td.dimension.full_name == "ordered_at" + assert td.dimension == "ordered_at" def test_metabase_aliased_cast_time_trunc_group_by_validates(dialect) -> None: @@ -1073,7 +1073,7 @@ def test_metabase_sunday_week_wrapper_recognised(dialect) -> None: assert result.query.time_dimensions is not None assert len(result.query.time_dimensions) == 1 assert result.query.time_dimensions[0].granularity == TimeGranularity.WEEK_SUNDAY - assert result.query.time_dimensions[0].dimension.full_name == "ordered_at" + assert result.query.time_dimensions[0].dimension == "ordered_at" def test_one_day_offset_on_non_week_is_preserved(dialect) -> None: diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 9635dae4c..8559f9984 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -371,7 +371,7 @@ async def test_cumsum_change_identity(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -402,7 +402,7 @@ async def test_nested_cumsum_of_cumsum(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -429,7 +429,7 @@ async def test_consecutive_periods_counts_trailing_true_run(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -457,7 +457,7 @@ async def test_consecutive_periods_with_non_boolean_argument(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -482,7 +482,7 @@ async def test_consecutive_periods_partitions_by_dimension(integration_env): source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -513,7 +513,7 @@ async def test_consecutive_periods_comparison_is_selectable(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -533,7 +533,7 @@ async def test_consecutive_periods_comparison_is_filterable(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], @@ -579,7 +579,7 @@ async def test_time_shift_default_granularity_calendar(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -618,7 +618,7 @@ async def test_time_shift_calendar_based(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -652,7 +652,7 @@ async def test_multiple_time_shifts_in_one_query(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -694,7 +694,7 @@ async def test_time_shift_with_date_range(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2025-03-01", "2025-03-31"], )], @@ -721,7 +721,7 @@ async def test_change_with_date_range(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2025-03-01", "2025-03-31"], )], @@ -745,7 +745,7 @@ async def test_change_pct_with_date_range(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2025-03-01", "2025-03-31"], )], @@ -770,7 +770,7 @@ async def test_multiple_date_range_shifts(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2025-02-01", "2025-02-28"], )], @@ -799,7 +799,7 @@ async def test_forward_row_shift_with_date_range(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2025-02-01", "2025-02-28"], )], @@ -827,7 +827,7 @@ async def test_post_filter_on_change(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -859,7 +859,7 @@ async def test_post_filter_with_base_filter(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -886,7 +886,7 @@ async def test_inline_transform_filter(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], @@ -910,7 +910,7 @@ async def test_inline_last_change_filter(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], @@ -926,7 +926,7 @@ async def test_inline_last_change_filter(integration_env): query2 = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], @@ -948,7 +948,7 @@ async def test_arithmetic_transform_filter(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], @@ -973,7 +973,7 @@ async def test_transform_on_filter_rhs(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], @@ -997,7 +997,7 @@ async def test_last_measure_type(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -1027,7 +1027,7 @@ async def test_last_function(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -1095,7 +1095,7 @@ async def test_filter_mixing_measure_and_row_conjuncts_splits(integration_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count")], @@ -1190,7 +1190,7 @@ async def test_last_with_joined_time_dimension(joined_time_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="stores.opened_at"), + dimension="stores.opened_at", granularity=TimeGranularity.YEAR, )], measures=[ @@ -1219,7 +1219,7 @@ async def test_last_with_multihop_joined_time_dimension(joined_time_env): query = SlayerQuery( source_model="order_items", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="orders.created_at"), + dimension="orders.created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -1298,7 +1298,7 @@ async def test_cross_model_measure_monthly(cross_model_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="*:count"), @@ -1447,7 +1447,7 @@ async def test_transform_on_cross_model(cross_model_env): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="customers.avg_score:avg"), @@ -1478,7 +1478,7 @@ async def test_query_backed_model_count(integration_env): name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total_amount:sum")], ) @@ -1499,7 +1499,7 @@ async def test_query_backed_model_aggregate(integration_env): name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], ) @@ -1519,7 +1519,7 @@ async def test_create_model_from_query(integration_env): source_query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total_amount:sum")], ) @@ -1568,7 +1568,7 @@ async def test_query_list_with_joins(cross_model_env): "joins": [{"target_model": "customer_scores", "join_pairs": [["customer_id", "id"]]}], }), time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="*:count"), @@ -1685,7 +1685,7 @@ async def test_formula_dimension_via_query_list(integration_env): name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="total_amount:sum")], ) @@ -2122,7 +2122,7 @@ async def test_filtered_last_picks_correct_row(integration_env): source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -2164,7 +2164,7 @@ async def test_time_dimension_label_fallback(integration_env): source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -3120,7 +3120,7 @@ async def test_dense_rank_partition_by_customer_executes(integration_env): source_model="orders", dimensions=[ColumnRef(name="customer_id")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) ], measures=[ ModelMeasure(formula="amount:sum"), diff --git a/tests/integration/test_integration_clickhouse.py b/tests/integration/test_integration_clickhouse.py index ad92f20bf..e6517dffb 100644 --- a/tests/integration/test_integration_clickhouse.py +++ b/tests/integration/test_integration_clickhouse.py @@ -465,7 +465,7 @@ async def test_time_shift_with_date_range(self, clickhouse_env: SlayerQueryEngin query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -483,7 +483,7 @@ async def test_consecutive_periods_with_boolean_predicate(self, clickhouse_env: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="total:sum"), @@ -504,7 +504,7 @@ async def test_change_with_date_range(self, clickhouse_env: SlayerQueryEngine) - query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -521,7 +521,7 @@ async def test_change_pct_with_date_range(self, clickhouse_env: SlayerQueryEngin query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -538,7 +538,7 @@ async def test_multiple_date_range_shifts(self, clickhouse_env: SlayerQueryEngin query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-02-29"], )], measures=[ @@ -634,7 +634,7 @@ async def test_cross_model_measure(self, clickhouse_cross_model_env: SlayerQuery query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="customers.avg_score:avg")], order=[OrderItem(column=ColumnRef(name="created_at"), direction="asc")], @@ -650,7 +650,7 @@ async def test_query_list_named(self, clickhouse_cross_model_env: SlayerQueryEng inner = SlayerQuery( name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) @@ -662,7 +662,7 @@ async def test_create_model_from_query(self, clickhouse_cross_model_env: SlayerQ source = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) diff --git a/tests/integration/test_integration_duckdb.py b/tests/integration/test_integration_duckdb.py index 62cee380a..f7a61ea1f 100644 --- a/tests/integration/test_integration_duckdb.py +++ b/tests/integration/test_integration_duckdb.py @@ -259,7 +259,7 @@ async def test_time_shift_with_date_range(self, duckdb_env: SlayerQueryEngine) - query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -279,7 +279,7 @@ async def test_change_with_date_range(self, duckdb_env: SlayerQueryEngine) -> No query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -298,7 +298,7 @@ async def test_change_pct_with_date_range(self, duckdb_env: SlayerQueryEngine) - query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -318,7 +318,7 @@ async def test_multiple_date_range_shifts(self, duckdb_env: SlayerQueryEngine) - query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-02-29"], )], measures=[ @@ -345,7 +345,7 @@ async def test_unbounded_cumsum_matches_running_total( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="total:sum"), diff --git a/tests/integration/test_integration_mysql.py b/tests/integration/test_integration_mysql.py index 32bfe35e4..dd8da2b0d 100644 --- a/tests/integration/test_integration_mysql.py +++ b/tests/integration/test_integration_mysql.py @@ -443,7 +443,7 @@ async def test_time_shift_with_date_range(self, mysql_env: SlayerQueryEngine) -> query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -461,7 +461,7 @@ async def test_consecutive_periods_with_boolean_predicate(self, mysql_env: Slaye query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="total:sum"), @@ -482,7 +482,7 @@ async def test_change_with_date_range(self, mysql_env: SlayerQueryEngine) -> Non query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -499,7 +499,7 @@ async def test_change_pct_with_date_range(self, mysql_env: SlayerQueryEngine) -> query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -516,7 +516,7 @@ async def test_multiple_date_range_shifts(self, mysql_env: SlayerQueryEngine) -> query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-02-29"], )], measures=[ @@ -615,7 +615,7 @@ async def test_cross_model_measure(self, mysql_cross_model_env: SlayerQueryEngin query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="customers.avg_score:avg")], order=[OrderItem(column=ColumnRef(name="created_at"), direction="asc")], @@ -631,7 +631,7 @@ async def test_query_list_named(self, mysql_cross_model_env: SlayerQueryEngine) inner = SlayerQuery( name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) @@ -643,7 +643,7 @@ async def test_create_model_from_query(self, mysql_cross_model_env: SlayerQueryE source = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) @@ -1102,7 +1102,7 @@ async def test_windowed_covar_samp_and_corr_keep_sample_semantics( """Trailing 90d windows (bucket end exclusive) over the six seeded orders.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="total:covar_samp(other=customer_id, window='90d')", name="cv"), diff --git a/tests/integration/test_integration_postgres.py b/tests/integration/test_integration_postgres.py index 8a1c5f4e5..bb1b876f3 100644 --- a/tests/integration/test_integration_postgres.py +++ b/tests/integration/test_integration_postgres.py @@ -312,7 +312,7 @@ async def test_time_shift_with_date_range(self, pg_env: SlayerQueryEngine) -> No query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -338,7 +338,7 @@ async def test_consecutive_periods_with_boolean_predicate(self, pg_env: SlayerQu query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="total:sum"), @@ -362,7 +362,7 @@ async def test_change_with_date_range(self, pg_env: SlayerQueryEngine) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -381,7 +381,7 @@ async def test_change_pct_with_date_range(self, pg_env: SlayerQueryEngine) -> No query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -401,7 +401,7 @@ async def test_multiple_date_range_shifts(self, pg_env: SlayerQueryEngine) -> No query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-02-29"], )], measures=[ @@ -497,7 +497,7 @@ async def test_cross_model_measure(self, pg_cross_model_env: SlayerQueryEngine) query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="customers.avg_score:avg")], order=[OrderItem(column=ColumnRef(name="created_at"), direction="asc")], @@ -517,7 +517,7 @@ async def test_query_list_named(self, pg_cross_model_env: SlayerQueryEngine) -> inner = SlayerQuery( name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) @@ -530,7 +530,7 @@ async def test_create_model_from_query(self, pg_cross_model_env: SlayerQueryEngi source = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) diff --git a/tests/integration/test_integration_snowflake.py b/tests/integration/test_integration_snowflake.py index 24b4dde18..9ff367a65 100644 --- a/tests/integration/test_integration_snowflake.py +++ b/tests/integration/test_integration_snowflake.py @@ -443,7 +443,7 @@ def test_date_trunc_quarter(sf_storage_with_models) -> None: result = run_sync(engine.execute(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.QUARTER, )], measures=[ModelMeasure(formula="quantity:sum")], @@ -459,7 +459,7 @@ def test_time_shift_dateadd(sf_storage_with_models) -> None: result = run_sync(engine.execute(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ diff --git a/tests/integration/test_integration_sqlserver.py b/tests/integration/test_integration_sqlserver.py index e55af9b98..ba2f68247 100644 --- a/tests/integration/test_integration_sqlserver.py +++ b/tests/integration/test_integration_sqlserver.py @@ -466,7 +466,7 @@ async def test_time_shift_with_date_range(self, sqlserver_env: SlayerQueryEngine query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -484,7 +484,7 @@ async def test_consecutive_periods_with_boolean_predicate(self, sqlserver_env: S query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="total:sum"), @@ -505,7 +505,7 @@ async def test_change_with_date_range(self, sqlserver_env: SlayerQueryEngine) -> query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -522,7 +522,7 @@ async def test_change_pct_with_date_range(self, sqlserver_env: SlayerQueryEngine query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -539,7 +539,7 @@ async def test_multiple_date_range_shifts(self, sqlserver_env: SlayerQueryEngine query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-02-29"], )], measures=[ @@ -634,7 +634,7 @@ async def test_cross_model_measure(self, sqlserver_cross_model_env: SlayerQueryE query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="customers.avg_score:avg")], order=[OrderItem(column=ColumnRef(name="created_at"), direction="asc")], @@ -650,7 +650,7 @@ async def test_query_list_named(self, sqlserver_cross_model_env: SlayerQueryEngi inner = SlayerQuery( name="monthly", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) @@ -662,7 +662,7 @@ async def test_create_model_from_query(self, sqlserver_cross_model_env: SlayerQu source = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) @@ -1225,7 +1225,7 @@ async def test_sqlserver_time_shift_uses_dateadd(sqlserver_env: SlayerQueryEngin query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ ModelMeasure(formula="total:sum"), diff --git a/tests/integration/test_integration_trino.py b/tests/integration/test_integration_trino.py index 30710da55..925a979eb 100644 --- a/tests/integration/test_integration_trino.py +++ b/tests/integration/test_integration_trino.py @@ -400,7 +400,7 @@ async def test_time_shift_with_date_range(self, trino_env: SlayerQueryEngine) -> result = await trino_env.execute(query=SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ @@ -416,7 +416,7 @@ async def test_time_shift_with_date_range(self, trino_env: SlayerQueryEngine) -> async def test_consecutive_periods_with_boolean_predicate(self, trino_env: SlayerQueryEngine) -> None: result = await trino_env.execute(query=SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="total:sum"), ModelMeasure(formula="consecutive_periods(total:sum > 200)", name="positive_run"), @@ -435,7 +435,7 @@ async def test_change_with_date_range(self, trino_env: SlayerQueryEngine) -> Non result = await trino_env.execute(query=SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ModelMeasure(formula="total:sum"), ModelMeasure(formula="change(total:sum)", name="amount_change")], @@ -448,7 +448,7 @@ async def test_change_pct_with_date_range(self, trino_env: SlayerQueryEngine) -> result = await trino_env.execute(query=SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], measures=[ModelMeasure(formula="total:sum"), ModelMeasure(formula="change_pct(total:sum)", name="pct")], @@ -461,7 +461,7 @@ async def test_multiple_date_range_shifts(self, trino_env: SlayerQueryEngine) -> result = await trino_env.execute(query=SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-02-29"], )], measures=[ @@ -634,7 +634,7 @@ class TestCrossModelAndMultistageTrino: async def test_cross_model_measure(self, trino_cross_model_env: SlayerQueryEngine) -> None: result = await trino_cross_model_env.execute(query=SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="customers.avg_score:avg")], order=[OrderItem(column=ColumnRef(name="created_at"), direction="asc")], )) @@ -645,7 +645,7 @@ async def test_cross_model_measure(self, trino_cross_model_env: SlayerQueryEngin async def test_query_list_named(self, trino_cross_model_env: SlayerQueryEngine) -> None: inner = SlayerQuery( name="monthly", source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) outer = SlayerQuery(source_model="monthly", measures=[ModelMeasure(formula="*:count")]) @@ -655,7 +655,7 @@ async def test_query_list_named(self, trino_cross_model_env: SlayerQueryEngine) async def test_create_model_from_query(self, trino_cross_model_env: SlayerQueryEngine) -> None: source = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="*:count"), ModelMeasure(formula="total:sum")], ) saved = await trino_cross_model_env.create_model_from_query(query=source, name="trino_monthly") diff --git a/tests/integration/test_integration_windowed_measures.py b/tests/integration/test_integration_windowed_measures.py index cc34046b4..f14e5c4bb 100644 --- a/tests/integration/test_integration_windowed_measures.py +++ b/tests/integration/test_integration_windowed_measures.py @@ -167,7 +167,7 @@ async def test_rolling_90d_sum_by_month(self, windowed_engine: SlayerQueryEngine query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], ) @@ -185,7 +185,7 @@ async def test_rolling_90d_avg_by_month(self, windowed_engine: SlayerQueryEngine query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "revenue:avg(window='90d')", "name": "rev_avg"}], ) @@ -207,7 +207,7 @@ async def test_rolling_90d_window_reaches_before_date_range_start( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], )], @@ -238,7 +238,7 @@ async def test_null_dimension_group_gets_its_real_windowed_value( source_model="orders", dimensions=[ColumnRef(name="region")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], ) @@ -289,7 +289,7 @@ async def test_order_only_windowed_sorts_by_rolling_value( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "id:count", "name": "n"}], order=[{"column": "revenue:sum(window='90d')", "direction": "desc"}], @@ -306,7 +306,7 @@ async def test_order_only_windowed_is_stripped_from_response( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "id:count", "name": "n"}], order=[{"column": "revenue:sum(window='90d')", "direction": "desc"}], @@ -330,7 +330,7 @@ async def test_order_only_windowed_top_n( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "id:count", "name": "n"}], order=[{"column": "revenue:sum(window='90d')", "direction": "asc"}], @@ -359,7 +359,7 @@ async def test_order_only_windowed_inside_composite( query = SlayerQuery( source_model="orders_c", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "id:count", "name": "n"}], order=[{"column": "id:count / revenue:sum(window='90d')", "direction": "desc"}], @@ -382,7 +382,7 @@ async def test_composite_dataset_rolling_values_are_as_derived( query = SlayerQuery( source_model="orders_c", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "revenue:sum(window='90d')", "name": "roll"}], ) @@ -404,7 +404,7 @@ async def test_hidden_and_public_windowed_together( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], order=[{"column": "revenue:sum(window='30d')", "direction": "desc"}], @@ -443,7 +443,7 @@ async def test_order_only_windowed_region_grain_discriminates( source_model="orders", dimensions=[ColumnRef(name="region")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "id:count", "name": "n"}], order=[{"column": "revenue:sum(window='90d')", "direction": "desc"}], @@ -593,7 +593,7 @@ async def _windowed_by_month( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=date_range, )], @@ -667,7 +667,7 @@ async def _prev(*, date_range=None, filters=None): query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=date_range, )], diff --git a/tests/integration/test_notebooks.py b/tests/integration/test_notebooks.py index 800a93257..fa7a92b6f 100644 --- a/tests/integration/test_notebooks.py +++ b/tests/integration/test_notebooks.py @@ -166,6 +166,7 @@ def _bootstrap_failure_is_transient(error_text: str) -> bool: r"broken pipe", r"could not establish", r"failed to (?:connect|download)", + r"remote file has changed", # CDN edges serving different ETags mid-read ) # httpfs transport errors DuckDB emits without naming the URL, but which can only diff --git a/tests/perf/test_bench.py b/tests/perf/test_bench.py index fc303e80f..996179227 100644 --- a/tests/perf/test_bench.py +++ b/tests/perf/test_bench.py @@ -29,11 +29,11 @@ def _execute(env: BenchEnv, loop: asyncio.AbstractEventLoop, **query_kwargs) -> MONTHLY_TD = [TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )] WEEKLY_TD = [TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.WEEK, + dimension="created_at", granularity=TimeGranularity.WEEK, )] @@ -121,7 +121,7 @@ def _execute(env: BenchEnv, loop: asyncio.AbstractEventLoop, **query_kwargs) -> ModelMeasure(formula="time_shift(total_cost:sum, -1, 'month')", name="prev"), ], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=QUERY_DATE_RANGE, )], diff --git a/tests/test_carrier_scope_matrix.py b/tests/test_carrier_scope_matrix.py index 79adb3c35..71833f817 100644 --- a/tests/test_carrier_scope_matrix.py +++ b/tests/test_carrier_scope_matrix.py @@ -201,7 +201,7 @@ async def test_first_last_ranked_explicit_time_arg(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="balance:last(ordered_at)")], ) sql = await _gen(query, _orders()) @@ -214,7 +214,7 @@ async def test_time_shift_cte(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="amount:sum"), ModelMeasure(formula="time_shift(amount:sum, -1)", name="prev"), @@ -368,7 +368,7 @@ async def test_windowed_measure_emits_wm_cte(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="amount:sum(window='90d')", name="rev_w")], ) sql = await _gen(query, _orders()) @@ -394,7 +394,7 @@ async def test_time_shift_cross_model_partition(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + dimension="created_at", granularity=TimeGranularity.MONTH)], dimensions=[ColumnRef(name="customers.region_id")], measures=[ ModelMeasure(formula="amount:sum"), diff --git a/tests/test_cross_model_derived_columns.py b/tests/test_cross_model_derived_columns.py index ff02c680b..d5c16664e 100644 --- a/tests/test_cross_model_derived_columns.py +++ b/tests/test_cross_model_derived_columns.py @@ -832,7 +832,7 @@ async def test_multihop_time_dim_to_derived_target_column(tmp_path) -> None: source_model="A", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="shifted_ts", model="B.C"), + dimension="B.C.shifted_ts", granularity=TimeGranularity.DAY, ) ], diff --git a/tests/test_dev1446_nested_transform_dedup.py b/tests/test_dev1446_nested_transform_dedup.py index df8c1b6e1..840ded4b4 100644 --- a/tests/test_dev1446_nested_transform_dedup.py +++ b/tests/test_dev1446_nested_transform_dedup.py @@ -23,7 +23,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, DatasourceConfig, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.storage.sqlite_conn import transaction from slayer.storage.yaml_storage import YAMLStorage @@ -99,7 +99,7 @@ def _query() -> SlayerQuery: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ], diff --git a/tests/test_dev1450fix_derived_time_dimension.py b/tests/test_dev1450fix_derived_time_dimension.py index a14e92f33..d1e9511ef 100644 --- a/tests/test_dev1450fix_derived_time_dimension.py +++ b/tests/test_dev1450fix_derived_time_dimension.py @@ -2,7 +2,7 @@ A ``TimeDimension`` whose column resolves to a DERIVED column (``Column.sql`` set) now works everywhere a base-column time dimension -does. The typed pipeline widens ``TimeTruncKey.column`` to +does. The typed pipeline widens ``TimeTruncKey.source`` to ``Union[ColumnKey, ColumnSqlKey]`` and the SQL generator applies ``DATE_TRUNC`` over the EXPANDED derived expression rather than over a bare identifier. @@ -155,7 +155,7 @@ def _bundle_local() -> ResolvedSourceBundle: class TestBindDerivedTimeDimension: def test_local_derived_td_binds_to_columnsqlkey(self) -> None: td = TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, ) bound = bind_time_dimension( @@ -164,7 +164,7 @@ def test_local_derived_td_binds_to_columnsqlkey(self) -> None: bundle=_bundle_local(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnSqlKey( + assert bound.bound.value_key.source == ColumnSqlKey( path=(), model="orders", column_name="effective_at", ) assert bound.bound.value_key.granularity == "month" @@ -172,7 +172,7 @@ def test_local_derived_td_binds_to_columnsqlkey(self) -> None: def test_joined_derived_td_carries_path(self) -> None: td = TimeDimension( - dimension=ColumnRef(name="customers.signup_eff"), + dimension="customers.signup_eff", granularity=TimeGranularity.MONTH, ) bound = bind_time_dimension( @@ -181,7 +181,7 @@ def test_joined_derived_td_carries_path(self) -> None: bundle=_bundle_local(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnSqlKey( + assert bound.bound.value_key.source == ColumnSqlKey( path=("customers",), model="customers", column_name="signup_eff", ) @@ -203,7 +203,7 @@ async def test_derived_td_dimension_groups_by_derived_expr(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "amount:sum"}, {"formula": "*:count"}], @@ -223,7 +223,7 @@ async def test_derived_td_carries_attribute_label(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "amount:sum"}], @@ -238,7 +238,7 @@ async def test_derived_td_order_by(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "amount:sum"}], @@ -256,7 +256,7 @@ async def test_derived_td_cumsum_over_order(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -277,7 +277,7 @@ async def test_derived_td_time_shift(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -299,7 +299,7 @@ async def test_derived_td_change(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -320,7 +320,7 @@ async def test_derived_td_consecutive_periods(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -341,7 +341,7 @@ async def test_derived_td_date_range(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="effective_at"), + dimension="effective_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01 00:00:00", "2024-02-28 00:00:00"], )], @@ -364,7 +364,7 @@ async def test_joined_derived_td(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.signup_eff"), + dimension="customers.signup_eff", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "amount:sum"}], @@ -385,7 +385,7 @@ async def test_cross_model_agg_with_joined_derived_td_shared_grain(engine): SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.signup_eff"), + dimension="customers.signup_eff", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "customers.revenue:sum"}], diff --git a/tests/test_dev1471_stage_date_range.py b/tests/test_dev1471_stage_date_range.py index d4c4e57d9..437353cec 100644 --- a/tests/test_dev1471_stage_date_range.py +++ b/tests/test_dev1471_stage_date_range.py @@ -8,7 +8,7 @@ import pytest from slayer.core.enums import TimeGranularity -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from tests._dev1471_fixtures import ( BACKENDS, date_str, @@ -30,7 +30,7 @@ _INNER = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) @@ -49,7 +49,7 @@ async def test_stage_date_range_restricts_outer_stage(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", "time_dimensions": [TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TG.MONTH, + dimension="created_at", granularity=TG.MONTH, date_range=["2025-02-01", "2025-03-31"], )], "measures": [{"formula": "rev:sum"}], @@ -70,7 +70,7 @@ async def test_stage_date_range_on_coarser_rebucketing(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", "time_dimensions": [TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TG.YEAR, + dimension="created_at", granularity=TG.YEAR, date_range=["2025-02-01", "2025-03-31"], )], "measures": [{"formula": "rev:sum"}], diff --git a/tests/test_dev1471_stage_time_axis.py b/tests/test_dev1471_stage_time_axis.py index a099cce8c..9cec6db96 100644 --- a/tests/test_dev1471_stage_time_axis.py +++ b/tests/test_dev1471_stage_time_axis.py @@ -9,7 +9,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, SlayerModel -from slayer.core.query import ColumnRef, OrderItem, SlayerQuery, TimeDimension +from slayer.core.query import OrderItem, SlayerQuery, TimeDimension from tests._dev1471_fixtures import ( BACKENDS, MONTHLY_INNER as _INNER, @@ -42,7 +42,7 @@ def _by_month(data: list[dict], value_key: str) -> dict: async def test_time_shift_over_stage_time_dimension(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [ {"formula": "rev:sum"}, {"formula": "time_shift(rev:sum, -1, 'month')", "name": "prev"}, @@ -59,7 +59,7 @@ async def test_time_shift_over_stage_time_dimension(backend: str) -> None: async def test_change_and_cumsum_over_stage_time_dimension(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [ {"formula": "change(rev:sum)", "name": "chg"}, {"formula": "cumsum(rev:sum)", "name": "cum"}, @@ -79,7 +79,7 @@ async def test_change_and_cumsum_over_stage_time_dimension(backend: str) -> None async def test_last_over_stage_time_dimension(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "last(rev:sum)", "name": "lst"}], }) with tempfile.TemporaryDirectory() as tmp: @@ -114,12 +114,12 @@ async def test_windowed_aggregate_over_stage_time_dimension(backend: str) -> Non windowed = [{"formula": "rev:sum(window='60d')", "name": "win"}] stage_outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": windowed, }) model_query = SlayerQuery.model_validate({ "source_model": "monthly", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="month_ts"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="month_ts", granularity=TG.MONTH)], "measures": windowed, }) with tempfile.TemporaryDirectory() as tmp: @@ -146,14 +146,14 @@ async def test_time_shift_over_multi_hop_flat_time_dimension(backend: str) -> No inner = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", "time_dimensions": [TimeDimension( - dimension=ColumnRef(name="customers.regions.last_activity_at"), granularity=TG.MONTH, + dimension="customers.regions.last_activity_at", granularity=TG.MONTH, )], "measures": [{"formula": "*:count", "name": "n"}], }) col = "customers__regions__last_activity_at" outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name=col), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension=col, granularity=TG.MONTH)], "measures": [ {"formula": "n:sum"}, {"formula": "time_shift(n:sum, -1, 'month')", "name": "prev"}, @@ -194,8 +194,8 @@ async def test_two_stage_time_dimensions_without_main_raise() -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", "time_dimensions": [ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH), - TimeDimension(dimension=ColumnRef(name="shipped_at"), granularity=TG.MONTH), + TimeDimension(dimension="created_at", granularity=TG.MONTH), + TimeDimension(dimension="shipped_at", granularity=TG.MONTH), ], "measures": [{"formula": "change(rev:sum)", "name": "chg"}], }) @@ -210,8 +210,8 @@ async def test_main_time_dimension_selects_stage_axis(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", "time_dimensions": [ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH), - TimeDimension(dimension=ColumnRef(name="shipped_at"), granularity=TG.MONTH), + TimeDimension(dimension="created_at", granularity=TG.MONTH), + TimeDimension(dimension="shipped_at", granularity=TG.MONTH), ], "main_time_dimension": "created_at", "measures": [{"formula": "change(rev:sum)", "name": "chg"}], @@ -229,7 +229,7 @@ async def test_main_time_dimension_selects_stage_axis(backend: str) -> None: async def test_order_by_stage_bucket(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum"}], "order": [OrderItem.model_validate({"column": "month(created_at)", "direction": "desc"})], }) @@ -244,8 +244,8 @@ async def test_two_granularities_of_one_stage_column(backend: str) -> None: outer = SlayerQuery.model_validate({ "source_model": "s1", "time_dimensions": [ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH), - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.YEAR), + TimeDimension(dimension="created_at", granularity=TG.MONTH), + TimeDimension(dimension="created_at", granularity=TG.YEAR), ], "measures": [{"formula": "rev:sum"}], }) diff --git a/tests/test_dev1471_stage_time_dimensions.py b/tests/test_dev1471_stage_time_dimensions.py index 979dcad3d..35cbef3cc 100644 --- a/tests/test_dev1471_stage_time_dimensions.py +++ b/tests/test_dev1471_stage_time_dimensions.py @@ -15,7 +15,7 @@ ) from slayer.core.keys import ColumnKey, TimeTruncKey from slayer.core.models import DatasourceConfig, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.core.scope import StageColumn, StageSchema from slayer.engine.binding import bind_time_dimension from slayer.engine.elaborate_env import check_time_dimension_column @@ -84,33 +84,33 @@ def test_bucketed_stage_column_binds_with_facts(self) -> None: name="created_at", sql_alias="created_at", type=DataType.TIMESTAMP, granularity=TG.MONTH, )) - td = TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.YEAR) + td = TimeDimension(dimension="created_at", granularity=TG.YEAR) bound = bind_time_dimension(td, scope=stage, bundle=_bundle()) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnKey(path=(), leaf="created_at") + assert bound.bound.value_key.source == ColumnKey(path=(), leaf="created_at") assert bound.bound.value_key.granularity == "year" assert bound.column_type == DataType.TIMESTAMP - assert bound.upstream_granularity == TG.MONTH + assert bound.bucketed_leaves == (("created_at", TG.MONTH),) def test_raw_stage_column_has_no_upstream_granularity(self) -> None: stage = _stage(StageColumn( name="created_at", sql_alias="created_at", type=DataType.TIMESTAMP, )) - td = TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.DAY) + td = TimeDimension(dimension="created_at", granularity=TG.DAY) bound = bind_time_dimension(td, scope=stage, bundle=_bundle()) - assert bound.upstream_granularity is None + assert bound.bucketed_leaves == () assert bound.column_type == DataType.TIMESTAMP def test_unknown_stage_column_raises_unknown_reference(self) -> None: stage = _stage(StageColumn(name="created_at", sql_alias="created_at", type=DataType.TIMESTAMP)) - td = TimeDimension(dimension=ColumnRef(name="not_a_column"), granularity=TG.MONTH) + td = TimeDimension(dimension="not_a_column", granularity=TG.MONTH) bundle = _bundle() with pytest.raises(UnknownReferenceError, match="created_at"): # lists the stage's columns bind_time_dimension(td, scope=stage, bundle=bundle) def test_dotted_name_on_stage_raises_illegal_scope(self) -> None: stage = _stage(StageColumn(name="created_at", sql_alias="created_at", type=DataType.TIMESTAMP)) - td = TimeDimension(dimension=ColumnRef(name="customers.created_at"), granularity=TG.MONTH) + td = TimeDimension(dimension="customers.created_at", granularity=TG.MONTH) bundle = _bundle() with pytest.raises(IllegalScopeReferenceError): bind_time_dimension(td, scope=stage, bundle=bundle) @@ -124,7 +124,7 @@ def test_non_temporal_column_rejected(self) -> None: with pytest.raises(TimeDimensionColumnError) as exc: check_time_dimension_column( name="s1.region", column_type=DataType.TEXT, - upstream_granularity=None, requested_granularity=TG.MONTH, + requested_granularity=TG.MONTH, ) assert "temporal" in str(exc.value) assert "TEXT" in str(exc.value) @@ -133,7 +133,7 @@ def test_untyped_column_fails_closed(self) -> None: with pytest.raises(TimeDimensionColumnError) as exc: check_time_dimension_column( name="s1.mystery", column_type=None, - upstream_granularity=None, requested_granularity=TG.MONTH, + requested_granularity=TG.MONTH, ) assert "temporal" in str(exc.value) @@ -141,7 +141,7 @@ def test_finer_granularity_rejected(self) -> None: with pytest.raises(TimeDimensionColumnError) as exc: check_time_dimension_column( name="s1.created_at", column_type=DataType.TIMESTAMP, - upstream_granularity=TG.MONTH, requested_granularity=TG.DAY, + bucketed_leaves=(("s1.created_at", TG.MONTH),), requested_granularity=TG.DAY, ) msg = str(exc.value) assert "month" in msg @@ -156,7 +156,7 @@ def test_non_nesting_rejected_both_ways(self, upstream, requested) -> None: with pytest.raises(TimeDimensionColumnError) as exc: check_time_dimension_column( name="s1.created_at", column_type=DataType.TIMESTAMP, - upstream_granularity=upstream, requested_granularity=requested, + bucketed_leaves=(("s1.created_at", upstream),), requested_granularity=requested, ) msg = str(exc.value) assert upstream.value in msg @@ -171,7 +171,8 @@ def test_valid_rebucketing_passes(self, upstream, requested) -> None: # Same, nesting-coarser, or any granularity on an un-truncated column. check_time_dimension_column( name="s1.created_at", column_type=DataType.TIMESTAMP, - upstream_granularity=upstream, requested_granularity=requested, + bucketed_leaves=() if upstream is None else (("s1.created_at", upstream),), + requested_granularity=requested, ) @@ -183,12 +184,12 @@ async def test_finer_granularity_end_to_end(self) -> None: engine = await _dryrun_engine([orders_model()]) inner = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.DAY)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.DAY)], "measures": [{"formula": "rev:sum", "name": "rev"}], }) with pytest.raises(TimeDimensionColumnError): @@ -201,7 +202,7 @@ async def test_finer_rebucket_after_passthrough_end_to_end(self) -> None: engine = await _dryrun_engine([orders_model()]) s1 = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) s2 = SlayerQuery.model_validate({ @@ -210,7 +211,7 @@ async def test_finer_rebucket_after_passthrough_end_to_end(self) -> None: }) s3 = SlayerQuery.model_validate({ "source_model": "s2", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.DAY)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.DAY)], "measures": [{"formula": "rev_sum:sum"}], }) with pytest.raises(TimeDimensionColumnError): @@ -224,7 +225,7 @@ async def test_non_temporal_column_end_to_end(self) -> None: }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="region"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="region", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum", "name": "rev"}], }) with pytest.raises(TimeDimensionColumnError): @@ -234,12 +235,12 @@ async def test_unknown_flat_name_end_to_end(self) -> None: engine = await _dryrun_engine([orders_model()]) inner = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="nope"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="nope", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum", "name": "rev"}], }) with pytest.raises(UnknownReferenceError, match="created_at"): # lists the stage's columns @@ -249,12 +250,12 @@ async def test_dotted_name_end_to_end(self) -> None: engine = await _dryrun_engine([orders_model()]) inner = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="customers.created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="customers.created_at", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum", "name": "rev"}], }) with pytest.raises(IllegalScopeReferenceError): @@ -284,14 +285,14 @@ async def test_same_granularity_multi_hop_flat_name(self, backend: str) -> None: inner = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", "time_dimensions": [TimeDimension( - dimension=ColumnRef(name="customers.regions.last_activity_at"), granularity=TG.MONTH, + dimension="customers.regions.last_activity_at", granularity=TG.MONTH, )], "measures": [{"formula": "*:count", "name": "n"}], }) outer = SlayerQuery.model_validate({ "source_model": "s1", "time_dimensions": [TimeDimension( - dimension=ColumnRef(name="customers__regions__last_activity_at"), granularity=TG.MONTH, + dimension="customers__regions__last_activity_at", granularity=TG.MONTH, )], "measures": [{"formula": "n:sum"}], }) @@ -304,12 +305,12 @@ async def test_same_granularity_multi_hop_flat_name(self, backend: str) -> None: async def test_coarser_year_over_month(self, backend: str) -> None: inner = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.YEAR)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.YEAR)], "measures": [{"formula": "rev:sum"}], }) with tempfile.TemporaryDirectory() as tmp: @@ -324,7 +325,7 @@ async def test_raw_temporal_column(self, backend: str) -> None: }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum"}], }) with tempfile.TemporaryDirectory() as tmp: @@ -340,7 +341,7 @@ async def test_cohort_partition_by_auto_name_with_same_column_filter(self, backe }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name=col), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension=col, granularity=TG.MONTH)], "filters": [f"{col} >= '2025-01-01'"], "measures": [{"formula": "*:count", "name": "cohort_size"}], }) @@ -355,24 +356,24 @@ async def test_cohort_partition_by_auto_name_with_same_column_filter(self, backe assert got == {"2025-02-01": 2, "2025-03-01": 2} # Every granularity binds on an aggregate-output column — day too. day_outer = outer.model_copy(update={ - "time_dimensions": [TimeDimension(dimension=ColumnRef(name=col), granularity=TG.DAY)], + "time_dimensions": [TimeDimension(dimension=col, granularity=TG.DAY)], }) await engine.execute(query=[inner, day_outer], dry_run=True) # must not raise async def test_three_stage_chain(self, backend: str) -> None: s1 = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "amount:sum", "name": "rev"}], }) s2 = SlayerQuery.model_validate({ "name": "s2", "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum"}], }) s3 = SlayerQuery.model_validate({ "source_model": "s2", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.YEAR)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.YEAR)], "measures": [{"formula": "rev_sum:sum"}], }) with tempfile.TemporaryDirectory() as tmp: @@ -387,7 +388,7 @@ async def test_stage_dimension_joins_stage_bucket_grain(self, backend: str) -> N }) outer = SlayerQuery.model_validate({ "source_model": "s1", "dimensions": ["region"], - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": [{"formula": "rev:sum"}], }) with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_dev1474_time_shift_cross_model_partition.py b/tests/test_dev1474_time_shift_cross_model_partition.py index 469b462a1..fd79233ba 100644 --- a/tests/test_dev1474_time_shift_cross_model_partition.py +++ b/tests/test_dev1474_time_shift_cross_model_partition.py @@ -205,7 +205,7 @@ def _qoq_query(*, measure_formula: str, measure_name: str) -> SlayerQuery: return SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.QUARTER, )], dimensions=[ColumnRef(name="stores.name")], @@ -248,7 +248,7 @@ async def test_multi_hop_partition_registers_every_hop(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.QUARTER, )], dimensions=[ColumnRef(name="stores.regions.name")], @@ -277,7 +277,7 @@ async def test_derived_local_dim_partition_not_broadcast(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], dimensions=[ColumnRef(name="status_up")], @@ -309,7 +309,7 @@ async def test_derived_joined_dim_partition_not_broadcast(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], dimensions=[ColumnRef(name="stores.tier")], @@ -338,9 +338,9 @@ async def test_secondary_time_dimension_partitions(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="ordered_at"), + TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH), - TimeDimension(dimension=ColumnRef(name="delivery_at"), + TimeDimension(dimension="delivery_at", granularity=TimeGranularity.QUARTER), ], main_time_dimension="ordered_at", @@ -370,7 +370,7 @@ async def test_explicit_partition_by_rejected_on_time_shift(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], dimensions=[ColumnRef(name="stores.name")], @@ -394,7 +394,7 @@ async def test_joined_time_axis_pulls_join_into_shifted_cte(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="stores.opened_at"), + dimension="stores.opened_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -427,7 +427,7 @@ async def test_query_filter_crossing_join_lifts_guard(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -455,7 +455,7 @@ async def test_model_filter_mode_a_crossing_join_lifts_guard(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -504,7 +504,7 @@ async def test_sjoin_on_is_null_safe(self, dialect: str, marker: str) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], dimensions=[ColumnRef(name="status")], @@ -539,7 +539,7 @@ async def test_local_partition_stays_joinless(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, )], dimensions=[ColumnRef(name="status")], @@ -574,9 +574,9 @@ async def test_joined_secondary_td_registers_join_and_partitions(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="ordered_at"), + TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH), - TimeDimension(dimension=ColumnRef(name="stores.opened_at"), + TimeDimension(dimension="stores.opened_at", granularity=TimeGranularity.QUARTER), ], main_time_dimension="ordered_at", @@ -777,9 +777,9 @@ async def test_prev_does_not_broadcast_across_second_time_dim( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="ordered_at"), + TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH), - TimeDimension(dimension=ColumnRef(name="delivery_at"), + TimeDimension(dimension="delivery_at", granularity=TimeGranularity.QUARTER), ], main_time_dimension="ordered_at", diff --git a/tests/test_dev1476_first_last_explicit_time.py b/tests/test_dev1476_first_last_explicit_time.py index 40c30f3a9..b082edbe8 100644 --- a/tests/test_dev1476_first_last_explicit_time.py +++ b/tests/test_dev1476_first_last_explicit_time.py @@ -18,11 +18,11 @@ Phase, ) from slayer.core.models import Column, DatasourceConfig, ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.ir.planned import ValueSlot from slayer.engine.query_engine import SlayerQueryEngine from slayer.engine.ranked_planner import ( - explicit_ranking_time_arg, + explicit_ranking_key, resolve_ranking_time_key, ) from slayer.ir.source_bundle import ResolvedSourceBundle @@ -302,7 +302,7 @@ async def test_d_cross_cross_model_derived_time_arg( resp = await engine.execute(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.DAY, )], measures=[{ @@ -749,7 +749,7 @@ def _scalar_first_arg_key(self) -> AggregateKey: ) def test_scalar_first_positional_is_not_an_explicit_time_arg(self) -> None: - assert explicit_ranking_time_arg(self._scalar_first_arg_key()) is None + assert explicit_ranking_key(self._scalar_first_arg_key()) is None def test_scalar_first_arg_gate_requires_default_time(self) -> None: # no default_time_dimension, and two temporal columns: no sole-column default diff --git a/tests/test_dev1645_invalid_postgres_sql.py b/tests/test_dev1645_invalid_postgres_sql.py index b9aa8b048..05d774d3e 100644 --- a/tests/test_dev1645_invalid_postgres_sql.py +++ b/tests/test_dev1645_invalid_postgres_sql.py @@ -282,7 +282,7 @@ async def test_orderby_joined_column_resolves_in_cte_wrapped_scope( orders, joined = _orders_customers_regions() query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], # windowed -> combined CTE path filters=["customers.regions.name == 'US'"], # pulls the join into resolved_joins order=[OrderItem(column=ColumnRef(name="name", model="customers.regions"), direction="desc")], @@ -362,7 +362,7 @@ async def test_joined_orderby_in_cte_wrapped_scope_resolves(self) -> None: orders, joined = _orders_customers_regions() query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], filters=["customers.regions.name == 'US'"], order=[OrderItem(column=ColumnRef(name="name", model="customers.regions"), direction="desc")], diff --git a/tests/test_dev1708_stage4_cte_scope.py b/tests/test_dev1708_stage4_cte_scope.py index a1f62ec30..b8decbdcd 100644 --- a/tests/test_dev1708_stage4_cte_scope.py +++ b/tests/test_dev1708_stage4_cte_scope.py @@ -55,7 +55,7 @@ ModelMeasure, SlayerModel, ) -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.sql.scope_check import assert_scope_closed from slayer.storage.yaml_storage import YAMLStorage @@ -82,7 +82,7 @@ async def test_cross_model_agg_grouped_by_crossing_derived_td(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="customers_v2.lifetime_value:sum")], ) @@ -107,7 +107,7 @@ async def test_host_base_local_measure_derived_td_crossing(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="amount:sum")], ) @@ -121,7 +121,7 @@ async def test_host_base_dim_only_derived_td_crossing(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], ) sql = await _gen(query) @@ -144,7 +144,7 @@ async def test_local_derived_td_unaffected(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="eff_created"), + dimension="eff_created", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="amount:sum")], ) @@ -162,7 +162,7 @@ async def test_one_hop_joined_derived_td_anchors_at_direct_alias(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.signup_eff"), + dimension="customers_v2.signup_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="amount:sum")], ) @@ -176,7 +176,7 @@ async def test_no_double_prefix_on_further_hop(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="amount:sum")], ) @@ -505,7 +505,7 @@ async def test_time_trunc_derived_grain_supported_not_raised(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="customers_v2.lifetime_value:sum")], ) diff --git a/tests/test_dev1713_naming.py b/tests/test_dev1713_naming.py index 8ff6eb322..8bdb94a5b 100644 --- a/tests/test_dev1713_naming.py +++ b/tests/test_dev1713_naming.py @@ -250,7 +250,7 @@ async def test_time_dimension_over_joined_column_dotted(self, engine) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, ) ], @@ -328,7 +328,7 @@ async def _check(query: SlayerQuery, expected: set) -> None: ], time_dimensions=[ TimeDimension( # joined TimeTruncKey - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, ) ], @@ -355,7 +355,7 @@ async def _check(query: SlayerQuery, expected: set) -> None: ], time_dimensions=[ TimeDimension( # joined TimeTruncKey - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, ) ], @@ -621,7 +621,7 @@ async def test_three_arithmetic_time_shifts_unique_ctes(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ], @@ -674,7 +674,7 @@ async def test_hidden_time_shift_alias_avoids_user_column(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -715,7 +715,7 @@ async def test_two_consecutive_periods_unique_ctes(self) -> None: source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ diff --git a/tests/test_dev1726_cte_case_folding.py b/tests/test_dev1726_cte_case_folding.py index 2b2c50c3b..2c733fe5f 100644 --- a/tests/test_dev1726_cte_case_folding.py +++ b/tests/test_dev1726_cte_case_folding.py @@ -50,7 +50,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.sql.dialects import _ALL_DIALECTS from slayer.sql.naming import ( CASE_FOLDING_SQLGLOT_DIALECTS, @@ -292,7 +292,7 @@ def _vehicle_query() -> SlayerQuery: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ], @@ -385,7 +385,7 @@ async def test_cp_family_dedups_on_folding_dialect(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ], diff --git a/tests/test_dev1728_derived_shared_grain.py b/tests/test_dev1728_derived_shared_grain.py index af9352ae2..f945d0475 100644 --- a/tests/test_dev1728_derived_shared_grain.py +++ b/tests/test_dev1728_derived_shared_grain.py @@ -55,7 +55,7 @@ ModelMeasure, SlayerModel, ) -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.sql.scope_check import assert_scope_closed from slayer.storage.sqlite_conn import transaction @@ -312,7 +312,7 @@ async def test_crossing_time_grain_first_last_regression(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="customers_v2.lifetime_value:last")], ) @@ -512,7 +512,7 @@ async def test_time_trunc_derived_grain_still_supported(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.region_opened_eff"), + dimension="customers_v2.region_opened_eff", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="customers_v2.lifetime_value:sum")], ) diff --git a/tests/test_dev1732_frame_bound_filters.py b/tests/test_dev1732_frame_bound_filters.py index bc761067a..9e14334e2 100644 --- a/tests/test_dev1732_frame_bound_filters.py +++ b/tests/test_dev1732_frame_bound_filters.py @@ -130,7 +130,7 @@ async def test_bound_on_derived_time_column_is_stripped_by_identity(self) -> Non """A derived (``Column.sql``) temporal column binds to a ``ColumnSqlKey``; ``_build_date_range_filter`` binds its date-range bound column through the same path that produces - ``TimeTruncKey.column``, so identity matching covers it for free.""" + ``TimeTruncKey.source``, so identity matching covers it for free.""" derived = ColumnSqlKey(path=(), model="orders", column_name="event_at") key = _cmp(">=", derived, _lit("2024-06-01")) assert strip_frame_bounds(key=key, time_columns=frozenset({derived})) is None @@ -372,7 +372,7 @@ def _joined_orders() -> SlayerModel: _MONTHLY_CREATED_AT = TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, ) @@ -580,7 +580,7 @@ async def test_second_time_dimension_bound_stripped_but_join_key_kept(self) -> N time_dimensions=[ _MONTHLY_CREATED_AT, TimeDimension( - dimension=ColumnRef(name="ship_date"), + dimension="ship_date", granularity=TimeGranularity.DAY, ), ], @@ -632,7 +632,7 @@ async def test_stripping_a_joined_td_bound_keeps_the_projection_join(self) -> No time_dimensions=[ _MONTHLY_CREATED_AT, TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.DAY, ), ], @@ -659,7 +659,7 @@ async def test_stripped_and_residual_cross_different_joins(self) -> None: time_dimensions=[ _MONTHLY_CREATED_AT, TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.DAY, ), ], @@ -773,7 +773,7 @@ async def test_date_range_still_stripped_from_src(self) -> None: query = _windowed_query( time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-06-01", "2024-12-31"], ), @@ -803,7 +803,7 @@ async def test_holds_every_selected_time_dimension_raw_column(self) -> None: time_dimensions=[ _MONTHLY_CREATED_AT, TimeDimension( - dimension=ColumnRef(name="ship_date"), + dimension="ship_date", granularity=TimeGranularity.DAY, ), ], @@ -830,7 +830,7 @@ async def test_only_non_hidden_time_dimensions_are_strippable(self) -> None: time_dimensions=[ _MONTHLY_CREATED_AT, TimeDimension( - dimension=ColumnRef(name="ship_date"), + dimension="ship_date", granularity=TimeGranularity.DAY, ), ], @@ -840,7 +840,7 @@ async def test_only_non_hidden_time_dimensions_are_strippable(self) -> None: _orders(), ) visible = { - s.key.column + s.key.source for s in plan.row_slots if isinstance(s.key, TimeTruncKey) and not s.hidden } @@ -849,7 +849,7 @@ async def test_only_non_hidden_time_dimensions_are_strippable(self) -> None: ) # A hidden slot's column never leaks into the set. hidden_cols = { - s.key.column + s.key.source for s in plan.row_slots if isinstance(s.key, TimeTruncKey) and s.hidden } @@ -921,7 +921,7 @@ async def test_date_range_still_omitted(self) -> None: _shift_query( time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-06-01", "2024-12-31"], ), diff --git a/tests/test_dev1737_date_functions_typing.py b/tests/test_dev1737_date_functions_typing.py index 47e62ff10..f99fb9c03 100644 --- a/tests/test_dev1737_date_functions_typing.py +++ b/tests/test_dev1737_date_functions_typing.py @@ -20,7 +20,7 @@ walk_value_keys, ) from slayer.core.models import DatasourceConfig -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.core.scope import ModelScope from slayer.engine.binding import bind_expr from slayer.engine.key_metadata import dimension_key_metadata, measure_key_type @@ -467,7 +467,7 @@ async def test_stage_measure_over_temporal_expression(self, engine) -> None: async def test_passthrough_bucket_rebucket_names_granularity(self, engine) -> None: s1 = SlayerQuery.model_validate({ "name": "s1", "source_model": "orders", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], "measures": [{"formula": "count(*)", "name": "n"}], }) s2 = SlayerQuery.model_validate({ @@ -476,7 +476,7 @@ async def test_passthrough_bucket_rebucket_names_granularity(self, engine) -> No }) s3 = SlayerQuery.model_validate({ "source_model": "s2", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.DAY)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY)], "measures": [{"formula": "total:sum", "name": "t"}], }) with pytest.raises(TimeDimensionColumnError, match="already bucketed at 'month'"): @@ -490,7 +490,7 @@ async def test_date_add_stage_column_is_a_time_axis(self, engine) -> None: }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="due"), granularity=TimeGranularity.MONTH)], + "time_dimensions": [TimeDimension(dimension="due", granularity=TimeGranularity.MONTH)], "measures": [{"formula": "n:sum", "name": "n_total"}], }) await engine.execute([s1, outer], dry_run=True) @@ -503,7 +503,7 @@ async def test_date_part_stage_column_is_not_a_time_axis(self, engine) -> None: }) outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="m"), granularity=TimeGranularity.MONTH)], + "time_dimensions": [TimeDimension(dimension="m", granularity=TimeGranularity.MONTH)], "measures": [{"formula": "n:sum", "name": "n_total"}], }) with pytest.raises(TimeDimensionColumnError, match="INT"): diff --git a/tests/test_dev1739_guards.py b/tests/test_dev1739_guards.py index 20fb9370a..60e00302d 100644 --- a/tests/test_dev1739_guards.py +++ b/tests/test_dev1739_guards.py @@ -10,7 +10,7 @@ from slayer.core.keys import AggregateKey, ColumnKey, reroot_value_key, walk_value_keys from slayer.core.keys import Grain from slayer.core.models import Column, SlayerModel -from slayer.core.query import ColumnRef, ModelMeasure, SlayerQuery, TimeDimension +from slayer.core.query import ModelMeasure, SlayerQuery, TimeDimension from slayer.sql.naming import canonical_aggregate_alias from tests._dev1739_fixtures import gen, month_td @@ -38,9 +38,9 @@ async def test_non_dimension_partition_key_raises(self) -> None: async def test_ambiguous_time_granularity_raises(self) -> None: q = _q( time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="ordered_at"), + TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH), - TimeDimension(dimension=ColumnRef(name="ordered_at"), + TimeDimension(dimension="ordered_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="amount:sum(partition_by=ordered_at)")], diff --git a/tests/test_dev1744_naming_allocator.py b/tests/test_dev1744_naming_allocator.py index f87c1e081..5587a9ec3 100644 --- a/tests/test_dev1744_naming_allocator.py +++ b/tests/test_dev1744_naming_allocator.py @@ -292,7 +292,7 @@ async def test_hidden_cross_model_agg_under_a_transform_keeps_its_alias( source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -419,7 +419,7 @@ async def test_windowed_cte_names_use_the_shared_helper( source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -600,7 +600,7 @@ async def test_windowed_families_survive_the_name(self, column, tmp_path) -> Non source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], diff --git a/tests/test_dev1744_result_key_contract.py b/tests/test_dev1744_result_key_contract.py index ed6231bf2..ae8cbab4a 100644 --- a/tests/test_dev1744_result_key_contract.py +++ b/tests/test_dev1744_result_key_contract.py @@ -232,7 +232,7 @@ async def test_time_dimension_key_has_no_granularity_suffix( source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -357,7 +357,7 @@ async def test_duration_windowed_measure_key(self, engine) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -377,7 +377,7 @@ async def test_two_windows_over_same_column_are_distinct_keys( source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], diff --git a/tests/test_dev1744_value_expr.py b/tests/test_dev1744_value_expr.py index 845894cbf..b13718e2f 100644 --- a/tests/test_dev1744_value_expr.py +++ b/tests/test_dev1744_value_expr.py @@ -471,7 +471,7 @@ def test_time_trunc_key(self) -> None: """Exact per-dialect SQL — a substring check would accept a truncation at the wrong granularity or over the wrong column.""" key = TimeTruncKey( - column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH, + source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH, ) out = render_value_key(key=key, ctx=_filter_ctx("postgres")) assert _sql(out, "postgres") == "DATE_TRUNC('MONTH', orders.created_at)" @@ -487,7 +487,7 @@ def test_time_trunc_goes_through_the_dialect_strategy(self, dialect) -> None: defect this module exists to remove. """ key = TimeTruncKey( - column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH, + source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH, ) out = _sql(render_value_key(key=key, ctx=_filter_ctx(dialect)), dialect) if dialect == "sqlite": @@ -502,7 +502,7 @@ def test_week_sunday_granularity_renders(self) -> None: ``DATE_TRUNC('WEEK_SUNDAY', col)``, which no dialect accepts. """ key = TimeTruncKey( - column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.WEEK_SUNDAY, + source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.WEEK_SUNDAY, ) out = _sql(render_value_key(key=key, ctx=_filter_ctx("postgres")), "postgres") assert "WEEK_SUNDAY" not in out.upper(), out @@ -1320,7 +1320,7 @@ async def test_shifted_cte_filter_call_site_executes(self, tmp_path) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -1373,13 +1373,13 @@ def _mutation_cases(): ), ( "TimeTruncKey.granularity", - TimeTruncKey(column=col, granularity=TimeGranularity.MONTH), - TimeTruncKey(column=col, granularity=TimeGranularity.YEAR), + TimeTruncKey(source=col, granularity=TimeGranularity.MONTH), + TimeTruncKey(source=col, granularity=TimeGranularity.YEAR), ), ( - "TimeTruncKey.column", - TimeTruncKey(column=col, granularity=TimeGranularity.MONTH), - TimeTruncKey(column=other, granularity=TimeGranularity.MONTH), + "TimeTruncKey.source", + TimeTruncKey(source=col, granularity=TimeGranularity.MONTH), + TimeTruncKey(source=other, granularity=TimeGranularity.MONTH), ), ("StarKey.path", StarKey(), StarKey(path=("customers",))), ( diff --git a/tests/test_dev1746_cte_assembly.py b/tests/test_dev1746_cte_assembly.py index 1204b5e76..48e9cbd36 100644 --- a/tests/test_dev1746_cte_assembly.py +++ b/tests/test_dev1746_cte_assembly.py @@ -72,7 +72,7 @@ def _windowed_query() -> SlayerQuery: source_model="orders_x", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="amount:sum(window='90d')", name="rev_w")], @@ -85,7 +85,7 @@ def _mixed_query() -> SlayerQuery: source_model="orders_x", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ diff --git a/tests/test_dev1746_null_safe_grain.py b/tests/test_dev1746_null_safe_grain.py index d874391f3..376fba922 100644 --- a/tests/test_dev1746_null_safe_grain.py +++ b/tests/test_dev1746_null_safe_grain.py @@ -85,7 +85,7 @@ def _windowed_query() -> SlayerQuery: source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="amount:sum(window='90d')", name="rev_w")], @@ -98,7 +98,7 @@ def _windowed_chain_query() -> SlayerQuery: source_model="orders_x", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="amount:sum(window='90d')", name="rev_w")], @@ -369,7 +369,7 @@ async def test_time_truncated_grain_member_is_null_safe(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers_v2.signup_at"), + dimension="customers_v2.signup_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="customers_v2.lifetime_value:sum")], diff --git a/tests/test_dev1746_projection_order.py b/tests/test_dev1746_projection_order.py index 679f60182..34380e83f 100644 --- a/tests/test_dev1746_projection_order.py +++ b/tests/test_dev1746_projection_order.py @@ -75,7 +75,7 @@ def _windowed_declared_first_query() -> SlayerQuery: source_model="orders_x", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -202,7 +202,7 @@ async def test_a_slot_that_is_both_projected_and_a_transform_operand( query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -355,7 +355,7 @@ def test_a_slot_may_repeat_once_per_declared_public_name(self) -> None: query = SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -456,7 +456,7 @@ def _transform_query(self) -> SlayerQuery: return SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -469,7 +469,7 @@ def _cross_model_transform_query(self) -> SlayerQuery: return SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -484,7 +484,7 @@ def _time_shift_query(self) -> SlayerQuery: return SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -497,7 +497,7 @@ def _consecutive_periods_query(self) -> SlayerQuery: return SlayerQuery( source_model="orders_x", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -562,7 +562,7 @@ async def test_local_transform_chain_executes_with_plan_ordered_stages( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -585,7 +585,7 @@ async def test_cross_model_transform_chain_executes( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -609,7 +609,7 @@ async def test_time_shift_chain_executes( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -638,7 +638,7 @@ async def test_consecutive_periods_chain_executes( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ diff --git a/tests/test_dev1747_local_with_chain.py b/tests/test_dev1747_local_with_chain.py index 13d20ac26..aaceb3dc9 100644 --- a/tests/test_dev1747_local_with_chain.py +++ b/tests/test_dev1747_local_with_chain.py @@ -47,7 +47,7 @@ _CHAIN_QUERY = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -61,7 +61,7 @@ _MULTI_STEP_QUERY = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -136,7 +136,7 @@ async def test_chain_still_orders_and_paginates(self) -> None: sql = await _sql(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], @@ -235,7 +235,7 @@ async def test_hidden_order_slot_survives_the_chain(self) -> None: sql = await _sql(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], @@ -247,7 +247,7 @@ async def test_post_phase_filter_still_wraps_the_chain(self) -> None: sql = await _sql(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], diff --git a/tests/test_dev1747_order_entry.py b/tests/test_dev1747_order_entry.py index 9f46bb148..3b93f99d6 100644 --- a/tests/test_dev1747_order_entry.py +++ b/tests/test_dev1747_order_entry.py @@ -145,7 +145,7 @@ def test_windowed_measure_is_cross_model_cte(self) -> None: entry = _sole_scoped(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "amount:sum(window='90d')", "name": "w"}], @@ -157,7 +157,7 @@ def test_transform_measure_is_transform_step(self) -> None: entry = _sole_scoped(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], diff --git a/tests/test_dev1747_order_resolver.py b/tests/test_dev1747_order_resolver.py index f8ed55599..97ef80906 100644 --- a/tests/test_dev1747_order_resolver.py +++ b/tests/test_dev1747_order_resolver.py @@ -55,7 +55,7 @@ _MEASURE = [{"formula": "amount:sum", "name": "rev"}] _MONTH = TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, ) #: One query per render path that builds its own ORDER BY term. D4's claim is @@ -253,7 +253,7 @@ async def test_transformed_measure_orders_at_the_chain_outer_wrap(self) -> None: sql = await _sql(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], @@ -292,7 +292,7 @@ async def test_windowed_measure_orders_on_its_cte_column(self) -> None: sql = await _sql(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "amount:sum(window='90d')", "name": "w"}], @@ -418,7 +418,7 @@ async def test_tsql_transform_chain_has_no_case_when_emulation(self) -> None: SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "cumsum(amount:sum)", "name": "cs"}], diff --git a/tests/test_dev1747_reroot_visitor.py b/tests/test_dev1747_reroot_visitor.py index ac3c182ec..d863d31ab 100644 --- a/tests/test_dev1747_reroot_visitor.py +++ b/tests/test_dev1747_reroot_visitor.py @@ -100,26 +100,26 @@ def test_time_trunc_key_reroots_its_column(self) -> None: not inherit that blind spot.""" out = reroot_value_key( TimeTruncKey( - column=ColumnKey(path=("customers",), leaf="signup_at"), + source=ColumnKey(path=("customers",), leaf="signup_at"), granularity=TimeGranularity.MONTH, ), target_path=TARGET, ) assert out == TimeTruncKey( - column=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.MONTH, + source=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.MONTH, ) def test_time_trunc_key_over_derived_column(self) -> None: out = reroot_value_key( TimeTruncKey( - column=ColumnSqlKey( + source=ColumnSqlKey( path=("customers",), model="customers", column_name="signup_d", ), granularity=TimeGranularity.DAY, ), target_path=TARGET, ) - assert out.column == ColumnSqlKey( + assert out.source == ColumnSqlKey( path=(), model="customers", column_name="signup_d", ) @@ -162,7 +162,7 @@ def test_transform_input_partition_and_time_keys(self) -> None: ), partition_keys=Grain.of({ColumnKey(path=("customers",), leaf="tier")}), time_key=TimeTruncKey( - column=ColumnKey(path=("customers",), leaf="signup_at"), + source=ColumnKey(path=("customers",), leaf="signup_at"), granularity=TimeGranularity.MONTH, ), ), @@ -172,7 +172,7 @@ def test_transform_input_partition_and_time_keys(self) -> None: assert isinstance(out.time_key, TimeTruncKey) assert out.input.source == ColumnKey(path=(), leaf="spend") assert out.partition_keys == Grain.of({ColumnKey(path=(), leaf="tier")}) - assert out.time_key.column == ColumnKey(path=(), leaf="signup_at") + assert out.time_key.source == ColumnKey(path=(), leaf="signup_at") def test_transform_scalar_args_are_type_prohibited_from_holding_keys(self) -> None: """``TransformKey.args``/``kwargs`` are ``Tuple[Scalar, ...]`` where @@ -269,7 +269,7 @@ def test_deeply_nested_mixed_composition(self) -> None: TimePointCmpKey( op=">=", operand=TimeTruncKey( - column=ColumnKey( + source=ColumnKey( path=("customers",), leaf="signup_at", ), granularity=TimeGranularity.DAY, @@ -292,7 +292,7 @@ def test_deeply_nested_mixed_composition(self) -> None: assert isinstance(time_point, TimePointCmpKey) assert isinstance(time_point.operand, TimeTruncKey) assert in_key.column == ColumnKey(path=(), leaf="tier") - assert time_point.operand.column == ColumnKey(path=(), leaf="signup_at") + assert time_point.operand.source == ColumnKey(path=(), leaf="signup_at") # --------------------------------------------------------------------------- @@ -314,7 +314,7 @@ def test_every_union_member_is_handled(self) -> None: path=("customers",), model="customers", column_name="cr", ), TimeTruncKey: TimeTruncKey( - column=ColumnKey(path=("customers",), leaf="signup_at"), + source=ColumnKey(path=("customers",), leaf="signup_at"), granularity=TimeGranularity.DAY, ), StarKey: StarKey(path=("customers",)), diff --git a/tests/test_dev1750_execution.py b/tests/test_dev1750_execution.py index 040d97643..81afaef67 100644 --- a/tests/test_dev1750_execution.py +++ b/tests/test_dev1750_execution.py @@ -18,7 +18,6 @@ import pytest from tests._dev1750_fixtures import ( - ColumnRef, ModelMeasure, SlayerQuery, TimeDimension, @@ -266,7 +265,7 @@ async def test_feb_prev_reads_january_outside_range(self, exec_engine) -> None: resp = await exec_engine.execute(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-03-31"], )], diff --git a/tests/test_dev1763_call_site_migration.py b/tests/test_dev1763_call_site_migration.py index 6c3c695f9..c4e3022d9 100644 --- a/tests/test_dev1763_call_site_migration.py +++ b/tests/test_dev1763_call_site_migration.py @@ -132,7 +132,7 @@ async def _generate(query: SlayerQuery, *, dialect: str = "postgres") -> str: _MONTH = TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, ) @@ -211,7 +211,7 @@ def test_context_accepts_scope_none(self) -> None: ("column", ColumnKey(leaf="amount")), ("derived", ColumnSqlKey(model="orders", column_name="net")), ("time_trunc", TimeTruncKey( - column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH)), + source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH)), ("aggregate", AggregateKey(source=ColumnKey(leaf="amount"), agg="sum")), ("nested_in_arithmetic", ArithmeticKey( op="+", @@ -521,7 +521,7 @@ def test_builder_output_is_returned(self) -> None: "column": ColumnKey(leaf="amount"), "derived": ColumnSqlKey(model="orders", column_name="net"), "time_trunc": TimeTruncKey( - column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH), + source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH), "aggregate": AggregateKey(source=ColumnKey(leaf="amount"), agg="sum"), "transform": TransformKey( op="cumsum", diff --git a/tests/test_dev1800_execution.py b/tests/test_dev1800_execution.py index e67029866..81a67f67e 100644 --- a/tests/test_dev1800_execution.py +++ b/tests/test_dev1800_execution.py @@ -325,7 +325,7 @@ class TestTermAloneExecutionParity: @staticmethod def _month_orders_td(): - return [TimeDimension(dimension=ColumnRef(name="ordered_at"), + return [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)] async def test_surviving_rows_identical_with_and_without_projection(self, attr_engine) -> None: diff --git a/tests/test_dev1825_regroup_execution.py b/tests/test_dev1825_regroup_execution.py index 93208df0e..ad9556e31 100644 --- a/tests/test_dev1825_regroup_execution.py +++ b/tests/test_dev1825_regroup_execution.py @@ -11,7 +11,6 @@ import pytest from slayer.core.query import ( - ColumnRef, ModelMeasure, SlayerQuery, TimeDimension, @@ -90,7 +89,7 @@ async def test_date_range_bound_copies_into_partition_aggregate(self, exec_engin # (5000, not > 5000) bands to 0. Every Jan city total is <= 5000 -> all # band 0, distinguishing the two behaviours by VALUE. jan = [TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-01-31"], )] diff --git a/tests/test_dev1825_substitute_keys.py b/tests/test_dev1825_substitute_keys.py index fec7ede79..654ede122 100644 --- a/tests/test_dev1825_substitute_keys.py +++ b/tests/test_dev1825_substitute_keys.py @@ -75,12 +75,12 @@ def test_nested_in_time_point_and_in(self) -> None: assert substitute_value_keys(ik, MAPPING).column == PLACEHOLDER def test_column_to_column_inside_time_trunc(self) -> None: - # TimeTruncKey.column is typed ColumnKey/ColumnSqlKey — a column-to- + # TimeTruncKey.source is typed ColumnKey/ColumnSqlKey — a column-to- # column mapping must reach through it. other = ColumnKey(path=(), leaf="shipped_at") - tt = TimeTruncKey(column=ColumnKey(path=(), leaf="ordered_at"), granularity=TimeGranularity.MONTH) + tt = TimeTruncKey(source=ColumnKey(path=(), leaf="ordered_at"), granularity=TimeGranularity.MONTH) out = substitute_value_keys(tt, {ColumnKey(path=(), leaf="ordered_at"): other}) - assert out.column == other + assert out.source == other class TestAtomicity: @@ -108,7 +108,7 @@ def test_every_union_member_has_a_sample(self) -> None: ColumnKey: CITY, ColumnSqlKey: ColumnSqlKey(path=(), model="orders", column_name="margin"), TimeTruncKey: TimeTruncKey( - column=ColumnKey(path=(), leaf="ordered_at"), granularity=TimeGranularity.MONTH, + source=ColumnKey(path=(), leaf="ordered_at"), granularity=TimeGranularity.MONTH, ), StarKey: StarKey(), LiteralKey: LiteralKey(value=Decimal("1")), diff --git a/tests/test_dev1827_value_key_traversal.py b/tests/test_dev1827_value_key_traversal.py index 4fd13f662..92da9e4cc 100644 --- a/tests/test_dev1827_value_key_traversal.py +++ b/tests/test_dev1827_value_key_traversal.py @@ -40,7 +40,7 @@ AMOUNT = ColumnKey(path=(), leaf="amount") TS = ColumnKey(path=(), leaf="created_at") JOINED = ColumnKey(path=("customers",), leaf="balance") -TT = TimeTruncKey(column=TS, granularity=TimeGranularity.MONTH) +TT = TimeTruncKey(source=TS, granularity=TimeGranularity.MONTH) AGG = AggregateKey(source=AMOUNT, agg="sum") CHANGE_TR = TransformKey(op="change", input=AGG, time_key=TT) RANK_TR = TransformKey(op="rank", input=AGG, partition_keys=Grain.of({CITY})) @@ -576,7 +576,7 @@ def test_replacement_value_is_not_re_substituted(self) -> None: # --------------------------------------------------------------------------- -# B1 — walk_value_keys reaches TimeTruncKey.column without side effects +# B1 — walk_value_keys reaches TimeTruncKey.source without side effects # --------------------------------------------------------------------------- class TestTimeTruncWidening: def test_walk_yields_the_wrapped_column(self) -> None: @@ -653,7 +653,7 @@ def test_iif_branch_crossing_is_discovered(self) -> None: def test_time_trunc_inside_a_source_crosses_via_its_column(self) -> None: # B1: the trunc's crossing lives on its wrapped column. joined_tt = TimeTruncKey( - column=ColumnKey(path=("customers",), leaf="balance"), + source=ColumnKey(path=("customers",), leaf="balance"), granularity=TimeGranularity.MONTH, ) key = AggregateKey( @@ -700,7 +700,7 @@ async def test_ungrouped_bare_column_beside_aggregate_splits_by_typing(self) -> query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count")], @@ -715,7 +715,7 @@ async def test_unsplittable_or_with_ungrouped_column_rejected(self) -> None: query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count")], diff --git a/tests/test_dev1832_cross_model_exec.py b/tests/test_dev1832_cross_model_exec.py index 042e9a8d4..8e6adc3b6 100644 --- a/tests/test_dev1832_cross_model_exec.py +++ b/tests/test_dev1832_cross_model_exec.py @@ -308,12 +308,10 @@ async def test_unanalysable_leaf_fails_closed(self): await gen(query, models=models) assert "unparseable" in str(ei.value) - async def test_first_over_expression_keeps_its_error(self): + async def test_first_over_expression_is_the_ranked_aggregate(self): query = orders_q(measures=[ ModelMeasure(formula="first(amount - customers.discount, ordered_at)", name="m")]) - with pytest.raises(ValueError, match="not supported over an expression") as ei: - await gen(query) - assert "cross-model" not in str(ei.value).lower() + assert "ROW_NUMBER" in (await gen(query)).upper() async def test_fully_attached_source_accepted(self, exec_backend): # Regression guard (DEV-1847): the expression gate must not reject it. diff --git a/tests/test_dev1832_golden_sql.py b/tests/test_dev1832_golden_sql.py index d4a9ace20..2d54c0eb1 100644 --- a/tests/test_dev1832_golden_sql.py +++ b/tests/test_dev1832_golden_sql.py @@ -12,7 +12,7 @@ from pathlib import Path from slayer.core.enums import TimeGranularity -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from tests._dev1832_fixtures import dev1832_models from tests._engine_helpers import _engine_generate @@ -22,7 +22,7 @@ DIALECTS = ["postgres", "sqlite", "duckdb", "mysql", "tsql", "bigquery", "snowflake"] ALLOWED_DELTAS: dict[str, str] = {} -_MONTH_TD = [TimeDimension(dimension=ColumnRef(name="ordered_at"), +_MONTH_TD = [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/test_dev1832_transform_source.py b/tests/test_dev1832_transform_source.py index 0c28ddcfa..73971ba9f 100644 --- a/tests/test_dev1832_transform_source.py +++ b/tests/test_dev1832_transform_source.py @@ -30,7 +30,6 @@ CHANGE_SUM_BY_MONTH, COLLAPSE_MIXED_BY_MONTH, CONSEC_SUM_BY_MONTH, - ColumnRef, CUMSUM_MINUS_LAST_BY_MONTH, EMPTY_GRAIN_MIXED_BY_MONTH, FIRST_SUM_BY_MONTH, @@ -179,7 +178,7 @@ async def test_scalar_call_wrapped_reaggregation_attaches_axis(self, exec_backen def _year_td() -> TimeDimension: - return TimeDimension(dimension=ColumnRef(name="ordered_at"), + return TimeDimension(dimension="ordered_at", granularity=TimeGranularity.YEAR) @@ -298,14 +297,13 @@ async def test_transform_in_scalar_call_aggregate_arg_needs_time_dim(self): with pytest.raises(ValueError, match="unambiguous time dimension"): await gen(query) - async def test_first_over_mixed_keeps_expression_error(self): + async def test_first_over_mixed_source_is_the_ranked_aggregate(self): query = sales_q( dimensions=["region"], measures=[ModelMeasure( - formula="first(quantity * avg(unit_price, partition_by=product))", + formula="first(quantity * avg(unit_price, partition_by=product), id)", name="m")]) - with pytest.raises(ValueError, match="not supported over an expression"): - await gen(query) + assert "ROW_NUMBER" in (await gen(query)).upper() # --------------------------------------------------------------------------- # diff --git a/tests/test_dev1835_semantic_pins.py b/tests/test_dev1835_semantic_pins.py index 48796f7b9..e55ae14f6 100644 --- a/tests/test_dev1835_semantic_pins.py +++ b/tests/test_dev1835_semantic_pins.py @@ -17,7 +17,6 @@ OK_W90, REGION_LAST, TimeDimension, - ColumnRef, TimeGranularity, make_exec_engine, make_shipped_exec_engine, @@ -62,7 +61,7 @@ async def test_explicit_bound_equals_date_range_for_windowed( ranged = await engine.execute(q( dimensions=["region"], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-12-31"], )], @@ -88,7 +87,7 @@ async def test_explicit_bound_equals_date_range_for_time_shift( ranged = await engine.execute(q( dimensions=["region"], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-12-31"], )], diff --git a/tests/test_dev1836_ranked_windowed.py b/tests/test_dev1836_ranked_windowed.py index 91c7d3b76..8c257c800 100644 --- a/tests/test_dev1836_ranked_windowed.py +++ b/tests/test_dev1836_ranked_windowed.py @@ -13,7 +13,7 @@ from slayer.core.enums import TimeGranularity from slayer.core.errors import SlayerError -from slayer.core.query import ColumnRef, TimeDimension +from slayer.core.query import TimeDimension from slayer.sql.scope_check import assert_scope_closed from tests._dev1836_fixtures import ( @@ -43,7 +43,7 @@ async def exec_backend(request): def signup_month_td() -> list[TimeDimension]: return [TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, )] diff --git a/tests/test_dev1836_reverse_hop_reroot.py b/tests/test_dev1836_reverse_hop_reroot.py index daff35849..2e053d9e8 100644 --- a/tests/test_dev1836_reverse_hop_reroot.py +++ b/tests/test_dev1836_reverse_hop_reroot.py @@ -99,7 +99,7 @@ async def engine() -> AsyncIterator[SlayerQueryEngine]: CM = ModelMeasure(formula="policy_amount.policy_amount:sum", name="cm") -MONTH = [TimeDimension(dimension=ColumnRef(name="created_at"), +MONTH = [TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/test_dev1838_kernels.py b/tests/test_dev1838_kernels.py index cb0f938ea..2f841ed42 100644 --- a/tests/test_dev1838_kernels.py +++ b/tests/test_dev1838_kernels.py @@ -14,7 +14,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.keys import ColumnKey, TimeTruncKey from slayer.core.models import Column, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.ir.planned import ( PlainProducerKernel, RankedProducerKernel, @@ -114,7 +114,7 @@ def test_cross_model_windowed_attach_carries_trailing_window_kernel(self) -> Non planned = _plan(q( dimensions=["customers.tier"], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure( diff --git a/tests/test_dev1841_association_errors.py b/tests/test_dev1841_association_errors.py index a9c00ac55..566a49f7e 100644 --- a/tests/test_dev1841_association_errors.py +++ b/tests/test_dev1841_association_errors.py @@ -16,7 +16,6 @@ from slayer.core.errors import SlayerError from tests._dev1841_fixtures import ( - ColumnRef, ModelMeasure, TimeDimension, TimeGranularity, @@ -38,7 +37,7 @@ #: (which would fire before association eligibility) must NOT satisfy it. _COMBO = re.compile(r"associat|attribut|distinct.entity|combination", re.IGNORECASE) _SIGNUP_TD = [TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/test_dev1842_keys_prepend.py b/tests/test_dev1842_keys_prepend.py index 972e2f4a5..0924dac17 100644 --- a/tests/test_dev1842_keys_prepend.py +++ b/tests/test_dev1842_keys_prepend.py @@ -52,7 +52,7 @@ def _samples() -> dict: ColumnKey: ColumnKey(path=(), leaf="spend"), ColumnSqlKey: ColumnSqlKey(path=(), model="customers", column_name="cr"), TimeTruncKey: TimeTruncKey( - column=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.DAY, + source=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.DAY, ), StarKey: StarKey(path=()), LiteralKey: LiteralKey(value=Decimal("1")), @@ -132,10 +132,10 @@ def test_literal_is_identity(self) -> None: def test_time_trunc_prepends_wrapped_column(self) -> None: out = K.prepend_value_key( - TimeTruncKey(column=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.MONTH), + TimeTruncKey(source=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.MONTH), host_path=HOST, ) - assert out.column == ColumnKey(path=("customers",), leaf="signup_at") + assert out.source == ColumnKey(path=("customers",), leaf="signup_at") def test_deep_host_path_prefixes_fully(self) -> None: out = K.prepend_value_key(ColumnKey(path=(), leaf="pop"), host_path=DEEP) @@ -176,7 +176,7 @@ def test_transform_input_partition_time(self) -> None: input=AggregateKey(source=ColumnKey(path=(), leaf="spend"), agg="sum"), partition_keys=Grain.of({ColumnKey(path=(), leaf="tier")}), time_key=TimeTruncKey( - column=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.MONTH, + source=ColumnKey(path=(), leaf="signup_at"), granularity=TimeGranularity.MONTH, ), ), host_path=HOST, @@ -185,7 +185,7 @@ def test_transform_input_partition_time(self) -> None: assert isinstance(out.time_key, TimeTruncKey) assert out.input.source == ColumnKey(path=("customers",), leaf="spend") assert out.partition_keys == Grain.of({ColumnKey(path=("customers",), leaf="tier")}) - assert out.time_key.column == ColumnKey(path=("customers",), leaf="signup_at") + assert out.time_key.source == ColumnKey(path=("customers",), leaf="signup_at") def test_arithmetic_scalarcall_time_point_in(self) -> None: out = K.prepend_value_key( diff --git a/tests/test_dev1853_named_edges.py b/tests/test_dev1853_named_edges.py index 57e0da1e6..f38961b63 100644 --- a/tests/test_dev1853_named_edges.py +++ b/tests/test_dev1853_named_edges.py @@ -16,7 +16,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.errors import AggregationNotAllowedError from slayer.core.models import Column, ModelMeasure -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from tests._dev1836_fixtures import broadcast_warnings @@ -70,7 +70,7 @@ async def test_time_dimension_grouping_and_ordering(self, named_engine) -> None: resp = await named_engine.execute(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="billing_customer.signup_at"), + dimension="billing_customer.signup_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "amount:sum", "name": "t"}], order=[{"column": "billing_customer.signup_at", @@ -198,7 +198,7 @@ async def test_derived_time_dimension_over_the_named_edge( resp = await named_engine.execute(SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="billing_customer.signup_day"), + dimension="billing_customer.signup_day", granularity=TimeGranularity.MONTH)], measures=[{"formula": "amount:sum", "name": "t"}])) months = {str(r["orders.billing_customer.signup_day"])[:7]: r["orders.t"] diff --git a/tests/test_dev1859_plan_structure.py b/tests/test_dev1859_plan_structure.py index e0132051f..2f7e3dbe6 100644 --- a/tests/test_dev1859_plan_structure.py +++ b/tests/test_dev1859_plan_structure.py @@ -214,14 +214,11 @@ def test_no_combined_phase_leak(self): for a in planned.regroup_attach_plans) -class TestFirstOverMixedKeepsExpressionError: - async def test_first_over_mixed_source_expression_error(self): - """Scenario: Ranked aggregation over a mixed source keeps the - expression error — never a mixing error, never wrong values.""" +class TestFirstOverMixedSource: + async def test_first_over_mixed_source_is_the_ranked_aggregate(self): q = sales_q( dimensions=["region"], measures=[ModelMeasure( - formula=f"first(quantity * {INNER_UP_PRODUCT})", + formula=f"first(quantity * {INNER_UP_PRODUCT}, id)", name="f")]) - with pytest.raises(ValueError, match="not supported over an expression"): - await gen(q) + assert "ROW_NUMBER" in (await gen(q)).upper() diff --git a/tests/test_dev1859_walker.py b/tests/test_dev1859_walker.py index 2036c6537..22e65c83a 100644 --- a/tests/test_dev1859_walker.py +++ b/tests/test_dev1859_walker.py @@ -53,7 +53,7 @@ def test_partition_keys_are_not_row_leaves(self): def test_time_key_is_not_a_row_leaf(self): key = TransformKey( op="rank", input=AGG, - time_key=TimeTruncKey(column=ColumnKey(leaf="ordered_at"), + time_key=TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH)) assert _walk(key) is None diff --git a/tests/test_dev1868_series_shift_exec.py b/tests/test_dev1868_series_shift_exec.py index 0438db06e..a0ca16805 100644 --- a/tests/test_dev1868_series_shift_exec.py +++ b/tests/test_dev1868_series_shift_exec.py @@ -10,7 +10,6 @@ import pytest from tests._dev1846_fixtures import ( - ColumnRef, ModelMeasure, SlayerQuery, TimeDimension, @@ -39,7 +38,7 @@ def _q(**kw) -> SlayerQuery: def _dr_td() -> list: - return [TimeDimension(dimension=ColumnRef(name="ordered_at"), + return [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-03-31"])] @@ -229,7 +228,7 @@ async def test_month_end_days_neither_duplicate_nor_collide( ) -> None: resp = await daily_engine.execute(SlayerQuery( source_model="daily", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="ordered_at"), + time_dimensions=[TimeDimension(dimension="ordered_at", granularity=TimeGranularity.DAY)], measures=[ModelMeasure(formula="time_shift(cumsum(revenue:sum), -1, 'month')", name="t")], diff --git a/tests/test_dev1871_time_inkey_traversal.py b/tests/test_dev1871_time_inkey_traversal.py index d597dfc42..2c4251105 100644 --- a/tests/test_dev1871_time_inkey_traversal.py +++ b/tests/test_dev1871_time_inkey_traversal.py @@ -18,7 +18,7 @@ from slayer.engine.bind_inputs import _attach_time_keys from slayer.engine.elaborate_env import check_time_transforms_resolved -_TD = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) +_TD = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) def _in_scalar_call() -> ScalarCallKey: diff --git a/tests/test_dev1879_formula_measure_naming.py b/tests/test_dev1879_formula_measure_naming.py index 5a9acc868..e4e3864e7 100644 --- a/tests/test_dev1879_formula_measure_naming.py +++ b/tests/test_dev1879_formula_measure_naming.py @@ -15,7 +15,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.models import Column, ModelMeasure -from slayer.core.query import ColumnRef, OrderItem, SlayerQuery, TimeDimension +from slayer.core.query import OrderItem, SlayerQuery, TimeDimension from slayer.core.refs import auto_name_from_expression from tests._dev1879_helpers import dry_response, mart_model @@ -43,7 +43,7 @@ def _q(**kw) -> SlayerQuery: def _month_td() -> list[TimeDimension]: return [ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) ] diff --git a/tests/test_dev1883_equivalence_guards.py b/tests/test_dev1883_equivalence_guards.py index c226a0b43..7d0f26844 100644 --- a/tests/test_dev1883_equivalence_guards.py +++ b/tests/test_dev1883_equivalence_guards.py @@ -11,7 +11,7 @@ from slayer.core.enums import TimeGranularity from slayer.core.errors import AmbiguousReferenceError, GranularityCallError from slayer.core.keys import ColumnKey, TimeTruncKey -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.bind_inputs import ( _assert_equivalent_tds_agree, _resolve_main_time_dimension, @@ -63,7 +63,7 @@ async def test_unit_resolve_raises_on_same_column_buckets(self, engine) -> None: async def test_single_bucket_still_resolves(self, engine) -> None: model = await _orders_model(engine) - td = TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + td = TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) q = SlayerQuery(source_model="orders", time_dimensions=[td], main_time_dimension="created_at") # A lone TD ignores main_time_dimension and resolves regardless. assert _resolve_main_time_dimension(query=q, model=model) is td @@ -73,7 +73,7 @@ async def test_distinct_columns_still_resolve(self, engine) -> None: q = _mtd(("month", "created_at"), ("year", "customers.created_at")) resolved = _resolve_main_time_dimension(query=q, model=model) assert resolved is not None - assert resolved.dimension.full_name == "created_at" + assert resolved.dimension == "created_at" async def test_ambiguous_default_resolves_to_none(self, engine) -> None: # A passive model default can't pick a bucket: resolve to None (lenient) so @@ -101,7 +101,7 @@ async def test_default_time_dimension_single_host_match_resolves(self, engine) - ) resolved = _resolve_main_time_dimension(query=q, model=model) assert resolved is not None - assert resolved.dimension.full_name == "created_at" + assert resolved.dimension == "created_at" async def _orders_with_default_axis(self, tmp_path): engine = await fx.build_engine(tmp_path) @@ -138,13 +138,13 @@ async def test_ambiguous_default_without_transform_runs(self, tmp_path) -> None: def _bound_td(col: str, gran: str, key: TimeTruncKey, **meta) -> tuple: - td = TimeDimension(dimension=ColumnRef(name=col), granularity=TimeGranularity(gran), **meta) + td = TimeDimension(dimension=col, granularity=TimeGranularity(gran), **meta) return (td, BoundExpr(value_key=key), col) class TestEquivalentTimeDimensionMetadataGuard: - K_MONTH = TimeTruncKey(column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH) - K_YEAR = TimeTruncKey(column=ColumnKey(leaf="created_at"), granularity=TimeGranularity.YEAR) + K_MONTH = TimeTruncKey(source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.MONTH) + K_YEAR = TimeTruncKey(source=ColumnKey(leaf="created_at"), granularity=TimeGranularity.YEAR) def test_conflicting_date_range_raises(self) -> None: tds = [ diff --git a/tests/test_dev1883_errors.py b/tests/test_dev1883_errors.py index 23998345e..68d1183bd 100644 --- a/tests/test_dev1883_errors.py +++ b/tests/test_dev1883_errors.py @@ -25,7 +25,6 @@ def assert_names_functional_form(msg: str) -> None: WRONG_SHAPES = [ "month()", "month(a, b)", - "month(upper(x))", "month(*)", "month(created_at, granularity=2)", ] @@ -55,6 +54,13 @@ def test_rejected_naming_shape_and_granularities(self, entry: str) -> None: assert_names_functional_form(msg) +class TestNonTemporalArgument: + async def test_fails_at_binding_as_a_time_dimension(self, exec_engine) -> None: + query = _q(["month(upper(status))"]) # accepted at construction + with pytest.raises(errors.TimeDimensionColumnError, match="TEXT"): + await exec_engine.execute(query, dry_run=True) + + class TestUnknownSingleColumnCall: async def test_typo_names_granularities_and_partition_by_hedge(self, exec_engine) -> None: query = _q(["mnth(created_at)"]) # accepted at construction; the callee resolves at binding diff --git a/tests/test_dev1883_functional_rewrite.py b/tests/test_dev1883_functional_rewrite.py index 5a1c05b87..b15f9efad 100644 --- a/tests/test_dev1883_functional_rewrite.py +++ b/tests/test_dev1883_functional_rewrite.py @@ -80,7 +80,7 @@ def test_canonical_serialization_moves_entry(self) -> None: dump = q.model_dump() assert not dump["dimensions"] assert [ - (td["dimension"]["name"], td["granularity"]) + (td["dimension"], td["granularity"]) for td in dump["time_dimensions"] ] == [("created_at", "month")] diff --git a/tests/test_dev1894_query_type_errors.py b/tests/test_dev1894_query_type_errors.py index 2833bcf69..4f86e0913 100644 --- a/tests/test_dev1894_query_type_errors.py +++ b/tests/test_dev1894_query_type_errors.py @@ -48,7 +48,7 @@ Case = Callable[[], Tuple[SlayerQuery, List[SlayerModel]]] -_MONTH = [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)] +_MONTH = [TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)] _NO_AXIS = "sum(cumsum(amount:sum(partition_by=region)))" @@ -174,7 +174,7 @@ def _rank_partition() -> Tuple[SlayerQuery, List[SlayerModel]]: ("time-dimension-column", lambda: (SlayerQuery( source_model="orders", measures=[_m("amount:sum", "t")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="status"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="status", granularity=TimeGranularity.MONTH)], ), [_orders()]), TimeDimensionColumnError, "time dimension 'status'", False), ] diff --git a/tests/test_dev1900_closure.py b/tests/test_dev1900_closure.py index 67f468ac2..8b31780cd 100644 --- a/tests/test_dev1900_closure.py +++ b/tests/test_dev1900_closure.py @@ -117,7 +117,7 @@ def test_a_string_is_never_sql(self): assert got == () def test_time_trunc_delegates_to_its_column(self): - key = TimeTruncKey(column=_regions_sqlkey("bad_pop"), granularity=TimeGranularity.MONTH) + key = TimeTruncKey(source=_regions_sqlkey("bad_pop"), granularity=TimeGranularity.MONTH) got = self._key(key) assert got is not None assert CROSS in got diff --git a/tests/test_dev1902_join_key_spelling.py b/tests/test_dev1902_join_key_spelling.py index 7ea92f1ba..4a303c574 100644 --- a/tests/test_dev1902_join_key_spelling.py +++ b/tests/test_dev1902_join_key_spelling.py @@ -199,7 +199,6 @@ def _customer_totals(*, key: str = "customer_id") -> SlayerModel: class TestQueryBackedJoinKeys: - @pytest.mark.xfail(strict=True, reason="DEV-1918: joins on a query-backed model are not traversable") async def test_join_on_backing_output_column_executes(self, all_renamed): await all_renamed.save_model(_customer_totals()) resp = await all_renamed.execute(_query( diff --git a/tests/test_dev1903_first_last_dispatch.py b/tests/test_dev1903_first_last_dispatch.py index 57508adfa..8e1f9e01c 100644 --- a/tests/test_dev1903_first_last_dispatch.py +++ b/tests/test_dev1903_first_last_dispatch.py @@ -162,9 +162,10 @@ def test_row_operand_binds_to_ranked_aggregate(self, formula, ranking): ("first(qty * avg(revenue, partition_by=store))", {"dimensions": ["store"]}), ("first(1)", {}), ]) - def test_row_grain_expression_keeps_expression_error(self, formula, kw): - with pytest.raises(ValueError, match="not supported over an expression"): - _measure_key(formula, **kw) + def test_row_grain_expression_binds_to_ranked_aggregate(self, formula, kw): + key = _measure_key(formula, **kw) + assert isinstance(key, AggregateKey), key + assert key.agg == "first" def test_transform_kwarg_error(self): with pytest.raises( diff --git a/tests/test_dev1903_transform_inputs.py b/tests/test_dev1903_transform_inputs.py index f0130b313..9de11aac5 100644 --- a/tests/test_dev1903_transform_inputs.py +++ b/tests/test_dev1903_transform_inputs.py @@ -146,7 +146,7 @@ def test_partition_and_time_keys_are_not_leaves(self): keyed = key.model_copy(update={ "partition_keys": Grain.of([ColumnKey(path=(), leaf="store")]), "time_key": TimeTruncKey( - column=ColumnKey(path=(), leaf="ordered_at"), granularity=TimeGranularity.MONTH), + source=ColumnKey(path=(), leaf="ordered_at"), granularity=TimeGranularity.MONTH), }) check_transform_inputs(roots=[keyed], projected_grain_keys=frozenset()) diff --git a/tests/test_dev1909_population_pushdown.py b/tests/test_dev1909_population_pushdown.py index aa812ff21..70187bc16 100644 --- a/tests/test_dev1909_population_pushdown.py +++ b/tests/test_dev1909_population_pushdown.py @@ -28,7 +28,7 @@ SlayerError, ) from slayer.core.models import Column, DataType -from slayer.core.query import ColumnRef, TimeDimension +from slayer.core.query import TimeDimension from slayer.engine.plan import plan_query from slayer.ir.source_bundle import ResolvedSourceBundle from slayer.sql.scope_check import assert_scope_closed @@ -80,13 +80,13 @@ def _orders_month_td(): return [TimeDimension( - dimension=ColumnRef(name="orders.ordered_at"), + dimension="orders.ordered_at", granularity=TimeGranularity.MONTH)] def _signup_month_td(): return [TimeDimension( - dimension=ColumnRef(name="signup_at"), + dimension="signup_at", granularity=TimeGranularity.MONTH)] diff --git a/tests/test_dev1915_windowed_exec.py b/tests/test_dev1915_windowed_exec.py index 121422dcd..bdf72b829 100644 --- a/tests/test_dev1915_windowed_exec.py +++ b/tests/test_dev1915_windowed_exec.py @@ -14,7 +14,7 @@ import pytest -from slayer.core.query import OrderItem, SlayerQuery, TimeDimension, ColumnRef +from slayer.core.query import OrderItem, SlayerQuery, TimeDimension from slayer.core.enums import TimeGranularity as TG from slayer.core.models import Column, ModelMeasure, SlayerModel from tests import _dev1471_fixtures as D71 @@ -288,12 +288,12 @@ async def test_two_stage_windowed_over_stage_axis(backend, agg, oracle): windowed = [{"formula": f"rev:{agg}(window='60d')", "name": "win"}] stage_outer = SlayerQuery.model_validate({ "source_model": "s1", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="created_at", granularity=TG.MONTH)], "measures": windowed, }) model_query = SlayerQuery.model_validate({ "source_model": "monthly", - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="month_ts"), granularity=TG.MONTH)], + "time_dimensions": [TimeDimension(dimension="month_ts", granularity=TG.MONTH)], "measures": windowed, }) with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_dev1922_source_model_union.py b/tests/test_dev1922_source_model_union.py index fa3d7cca7..6cc4555ff 100644 --- a/tests/test_dev1922_source_model_union.py +++ b/tests/test_dev1922_source_model_union.py @@ -408,27 +408,27 @@ def test_column_key_validates_and_dumps_as_dimension(self) -> None: time_dimensions=[{"column": "created_at", "granularity": "month"}], ) assert q.time_dimensions - assert q.time_dimensions[0].dimension.name == "created_at" + assert q.time_dimensions[0].dimension == "created_at" dumped = q.model_dump(mode="json", exclude_none=True)["time_dimensions"][0] - assert dumped == {"dimension": {"name": "created_at"}, "granularity": "month"} + assert dumped == {"dimension": "created_at", "granularity": "month"} def test_column_key_accepts_a_column_ref_object(self) -> None: td = TimeDimension.model_validate({ "column": {"name": "created_at", "model": "orders"}, "granularity": "day", }) - assert (td.dimension.name, td.dimension.model) == ("created_at", "orders") + assert td.dimension == "orders.created_at" def test_dimension_key_and_keyword_still_validate(self) -> None: via_key = TimeDimension.model_validate({"dimension": "created_at", "granularity": "month"}) via_kw = TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, ) assert via_key == via_kw - assert via_kw.dimension.name == "created_at" + assert via_kw.dimension == "created_at" def test_dimension_wins_when_both_keys_are_present(self) -> None: td = TimeDimension.model_validate({"dimension": "a", "column": "b", "granularity": "month"}) - assert td.dimension.name == "a" + assert td.dimension == "a" def test_schema_advertises_dimension_only(self) -> None: props = SlayerQuery.model_json_schema()["$defs"]["TimeDimension"]["properties"] diff --git a/tests/test_dev1929_column_granularity.py b/tests/test_dev1929_column_granularity.py index 246c8265e..044996e34 100644 --- a/tests/test_dev1929_column_granularity.py +++ b/tests/test_dev1929_column_granularity.py @@ -16,7 +16,7 @@ from slayer.core.enums import DataType, TimeGranularity from slayer.core.errors import TimeDimensionColumnError from slayer.core.models import Column, DatasourceConfig, ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.core.scope import ModelScope, StageColumn, StageSchema from slayer.engine.binding import bind_time_dimension from slayer.engine.elaborate_env import check_time_dimension_column @@ -172,27 +172,27 @@ def _orders_customers(self, *, gran_o=None, gran_c=None): def test_bare_column_returns_handset_granularity(self) -> None: orders, customers = self._orders_customers(gran_o=TG.DAY) bound = bind_time_dimension( - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TG.DAY), + TimeDimension(dimension="created_at", granularity=TG.DAY), scope=ModelScope(source_model=orders), bundle=ResolvedSourceBundle(dialect="postgres", source_model=orders, referenced_models=[customers]), ) - assert bound.upstream_granularity == TG.DAY + assert bound.bucketed_leaves == (("created_at", TG.DAY),) assert bound.column_type == DataType.TIMESTAMP def test_dotted_join_path_returns_handset_granularity(self) -> None: orders, customers = self._orders_customers(gran_c=TG.MONTH) bound = bind_time_dimension( - TimeDimension(dimension=ColumnRef(name="customers.signup_at"), granularity=TG.YEAR), + TimeDimension(dimension="customers.signup_at", granularity=TG.YEAR), scope=ModelScope(source_model=orders), bundle=ResolvedSourceBundle(dialect="postgres", source_model=orders, referenced_models=[customers]), ) - assert bound.upstream_granularity == TG.MONTH + assert bound.bucketed_leaves == (("customers.signup_at", TG.MONTH),) def test_rebucket_message_is_generalised(self) -> None: with pytest.raises(TimeDimensionColumnError) as exc: check_time_dimension_column( name="monthly.created_at", column_type=DataType.TIMESTAMP, - upstream_granularity=TG.MONTH, requested_granularity=TG.DAY, + bucketed_leaves=(("monthly.created_at", TG.MONTH),), requested_granularity=TG.DAY, ) msg = str(exc.value) assert "month" in msg @@ -423,18 +423,18 @@ def test_dotted_ref_to_sibling_standin_reads_the_bucket(self) -> None: ) bundle = ResolvedSourceBundle(dialect="postgres", source_model=host, referenced_models=[sibling]) bound = bind_time_dimension( - TimeDimension(dimension=ColumnRef(name="s1.created_at"), granularity=TG.DAY), + TimeDimension(dimension="s1.created_at", granularity=TG.DAY), scope=ModelScope(source_model=host), bundle=bundle, ) - assert bound.upstream_granularity == TG.MONTH + assert bound.bucketed_leaves == (("s1.created_at", TG.MONTH),) with pytest.raises(TimeDimensionColumnError): check_time_dimension_column( name="s1.created_at", column_type=bound.column_type, - upstream_granularity=bound.upstream_granularity, requested_granularity=TG.DAY, + bucketed_leaves=bound.bucketed_leaves, requested_granularity=TG.DAY, ) check_time_dimension_column( # month re-binds name="s1.created_at", column_type=bound.column_type, - upstream_granularity=bound.upstream_granularity, requested_granularity=TG.MONTH, + bucketed_leaves=bound.bucketed_leaves, requested_granularity=TG.MONTH, ) async def test_model_extension_over_bucketed_sibling(self) -> None: diff --git a/tests/test_dev1934_formula_agg.py b/tests/test_dev1934_formula_agg.py index 7a6b267b2..4c05cb99d 100644 --- a/tests/test_dev1934_formula_agg.py +++ b/tests/test_dev1934_formula_agg.py @@ -449,7 +449,7 @@ async def _windowed_sql(formula: str, agg: Aggregation) -> str: ]}) query = SlayerQuery( source_model="orders", measures=[ModelMeasure(formula=formula, name="m")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="ordered_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)], ) return " ".join((await _engine_generate(query=query, model=model)).split()) diff --git a/tests/test_dev1934_save_time_formula.py b/tests/test_dev1934_save_time_formula.py index 7ba812009..3997a9b9d 100644 --- a/tests/test_dev1934_save_time_formula.py +++ b/tests/test_dev1934_save_time_formula.py @@ -389,7 +389,7 @@ def test_blank_builtin_formula_and_param_defaults_become_absent(self) -> None: (agg,) = model.aggregations assert agg.formula is None assert [p.name for p in agg.params] == ["w2"] - assert model.version == 16 + assert model.version == 17 assert raw["aggregations"][0]["formula"] == " " assert len(raw["aggregations"][0]["params"]) == 2 diff --git a/tests/test_dev1942_golden_sql.py b/tests/test_dev1942_golden_sql.py index a7a03a43c..ed407d590 100644 --- a/tests/test_dev1942_golden_sql.py +++ b/tests/test_dev1942_golden_sql.py @@ -13,7 +13,7 @@ from pathlib import Path from slayer.core.enums import TimeGranularity -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from tests._dev1832_fixtures import dev1832_models from tests._engine_helpers import _engine_generate @@ -23,7 +23,7 @@ DIALECTS = ["postgres", "sqlite", "duckdb", "mysql", "tsql", "bigquery", "snowflake"] ALLOWED_DELTAS: dict[str, str] = {} -_MONTH_TD = [TimeDimension(dimension=ColumnRef(name="ordered_at"), +_MONTH_TD = [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH)] _X = "amount:sum(partition_by=[region, ordered_at])" _STANDALONE = f"min({_X}, partition_by=region)" diff --git a/tests/test_dev1946_transform_parameter.py b/tests/test_dev1946_transform_parameter.py index 0d628fdcb..5943912f2 100644 --- a/tests/test_dev1946_transform_parameter.py +++ b/tests/test_dev1946_transform_parameter.py @@ -52,7 +52,6 @@ ) from tests._dev1946_fixtures import ( DET_CUMSUM, - EXPR_ARG, LAST_PARAM, LAST_RANK_KEY, LOCAL_SALES, @@ -526,14 +525,6 @@ def test_transform_ranking_key_refused(self): with pytest.raises(ValueError, match="ranks by a column"): bind_expr(parsed, scope=scope, bundle=bundle) - def test_expression_argument_still_refused(self): - """Non-goal: an expression parameter stays refused, and the reworded - message names grained transforms among the accepted kinds (D1).""" - scope, bundle = _sales_scope_bundle() - parsed = parse_expr(EXPR_ARG) - with pytest.raises(ValueError, match="grained transform"): - bind_expr(parsed, scope=scope, bundle=bundle) - def test_time_bucketed_partition_key_granularity_in_fragment(self): """A rank parameter partitioned by the same column at different granularities gets distinct canonical fragments — the fragment keeps a @@ -544,6 +535,6 @@ def test_time_bucketed_partition_key_granularity_in_fragment(self): def _ranked(gran: TimeGranularity) -> TransformKey: return TransformKey(op="rank", input=inner, partition_keys=Grain.of([ - TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=gran)])) + TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=gran)])) assert agg_kwarg_canonical_str(_ranked(TimeGranularity.MONTH)) != agg_kwarg_canonical_str(_ranked(TimeGranularity.YEAR)) diff --git a/tests/test_dev1953_partition_alias.py b/tests/test_dev1953_partition_alias.py index d44376f75..914a90c2b 100644 --- a/tests/test_dev1953_partition_alias.py +++ b/tests/test_dev1953_partition_alias.py @@ -5,7 +5,6 @@ from __future__ import annotations -import re from collections import defaultdict from typing import Callable, Dict, Optional, Tuple @@ -173,24 +172,26 @@ def test_attach_carrying_alias_binds_to_dimension_value(self): assert isinstance(rank, TransformKey) assert rank.partition_keys == Grain.of([amap["spend_band"]]) - @pytest.mark.parametrize(argnames="formula,message", argvalues=[ - ("rank(sum(amount), partition_by=sum(amount), direction='desc')", - "'sum(amount)' is an expression.\n at transform 'rank' partition_by"), - ("sum(amount, partition_by=sum(amount))", - "'sum(amount)' is an expression.\n at aggregation partition_by"), + @pytest.mark.parametrize(argnames="formula", argvalues=[ + "rank(sum(amount), partition_by=sum(amount), direction='desc')", + "sum(amount, partition_by=sum(amount))", ]) - def test_non_column_element_names_construct(self, formula, message): + def test_aggregate_element_names_the_computed_dimension_remedy(self, formula): amap = _alias(name="ureg", expr="upper(region)") - with pytest.raises(ValueError, match=re.escape(message)): + with pytest.raises(PartitionKeyError, match=r"(?s)'sum\(amount\)'.*computed dimension"): _bind(formula=formula, alias_map=amap) @pytest.mark.parametrize(argnames="formula", argvalues=[ "sum(amount, partition_by=upper(region))", "rank(sum(amount), partition_by=[city, upper(region)], direction='desc')", ]) - def test_expression_element_is_a_typed_partition_key_error(self, formula): - with pytest.raises(PartitionKeyError, match=re.escape("'upper(region)' is an expression")): - _bind(formula=formula) + def test_expression_element_equal_to_an_alias_binds_to_it(self, formula): + amap = _alias(name="ureg", expr="upper(region)") + expression = _bind(formula=formula, alias_map=amap) + named = _bind(formula=formula.replace("upper(region)", "ureg"), alias_map=amap) + assert isinstance(expression, (AggregateKey, TransformKey)) + assert isinstance(named, (AggregateKey, TransformKey)) + assert expression.partition_keys == named.partition_keys # --------------------------------------------------------------------------- # diff --git a/tests/test_dev1954_canonical_paths.py b/tests/test_dev1954_canonical_paths.py index 26ae21e84..321185c5b 100644 --- a/tests/test_dev1954_canonical_paths.py +++ b/tests/test_dev1954_canonical_paths.py @@ -19,7 +19,7 @@ from slayer.core.join_walker import walk, walk_cancelling from slayer.core.keys import AggregateKey, ColumnKey, ColumnSqlKey, StarKey, TimeTruncKey from slayer.core.models import ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, TimeDimension +from slayer.core.query import TimeDimension from slayer.core.scope import ModelScope from slayer.engine import bind_inputs, param_binding, schema_drift from slayer.engine.binding import bind_expr, bind_time_dimension @@ -166,11 +166,11 @@ def test_star_key(self) -> None: def test_time_trunc_key(self) -> None: bound = bind_time_dimension( - TimeDimension(dimension=ColumnRef(name="customers.regions.founded_at"), + TimeDimension(dimension="customers.regions.founded_at", granularity=TimeGranularity.YEAR), scope=_scope(), bundle=_bundle()) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == \ + assert bound.bound.value_key.source == \ ColumnKey(path=("customers", "hr"), leaf="founded_at") assert bound.bound.routed_dotted == "customers.hr.founded_at" @@ -271,7 +271,7 @@ async def test_metadata_matches_the_edge_name_spelling(self, engine) -> None: async def test_named_edge_time_dimension(self, engine) -> None: query = orders_q( time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.regions.founded_at"), + dimension="customers.regions.founded_at", granularity=TimeGranularity.YEAR)], measures=["amount:sum"], order=[{"column": "customers.regions.founded_at", "direction": "asc"}]) @@ -315,7 +315,7 @@ async def test_aggregate_filter_interns_to_the_selected_slot(self, engine) -> No assert by_key(resp, RNAME, "orders.customers.hr.pop_max") == {"South": 200.0} async def test_functional_order_on_a_time_dimension(self, engine) -> None: - td = [TimeDimension(dimension=ColumnRef(name="customers.hr.founded_at"), + td = [TimeDimension(dimension="customers.hr.founded_at", granularity=TimeGranularity.YEAR)] mixed = orders_q(time_dimensions=td, measures=["amount:sum"], order=[ {"column": "max(customers.regions.founded_at)", "direction": "desc"}]) diff --git a/tests/test_dev1958_cte_dependencies.py b/tests/test_dev1958_cte_dependencies.py index dc45b1563..7877e5d1f 100644 --- a/tests/test_dev1958_cte_dependencies.py +++ b/tests/test_dev1958_cte_dependencies.py @@ -13,7 +13,6 @@ from slayer.sql.scope_check import assert_dependency_ordered_ctes, assert_scope_closed from tests._dev1832_fixtures import ( - ColumnRef, ModelMeasure, TimeDimension, TimeGranularity, @@ -34,7 +33,7 @@ def _q(formula: str, *, date_range: bool = False): - td = ([TimeDimension(dimension=ColumnRef(name="ordered_at"), + td = ([TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-03-31"])] if date_range else month_td()) diff --git a/tests/test_dev1958_shifted_leaf_grain.py b/tests/test_dev1958_shifted_leaf_grain.py index 18b52df75..059daee0a 100644 --- a/tests/test_dev1958_shifted_leaf_grain.py +++ b/tests/test_dev1958_shifted_leaf_grain.py @@ -10,7 +10,6 @@ import pytest from tests._dev1832_fixtures import ( - ColumnRef, ModelMeasure, dev1832_models, TimeDimension, @@ -66,7 +65,7 @@ async def filtered_engine(request): def _dr_td() -> list: - return [TimeDimension(dimension=ColumnRef(name="ordered_at"), + return [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-03-31"])] diff --git a/tests/test_dev1958_stage_axis.py b/tests/test_dev1958_stage_axis.py index 862477f92..9c3cd8351 100644 --- a/tests/test_dev1958_stage_axis.py +++ b/tests/test_dev1958_stage_axis.py @@ -7,7 +7,6 @@ import pytest from tests._dev1832_fixtures import ( - ColumnRef, ModelMeasure, SlayerQuery, TimeDimension, @@ -33,7 +32,7 @@ async def test_shifted_share_over_stage_time_dimension(exec_engine) -> None: measures=[ModelMeasure(formula="amount:sum", name="rev")]) outer = SlayerQuery.model_validate({ "source_model": "s1", "dimensions": ["region"], - "time_dimensions": [TimeDimension(dimension=ColumnRef(name="ordered_at"), + "time_dimensions": [TimeDimension(dimension="ordered_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-03-31"])], "measures": [ModelMeasure( diff --git a/tests/test_dev1964_dual_phase_consumption.py b/tests/test_dev1964_dual_phase_consumption.py index 8ca51ff2e..2532b9fb6 100644 --- a/tests/test_dev1964_dual_phase_consumption.py +++ b/tests/test_dev1964_dual_phase_consumption.py @@ -415,7 +415,7 @@ def test_name_is_key_derived_not_encounter_order(self): assert with_later[a] == with_earlier[a] def test_time_bucket_spelled_like_a_column(self): - bucket = TimeTruncKey(column=ColumnKey(leaf="city"), granularity=TimeGranularity.MONTH) + bucket = TimeTruncKey(source=ColumnKey(leaf="city"), granularity=TimeGranularity.MONTH) names = _grain_names([bucket, ColumnKey(leaf="city_month")]) assert names[ColumnKey(leaf="city_month")] == "city_month" assert len(set(names.values())) == 2 @@ -448,7 +448,7 @@ def seed(path: str) -> None: Column(name="created_at", type=DataType.TIMESTAMP), ]), ] - months = [TimeDimension(dimension=ColumnRef(name=n), granularity=TimeGranularity.MONTH) + months = [TimeDimension(dimension=n, granularity=TimeGranularity.MONTH) for n in ("created_at", "customers.created_at")] q = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="region")], time_dimensions=months, diff --git a/tests/test_dev2006_kernel_operands.py b/tests/test_dev2006_kernel_operands.py index 982eb78da..32dc00216 100644 --- a/tests/test_dev2006_kernel_operands.py +++ b/tests/test_dev2006_kernel_operands.py @@ -324,7 +324,7 @@ async def test_cross_model_last_needing_association(self, dev1841_engine): query = f1841.assoc_q( dimensions=["status"], time_dimensions=[f1841.TimeDimension( - dimension=f1841.ColumnRef(name="customers.signup_at"), + dimension="customers.signup_at", granularity=f1841.TimeGranularity.MONTH, )], measures=[f1841.ModelMeasure(formula="customers.spend:last", name="l")], diff --git a/tests/test_dev2013_expression_attribution.py b/tests/test_dev2013_expression_attribution.py index 34d21e79e..a01609df2 100644 --- a/tests/test_dev2013_expression_attribution.py +++ b/tests/test_dev2013_expression_attribution.py @@ -559,8 +559,8 @@ def _derived(name: str) -> ColumnSqlKey: class TestCombinator: @pytest.mark.parametrize("key,expected", [ (_lit("x"), True), - (TimeTruncKey(column=CITY, granularity=TimeGranularity.MONTH), True), - (TimeTruncKey(column=PRODUCT, granularity=TimeGranularity.MONTH), False), + (TimeTruncKey(source=CITY, granularity=TimeGranularity.MONTH), True), + (TimeTruncKey(source=PRODUCT, granularity=TimeGranularity.MONTH), False), (_eq(CITY, _lit("Alpha")), True), (_eq(PRODUCT, _lit("P")), False), (ArithmeticKey(op="+", operands=(CITY, PRODUCT)), False), @@ -616,8 +616,8 @@ def test_fail_closed_kind_member_determined_at_every_node(self, member): assert _determines(ArithmeticKey(op="+", operands=(member, _lit(1))), grain) is True def test_finer_bucket_does_not_determine_coarser(self): - day = TimeTruncKey(column=CITY, granularity=TimeGranularity.DAY) - month = TimeTruncKey(column=CITY, granularity=TimeGranularity.MONTH) + day = TimeTruncKey(source=CITY, granularity=TimeGranularity.DAY) + month = TimeTruncKey(source=CITY, granularity=TimeGranularity.MONTH) assert _determines(month, Grain.of([day])) is False @pytest.mark.parametrize("name,expected", [ @@ -639,7 +639,7 @@ def test_unanalysable_definition_inside_composite(self): (NAME, ["customers.regions.name"]), (ColumnSqlKey(path=("customers",), model="customers", column_name="rid10"), ["customers.rid10"]), - (TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH), + (TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH), ["ordered_at", "month"]), (StarKey(), ["*"]), (_lit("Alpha"), ["'Alpha'"]), diff --git a/tests/test_dev2015_time_dimensions.py b/tests/test_dev2015_time_dimensions.py index 8fbac86df..2fcd4fbe5 100644 --- a/tests/test_dev2015_time_dimensions.py +++ b/tests/test_dev2015_time_dimensions.py @@ -4,7 +4,7 @@ import pytest -from slayer.core.errors import QueryTypeError +from slayer.core.errors import QueryTypeError, TimeDimensionColumnError from slayer.core.query import SlayerQuery from tests._dev2015_fixtures import BACKENDS, BUILT_IN_GRANULARITIES, GRANULARITY_NAMES, cg_engine, m @@ -55,7 +55,7 @@ async def test_fails_at_binding_listing_granularities(self, engine, fields) -> N if "dimensions" in fields: assert "partition_by" in msg - @pytest.mark.parametrize("entry", ["fiscal_year()", "fiscal_year(order_date, amount)", "fiscal_year(upper(id))"]) + @pytest.mark.parametrize("entry", ["fiscal_year()", "fiscal_year(order_date, amount)"]) async def test_wrong_shape_datasource_callee(self, engine, entry) -> None: query = _q(dimensions=[entry]) with pytest.raises(QueryTypeError) as exc: @@ -64,6 +64,11 @@ async def test_wrong_shape_datasource_callee(self, engine, entry) -> None: assert "fiscal_year" in msg assert "(col" in msg or "gran(" in msg + async def test_non_temporal_argument_is_a_time_dimension_type_error(self, engine) -> None: + query = _q(dimensions=["fiscal_year(upper(id))"]) + with pytest.raises(TimeDimensionColumnError, match="TEXT"): + await engine.execute(query, dry_run=True) + def test_bare_column_string_still_rejected_at_construction(self) -> None: with pytest.raises(ValueError) as exc: SlayerQuery.model_validate({"source_model": "orders", "time_dimensions": ["order_date"]}) diff --git a/tests/test_dev2062_alias_reference_rule.py b/tests/test_dev2062_alias_reference_rule.py index 6d6af11b2..0422b77d9 100644 --- a/tests/test_dev2062_alias_reference_rule.py +++ b/tests/test_dev2062_alias_reference_rule.py @@ -99,7 +99,7 @@ def test_unslotted_composite_renders_structurally() -> None: _SLOTTED = { "column": ColumnKey(leaf="amount"), - "time_trunc": TimeTruncKey(column=ColumnKey(leaf="order_date"), granularity=TimeGranularity.MONTH), + "time_trunc": TimeTruncKey(source=ColumnKey(leaf="order_date"), granularity=TimeGranularity.MONTH), "aggregate": _SUM, "transform": TransformKey(op="cumsum", input=_RATIO), } diff --git a/tests/test_dev2073_join_edge_identity.py b/tests/test_dev2073_join_edge_identity.py index e14b2ca5e..0fbd6bc9b 100644 --- a/tests/test_dev2073_join_edge_identity.py +++ b/tests/test_dev2073_join_edge_identity.py @@ -6,7 +6,9 @@ import pytest -from slayer.core.join_edges import JoinEdgeRef, edge_reference, relationship_signature, resolve_join_ref +from slayer.core.enums import JoinCardinality, JoinType +from slayer.core.join_edges import JoinEdgeRef, edge_reference, relationship_signature, resolve_join_ref, same_edge +from slayer.core.models import ModelJoin from slayer.engine.query_engine import SlayerQueryEngine from tests._dev2073_fixtures import ( @@ -88,6 +90,24 @@ def test_differs_on_key_pairs(self) -> None: assert billing != shipping +class TestSameEdge: + def test_ignores_metadata_and_pair_order(self) -> None: + stored = ModelJoin(target_model="t", join_pairs=[["a", "x"], ["b", "y"]], description="stored", meta={"k": 1}) + repeat = ModelJoin(target_model="t", join_pairs=[["b", "y"], ["a", "x"]]) + assert same_edge(stored, repeat) + + @pytest.mark.parametrize("change", [ + {"name": "other"}, + {"join_type": JoinType.INNER}, + {"cardinality": JoinCardinality.MANY_TO_ONE}, + {"join_pairs": [["a", "y"]]}, + {"target_model": "u"}, + ]) + def test_any_semantic_field_differing_is_another_edge(self, change: dict) -> None: + stored = ModelJoin(target_model="t", join_pairs=[["a", "x"]]) + assert not same_edge(stored, stored.model_copy(update=change)) + + class TestEngineRemovalByExactReference: async def test_removes_exactly_the_referenced_join(self) -> None: async with model_store(addresses_model(), orders_with(*unnamed_pair())) as storage: diff --git a/tests/test_dev2074_frame_local_revisit.py b/tests/test_dev2074_frame_local_revisit.py index be21175e2..5838b480f 100644 --- a/tests/test_dev2074_frame_local_revisit.py +++ b/tests/test_dev2074_frame_local_revisit.py @@ -15,7 +15,7 @@ from slayer.core.format import NumberFormat, NumberFormatType from slayer.core.join_walker import follow, resolved_terminal, walk, walk_cancelling from slayer.core.models import Column, DatasourceConfig, ModelJoin, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.engine.schema_drift import _build_stage_graph, _stage_referenced_columns_for_base from slayer.storage.sqlite_conn import transaction @@ -412,7 +412,7 @@ async def test_time_dimension_is_owned_by_its_occurrence(self, ctx: _Ctx) -> Non f"SELECT CAST(lo.ordered_at AS VARCHAR), COUNT(*) {_ORDERS_THROUGH_LAST} " "GROUP BY lo.ordered_at ORDER BY lo.ordered_at DESC") assert [(str(t)[:7], n) for t, n in oracle] == [("2024-03", 1), ("2024-02", 2)] - td = TimeDimension(dimension=ColumnRef(name="customer.last_ordered_at"), + td = TimeDimension(dimension="customer.last_ordered_at", granularity=TimeGranularity.MONTH) resp = await ctx.engine.execute(_q( time_dimensions=[td], measures=[{"formula": "count(*)", "name": "n"}], diff --git a/tests/test_dev2101_stored_upgrade.py b/tests/test_dev2101_stored_upgrade.py index d8fffcefa..6b7722fd1 100644 --- a/tests/test_dev2101_stored_upgrade.py +++ b/tests/test_dev2101_stored_upgrade.py @@ -63,9 +63,9 @@ def _query(**fields: Any) -> SlayerQuery: class TestVersions: def test_versions_bumped(self) -> None: - assert CURRENT_VERSIONS["SlayerModel"] == 16 - assert CURRENT_VERSIONS["SlayerQuery"] == 7 - assert CURRENT_VERSIONS["Memory"] == 5 + assert CURRENT_VERSIONS["SlayerModel"] == 17 + assert CURRENT_VERSIONS["SlayerQuery"] == 8 + assert CURRENT_VERSIONS["Memory"] == 6 class TestTokenRewrite: diff --git a/tests/test_dev2119_expression_time_dimensions.py b/tests/test_dev2119_expression_time_dimensions.py new file mode 100644 index 000000000..7ec72d324 --- /dev/null +++ b/tests/test_dev2119_expression_time_dimensions.py @@ -0,0 +1,333 @@ +"""Expression time dimensions in every time position, executed on SQLite and DuckDB (spec: queries/time-dimensions).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import pytest + +from slayer.core.errors import ( + GranularityCallError, + QueryTypeError, + TimeDimensionColumnError, + TimeLiteralError, + UnsafeJoinInputError, +) +from slayer.core.query import SlayerQuery +from slayer.engine.query_engine import SlayerQueryEngine, SlayerResponse +from tests._dev2119_fixtures import ( + BACKENDS, + CREATED_MONTH_COUNT, + EFF, + EFF_MONTH_COUNT, + EFF_MONTH_SUM, + EFF_NAME, + bucket, + by_bucket, + engine_for, + m, + num, + q, + td, +) +from tests._time_points_fixtures import PinnedClock + +KEY = f"orders.{EFF_NAME}" +CAST_DAY = "CAST(created_at AS DATE)" +CAST_KEY = "orders.cast_created_at_as_date" +#: Months of ``EFF`` from February: plain sum, trailing-3-month sum, change vs the previous month. +FEB_APR_SUM = {"2025-02": 50.0, "2025-03": 100.0, "2025-04": 50.0} +FEB_APR_WINDOW = {"2025-02": 60.0, "2025-03": 160.0, "2025-04": 200.0} +FEB_APR_CHANGE = {"2025-02": 40.0, "2025-03": 50.0, "2025-04": -50.0} + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with engine_for(request.param) as e: + yield e + + +async def _plan_fails(engine: SlayerQueryEngine, query: Any, error: type[Exception]) -> Exception: + with pytest.raises(error) as ei: + await engine.execute(query, dry_run=True) + return ei.value + + +def _count(**kw: Any) -> SlayerQuery: + return q(measures=[m("count(*)")], **kw) + + +def _rows(resp: SlayerResponse, *, key: str) -> set[tuple]: + """Rows as ``(bucket, values...)`` with the bucket under ``key`` normalised.""" + return {(bucket(r[key]), *(num(r[c]) for c in resp.columns if c != key)) for r in resp.data} + + +class TestExpressionHomes: + async def test_coalesced_timestamp_groups_by_month(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td(EFF)])) + assert by_bucket(resp, KEY, "orders.v") == EFF_MONTH_COUNT + + async def test_cast_text_to_date(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td("CAST(created_txt AS DATE)")])) + assert by_bucket(resp, "orders.cast_created_txt_as_date", "orders.v") == CREATED_MONTH_COUNT + + async def test_cross_model_expression_over_a_to_one_hop(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td("coalesce(customers.signup_ts, created_at)")])) + got = by_bucket(resp, "orders.coalesce_customers_signup_ts_created_at", "orders.v") + assert got == {"2024-11": 3, "2025-02": 2, "2025-03": 1} + + async def test_stage_scope_expression(self, engine) -> None: + inner = SlayerQuery.model_validate({ + "name": "s1", "source_model": "orders", "dimensions": ["id", "shipped_at", "created_at"], + "measures": [{"formula": "sum(amount)", "name": "rev"}], + }) + outer = SlayerQuery.model_validate({ + "source_model": "s1", "time_dimensions": [td(EFF)], "measures": [{"formula": "sum(rev)", "name": "v"}], + }) + resp = await engine.execute([inner, outer]) + assert by_bucket(resp, f"s1.{EFF_NAME}", "s1.v") == EFF_MONTH_SUM + + +class TestExpressionTyping: + async def test_leaf_across_a_fanning_hop_fails_closed(self, engine) -> None: + query = SlayerQuery.model_validate({ + "source_model": "accounts", "measures": [{"formula": "count(*)", "name": "v"}], + "time_dimensions": [td("coalesce(orders.shipped_at, signup_ts)")], + }) + msg = str(await _plan_fails(engine, query, UnsafeJoinInputError)) + assert "shipped_at" in msg + assert "orders" in msg + + async def test_non_temporal_expression_rejected(self, engine) -> None: + msg = str(await _plan_fails(engine, _count(time_dimensions=[td("upper(status)")]), TimeDimensionColumnError)) + assert "upper_status" in msg or "upper(status)" in msg + assert "TEXT" in msg.upper() + + async def test_undetermined_type_names_the_cast_remedy(self, engine) -> None: + query = _count(time_dimensions=[td("CASE WHEN status = 'paid' THEN NULL END")]) + msg = str(await _plan_fails(engine, query, TimeDimensionColumnError)) + assert "CAST" in msg + + async def test_aggregate_inside_rejected(self, engine) -> None: + query = _count(time_dimensions=[td("max(created_at, partition_by=status)")]) + msg = str(await _plan_fails(engine, query, QueryTypeError)) + assert "row-level" in msg + + +class TestTimePositions: + async def test_date_range(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td(EFF, date_range=["2025-01-01", "2025-03-31"])])) + assert by_bucket(resp, KEY, "orders.v") == {"2025-01": 1, "2025-02": 2, "2025-03": 2} + + async def test_timestamp_expression_takes_instant_bounds(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td(EFF, date_range=["2025-01-07 09:30:00", "2025-02-28"])])) + assert by_bucket(resp, KEY, "orders.v") == {"2025-02": 2} + + async def test_date_expression_takes_date_bounds_whole_periods(self, engine) -> None: + resp = await engine.execute(_count( + time_dimensions=[td(CAST_DAY, date_range=["2025-01", "2025-02"])], whole_periods_only=True, + )) + assert by_bucket(resp, "orders.cast_created_at_as_date", "orders.v") == {"2025-01": 2, "2025-02": 2} + + async def test_date_expression_rejects_a_sub_day_point(self, engine) -> None: + query = _count(time_dimensions=[td(CAST_DAY, date_range=["2025-01-05 12:00:00", "2025-02-28"])]) + await _plan_fails(engine, query, TimeLiteralError) + + @pytest.mark.parametrize("dimension, key, snap, expected", [ + (CAST_DAY, CAST_KEY, False, {"2025-01": 1, "2025-02": 2}), + (CAST_DAY, CAST_KEY, True, {"2025-01": 2, "2025-02": 2}), + (EFF, KEY, False, {"2025-02": 2, "2025-03": 1}), + (EFF, KEY, True, {"2025-01": 1, "2025-02": 2}), + ], ids=["date", "date-snapped", "timestamp", "timestamp-snapped"]) + async def test_whole_periods_snap_explicit_bounds(self, engine, dimension: str, key: str, snap: bool, + expected: dict) -> None: + resp = await engine.execute(_count( + time_dimensions=[td(dimension, date_range=["2025-01-15", "2025-03-10"])], whole_periods_only=snap, + )) + assert by_bucket(resp, key, "orders.v") == expected + + @pytest.mark.parametrize("dimension, key, expected", [ + (CAST_DAY, CAST_KEY, {"2025-01": 2, "2025-02": 2}), + (EFF, KEY, {"2025-01": 1, "2025-02": 2}), + ], ids=["date", "timestamp"]) + async def test_whole_periods_snap_mirrored_filter_bounds(self, engine, dimension: str, key: str, + expected: dict) -> None: + resp = await engine.execute(_count( + time_dimensions=[td(dimension)], whole_periods_only=True, + filters=[f"'2025-01-15' <= {dimension}", f"{dimension} <= '2025-03-10'"], + )) + assert by_bucket(resp, key, "orders.v") == expected + + @pytest.mark.parametrize("dimension, key, expected", [ + (CAST_DAY, CAST_KEY, {"2024-12": 1, "2025-01": 2, "2025-02": 2}), + (EFF, KEY, {"2025-01": 1, "2025-02": 2}), + ], ids=["date", "timestamp"]) + @pytest.mark.parametrize("backend", BACKENDS) + async def test_whole_periods_synthesized_upper_bound(self, backend: str, dimension: str, key: str, + expected: dict) -> None: + async with engine_for(backend, clock=PinnedClock(datetime(2025, 3, 10, 12, 0, 0))) as e: + resp = await e.execute(_count(time_dimensions=[td(dimension)], whole_periods_only=True)) + assert by_bucket(resp, key, "orders.v") == expected + + async def test_rolling_window_and_change_over_an_expression_axis(self, engine) -> None: + resp = await engine.execute(q( + time_dimensions=[td(EFF, date_range=["2025-02-01", "2025-04-30"])], + measures=[m("sum(amount)", "s"), m("sum(amount, window='3m')", "w"), m("change(sum(amount))", "c")], + )) + assert by_bucket(resp, KEY, "orders.s") == FEB_APR_SUM + assert by_bucket(resp, KEY, "orders.w") == FEB_APR_WINDOW + assert by_bucket(resp, KEY, "orders.c") == FEB_APR_CHANGE + + @pytest.mark.parametrize("bound", [f"{EFF} >= '2025-02'", f"month({EFF}) >= '2025-02-01'"]) + async def test_filter_on_the_expression_is_a_frame_bound(self, engine, bound: str) -> None: + resp = await engine.execute(q(time_dimensions=[td(EFF)], filters=[bound], + measures=[m("sum(amount, window='3m')", "w")])) + assert by_bucket(resp, KEY, "orders.w") == FEB_APR_WINDOW + + @pytest.mark.parametrize("measures", [ + [m("count(*)", "n"), m("sum(amount)", "s")], + [m("sum(amount, window='3m')", "w"), m("change(sum(amount))", "c"), m("cumsum(count(*))", "k")], + [m("last(amount)", "l"), m("first(amount)", "f")], + ], ids=["plain", "time-ordered", "ranked"]) + async def test_equal_to_a_column_holding_the_per_row_values(self, engine, measures: list) -> None: + by_expr = await engine.execute(q(time_dimensions=[td(EFF)], measures=measures)) + by_col = await engine.execute(q(time_dimensions=[td("eff")], measures=measures)) + assert _rows(by_expr, key=KEY) == _rows(by_col, key="orders.eff") + + +class TestStageBuckets: + def _inner(self) -> SlayerQuery: + return SlayerQuery.model_validate({ + "name": "s1", "source_model": "orders", "time_dimensions": [td(EFF)], + "measures": [{"formula": "sum(amount)", "name": "rev"}], + }) + + def _outer(self, granularity: str) -> SlayerQuery: + return SlayerQuery.model_validate({ + "source_model": "s1", "time_dimensions": [td(EFF_NAME, granularity)], + "measures": [{"formula": "sum(rev)", "name": "v"}], + }) + + async def test_coarser_rebucket_sums_the_monthly_sums(self, engine) -> None: + resp = await engine.execute([self._inner(), self._outer("year")]) + assert by_bucket(resp, f"s1.{EFF_NAME}", "s1.v", width=4) == {"2025": 210.0} + + async def test_finer_rebucket_rejected(self, engine) -> None: + msg = str(await _plan_fails(engine, [self._inner(), self._outer("day")], TimeDimensionColumnError)) + assert "month" in msg + assert "day" in msg + + +class TestPerLeafRebucketing: + def _monthly(self, dimension: str, granularity: str) -> SlayerQuery: + return SlayerQuery.model_validate({ + "source_model": "monthly", "time_dimensions": [td(dimension, granularity)], + "measures": [{"formula": "sum(rev)", "name": "v"}], + }) + + async def test_cast_of_a_month_bucketed_column_at_day_rejected(self, engine) -> None: + msg = str(await _plan_fails(engine, self._monthly(CAST_DAY, "day"), TimeDimensionColumnError)) + assert "created_at" in msg + assert "month" in msg + assert "day" in msg + + @pytest.mark.parametrize("granularity, width, expected", [ + ("month", 7, {"2024-12": 60.0, "2025-01": 30.0, "2025-02": 70.0, "2025-03": 50.0}), + ("year", 4, {"2024": 60.0, "2025": 150.0}), + ]) + async def test_same_and_coarser_granularities_bind(self, engine, granularity: str, width: int, + expected: dict) -> None: + resp = await engine.execute(self._monthly(CAST_DAY, granularity)) + assert by_bucket(resp, "monthly.cast_created_at_as_date", "monthly.v", width=width) == expected + + async def test_bucketed_leaf_beside_an_unbucketed_one_binds_the_rule(self, engine) -> None: + query = self._monthly("coalesce(created_at, refunded_at)", "day") + msg = str(await _plan_fails(engine, query, TimeDimensionColumnError)) + assert "created_at" in msg + assert "month" in msg + + +class TestNamesAndKeys: + async def test_unnamed_expression_key(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td(EFF)])) + assert resp.columns == [KEY, "orders.v"] + + async def test_named_expression_key_and_main_time_dimension(self, engine) -> None: + resp = await engine.execute(q( + time_dimensions=[td("created_at"), td(EFF, name="effective_at")], main_time_dimension="effective_at", + measures=[m("cumsum(count(*))")], + )) + assert set(resp.columns) == {"orders.created_at", "orders.effective_at", "orders.v"} + got = {(bucket(r["orders.created_at"]), bucket(r["orders.effective_at"])): num(r["orders.v"]) + for r in resp.data} + assert got == { + ("2024-12", "2025-03"): 1, ("2025-01", "2025-01"): 1, ("2025-01", "2025-02"): 2, + ("2025-02", "2025-02"): 1, ("2025-02", "2025-03"): 2, ("2025-03", "2025-04"): 1, + } + + async def test_named_column_time_dimension(self, engine) -> None: + named = await engine.execute(_count(time_dimensions=[td("created_at", name="m")])) + assert named.columns == ["orders.m", "orders.v"] + assert by_bucket(named, "orders.m", "orders.v") == CREATED_MONTH_COUNT + + async def test_named_time_dimension_never_carries_the_granularity_suffix(self, engine) -> None: + resp = await engine.execute(_count(time_dimensions=[td(EFF, name="em"), td(EFF, "year", name="ey")])) + assert set(resp.columns) == {"orders.em", "orders.ey", "orders.v"} + + async def test_partition_by_a_time_dimension_name(self, engine) -> None: + resp = await engine.execute(q( + dimensions=["region"], time_dimensions=[td(EFF, name="effective_at")], + measures=[m("sum(amount, partition_by=effective_at)")], + )) + got = by_bucket(resp, "orders.effective_at", "orders.v", by=["orders.region"]) + assert got == {("EU", "2025-01"): 10.0, ("EU", "2025-02"): 50.0, ("EU", "2025-04"): 50.0, + ("US", "2025-02"): 50.0, ("US", "2025-03"): 100.0} + + async def test_two_granularities_of_one_expression(self, engine) -> None: + resp = await engine.execute(_count(dimensions=[f"month({EFF})", f"year({EFF})"])) + assert set(resp.columns) == {f"{KEY}.month", f"{KEY}.year", "orders.v"} + got = {(bucket(r[f"{KEY}.month"]), bucket(r[f"{KEY}.year"], width=4)): num(r["orders.v"]) for r in resp.data} + assert got == {(k, "2025"): v for k, v in EFF_MONTH_COUNT.items()} + + async def test_equivalent_spellings_with_differing_label_rejected_at_binding(self, engine) -> None: + query = _count(time_dimensions=[td(EFF, label="a"), td("COALESCE(shipped_at,created_at)", label="b")]) + msg = str(await _plan_fails(engine, query, GranularityCallError)) + assert "Conflicting" in msg + + @pytest.mark.parametrize("extra", [{}, {"label": "a"}, {"name": "effective_at"}]) + async def test_equivalent_spellings_with_agreeing_metadata_dedupe_at_binding(self, engine, extra: dict) -> None: + once = await engine.execute(_count(time_dimensions=[td(EFF, **extra)])) + twice = await engine.execute(_count(time_dimensions=[td(EFF, **extra), + td("COALESCE(shipped_at,created_at)", **extra)])) + assert twice.columns == once.columns + assert sorted(map(str, twice.data)) == sorted(map(str, once.data)) + + async def test_main_time_dimension_by_expression_name(self, engine) -> None: + resp = await engine.execute(q( + dimensions=["month(created_at)"], time_dimensions=[td(EFF, name="effective_at")], + main_time_dimension="effective_at", measures=[m("cumsum(count(*))")], + )) + got = {(bucket(r["orders.created_at"]), bucket(r["orders.effective_at"])): num(r["orders.v"]) + for r in resp.data} + assert got[("2025-01", "2025-02")] == 2 + assert got[("2025-02", "2025-03")] == 2 + + +class TestOrderKey: + async def test_order_by_a_projected_expression_bucket(self, engine) -> None: + resp = await engine.execute(_count( + dimensions=[f"month({EFF})"], order=[{"column": f"month({EFF})", "direction": "desc"}], + )) + assert [bucket(r[KEY]) for r in resp.data] == ["2025-04", "2025-03", "2025-02", "2025-01"] + + async def test_order_by_an_unprojected_granularity_names_the_remedy(self, engine) -> None: + query = _count(dimensions=[f"year({EFF})"], order=[{"column": f"month({EFF})"}]) + msg = str(await _plan_fails(engine, query, GranularityCallError)) + assert "month" in msg + + async def test_functional_dimension_equals_explicit_form(self, engine) -> None: + functional = await engine.execute(_count(dimensions=[f"month({EFF})"])) + explicit = await engine.execute(_count(time_dimensions=[td(EFF)])) + assert functional.sql == explicit.sql + assert sorted(functional.data, key=repr) == sorted(explicit.data, key=repr) diff --git a/tests/test_dev2119_granularity_calls.py b/tests/test_dev2119_granularity_calls.py new file mode 100644 index 000000000..9d12f9570 --- /dev/null +++ b/tests/test_dev2119_granularity_calls.py @@ -0,0 +1,63 @@ +"""A granularity call over any row-level argument, executed on SQLite and DuckDB (spec: queries/time-points).""" + +from __future__ import annotations + +import pytest + +from slayer.core.errors import DateOperandTypeError, TimeDimensionColumnError +from slayer.engine.query_engine import SlayerResponse +from tests._dev2119_fixtures import BACKENDS, EFF, engine_for, m, num, q + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with engine_for(request.param) as e: + yield e + + +def _ids(resp: SlayerResponse) -> set[int]: + return {int(r["orders.id"]) for r in resp.data} + + +def _by_id(resp: SlayerResponse, name: str) -> dict[int, float | None]: + return {int(r["orders.id"]): num(r[f"orders.{name}"]) for r in resp.data} + + +class TestExpressionArgument: + async def test_in_a_filter(self, engine) -> None: + resp = await engine.execute(q(dimensions=["id"], filters=[f"month({EFF}) >= '2025-02-15'"])) + assert _ids(resp) == {4, 5, 6} + + async def test_compared_with_another_granularity_call(self, engine) -> None: + resp = await engine.execute(q(dimensions=["id"], filters=[f"month({EFF}) = month(created_at)"])) + assert _ids(resp) == {1, 3} + + async def test_in_a_computed_dimension_over_a_cast(self, engine) -> None: + resp = await engine.execute(q( + dimensions=[{"expression": "year(CAST(created_txt AS DATE))", "name": "y"}], measures=[m("count(*)")], + )) + assert {str(r["orders.y"])[:4]: num(r["orders.v"]) for r in resp.data} == {"2024": 1, "2025": 5} + + async def test_in_an_aggregation_source(self, engine) -> None: + resp = await engine.execute(q(measures=[m(f"count_distinct(month({EFF}))")])) + assert [num(r["orders.v"]) for r in resp.data] == [4] + + async def test_in_arithmetic(self, engine) -> None: + resp = await engine.execute(q(dimensions=[ + "id", {"expression": f"date_diff('day', month({EFF}), {EFF})", "name": "d"}, + ])) + assert _by_id(resp, "d") == {1: 6, 2: 1, 3: 9, 4: 2, 5: 0, 6: 19} + + +class TestNonTemporalArgument: + async def test_in_a_filter_names_the_operand(self, engine) -> None: + query = q(dimensions=["id"], filters=["month(upper(status)) >= '2025-01-01'"]) + with pytest.raises(DateOperandTypeError) as ei: + await engine.execute(query, dry_run=True) + assert "upper(status)" in str(ei.value) + + async def test_as_a_dimension_is_a_time_dimension_type_error(self, engine) -> None: + query = q(dimensions=["month(upper(status))"], measures=[m("count(*)")]) + with pytest.raises(TimeDimensionColumnError) as ei: + await engine.execute(query, dry_run=True) + assert "TEXT" in str(ei.value).upper() diff --git a/tests/test_dev2119_partition_keys.py b/tests/test_dev2119_partition_keys.py new file mode 100644 index 000000000..2e380b296 --- /dev/null +++ b/tests/test_dev2119_partition_keys.py @@ -0,0 +1,114 @@ +"""Row-level expressions as ``partition_by=`` keys (spec: queries/partitioned-aggregates).""" + +from __future__ import annotations + +import pytest + +from slayer.core.errors import PartitionKeyError +from slayer.engine.query_engine import SlayerQueryEngine +from tests._dev2119_fixtures import BACKENDS, EFF, EFF_NAME, by_bucket, by_dims, column, engine_for, m, q, td + +UPPER_CITY = {("EU", "BERLIN"): 70.0, ("EU", "PARIS"): 50.0, ("US", "NYC"): 90.0, ("US", "PARIS"): 50.0} +#: ``sum(amount, partition_by=BAND)`` per (region, band): hi = NYC 90 + berlin 50, lo = the rest. +BAND = "CASE WHEN sum(amount, partition_by=city) > 45 THEN 'hi' ELSE 'lo' END" +BAND_TOTALS = {("EU", "hi"): 140.0, ("EU", "lo"): 70.0, ("US", "hi"): 140.0, ("US", "lo"): 70.0} + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with engine_for(request.param) as e: + yield e + + +def _dims(resp, *names: str) -> dict[tuple, float | None]: + return by_dims(resp, [f"orders.{n}" for n in names]) + + +class TestComputedDimensionPosition: + async def test_expression_key_equals_named_computed_dimension(self, engine: SlayerQueryEngine) -> None: + expr = await engine.execute(q(dimensions=["region", "upper(city)"], + measures=[m("sum(amount, partition_by=upper(city))")])) + named = await engine.execute(q(dimensions=["region", {"expression": "upper(city)", "name": "ucity"}], + measures=[m("sum(amount, partition_by=ucity)")])) + assert _dims(expr, "region", "upper_city") == UPPER_CITY, expr.sql + assert _dims(named, "region", "ucity") == UPPER_CITY + + async def test_expression_key_not_a_query_dimension_fails(self, engine: SlayerQueryEngine) -> None: + query = q(dimensions=["region"], measures=[m("sum(amount, partition_by=upper(city))")]) + with pytest.raises(PartitionKeyError) as ei: + await engine.execute(query, dry_run=True) + msg = str(ei.value) + assert "upper(city)" in msg + assert "computed dimension" in msg.lower() + assert "ModelExtension" not in msg + + async def test_finer_grain_exemption(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(dimensions=["region"], + measures=[m("avg(sum(amount, partition_by=[region, upper(city)]))")])) + assert _dims(resp, "region") == {("EU",): 40.0, ("US",): 65.0}, resp.sql + + +class TestRankFamily: + async def test_rank_partitioned_by_expression(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q( + dimensions=["region", "upper(city)"], + measures=[m("rank(sum(amount), partition_by=upper(city), direction='desc')")], + )) + assert _dims(resp, "region", "upper_city") == { + ("EU", "BERLIN"): 1.0, ("EU", "PARIS"): 2.0, ("US", "NYC"): 1.0, ("US", "PARIS"): 1.0, + }, resp.sql + + +class TestTimeDimensionSource: + async def test_key_equal_to_expression_source_is_its_bucket(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(dimensions=["region"], time_dimensions=[td(EFF)], + measures=[m(f"sum(amount, partition_by={EFF})")])) + assert by_bucket(resp, column(resp, EFF_NAME), "orders.v", by=["orders.region"]) == { + ("EU", "2025-01"): 10.0, ("EU", "2025-02"): 50.0, ("EU", "2025-04"): 50.0, + ("US", "2025-02"): 50.0, ("US", "2025-03"): 100.0, + }, resp.sql + + async def test_source_at_several_granularities_is_ambiguous(self, engine: SlayerQueryEngine) -> None: + query = q(dimensions=["region"], time_dimensions=[td(EFF), td(EFF, "year")], + measures=[m(f"sum(amount, partition_by={EFF})")]) + with pytest.raises(PartitionKeyError, match="ambiguous"): + await engine.execute(query, dry_run=True) + + +class TestAttributability: + @pytest.mark.parametrize("mode", ["broadcast", "error"]) + async def test_joined_leaf_over_unproven_hop(self, engine: SlayerQueryEngine, mode: str) -> None: + query = q(dimensions=["region"], to_many_handling=mode, + measures=[m("sum(amount, partition_by=upper(regions.manager))")]) + with pytest.raises(PartitionKeyError) as ei: + await engine.execute(query, dry_run=True) + msg = str(ei.value) + assert "regions.manager" in msg + assert "unproven" in msg + assert "attributable" in msg + assert "Declare join cardinality" in msg + + +class TestAggregateBearingKey: + async def test_inline_key_rejected_with_computed_dimension_remedy(self, engine: SlayerQueryEngine) -> None: + query = q(dimensions=["region"], measures=[m(f"sum(amount, partition_by={BAND})")]) + with pytest.raises(PartitionKeyError) as ei: + await engine.execute(query, dry_run=True) + msg = str(ei.value) + assert "sum(amount" in msg + assert "computed dimension" in msg.lower() + assert "ModelExtension" not in msg + + async def test_key_equal_to_computed_dimension_executes_as_it(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(dimensions=["region", {"expression": BAND, "name": "band"}], + measures=[m(f"sum(amount, partition_by={BAND})")])) + assert _dims(resp, "region", "band") == BAND_TOTALS, resp.sql + + +class TestMixedSourceFirst: + async def test_first_over_mixed_source_picks_at_ranked_row(self, engine: SlayerQueryEngine) -> None: + # avg(price) per product: A 7/3, B 14/3; earliest by created_at: EU row 1 (qty 1, A), US row 6 (qty 1, B). + resp = await engine.execute(q(dimensions=["region"], + measures=[m("first(quantity * avg(price, partition_by=product))")])) + got = _dims(resp, "region") + assert got == {("EU",): pytest.approx(7 / 3), ("US",): pytest.approx(14 / 3)}, resp.sql diff --git a/tests/test_dev2119_ranked_expressions.py b/tests/test_dev2119_ranked_expressions.py new file mode 100644 index 000000000..e0065a854 --- /dev/null +++ b/tests/test_dev2119_ranked_expressions.py @@ -0,0 +1,198 @@ +"""Expression ranking keys, values and column-valued parameters (spec: aggregations/functional-form, aggregations/expression-aggregation).""" + +from __future__ import annotations + +import pytest + +from slayer.core.enums import DataType +from slayer.core.errors import ParameterGrainError, UnsafeJoinInputError +from slayer.core.models import Aggregation, AggregationParam, Column, ModelJoin, SlayerModel +from slayer.core.query import SlayerQuery +from slayer.engine.query_engine import SlayerQueryEngine, SlayerResponse +from tests._dev2119_fixtures import BACKENDS, EFF, by_bucket, by_dims, dev2119_models, engine_for, m, num, q, td + +# Avg price per product: A (rows 1, 3, 5) 7/3, B (rows 2, 4, 6) 14/3. +AVG_A = 7 / 3 +AVG_B = 14 / 3 + + +def _models() -> list[SlayerModel]: + """The dev2119 models plus a ``wsum`` custom aggregation and an ``order_facts`` host reaching ``accounts``.""" + models = dev2119_models() + orders = next(x for x in models if x.name == "orders") + orders.aggregations.append(Aggregation( + name="wsum", formula="SUM({value} * {weight})", params=[AggregationParam(name="weight", sql="quantity")], + )) + models.append(SlayerModel( + name="order_facts", sql_table="orders", data_source="test", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="customer_id", type=DataType.INT), + Column(name="amount", type=DataType.DOUBLE), + ], + joins=[ModelJoin(target_model="accounts", join_pairs=[["customer_id", "id"]])], + )) + return models + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with engine_for(request.param, models=_models()) as e: + yield e + + +@pytest.fixture +async def sqlite_engine(): + async with engine_for("sqlite", models=_models()) as e: + yield e + + +def _by_region(resp: SlayerResponse, value: str = "orders.v") -> dict: + return {k[0]: v for k, v in by_dims(resp, ["orders.region"], value).items()} + + +def _single(resp: SlayerResponse) -> float | None: + assert len(resp.data) == 1, resp.data + return num(next(iter(resp.data[0].values()))) + + +async def _sql(engine: SlayerQueryEngine, query: SlayerQuery) -> str: + sql = (await engine.execute(query, dry_run=True)).sql + assert sql is not None + return sql + + +# --------------------------------------------------------------------------- # +# Default ranking key +# --------------------------------------------------------------------------- # +class TestDefaultRankingKey: + async def test_temporal_computed_dimension_ranks_each_cell(self, engine: SlayerQueryEngine) -> None: + """Spec scenario: each (EFF, month) cell holds one order, so it carries that order's amount.""" + resp = await engine.execute(q( + dimensions=["id", {"expression": EFF, "name": "e"}], time_dimensions=[td("created_at")], + measures=[m("last(amount)")], + )) + assert by_dims(resp, ["orders.id"]) == {(i,): float(10 * i) for i in range(1, 7)} + + async def test_expression_time_dimension_source(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(time_dimensions=[td(EFF)], measures=[m("last(amount)")])) + key = "orders.coalesce_shipped_at_created_at" + assert by_bucket(resp, key, "orders.v") == {"2025-01": 10.0, "2025-02": 30.0, "2025-03": 60.0, "2025-04": 50.0} + + +# --------------------------------------------------------------------------- # +# Expression ranking keys +# --------------------------------------------------------------------------- # +class TestExpressionRankingKey: + async def test_coalesced_ranking_key(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(SlayerQuery.model_validate({ + "source_model": "balances", "dimensions": ["customer_id"], + "measures": [{"formula": "last(balance, coalesce(updated_at, created_at))", "name": "v"}], + })) + assert by_dims(resp, ["balances.customer_id"], "balances.v") == {(1,): 150.0, (2,): 70.0} + + async def test_cross_model_ranked_aggregate(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q( + dimensions=["customers.region"], + measures=[m("last(customers.spend, coalesce(customers.updated_at, customers.signup_ts), " + "partition_by=customers.region)")], + )) + assert by_dims(resp, ["orders.customers.region"]) == {("North",): 300.0, ("South",): 200.0} + + async def test_fanning_leaf_local_producer(self, sqlite_engine: SlayerQueryEngine) -> None: + query = SlayerQuery.model_validate({ + "source_model": "accounts", + "measures": [{"formula": "last(spend, coalesce(orders.created_at, signup_ts))", "name": "v"}], + }) + with pytest.raises(UnsafeJoinInputError) as ei: + await sqlite_engine.execute(query) + assert "created_at" in str(ei.value) + assert "orders" in str(ei.value) + + async def test_fanning_leaf_target_rooted_producer(self, sqlite_engine: SlayerQueryEngine) -> None: + query = SlayerQuery.model_validate({ + "source_model": "order_facts", + "measures": [{"formula": "last(accounts.spend, coalesce(accounts.orders.created_at, accounts.signup_ts))", + "name": "v"}], + }) + with pytest.raises(UnsafeJoinInputError) as ei: + await sqlite_engine.execute(query) + assert "created_at" in str(ei.value) + assert "orders" in str(ei.value) + + async def test_distinct_ranking_keys_stay_separate_producers(self, engine: SlayerQueryEngine) -> None: + query = q(dimensions=["region"], measures=[m(f"last(amount, {EFF})"), m("last(amount, created_at)", "c")]) + assert (await _sql(engine, query)).upper().count("ROW_NUMBER") == 2 + resp = await engine.execute(query) + assert _by_region(resp) == {"EU": 50.0, "US": 60.0} + assert _by_region(resp, "orders.c") == {"EU": 50.0, "US": 40.0} + + async def test_equal_ranking_keys_share_one_producer(self, engine: SlayerQueryEngine) -> None: + query = q(dimensions=["region"], measures=[ + m(f"last(amount, {EFF})"), m("last(amount, COALESCE(orders.shipped_at,created_at))", "c"), + ]) + assert (await _sql(engine, query)).upper().count("ROW_NUMBER") == 1 + resp = await engine.execute(query) + assert _by_region(resp) == _by_region(resp, "orders.c") == {"EU": 50.0, "US": 60.0} + + @pytest.mark.parametrize("formula", [ + "last(amount, coalesce(created_at, max(created_at, partition_by=region)))", + "last(amount, 1 + 1)", + ]) + async def test_non_row_level_ranking_key_rejected(self, sqlite_engine: SlayerQueryEngine, formula: str) -> None: + query = q(measures=[m(formula)]) + with pytest.raises(ValueError, match="ranks by a column"): + await sqlite_engine.execute(query) + + +# --------------------------------------------------------------------------- # +# first / last over expression values +# --------------------------------------------------------------------------- # +class TestRankedExpressionValue: + async def test_first_over_cross_model_expression(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(dimensions=["region"], measures=[m("first(amount - customers.discount)")])) + got = _by_region(resp) + assert got == {"EU": pytest.approx(9.9), "US": pytest.approx(59.9)} + + async def test_first_of_a_literal(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(dimensions=["region"], measures=[m("first(1)")])) + assert _by_region(resp) == {"EU": 1.0, "US": 1.0} + + async def test_first_over_mixed_source(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q( + dimensions=["region"], measures=[m("first(quantity * avg(price, partition_by=product))")], + )) + assert _by_region(resp) == {"EU": pytest.approx(AVG_A), "US": pytest.approx(AVG_B)} + + +# --------------------------------------------------------------------------- # +# Column-valued parameters +# --------------------------------------------------------------------------- # +class TestExpressionParameters: + async def test_expression_weight_named_and_positional(self, engine: SlayerQueryEngine) -> None: + baseline = _single(await engine.execute(q(measures=[m("weighted_avg(price, weight=quantity)")]))) + assert baseline == pytest.approx(40 / 13) + named = await engine.execute(q(measures=[{"formula": "weighted_avg(price, weight=quantity * 2)"}])) + positional = await engine.execute(q(measures=[{"formula": "weighted_avg(price, quantity * 2)"}])) + assert _single(named) == pytest.approx(baseline) + assert _single(positional) == pytest.approx(baseline) + assert named.columns == positional.columns + + async def test_expression_second_input_of_corr(self, engine: SlayerQueryEngine) -> None: + plain = _single(await engine.execute(q(measures=[m("corr(amount, quantity)")]))) + shifted = _single(await engine.execute(q(measures=[m("corr(amount, quantity + 1)")]))) + assert shifted == pytest.approx(plain) + + async def test_cross_model_expression_weight(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(q(measures=[m("weighted_avg(amount, weight=coalesce(customers.discount, 0))")])) + assert _single(resp) == pytest.approx(34 / 0.8) + + @pytest.mark.parametrize("formula", ["wsum(amount, weight=quantity + 1)", "wsum(amount, quantity + 1)"]) + async def test_custom_aggregation_expression_parameter(self, engine: SlayerQueryEngine, formula: str) -> None: + assert _single(await engine.execute(q(measures=[m(formula)]))) == 690.0 + + async def test_undetermined_expression_parameter(self, sqlite_engine: SlayerQueryEngine) -> None: + query = q(dimensions=["region"], + measures=[m("weighted_avg(sum(amount, partition_by=[city, region]), weight=id * 2)")]) + with pytest.raises(ParameterGrainError, match="weight"): + await sqlite_engine.execute(query) diff --git a/tests/test_dev2119_ranked_kernel.py b/tests/test_dev2119_ranked_kernel.py new file mode 100644 index 000000000..959639539 --- /dev/null +++ b/tests/test_dev2119_ranked_kernel.py @@ -0,0 +1,69 @@ +"""Ranked first/last kernel over expression values and ranking keys: executed values and Tier-1 emission.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from slayer.core.query import SlayerQuery +from slayer.engine.query_engine import SlayerQueryEngine +from tests._dev2119_fixtures import BACKENDS, EFF, TIER1, by_bucket, by_dims, emit, engine_for, m, q, td + +MIXED = "quantity * avg(price, partition_by=product)" + +#: (formula, per-region value) over ``dimensions=["region"]``. +REGION_SHAPES: dict[str, tuple[str, dict[str, Any]]] = { + "expression_value": ("last(amount * quantity)", {"EU": 200.0, "US": 80.0}), + "expression_key_last": (f"last(amount, {EFF})", {"EU": 50.0, "US": 60.0}), + "expression_key_first": (f"first(amount, {EFF})", {"EU": 10.0, "US": 30.0}), + "value_and_key": (f"last(amount * quantity, {EFF})", {"EU": 200.0, "US": 60.0}), + "joined_leaves": ("last(amount - coalesce(customers.discount, 0), coalesce(customers.signup_ts, created_at))", + {"EU": 49.5, "US": 40.0}), + "attached_constituent": (f"last({MIXED}, {EFF})", {"EU": pytest.approx(28 / 3), "US": pytest.approx(14 / 3)}), +} +#: (formula, per created_at month value) over a month time dimension on ``created_at``. +WINDOWED_SHAPES: dict[str, tuple[str, dict[str, float]]] = { + "windowed_last": (f"last(amount, {EFF}, window='2m')", + {"2024-12": 60.0, "2025-01": 60.0, "2025-02": 40.0, "2025-03": 50.0}), + "windowed_first": (f"first(amount, {EFF}, window='2m')", + {"2024-12": 60.0, "2025-01": 10.0, "2025-02": 10.0, "2025-03": 30.0}), +} + + +def _region_query(formula: str) -> SlayerQuery: + return q(dimensions=["region"], measures=[m(formula)]) + + +def _windowed_query(formula: str) -> SlayerQuery: + return q(time_dimensions=[td("created_at")], measures=[m(formula)]) + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with engine_for(request.param) as e: + yield e + + +class TestExecutedValues: + @pytest.mark.parametrize("shape", list(REGION_SHAPES)) + async def test_region_shapes(self, engine: SlayerQueryEngine, shape: str) -> None: + formula, expected = REGION_SHAPES[shape] + resp = await engine.execute(_region_query(formula)) + assert {k[0]: v for k, v in by_dims(resp, ["orders.region"]).items()} == expected + + @pytest.mark.parametrize("shape", list(WINDOWED_SHAPES)) + async def test_windowed_shapes(self, engine: SlayerQueryEngine, shape: str) -> None: + formula, expected = WINDOWED_SHAPES[shape] + resp = await engine.execute(_windowed_query(formula)) + assert by_bucket(resp, "orders.created_at", "orders.v") == expected + + +class TestTier1Emission: + @pytest.mark.parametrize("dialect", TIER1) + @pytest.mark.parametrize("shape", [*REGION_SHAPES, *WINDOWED_SHAPES]) + async def test_emits_on_every_dialect(self, shape: str, dialect: str) -> None: + query = (_region_query(REGION_SHAPES[shape][0]) if shape in REGION_SHAPES + else _windowed_query(WINDOWED_SHAPES[shape][0])) + sql = await emit(query, dialect=dialect) + assert "ROW_NUMBER" in sql.upper(), sql diff --git a/tests/test_dev2119_stored_upgrade.py b/tests/test_dev2119_stored_upgrade.py new file mode 100644 index 000000000..6bb09af14 --- /dev/null +++ b/tests/test_dev2119_stored_upgrade.py @@ -0,0 +1,201 @@ +"""Stored object-form time dimensions are upgraded to expression text (spec: models/stored-upgrade).""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +import yaml + +from slayer.core.models import DatasourceConfig, SlayerModel +from slayer.core.query import SlayerQuery +from slayer.memories.models import Memory +from slayer.sql import engine_factory +from slayer.storage import migrations as mig +from slayer.storage.sqlite_conn import transaction +from tests._stored_upgrade_fixtures import ( + BACKENDS, + DS, + customers_v10, + memory_v2, + orders_v10, + query_backed_v10, + raw_doc, + raw_store, + rev_query, + run, + seed_shop, + stored_bytes, +) +from tests.test_upgrade_corpus_v0_10_2 import DATASOURCES, EXPECTED, MEMORY_PREFIX, _open_store, _run + +SIGNUP_OBJECT = {"name": "signed_up_at", "model": "customers"} +SIGNUP_TEXT = "customers.signed_up_at" +#: sum(amount) by the month of the customer's signup. +REV_BY_SIGNUP_MONTH = {"2023-06": 30.0, "2024-03": 90.0, "2024-08": 90.0} +#: sum(amount) by the year of ordered_at. +REV_BY_ORDER_YEAR = {"2023": 10.0, "2024": 150.0, "2025": 50.0} + + +def _td(dimension: Any, *, key: str = "dimension", granularity: str = "month") -> dict: + return {key: dimension, "granularity": granularity} + + +def _query(*, version: int | None, tds: list[dict]) -> dict: + data: dict = rev_query(time_dimensions=tds) + if version is not None: + data["version"] = version + return data + + +def _dumped_tds(query: SlayerQuery) -> list: + return query.model_dump(mode="json", exclude_none=True)["time_dimensions"] + + +def _cells(resp, *, dim_suffix: str, width: int) -> dict[str, float]: + row = resp.data[0] + dim = next(k for k in row if k.endswith(dim_suffix)) + val = next(k for k in row if k != dim) + return {str(r[dim]).replace("T", " ")[:width]: float(r[val]) for r in resp.data} + + +class TestObjectFormBecomesText: + @pytest.mark.parametrize(("stored", "text"), [ + pytest.param(_td(SIGNUP_OBJECT), SIGNUP_TEXT, id="dimension-object"), + pytest.param(_td(SIGNUP_OBJECT, key="column"), SIGNUP_TEXT, id="column-alias-object"), + pytest.param(_td({"name": "ordered_at"}), "ordered_at", id="name-only"), + pytest.param(_td({"name": "ordered_at", "model": None, "label": None}), "ordered_at", id="null-model"), + ]) + def test_stored_query(self, stored: dict, text: str) -> None: + query = SlayerQuery.model_validate(_query(version=7, tds=[stored])) + [td] = query.time_dimensions or [] + assert td.dimension == text + assert _dumped_tds(query) == [{"dimension": text, "granularity": "month"}] + + def test_migration_rewrites_only_stored_objects(self) -> None: + text = [_td("coalesce(shipped_at, created_at)"), _td(SIGNUP_TEXT, granularity="year")] + versioned = mig.migrate("SlayerQuery", _query(version=7, tds=[_td(SIGNUP_OBJECT), *text])) + assert versioned["time_dimensions"] == [{"dimension": SIGNUP_TEXT, "granularity": "month"}, *text] + unversioned = mig.migrate("SlayerQuery", _query(version=None, tds=[_td(SIGNUP_OBJECT)])) + assert unversioned["time_dimensions"][0]["dimension"] == SIGNUP_OBJECT + + def test_model_source_queries(self) -> None: + stages = [_query(version=None, tds=[_td(SIGNUP_OBJECT)])] + model = SlayerModel.model_validate(query_backed_v10(name="rev_q", version=16, stages=stages)) + [query] = model.source_queries or [] + assert [td.dimension for td in query.time_dimensions or []] == [SIGNUP_TEXT] + dumped = model.model_dump(mode="json", exclude_none=True) + assert dumped["source_queries"][0]["time_dimensions"] == [{"dimension": SIGNUP_TEXT, "granularity": "month"}] + + def test_every_stage_of_a_multi_stage_model(self) -> None: + inner = {**_query(version=None, tds=[_td(SIGNUP_OBJECT)]), "name": "s1"} + outer = {"source_model": "s1", "measures": [{"formula": "sum(rev)", "name": "rev"}], + "time_dimensions": [_td({"name": "customers__signed_up_at"}, key="column", granularity="year")]} + model = SlayerModel.model_validate(query_backed_v10(name="two", version=16, stages=[inner, outer])) + assert [[td.dimension for td in s.time_dimensions or []] for s in model.source_queries or []] == [ + [SIGNUP_TEXT], ["customers__signed_up_at"], + ] + + def test_memory_query(self) -> None: + mem = Memory.model_validate(memory_v2(memory_id="m", version=5, query=_query(version=None, tds=[ + _td({"name": "ordered_at"}, granularity="year"), + ]))) + assert mem.query is not None + assert _dumped_tds(mem.query) == [{"dimension": "ordered_at", "granularity": "year"}] + + +@pytest.fixture +def shop_ds(tmp_path): + db = str(tmp_path / "shop.duckdb") + seed_shop(db, dialect="duckdb") + ds = DatasourceConfig(name=DS, type="duckdb", database=db) + try: + yield ds + finally: + engine_factory.invalidate_engine(ds) + + +class TestStoredDocumentsUpgrade: + @pytest.mark.parametrize("backend", BACKENDS) + async def test_query_backed_model(self, tmp_path, shop_ds, backend: str) -> None: + stage = rev_query(time_dimensions=[_td(SIGNUP_OBJECT)]) + storage = await raw_store(backend=backend, base=str(tmp_path / "store"), datasource=shop_ds, + models=[orders_v10(), customers_v10(), query_backed_v10(name="rev_q", stages=[stage])]) + model = await storage.get_model("rev_q", data_source=DS) + assert model is not None + [loaded] = model.source_queries or [] + assert _dumped_tds(loaded) == [{"dimension": SIGNUP_TEXT, "granularity": "month"}] + raw = await raw_doc(storage, "rev_q") + assert raw["version"] == mig.CURRENT_VERSIONS["SlayerModel"] + assert raw["source_queries"][0]["time_dimensions"][0]["dimension"] == SIGNUP_TEXT + resp = await run(storage, {"source_model": "rev_q", "dimensions": ["customers__signed_up_at"], + "measures": ["sum(rev)"]}) + assert _cells(resp, dim_suffix="signed_up_at", width=7) == REV_BY_SIGNUP_MONTH + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_resave_writes_text(self, tmp_path, shop_ds, backend: str) -> None: + stage = rev_query(time_dimensions=[_td(SIGNUP_OBJECT)]) + base = str(tmp_path / "store") + storage = await raw_store(backend=backend, base=base, datasource=shop_ds, + models=[orders_v10(), customers_v10(), query_backed_v10(name="rev_q", stages=[stage])]) + model = await storage.get_model("rev_q", data_source=DS) + assert model is not None + await storage.save_model(model) + text = stored_bytes(backend=backend, base=base, name="rev_q") + doc = yaml.safe_load(text) if backend == "yaml" else json.loads(text) + assert doc["source_queries"][0]["time_dimensions"][0]["dimension"] == SIGNUP_TEXT + + @pytest.mark.parametrize("backend", BACKENDS) + async def test_memory_bundled_query(self, tmp_path, shop_ds, backend: str) -> None: + query = _query(version=4, tds=[_td({"name": "ordered_at", "model": None}, granularity="year")]) + base = str(tmp_path / "store") + storage = await raw_store(backend=backend, base=base, datasource=shop_ds, + models=[orders_v10(), customers_v10()], + memories=[memory_v2(memory_id="m_td", query=query)]) + mem = await storage.get_memory("m_td") + assert mem.query is not None + assert _dumped_tds(mem.query) == [{"dimension": "ordered_at", "granularity": "year"}] + assert _cells(await run(storage, mem.query), dim_suffix="ordered_at", width=4) == REV_BY_ORDER_YEAR + await storage.save_memory(learning=mem.learning, entities=mem.entities, query=mem.query, id=mem.id) + assert _stored_memory_td(backend=backend, storage=storage, base=base, memory_id="m_td") == "ordered_at" + + +def _stored_memory_td(*, backend: str, storage: Any, base: str, memory_id: str) -> Any: + if backend == "yaml": + with open(storage._memory_md_path(memory_id), encoding="utf-8") as f: # NOSONAR(S7493) — test read + front = f.read().split("---\n")[1] + doc = yaml.safe_load(front) + else: + with transaction(f"{base}/store.db") as conn: + doc = json.loads(conn.execute("SELECT data FROM memories WHERE id = ?", (memory_id,)).fetchone()[0]) + return doc["query"]["time_dimensions"][0]["dimension"] + + +class TestReleasedCorpus: + @pytest.mark.parametrize("backend", ["yaml", "sqlite"]) + async def test_every_time_dimension_loads_as_text_with_its_results(self, tmp_path, backend: str) -> None: + storage = _open_store(backend=backend, tmp=tmp_path, online=True) + try: + texts: list = [] + keys: list[str] = [] + for ds, name in sorted(await storage._list_all_model_identities()): + model = await storage.get_model(name, data_source=ds) + assert model is not None + for query in model.source_queries or []: + if query.time_dimensions: + texts += [td["dimension"] for td in _dumped_tds(query)] + keys.append(name) + for mid in sorted(k.removeprefix(MEMORY_PREFIX) for k in EXPECTED if k.startswith(MEMORY_PREFIX)): + mem = await storage.get_memory(mid) + if mem.query is not None and mem.query.time_dimensions: + texts += [td["dimension"] for td in _dumped_tds(mem.query)] + keys.append(f"{MEMORY_PREFIX}{mid}") + assert texts == ["ordered_at"] * 7 + for key in keys: + assert await _run(storage, key) == EXPECTED[key]["rows"], key + finally: + for name in DATASOURCES: + ds_config = await storage.get_datasource(name) + if ds_config is not None: + engine_factory.invalidate_engine(ds_config) diff --git a/tests/test_dev2119_time_dimension_forms.py b/tests/test_dev2119_time_dimension_forms.py new file mode 100644 index 000000000..dcba80751 --- /dev/null +++ b/tests/test_dev2119_time_dimension_forms.py @@ -0,0 +1,185 @@ +"""Time dimensions as Mode-B text: coercion, dumps, schema, functional forms, names (spec: queries/time-dimensions).""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pydantic import ValidationError + +from slayer.core.errors import GranularityCallError +from slayer.core.query import SlayerQuery, TimeDimension +from tests._dev2119_fixtures import EFF, EFF_NAME + +CAST_TXT = "CAST(created_txt AS DATE)" + + +def _q(**kw: Any) -> SlayerQuery: + return SlayerQuery.model_validate({"source_model": "orders", "measures": [{"formula": "count(*)", "name": "n"}], + **kw}) + + +def _tds(query: SlayerQuery) -> list[TimeDimension]: + assert query.time_dimensions is not None + return query.time_dimensions + + +def _dumped_tds(query: SlayerQuery) -> list[dict]: + return query.model_dump(exclude_none=True)["time_dimensions"] + + +def _td_schema() -> dict: + defs = SlayerQuery.model_json_schema()["$defs"] + return defs["TimeDimension"] + + +class TestTextCoercion: + def test_column_alias_validates_and_dumps_as_dimension_text(self) -> None: + query = _q(time_dimensions=[{"column": "created_at", "granularity": "month"}]) + assert _tds(query)[0].dimension == "created_at" + assert _dumped_tds(query) == [{"dimension": "created_at", "granularity": "month"}] + + def test_dimension_keyword_takes_text(self) -> None: + td = TimeDimension(dimension="created_at", granularity="month") + assert td.dimension == "created_at" + + @pytest.mark.parametrize("key", ["dimension", "column"]) + def test_legacy_object_form_reads_as_dotted_text(self, key: str) -> None: + legacy = _q(time_dimensions=[{key: {"name": "created_at", "model": "customers"}, "granularity": "month"}]) + text = _q(time_dimensions=[{"dimension": "customers.created_at", "granularity": "month"}]) + assert legacy == text + assert _dumped_tds(legacy) == [{"dimension": "customers.created_at", "granularity": "month"}] + + def test_legacy_object_without_model_reads_as_its_name(self) -> None: + query = _q(time_dimensions=[{"dimension": {"name": "created_at"}, "granularity": "month"}]) + assert _tds(query)[0].dimension == "created_at" + + @pytest.mark.parametrize("text", [EFF, CAST_TXT, "coalesce(customers.signup_ts, created_at)"]) + def test_expression_text_kept_verbatim(self, text: str) -> None: + query = _q(time_dimensions=[{"dimension": text, "granularity": "month"}]) + assert _tds(query)[0].dimension == text + assert _dumped_tds(query) == [{"dimension": text, "granularity": "month"}] + + def test_dump_round_trips(self) -> None: + query = _q(time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "effective_at"}]) + assert SlayerQuery.model_validate(query.model_dump()) == query + + +class TestSchema: + def test_dimension_is_a_string_property_without_column(self) -> None: + props = _td_schema()["properties"] + assert props["dimension"]["type"] == "string" + assert "column" not in props + + def test_name_is_advertised(self) -> None: + assert "name" in _td_schema()["properties"] + + +class TestFunctionalForms: + def test_functional_dimension_over_an_expression(self) -> None: + functional = _q(dimensions=[f"month({EFF})"]) + explicit = _q(time_dimensions=[{"dimension": EFF, "granularity": "month"}]) + assert functional == explicit + assert functional.dimensions is None + + def test_functional_time_dimension_string_over_a_cast(self) -> None: + functional = _q(time_dimensions=[f"month({CAST_TXT})"]) + explicit = _q(time_dimensions=[{"dimension": CAST_TXT, "granularity": "month"}]) + assert functional == explicit + + def test_functional_order_of_appearance_with_expressions(self) -> None: + query = _q(time_dimensions=[{"dimension": "created_at", "granularity": "year"}], + dimensions=["region", f"month({EFF})", f"week({CAST_TXT})"]) + assert [(t.dimension, t.granularity) for t in _tds(query)] == [ + ("created_at", "year"), (EFF, "month"), (CAST_TXT, "week"), + ] + assert query.dimensions is not None + assert [d.name for d in query.dimensions] == ["region"] + + def test_non_temporal_argument_is_not_a_shape_error(self) -> None: + query = _q(dimensions=["month(upper(status))"]) + assert [(t.dimension, t.granularity) for t in _tds(query)] == [("upper(status)", "month")] + + @pytest.mark.parametrize("entry", ["month()", "month(a, b)", "month(*)", "month(created_at, granularity=2)"]) + @pytest.mark.parametrize("field", ["dimensions", "time_dimensions"]) + def test_wrong_shapes_still_rejected(self, entry: str, field: str) -> None: + with pytest.raises((GranularityCallError, ValidationError)) as ei: + _q(**{field: [entry]}) + msg = str(ei.value) + assert "gran(" in msg + assert "month" in msg + assert "quarter" in msg + + +class TestNames: + def test_name_field_dumps(self) -> None: + query = _q(time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "effective_at"}]) + assert _tds(query)[0].name == "effective_at" + assert _dumped_tds(query) == [{"dimension": EFF, "granularity": "month", "name": "effective_at"}] + + def test_unnamed_has_no_name(self) -> None: + assert _tds(_q(time_dimensions=[{"dimension": EFF, "granularity": "month"}]))[0].name is None + + def test_collision_with_a_computed_dimension(self) -> None: + with pytest.raises(ValueError, match="'m'|\\bm\\b"): + _q(dimensions=[{"expression": "upper(city)", "name": "m"}], + time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "m"}]) + + def test_collision_between_time_dimensions(self) -> None: + with pytest.raises(ValueError, match="'m'|\\bm\\b"): + _q(time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "m"}, + {"dimension": "created_at", "granularity": "year", "name": "m"}]) + + def test_collision_with_a_plain_dimension(self) -> None: + with pytest.raises(ValueError, match="region"): + _q(dimensions=["region"], time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "region"}]) + + @pytest.mark.parametrize("entries", [ + {"dimensions": [{"expression": EFF}], "time_dimensions": [{"dimension": EFF, "granularity": "month"}]}, + {"dimensions": [{"expression": "upper(city)", "name": EFF_NAME}], + "time_dimensions": [{"dimension": EFF, "granularity": "month"}]}, + {"time_dimensions": [{"dimension": EFF, "granularity": "month"}, + {"dimension": "created_at", "granularity": "year", "name": EFF_NAME}]}, + {"time_dimensions": [{"dimension": "created_at", "granularity": "year", "name": EFF_NAME}, + {"dimension": EFF, "granularity": "month"}]}, + {"time_dimensions": [{"dimension": "created_at + 1", "granularity": "month"}, + {"dimension": "created_at - 1", "granularity": "year"}]}, + ], ids=["computed-auto", "computed-named", "td-named-after", "td-named-before", "sanitised-alike"]) + def test_auto_name_collision_fails_at_construction(self, entries: dict) -> None: + with pytest.raises(ValueError, match="used twice"): + _q(**entries) + + def test_one_explicit_name_at_two_granularities_collides(self) -> None: + with pytest.raises(ValueError, match="used twice"): + _q(time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "m"}, + {"dimension": EFF, "granularity": "year", "name": "m"}]) + + def test_one_explicit_name_on_two_spellings_is_left_to_binding(self) -> None: + query = _q(time_dimensions=[{"dimension": EFF, "granularity": "month", "name": "m"}, + {"dimension": "COALESCE(shipped_at,created_at)", "granularity": "month", + "name": "m"}]) + assert len(_tds(query)) == 2 + + @pytest.mark.parametrize("dimension", [EFF, "created_at"]) + def test_one_source_at_two_granularities_is_not_a_collision(self, dimension: str) -> None: + query = _q(time_dimensions=[{"dimension": dimension, "granularity": "month"}, + {"dimension": dimension, "granularity": "year"}]) + assert [t.granularity for t in _tds(query)] == ["month", "year"] + + +class TestDedupe: + @pytest.mark.parametrize("second", [f"month({EFF})", {"dimension": EFF, "granularity": "month"}]) + def test_exact_duplicate_expression_dedupes(self, second: Any) -> None: + query = _q(dimensions=[f"month({EFF})"], time_dimensions=[second]) + assert [(t.dimension, t.granularity) for t in _tds(query)] == [(EFF, "month")] + + def test_model_prefixed_column_dedupes(self) -> None: + query = _q(time_dimensions=[{"dimension": "created_at", "granularity": "month"}, + {"dimension": "orders.created_at", "granularity": "month"}]) + assert len(_tds(query)) == 1 + + @pytest.mark.parametrize("diff", [{"label": "b"}, {"date_range": ["2025-01-01", "2025-02-01"]}, {"name": "b"}]) + def test_same_text_differing_metadata_rejected(self, diff: dict) -> None: + with pytest.raises((GranularityCallError, ValidationError), match="(?i)conflicting|collision"): + _q(time_dimensions=[{"dimension": EFF, "granularity": "month", "label": "a"}, + {"dimension": EFF, "granularity": "month", "label": "a", **diff}]) diff --git a/tests/test_dev2119_time_dimension_variables.py b/tests/test_dev2119_time_dimension_variables.py new file mode 100644 index 000000000..0bfd28358 --- /dev/null +++ b/tests/test_dev2119_time_dimension_variables.py @@ -0,0 +1,83 @@ +"""Literal ``{var}`` placeholders inside a time-dimension expression (spec: queries/variables).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from slayer.core.query import SlayerQuery, extract_placeholder_names +from slayer.engine.query_engine import SlayerQueryEngine +from tests._dev2015_fixtures import DS_GRANULARITIES +from tests._dev2119_fixtures import BACKENDS, by_bucket, engine_for, m, q, td + +SHIFTED = "coalesce(shipped_at, created_at + INTERVAL '{days}' DAY)" +KEY = "orders.coalesce_shipped_at_created_at_interval_days_day" + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with engine_for(request.param) as e: + yield e + + +def _query(**kw: Any) -> SlayerQuery: + return q(measures=[m("count(*)")], **kw) + + +class TestConstruction: + @pytest.mark.parametrize("entry", [td(SHIFTED), f"month({SHIFTED})"], ids=["object", "functional"]) + def test_literal_placeholder_accepted(self, entry: Any) -> None: + query = _query(time_dimensions=[entry]) + assert query.time_dimensions is not None + assert query.time_dimensions[0].dimension == SHIFTED + + def test_functional_form_in_dimensions_moves_to_time_dimensions(self) -> None: + query = _query(dimensions=[f"month({SHIFTED})"]) + assert query.dimensions is None + assert query.time_dimensions is not None + assert query.time_dimensions[0].dimension == SHIFTED + + def test_placeholder_names_include_the_expression(self) -> None: + assert extract_placeholder_names(_query(time_dimensions=[td(SHIFTED)])) == {"days"} + + +class TestExecution: + #: Order 3 (created 2025-02-10) is the one unshipped row; the others bucket by ``shipped_at``. + @pytest.mark.parametrize(("days", "expected"), [ + (0, {"2025-01": 1.0, "2025-02": 2.0, "2025-03": 2.0, "2025-04": 1.0}), + (30, {"2025-01": 1.0, "2025-02": 1.0, "2025-03": 3.0, "2025-04": 1.0}), + ]) + async def test_substituted_value_buckets_and_template_names( + self, engine: SlayerQueryEngine, days: int, expected: dict, + ) -> None: + resp = await engine.execute(_query(time_dimensions=[td(SHIFTED)], variables={"days": days})) + assert by_bucket(resp, KEY, "orders.v") == expected + + async def test_named_time_dimension_keeps_its_name(self, engine: SlayerQueryEngine) -> None: + resp = await engine.execute(_query(time_dimensions=[td(SHIFTED, name="effective_at")], + variables={"days": 30})) + assert by_bucket(resp, "orders.effective_at", "orders.v")["2025-03"] == 3.0 + + async def test_missing_variable_is_rejected(self, engine: SlayerQueryEngine) -> None: + query = _query(time_dimensions=[td(SHIFTED)]) + with pytest.raises(ValueError, match="days"): + await engine.execute(query) + + +class TestTemplateIdentity: + async def test_custom_granularity_call_names_from_its_template(self) -> None: + async with engine_for("duckdb", datasource_fields=DS_GRANULARITIES) as engine: + keys = {days: (await engine.execute(_query(dimensions=[f"fiscal_year({SHIFTED})"], + variables={"days": days}))).columns + for days in (0, 30)} + assert KEY in keys[0] + assert keys[0] == keys[30] + + async def test_equal_values_keep_distinct_templates_apart(self, engine: SlayerQueryEngine) -> None: + shifted = "created_at + INTERVAL '{%s}' DAY" + resp = await engine.execute(_query(time_dimensions=[td(shifted % "a"), td(shifted % "b")], + variables={"a": 0, "b": 0})) + assert resp.columns == [ + "orders.created_at_interval_a_day.month", "orders.created_at_interval_b_day.month", "orders.v", + ] diff --git a/tests/test_dev2119_time_spine.py b/tests/test_dev2119_time_spine.py new file mode 100644 index 000000000..4f4b2af9f --- /dev/null +++ b/tests/test_dev2119_time_spine.py @@ -0,0 +1,123 @@ +"""An expression is never an axis; a fact expression time dimension is a dimension of P (spec: queries/time-spine).""" + +from __future__ import annotations + +import pytest + +from slayer.core.enums import DataType +from slayer.core.errors import TimeSpineError +from slayer.core.models import Column, ModelJoin, SlayerModel +from slayer.core.refs import auto_name_from_expression +from slayer.engine.query_engine import SlayerResponse +from tests._dev1737_fixtures import TableSpec +from tests._dev2015_fixtures import ( + BACKENDS, + JAN_JUN, + MONTHS, + TWO_FACTS, + bucket_key, + num, + spine_engine, + spine_models, + spine_query, + spine_td, + spine_tables, + value, +) + +EFF_EXPR = "coalesce(orders.shipped_at, orders.order_date)" +SPINE_EXPRESSIONS = ["time_spine.timestamp + INTERVAL '1 day'", "coalesce(time_spine.timestamp, orders.order_date)"] + +#: Order 1 (Jan, N, 100) ships in Feb, order 2 (Jan, S, 50) never ships, order 3 (Feb, N, 70) ships in March. +ORDERS = TableSpec( + name="orders", + columns=[("id", "INT"), ("customer_id", "INT"), ("order_date", "DATE"), ("shipped_at", "DATE"), + ("amount", "DOUBLE")], + rows=[(1, 1, "2025-01-10", "2025-02-02", 100.0), (2, 2, "2025-01-20", None, 50.0), + (3, 1, "2025-02-05", "2025-03-01", 70.0)], +) +EFF_MONTHS = ["2025-01", "2025-02", "2025-03"] +#: (o, n) per (spine month, coalesced month); returns are broadcast per spine month. +ORDER_CELLS = {("2025-01", "2025-01"): (50.0, 1.0), ("2025-01", "2025-02"): (100.0, 1.0), + ("2025-02", "2025-03"): (70.0, 1.0)} +RETURNS_BY_MONTH = {"2025-02": 20.0, "2025-03": 10.0, "2025-05": 5.0} + + +def _orders_model() -> SlayerModel: + return SlayerModel( + name="orders", sql_table="orders", data_source="test", default_time_dimension="order_date", + columns=[ + Column(name="id", type=DataType.INT, primary_key=True), + Column(name="customer_id", type=DataType.INT), + Column(name="order_date", type=DataType.DATE), + Column(name="shipped_at", type=DataType.DATE), + Column(name="amount", type=DataType.DOUBLE), + Column(name="eff", type=DataType.DATE, sql="COALESCE(shipped_at, order_date)"), + ], + joins=[ModelJoin(target_model="customers", join_pairs=[["customer_id", "id"]])], + ) + + +def _models() -> list[SlayerModel]: + return [_orders_model() if model.name == "orders" else model for model in spine_models()] + + +def _tables() -> list[TableSpec]: + return [ORDERS if table.name == "orders" else table for table in spine_tables()] + + +def _expected() -> dict[tuple[str, str], tuple]: + out: dict[tuple[str, str], tuple] = {} + for spine in MONTHS: + for eff in EFF_MONTHS: + o, n = ORDER_CELLS.get((spine, eff), (None, 0.0)) + out[(spine, eff)] = (o, RETURNS_BY_MONTH.get(spine), n) + return out + + +def _cells(resp: SlayerResponse) -> dict[tuple[str, str], tuple]: + [bucket] = [k for k in resp.columns if k != "time_spine.timestamp" and k.rsplit(".", 1)[-1] not in ("o", "r", "n")] + out: dict[tuple[str, str], tuple] = {} + for row in resp.data: + key = (bucket_key(row["time_spine.timestamp"]), bucket_key(row[bucket])) + assert key not in out, key + out[key] = tuple(num(value(row, name)) for name in ("o", "r", "n")) + return out + + +@pytest.fixture(params=BACKENDS) +async def engine(request): + async with spine_engine(request.param, models=_models(), tables=_tables()) as eng: + yield eng + + +class TestSpineExpressionRejected: + @pytest.mark.parametrize("expression", SPINE_EXPRESSIONS) + @pytest.mark.parametrize("with_axis", [False, True], ids=["alone", "beside-the-axis"]) + async def test_typed_error_before_sql(self, engine, expression: str, with_axis: bool) -> None: + tds = [spine_td()] if with_axis else [] + query = spine_query(measures=TWO_FACTS, time_dimensions=[ + *tds, {"dimension": expression, "granularity": "month", "date_range": JAN_JUN}, + ]) + with pytest.raises(TimeSpineError) as info: + await engine.execute(query, dry_run=True) + msg = str(info.value) + assert expression in msg or auto_name_from_expression(expression) in msg, msg + assert "time_spine.timestamp" in (info.value.suggestion or ""), msg + + +class TestFactExpressionIsADimensionOfP: + async def test_values_per_spine_month_and_bucket(self, engine) -> None: + resp = await engine.execute(spine_query(measures=TWO_FACTS, time_dimensions=[ + spine_td(), {"dimension": EFF_EXPR, "granularity": "month"}, + ])) + assert _cells(resp) == _expected() + + async def test_equal_to_the_column_holding_the_value(self, engine) -> None: + expr = await engine.execute(spine_query(measures=TWO_FACTS, time_dimensions=[ + spine_td(), {"dimension": EFF_EXPR, "granularity": "month"}, + ])) + col = await engine.execute(spine_query(measures=TWO_FACTS, time_dimensions=[ + spine_td(), {"dimension": "orders.eff", "granularity": "month"}, + ])) + assert _cells(expr) == _cells(col) diff --git a/tests/test_distinct_dimension_values.py b/tests/test_distinct_dimension_values.py index ed0467e27..0eee78534 100644 --- a/tests/test_distinct_dimension_values.py +++ b/tests/test_distinct_dimension_values.py @@ -197,7 +197,7 @@ def test_dimensions_plus_time_dimensions(self) -> None: source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity="day"), + TimeDimension(dimension="created_at", granularity="day"), ], distinct_dimension_values=False, ) @@ -208,7 +208,7 @@ def test_time_dimensions_only(self) -> None: q = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity="day"), + TimeDimension(dimension="created_at", granularity="day"), ], distinct_dimension_values=False, ) @@ -557,7 +557,7 @@ async def test_flag_false_no_group_by_with_time_dimension(self) -> None: q = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity="day"), + TimeDimension(dimension="created_at", granularity="day"), ], distinct_dimension_values=False, ) diff --git a/tests/test_entity_resolution.py b/tests/test_entity_resolution.py index 34d465f16..d767cda38 100644 --- a/tests/test_entity_resolution.py +++ b/tests/test_entity_resolution.py @@ -593,7 +593,7 @@ async def test_time_dimension_extracted( source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="ordered_at"), + dimension="ordered_at", granularity=TimeGranularity.DAY, ) ], diff --git a/tests/test_expression_aggregations.py b/tests/test_expression_aggregations.py index bf46ecdd2..2bf99adbc 100644 --- a/tests/test_expression_aggregations.py +++ b/tests/test_expression_aggregations.py @@ -425,13 +425,6 @@ async def test_mixed_row_and_attached_source_accepted(self) -> None: resp = await _dry(q) assert resp.sql - async def test_first_last_over_expression_rejected(self) -> None: - # first/last need a plain column — the ranked kernel can't rank an - # expression, so reject at binding instead of crashing at SQL time. - q = _q(measures=["last(amount - cost, created_at)"]) - with pytest.raises(ValueError, match="(?i)expression"): - await _dry(q) - # =========================================================================== # Gates and types. diff --git a/tests/test_filtered_local_isolation.py b/tests/test_filtered_local_isolation.py index 3a64a16d0..0c348ef28 100644 --- a/tests/test_filtered_local_isolation.py +++ b/tests/test_filtered_local_isolation.py @@ -23,7 +23,7 @@ ModelJoin, SlayerModel, ) -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.ir.planned import MaskTyping, RankedProducerKernel from slayer.ir.source_bundle import ResolvedSourceBundle from slayer.engine.plan import plan_query @@ -388,7 +388,7 @@ def test_filtered_local_first_last_isolates_without_nesting(self): dimensions=["claim.claim_number"], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], diff --git a/tests/test_memories_storage.py b/tests/test_memories_storage.py index ee4b7bc11..c86d9f141 100644 --- a/tests/test_memories_storage.py +++ b/tests/test_memories_storage.py @@ -69,7 +69,7 @@ async def test_save_returns_memory_with_str_id( assert memory.learning.startswith("orders.is_returned") assert memory.entities == ["mydb.orders.is_returned"] assert memory.query is None - assert memory.version == 5 + assert memory.version == 6 assert memory.created_at is not None async def test_save_with_query_persists_query( diff --git a/tests/test_memory_string_ids.py b/tests/test_memory_string_ids.py index ea8b580fc..693182401 100644 --- a/tests/test_memory_string_ids.py +++ b/tests/test_memory_string_ids.py @@ -246,7 +246,7 @@ def test_v1_int_id_stringified(self) -> None: } m = Memory.model_validate(v1) assert m.id == "42" - assert m.version == 5 + assert m.version == 6 def test_v1_no_version_assumed_v1(self) -> None: # No version field → treated as v1; migrator stringifies. @@ -257,7 +257,7 @@ def test_v1_no_version_assumed_v1(self) -> None: } m = Memory.model_validate(legacy) assert m.id == "7" - assert m.version == 5 + assert m.version == 6 async def test_v2_save_round_trip( self, storage: StorageBackend, @@ -268,7 +268,7 @@ async def test_v2_save_round_trip( ) loaded = await storage.get_memory(m.id) assert loaded.id == "kb.policy" - assert loaded.version == 5 + assert loaded.version == 6 def test_duplicate_int_string_rows_same_content_normalized(self) -> None: """The v2 migrator deduplicates rows that exist under both int and diff --git a/tests/test_model_access_caches.py b/tests/test_model_access_caches.py index b8deb78be..4370122e6 100644 --- a/tests/test_model_access_caches.py +++ b/tests/test_model_access_caches.py @@ -200,5 +200,5 @@ async def test_concurrent_first_reads_of_a_pre_v15_store_agree(backend: str) -> models = await asyncio.gather(*(fin.get_model("orders", data_source="shop") for _ in range(8))) assert all(sorted(names) == ["customers", "orders"] for names in listings) assert all(m is not None and [j.target_model for j in m.joins] == ["customers"] for m in models) - assert (await raw_doc(inner, "orders"))["version"] == 16 + assert (await raw_doc(inner, "orders"))["version"] == 17 assert sorted(await fin.list_models("shop")) == ["customers", "orders"] diff --git a/tests/test_model_access_tags.py b/tests/test_model_access_tags.py index 28da6e7df..f84b8820c 100644 --- a/tests/test_model_access_tags.py +++ b/tests/test_model_access_tags.py @@ -44,9 +44,9 @@ class TestField: def test_defaults_to_untagged(self) -> None: assert _model().access_tags == [] - def test_schema_version_is_16(self) -> None: - assert CURRENT_VERSIONS["SlayerModel"] == 16 - assert _model().version == 16 + def test_schema_version_is_17(self) -> None: + assert CURRENT_VERSIONS["SlayerModel"] == 17 + assert _model().version == 17 def test_json_round_trip(self) -> None: model = _model(access_tags=["hr", "HR Team"], hidden=True) @@ -71,7 +71,7 @@ async def test_v14_model_loads_untagged_and_writes_back_at_v16(backend: str) -> models=[orders_v10(version=14), customers_v10(version=14)], ) assert (await must_get(storage, "orders", SHOP)).access_tags == [] - assert (await raw_doc(storage, "orders"))["version"] == 16 + assert (await raw_doc(storage, "orders"))["version"] == 17 class TestMcp: diff --git a/tests/test_models.py b/tests/test_models.py index 72573506e..33a288a5a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1479,17 +1479,15 @@ def test_order_column_dict_still_works(self) -> None: def test_time_dimension_string(self) -> None: td = TimeDimension(dimension="created_at", granularity="month") - assert td.dimension.name == "created_at" - assert td.dimension.model is None + assert td.dimension == "created_at" def test_time_dimension_dotted_string(self) -> None: td = TimeDimension(dimension="customers.ordered_at", granularity="month") - assert td.dimension.name == "ordered_at" - assert td.dimension.model == "customers" + assert td.dimension == "customers.ordered_at" def test_time_dimension_dict_still_works(self) -> None: - td = TimeDimension(dimension={"name": "created_at"}, granularity="month") - assert td.dimension.name == "created_at" + td = TimeDimension.model_validate({"dimension": {"name": "created_at"}, "granularity": "month"}) + assert td.dimension == "created_at" def test_query_with_simplified_order_and_time_dimensions(self) -> None: query = SlayerQuery( @@ -1498,7 +1496,7 @@ def test_query_with_simplified_order_and_time_dimensions(self) -> None: time_dimensions=[{"dimension": "created_at", "granularity": "month"}], order=[{"column": "_count", "direction": "desc"}], ) - assert query.time_dimensions[0].dimension.name == "created_at" + assert query.time_dimensions[0].dimension == "created_at" assert query.order[0].column.name == "_count" assert query.order[0].direction == "desc" @@ -1818,8 +1816,7 @@ def test_time_dimension_stripped(self) -> None: time_dimensions=[{"dimension": "orders.created_at", "granularity": "month"}], ) stripped = q.strip_source_model_prefix() - assert stripped.time_dimensions[0].dimension.model is None - assert stripped.time_dimensions[0].dimension.name == "created_at" + assert stripped.time_dimensions[0].dimension == "created_at" def test_time_dimension_preserves_other_fields(self) -> None: """Granularity, date_range, label are preserved after stripping.""" @@ -1834,8 +1831,7 @@ def test_time_dimension_preserves_other_fields(self) -> None: ) stripped = q.strip_source_model_prefix() td = stripped.time_dimensions[0] - assert td.dimension.name == "created_at" - assert td.dimension.model is None + assert td.dimension == "created_at" assert td.granularity == TimeGranularity.MONTH assert td.date_range == ["2024-01-01", "2024-12-31"] assert td.label == "Month" @@ -1846,7 +1842,7 @@ def test_time_dimension_cross_model_not_stripped(self) -> None: time_dimensions=[{"dimension": "customers.created_at", "granularity": "day"}], ) stripped = q.strip_source_model_prefix() - assert stripped.time_dimensions[0].dimension.model == "customers" + assert stripped.time_dimensions[0].dimension == "customers.created_at" # --- Fields (formulas) --- @@ -1886,6 +1882,18 @@ def test_formula_cross_model_not_stripped(self) -> None: stripped = q.strip_source_model_prefix() assert stripped.measures[0].formula == "customers.score:avg" + def test_formula_inner_hop_not_stripped(self) -> None: + """A revisiting customers.orders hop stays, so binding refuses it instead of reading customers.""" + q = SlayerQuery.model_validate({"source_model": "orders", "measures": ["count(customers.orders.id)"]}) + stripped = q.strip_source_model_prefix() + assert stripped.measures is not None + assert stripped.measures[0].formula == "count(customers.orders.id)" + + def test_filter_inner_hop_not_stripped(self) -> None: + q = SlayerQuery(source_model="orders", filters=["customers.orders.status = 'paid'"]) + stripped = q.strip_source_model_prefix() + assert stripped.filters == ["customers.orders.status = 'paid'"] + def test_formula_name_and_label_preserved(self) -> None: """Field name and label are preserved after formula stripping.""" q = SlayerQuery( diff --git a/tests/test_nested_dag_cross_stage_refs.py b/tests/test_nested_dag_cross_stage_refs.py index 5736bf4f3..4a7c8d8b1 100644 --- a/tests/test_nested_dag_cross_stage_refs.py +++ b/tests/test_nested_dag_cross_stage_refs.py @@ -21,7 +21,7 @@ SlayerModel, SourceModelOrigin, ) -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.engine.query_engine import SlayerQueryEngine from slayer.storage.sqlite_storage import SQLiteStorage from slayer.storage.yaml_storage import YAMLStorage @@ -355,7 +355,7 @@ async def test_multi_hop_dotted_time_dim_cross_stage(self) -> None: name="s1", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.regions.last_activity_at"), + dimension="customers.regions.last_activity_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "*:count"}], @@ -364,7 +364,7 @@ async def test_multi_hop_dotted_time_dim_cross_stage(self) -> None: source_model="s1", time_dimensions=[TimeDimension( # Typed contract: flat downstream alias. - dimension=ColumnRef(name="customers__regions__last_activity_at"), + dimension="customers__regions__last_activity_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "*:count"}], @@ -742,7 +742,7 @@ async def test_time_shift_over_multi_hop_dotted_time_dim(self) -> None: name="s1", source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.regions.last_activity_at"), + dimension="customers.regions.last_activity_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "*:count"}], @@ -751,7 +751,7 @@ async def test_time_shift_over_multi_hop_dotted_time_dim(self) -> None: source_model="s1", time_dimensions=[TimeDimension( # Typed contract: downstream flat alias. - dimension=ColumnRef(name="customers__regions__last_activity_at"), + dimension="customers__regions__last_activity_at", granularity=TimeGranularity.MONTH, )], measures=[ diff --git a/tests/test_notebook_network_guard.py b/tests/test_notebook_network_guard.py index 9514be78a..5a1a505df 100644 --- a/tests/test_notebook_network_guard.py +++ b/tests/test_notebook_network_guard.py @@ -24,6 +24,8 @@ f"HTTP Error: HTTP GET error on '{_URL}' (HTTP 503 Service Unavailable)", f"IO Error: Could not resolve host: {_HOST}", "curl: (22) The requested URL returned error: 403 install.duckdb.org", + f'HTTP Error: ETag on reading file "{_URL}" was initially W/"a" and now it returned W/"b", ' + "this likely means the remote file has changed.", ], ) def test_external_host_outage_is_transient(text): @@ -55,6 +57,7 @@ def test_hostless_httpfs_transport_error_is_transient(text): "curl: (22) The requested URL returned error: 404 install.duckdb.org", # A transient status but no external host named — not a classifiable outage. "HTTP Error: HTTP GET error on 'https://example.internal/x' (HTTP 503)", + "IO Error: this likely means the remote file has changed (no external host named)", ], ) def test_real_bugs_are_not_transient(text): diff --git a/tests/test_projection_trim.py b/tests/test_projection_trim.py index d6e3002cc..968492198 100644 --- a/tests/test_projection_trim.py +++ b/tests/test_projection_trim.py @@ -277,7 +277,7 @@ async def test_time_dimensions_included_in_projection( source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count", name="rows")], @@ -333,7 +333,7 @@ async def test_window_transform_with_declared_inner_measure( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -372,7 +372,7 @@ async def test_consecutive_periods_projection_trim( source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="consecutive_periods(revenue:sum > 0)", name="streak")], @@ -532,7 +532,7 @@ async def test_attributes_omit_filter_extracted_hidden_aliases( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="revenue:sum", name="total")], @@ -607,7 +607,7 @@ async def test_filter_on_hidden_change_field_does_not_leak( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="revenue:sum", name="total")], @@ -661,7 +661,7 @@ async def test_rank_inside_arithmetic_reuses_named_arg( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -860,7 +860,7 @@ async def test_declared_measure_in_outer_projection_hidden_trimmed( order=[OrderItem(column="quantity:sum", direction="desc")], filters=["change(revenue:sum) > 0"], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], ) @@ -894,7 +894,7 @@ async def test_declared_transform_in_outer_projection_inner_trimmed( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="cumsum(revenue:sum)", name="cum")], @@ -934,7 +934,7 @@ async def test_declared_projection_order_and_hidden_exclusion( source_model="orders", dimensions=[ColumnRef(name="status"), ColumnRef(name="region")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -1048,7 +1048,7 @@ async def test_post_filter_plus_order_plus_trim( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="revenue:sum", name="total")], @@ -1155,7 +1155,7 @@ async def test_structural_reuse_inline_subagg_collapses( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ @@ -1187,7 +1187,7 @@ async def test_nested_window_formula_stages_without_duplicate( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="rank(rank(revenue:sum, direction='desc'), direction='desc')", name="r2")], @@ -1289,7 +1289,7 @@ async def test_dim_and_time_dim_resolving_to_same_alias_rejected( source_model="orders", dimensions=[ColumnRef(name="created_at")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[ModelMeasure(formula="*:count")], diff --git a/tests/test_query_backed_declared_join_boundaries.py b/tests/test_query_backed_declared_join_boundaries.py new file mode 100644 index 000000000..aeb6b0aec --- /dev/null +++ b/tests/test_query_backed_declared_join_boundaries.py @@ -0,0 +1,119 @@ +"""Saved relation joins survive consumption without leaking into ordinary stages.""" + +from __future__ import annotations + +import pytest + +from slayer.core.errors import UnrelatedDatasetError +from slayer.core.models import ModelJoin + +from tests._dev1902_fixtures import ( + AMOUNT_BY_CUSTOMER, + _seed_duckdb, + _seed_sqlite, + cells, + customers_model, + orders_model, + renamed_graph, +) +from tests._engine_helpers import seeded_exec_engine +from tests.test_dev1902_join_key_spelling import _customer_totals, _query + + +@pytest.fixture(params=["sqlite", "duckdb"]) +async def engine(request): + seed = _seed_duckdb if request.param == "duckdb" else _seed_sqlite + async with seeded_exec_engine( + dialect=request.param, seed=seed, models=renamed_graph() + ) as (query_engine, _db): + yield query_engine + + +async def test_join_target_keeps_its_onward_relationship(engine): + await engine.save_model(_customer_totals()) + consumer = orders_model().model_copy(update={ + "name": "consumer", + "joins": [ModelJoin(target_model="customer_totals", join_pairs=[["customer_id", "customer_id"]])], + }) + await engine.save_model(consumer) + + response = await engine.execute(_query( + source_model="consumer", + dimensions=["customer_totals.customers.name"], + measures=[{"formula": "sum(amount)", "name": "total"}], + )) + + assert cells(response, dim_suffix="customers.name", measure="total") == AMOUNT_BY_CUSTOMER + + +async def test_saved_relation_without_outgoing_joins_can_use_incoming_edge(engine): + await engine.save_model(_customer_totals().model_copy(update={"joins": []})) + customers = customers_model() + await engine.save_model(customers.model_copy(update={"joins": [ + *customers.joins, + ModelJoin(target_model="customer_totals", join_pairs=[["id", "customer_id"]], name="totals"), + ]})) + + response = await engine.execute(_query( + source_model="customer_totals", + dimensions=["customers.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + + assert cells(response, dim_suffix="name", measure="total") == AMOUNT_BY_CUSTOMER + + +async def test_extension_keeps_distinct_named_stored_and_added_joins(engine): + totals = _customer_totals() + await engine.save_model(totals.model_copy(update={ + "joins": [totals.joins[0].model_copy(update={"name": "owner"})], + })) + + response = await engine.execute(_query( + source_model={"source_name": "customer_totals", "joins": [{ + "target_model": "customers", "join_pairs": [["customer_id", "id"]], "name": "reviewer", + }]}, + dimensions=["owner.name", "reviewer.tier"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + + assert { + row["customer_totals.owner.name"]: ( + row["customer_totals.reviewer.tier"], row["customer_totals.total"] + ) + for row in response.data + } == {"Ann": ("gold", 30.0), "Bob": ("silver", 30.0), "Cid": ("gold", 90.0)} + + +async def test_ordinary_stage_does_not_inherit_backing_source_joins(engine): + stage = _query( + name="s", source_model="orders", dimensions=["customer_id"], + measures=[{"formula": "sum(amount)", "name": "amt"}], + ) + consumer = _query( + source_model="s", dimensions=["customers.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + ) + + with pytest.raises(UnrelatedDatasetError): + await engine.execute([stage, consumer]) + + +async def test_legal_stage_named_join_target_is_not_a_synthetic_self_join(engine): + await engine.save_model(customers_model().model_copy(update={"name": "_stage"})) + totals = _customer_totals() + await engine.save_model(totals.model_copy(update={ + "joins": [totals.joins[0].model_copy(update={"target_model": "_stage"})], + })) + + unjoined = await engine.execute(_query( + source_model="customer_totals", + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + assert unjoined.data == [{"customer_totals.total": 150.0}] + + joined = await engine.execute(_query( + source_model="customer_totals", dimensions=["_stage.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + assert cells(joined, dim_suffix="_stage.name", measure="total") == AMOUNT_BY_CUSTOMER diff --git a/tests/test_query_backed_declared_joins.py b/tests/test_query_backed_declared_joins.py new file mode 100644 index 000000000..94de7d720 --- /dev/null +++ b/tests/test_query_backed_declared_joins.py @@ -0,0 +1,82 @@ +"""Saved query-backed models retain their declared relationship graph.""" + +from __future__ import annotations + +import pytest + +from slayer.core.errors import AmbiguousJoinPathError + +from tests._dev1902_fixtures import ( + AMOUNT_BY_CUSTOMER, + cells, + make_exec_engine, + orders_model, + renamed_graph, +) +from tests.test_dev1902_join_key_spelling import _customer_totals, _query + + +@pytest.fixture(params=["sqlite", "duckdb"]) +async def saved_totals_engine(request): + async for engine in make_exec_engine(request, models=renamed_graph()): + await engine.save_model(_customer_totals()) + yield engine + + +async def test_saved_query_backed_join_filters_population(saved_totals_engine): + response = await saved_totals_engine.execute(_query( + source_model="customer_totals", + measures=[{"formula": "sum(amt)", "name": "total"}], + filters=["customers.tier = 'gold'"], + )) + assert response.data == [{"customer_totals.total": 120.0}] + + +async def test_saved_query_backed_join_traverses_multihop_for_aggregate(saved_totals_engine): + response = await saved_totals_engine.execute(_query( + source_model="customer_totals", + dimensions=["customers.regions.name"], + measures=[{"formula": "sum(customers.credit)", "name": "credit"}], + )) + assert cells(response, dim_suffix="regions.name", measure="credit") == { + "North": 300.0, + "South": 300.0, + } + + +async def test_repeating_saved_join_in_extension_is_still_usable(saved_totals_engine): + model = _customer_totals() + response = await saved_totals_engine.execute(_query( + source_model={ + "source_name": "customer_totals", + "joins": [join.model_dump() for join in model.joins], + }, + dimensions=["customers.name"], + measures=[{"formula": "sum(amt)", "name": "total"}], + )) + assert cells(response, dim_suffix="customers.name", measure="total") == { + "Ann": 30.0, + "Bob": 30.0, + "Cid": 90.0, + } + + +async def test_repeating_stored_join_in_table_model_extension_is_that_edge(saved_totals_engine): + response = await saved_totals_engine.execute(_query( + source_model={"source_name": "orders", "joins": [ + orders_model().joins[0].model_copy(update={"description": "repeated"}).model_dump()]}, + dimensions=["customers.name"], + measures=[{"formula": "sum(amount)", "name": "total"}], + )) + assert cells(response, dim_suffix="customers.name", measure="total") == AMOUNT_BY_CUSTOMER + + +async def test_extension_join_differing_from_stored_one_stays_a_parallel_edge(saved_totals_engine): + query = _query( + source_model={"source_name": "orders", "joins": [ + {"target_model": "customers", "join_pairs": [["id", "id"]]}]}, + dimensions=["customers.name"], + measures=[{"formula": "sum(amount)", "name": "total"}], + ) + with pytest.raises(AmbiguousJoinPathError): + await saved_totals_engine.execute(query) diff --git a/tests/test_rank_direction_migration.py b/tests/test_rank_direction_migration.py index 2449c5eb7..77f4808b1 100644 --- a/tests/test_rank_direction_migration.py +++ b/tests/test_rank_direction_migration.py @@ -86,9 +86,9 @@ def test_gate(self, probe, payload, plain, stored): assert out["version"] == 3 def test_current_versions(self): - assert mig.CURRENT_VERSIONS["SlayerModel"] == 16 - assert mig.CURRENT_VERSIONS["SlayerQuery"] == 7 - assert mig.CURRENT_VERSIONS["Memory"] == 5 + assert mig.CURRENT_VERSIONS["SlayerModel"] == 17 + assert mig.CURRENT_VERSIONS["SlayerQuery"] == 8 + assert mig.CURRENT_VERSIONS["Memory"] == 6 for key in (("SlayerModel", 12), ("SlayerQuery", 4), ("Memory", 2)): assert key in mig._REGISTRY diff --git a/tests/test_reagg_outer_grain_attribution.py b/tests/test_reagg_outer_grain_attribution.py index 1a619db0a..967dfc06b 100644 --- a/tests/test_reagg_outer_grain_attribution.py +++ b/tests/test_reagg_outer_grain_attribution.py @@ -56,7 +56,7 @@ NAME_KEY = ColumnKey(leaf="name") ACCOUNT_KEY = ColumnKey(path=("account_snapshots",), leaf="account_id") MONTH_KEY = TimeTruncKey( - column=ColumnKey(path=("account_snapshots",), leaf="snapshot_date"), granularity=TimeGranularity.MONTH) + source=ColumnKey(path=("account_snapshots",), leaf="snapshot_date"), granularity=TimeGranularity.MONTH) BY_NAME_MONTH = ("name", "snapshot_date") BY_NAME_ACCOUNT = ("name", "account_id") SUM_MAX_Q_BY_OUTER_ACCOUNT = f"sum({MAX_Q}, partition_by=[{ACCOUNT}])" diff --git a/tests/test_saved_query_refinement.py b/tests/test_saved_query_refinement.py index c16b47151..a2f74b135 100644 --- a/tests/test_saved_query_refinement.py +++ b/tests/test_saved_query_refinement.py @@ -42,7 +42,7 @@ def dim_names(q: SlayerQuery) -> list[str | None]: def td_keys(q: SlayerQuery) -> list[tuple[str, str]]: - return [(td.dimension.full_name, str(td.granularity)) for td in q.time_dimensions or []] + return [(td.dimension, str(td.granularity)) for td in q.time_dimensions or []] def only_td(q: SlayerQuery) -> TimeDimension: @@ -76,13 +76,13 @@ def test_input_coercions_match_query(self) -> None: def test_functional_granularity_in_dimensions_becomes_time_dimension(self) -> None: r = QueryRefinement.model_validate({"dimensions": ["region", "year(ordered_at)"]}) assert [d.name for d in r.dimensions or []] == ["region"] - assert [(td.dimension.name, str(td.granularity)) for td in r.time_dimensions or []] == [ + assert [(td.dimension, str(td.granularity)) for td in r.time_dimensions or []] == [ ("ordered_at", "year"), ] def test_time_dimension_string_form(self) -> None: r = QueryRefinement.model_validate({"time_dimensions": ["year(ordered_at)"]}) - assert [(td.dimension.name, str(td.granularity)) for td in r.time_dimensions or []] == [ + assert [(td.dimension, str(td.granularity)) for td in r.time_dimensions or []] == [ ("ordered_at", "year"), ] diff --git a/tests/test_schema_drift_typed.py b/tests/test_schema_drift_typed.py index 4e285458a..bddda35a6 100644 --- a/tests/test_schema_drift_typed.py +++ b/tests/test_schema_drift_typed.py @@ -30,6 +30,7 @@ _build_stage_graph, _filter_refs_dsl, _measure_formula_refs, + _ref_stored_edges, ) from slayer.core.syntax import ( AggCall, @@ -110,6 +111,19 @@ def test_parallel_extension_targets_unaddressable_by_bare_name(self) -> None: ref="customers.name", base_name="customers", graph=graph, ) is None + def test_extension_repeating_a_stored_join_is_that_stored_edge(self) -> None: + join = ModelJoin(target_model="customers", join_pairs=[["customer_id", "id"]]) + models = _ext_models() + models["orders"] = models["orders"].model_copy(update={"joins": [join]}) + graph = _build_stage_graph( + stage=SlayerQuery(source_model=ModelExtension(source_name="orders", joins=[join])), + stage_source_name="orders", models_by_name=models, + ) + assert _attribute_ref_to_base( + ref="customers.name", base_name="customers", graph=graph, + ) == "name" + assert [e.target_model for e in _ref_stored_edges(ref="customers.name", graph=graph)] == ["customers"] + class TestWalkParsedRefs: """The shared reference-node yielder over a ``ParsedExpr`` tree.""" diff --git a/tests/test_sql_generator.py b/tests/test_sql_generator.py index d703104b6..d12b4ad91 100644 --- a/tests/test_sql_generator.py +++ b/tests/test_sql_generator.py @@ -223,7 +223,7 @@ async def test_time_dim_only_query_deduplicates( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await _generate(generator, query, orders_model) @@ -311,7 +311,7 @@ async def test_time_dimension_with_granularity(self, generator: SQLGenerator, or source_model="orders", measures=[ModelMeasure(formula="*:count")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await _generate(generator, query, orders_model) @@ -324,7 +324,7 @@ async def test_time_dimension_with_date_range(self, generator: SQLGenerator, ord measures=[ModelMeasure(formula="*:count")], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.DAY, date_range=["2024-01-01", "2024-12-31"], ), @@ -632,7 +632,7 @@ async def test_cumsum(self, generator: SQLGenerator, orders_model: SlayerModel) orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="cumsum(revenue:sum)", name="rev_running")], ) sql = await _generate(generator, query, orders_model) @@ -649,7 +649,7 @@ async def test_cumsum_over_week_sunday_time_dim( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.WEEK_SUNDAY, )], measures=[ @@ -671,7 +671,7 @@ async def test_time_shift_over_week_sunday_uses_one_week_interval( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.WEEK_SUNDAY, )], measures=[ @@ -688,7 +688,7 @@ async def test_cumsum_partitions_by_dimensions(self, generator: SQLGenerator, or query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "cumsum(revenue:sum)", "name": "running_revenue"}], ) sql = await _generate(generator=generator, query=query, model=orders_model) @@ -702,7 +702,7 @@ async def test_consecutive_periods_uses_reset_group_ctes(self, generator: SQLGen query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "consecutive_periods(revenue:sum > 0)", "name": "positive_streak"}], ) sql = await _generate(generator=generator, query=query, model=orders_model) @@ -726,7 +726,7 @@ async def test_consecutive_periods_no_implicit_nulls_last_sqlite( source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "consecutive_periods(revenue:sum > 0)", @@ -742,7 +742,7 @@ async def test_consecutive_periods_no_implicit_nulls_last_sqlite( async def test_consecutive_periods_comparison_generates_expression(self, generator: SQLGenerator, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "consecutive_periods(revenue:sum > 0) >= 2", "name": "long_enough"}], ) sql = await _generate(generator=generator, query=query, model=orders_model) @@ -756,7 +756,7 @@ async def test_windowed_sum_uses_range_join_primitive(self, generator: SQLGenera query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}], ) sql = await _generate(generator=generator, query=query, model=orders_model) @@ -781,8 +781,8 @@ async def test_windowed_sum_preserves_other_time_dim_grain( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), - TimeDimension(dimension=ColumnRef(name="delivery_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), + TimeDimension(dimension="delivery_at", granularity=TimeGranularity.MONTH), ], measures=[{"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}], ) @@ -825,7 +825,7 @@ async def test_windowed_sum_excludes_unrelated_joins( query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], # local to orders — no join needed - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ {"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}, {"formula": "customers.id:count_distinct", "name": "n_customers"}, @@ -848,7 +848,7 @@ async def test_windowed_sum_keeps_joins_used_by_query_filter( query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}], filters=["customers.region_id = 5"], ) @@ -898,7 +898,7 @@ async def test_windowed_sum_keeps_transitive_joins_for_multi_hop_filter( query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}], filters=["customers.regions.name = 'US'"], ) @@ -918,7 +918,7 @@ async def test_filter_on_windowed_measure_is_post_filter( """A filter on a windowed measure must apply post-aggregation, not as HAVING on the base CTE. The base CTE doesn't compute the windowed value — applying a HAVING there would use the wrong (non-windowed) aggregate.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}], filters=["revenue:sum(window='90d') > 100"], ) @@ -941,7 +941,7 @@ async def test_filter_on_windowed_measure_is_post_filter( async def test_window_duration_full_compact_syntax(self, generator: SQLGenerator, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.DAY)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY)], measures=[{"formula": "revenue:avg(window='1y2m3w5d6h7min8s')", "name": "avg_window"}], ) sql = await _generate(generator=generator, query=query, model=orders_model) @@ -962,7 +962,7 @@ async def test_window_duration_full_compact_syntax(self, generator: SQLGenerator async def test_windowed_sum_sqlite_duration_modifiers(self, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.DAY)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY)], measures=[{"formula": "revenue:sum(window='1w2d3h4min5s')", "name": "revenue_window"}], ) sql = await _generate( @@ -984,7 +984,7 @@ async def test_two_windowed_measures_emit_distinct_ctes( query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ {"formula": "revenue:sum(window='90d')", "name": "rev_90d"}, {"formula": "revenue:avg(window='30d')", "name": "rev_30d"}, @@ -1020,7 +1020,7 @@ async def test_windowed_source_column_filter_wraps_case_in_src( ) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "paid_revenue:sum(window='90d')", "name": "pr_90d"}], ) sql = await _generate(generator=generator, query=query, model=model) @@ -1052,7 +1052,7 @@ async def test_windowed_source_column_filter_crossing_join_kept_in_src( ) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "flagged_revenue:sum(window='90d')", "name": "fr_90d"}], ) sql = await _generate(generator=generator, query=query, model=orders, extra_models=[customers]) @@ -1087,7 +1087,7 @@ async def test_windowed_model_filter_crossing_join_kept_in_src( ) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], ) sql = await _generate(generator=generator, query=query, model=orders, extra_models=[customers]) @@ -1104,7 +1104,7 @@ async def test_order_by_windowed_alias_resolves( """ORDER BY the windowed measure's public alias must resolve against the combined SELECT (the ``_cm_`` join-back column), not the base.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "revenue_90d"}], order=[{"column": {"name": "revenue_90d"}, "direction": "desc"}], ) @@ -1131,7 +1131,7 @@ async def test_windowed_row_filter_applied_inside_src( """F4: a query WHERE-phase row filter constrains the ``_src`` scope (host-rooted scope inherits host row filters).""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], filters=["status = 'completed'"], ) @@ -1149,7 +1149,7 @@ async def test_windowed_date_range_not_applied_inside_src( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-06-01", "2024-12-31"], )], @@ -1173,7 +1173,7 @@ async def test_windowed_explicit_time_filter_does_not_truncate_src( """DEV-1732: an EXPLICIT relational bound on the raw window time column is a FRAME bound, not a population filter — so it bounds ``_base`` and is stripped from ``_src``, exactly like the ``date_range`` spelling of the same intent.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], filters=["created_at >= '2024-06-01'"], ) @@ -1194,7 +1194,7 @@ async def test_windowed_output_is_scope_closed( query = SlayerQuery( source_model="orders", dimensions=[ColumnRef(name="status")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_90d"}], ) sql = await _generate(generator=generator, query=query, model=orders_model) @@ -1208,7 +1208,7 @@ async def test_same_windowed_measure_two_aliases_both_surface( """Codex#2: the same windowed formula selected under two names interns to ONE slot / ONE ``_cm_`` CTE, but BOTH public aliases must surface in the combined projection (the CTE's single aggregate column is remapped ``AS`` each alias) — the later alias must not be dropped.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ {"formula": "revenue:sum(window='90d')", "name": "rev_a"}, {"formula": "revenue:sum(window='90d')", "name": "rev_b"}, @@ -1244,7 +1244,7 @@ async def test_windowed_joined_time_dimension_registers_join_in_src( query = SlayerQuery( source_model="orders", time_dimensions=[TimeDimension( - dimension=ColumnRef(name="customers.signup_at"), granularity=TimeGranularity.MONTH, + dimension="customers.signup_at", granularity=TimeGranularity.MONTH, )], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_w"}], ) @@ -1261,7 +1261,7 @@ async def test_time_shift_row_based(self, generator: SQLGenerator, orders_model: orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="time_shift(revenue:sum, -1)", name="rev_prev")], ) sql = await _generate(generator, query, orders_model) @@ -1274,7 +1274,7 @@ async def test_lag(self, generator: SQLGenerator, orders_model: SlayerModel) -> orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="lag(revenue:sum, 1)", name="rev_prev")], ) sql = await _generate(generator, query, orders_model) @@ -1285,7 +1285,7 @@ async def test_lead(self, generator: SQLGenerator, orders_model: SlayerModel) -> orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="lead(revenue:sum, 1)", name="rev_next")], ) sql = await _generate(generator, query, orders_model) @@ -1296,7 +1296,7 @@ async def test_change(self, generator: SQLGenerator, orders_model: SlayerModel) orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="change(revenue:sum)", name="rev_change")], ) sql = await _generate(generator, query, orders_model) @@ -1311,7 +1311,7 @@ async def test_change_pct(self, generator: SQLGenerator, orders_model: SlayerMod orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="change_pct(revenue:sum)", name="rev_pct")], ) sql = await _generate(generator, query, orders_model) @@ -1334,7 +1334,7 @@ async def test_last(self, generator: SQLGenerator, orders_model: SlayerModel) -> orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="last(revenue:sum)", name="latest_rev")], ) sql = await _generate(generator, query, orders_model) @@ -1347,7 +1347,7 @@ async def test_last_measure_type(self, generator: SQLGenerator, orders_model: Sl orders_model.columns.append(Column(name="balance", sql="balance", type=DataType.DOUBLE)) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="balance:last")], ) sql = await _generate(generator, query, orders_model) @@ -1366,7 +1366,7 @@ async def test_last_with_explicit_time_column(self, generator: SQLGenerator, ord orders_model.columns.append(Column(name="ordered_at", sql="ordered_at", type=DataType.TIMESTAMP)) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="balance:last(ordered_at)")], ) sql = await _generate(generator, query, orders_model) @@ -1381,7 +1381,7 @@ async def test_first_with_explicit_time_column(self, generator: SQLGenerator, or orders_model.columns.append(Column(name="ordered_at", sql="ordered_at", type=DataType.TIMESTAMP)) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="balance:first(ordered_at)")], ) sql = await _generate(generator, query, orders_model) @@ -1396,7 +1396,7 @@ async def test_multiple_last_different_time_columns(self, generator: SQLGenerato orders_model.columns.append(Column(name="updated_at", sql="updated_at", type=DataType.TIMESTAMP)) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="revenue:last(ordered_at)"), ModelMeasure(formula="balance:last(updated_at)"), @@ -1418,7 +1418,7 @@ async def test_mixed_explicit_and_default_time_columns(self, generator: SQLGener orders_model.columns.append(Column(name="ordered_at", sql="ordered_at", type=DataType.TIMESTAMP)) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="revenue:last"), ModelMeasure(formula="balance:last(ordered_at)"), @@ -1436,7 +1436,7 @@ async def test_same_explicit_time_column_shared(self, generator: SQLGenerator, o orders_model.columns.append(Column(name="ordered_at", sql="ordered_at", type=DataType.TIMESTAMP)) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="revenue:last(ordered_at)"), ModelMeasure(formula="balance:first(ordered_at)"), @@ -1455,7 +1455,7 @@ async def test_time_shift(self, generator: SQLGenerator, orders_model: SlayerMod orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="time_shift(revenue:sum, -1, 'year')", name="rev_prev_year")], ) sql = await _generate(generator, query, orders_model) @@ -1470,7 +1470,7 @@ async def test_time_shift_shifted_date_range(self, generator: SQLGenerator, orde source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], ) @@ -1490,7 +1490,7 @@ async def test_time_shift_yoy_shifted_date_range(self, generator: SQLGenerator, source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], ) @@ -1508,7 +1508,7 @@ async def test_change_shifted_date_range(self, generator: SQLGenerator, orders_m source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], ) @@ -1525,7 +1525,7 @@ async def test_no_date_range_no_shift(self, generator: SQLGenerator, orders_mode orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="time_shift(revenue:sum, -1, 'month')", name="rev_prev")], ) sql = await _generate(generator, query, orders_model) @@ -1539,7 +1539,7 @@ async def test_forward_time_shift_with_date_range(self, generator: SQLGenerator, source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-03-01", "2024-03-31"], ) @@ -1557,7 +1557,7 @@ async def test_quarter_date_shift(self, generator: SQLGenerator, orders_model: S source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.QUARTER, date_range=["2024-07-01", "2024-09-30"], ) @@ -1579,7 +1579,7 @@ async def test_multiple_time_shifts_in_arithmetic_unique_ctes( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) ], measures=[ ModelMeasure(formula="revenue:sum - time_shift(revenue:sum, -1, 'month')", name="growth"), @@ -1601,7 +1601,7 @@ async def test_multiple_time_shifts_resolve_to_distinct_aliases( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) ], measures=[ ModelMeasure(formula="revenue:sum - time_shift(revenue:sum, -1, 'month')", name="growth"), @@ -1650,7 +1650,7 @@ async def test_hidden_transform_name_avoids_user_measure_collision( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) ], measures=[ ModelMeasure(formula="revenue:sum - time_shift(revenue:sum, -1, 'month')", name="growth"), @@ -1688,7 +1688,7 @@ async def test_hidden_transform_name_avoids_dimension_collision( source_model="orders", dimensions=[ColumnRef(name="_t0_growth")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) ], measures=[ ModelMeasure(formula="revenue:sum - time_shift(revenue:sum, -1, 'month')", name="growth"), @@ -1719,7 +1719,7 @@ async def test_dev_1692_repro_shape( source_model="orders", dimensions=[ColumnRef(name="status")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH) + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH) ], measures=[ ModelMeasure(formula="revenue:sum", name="total_revenue"), @@ -1744,7 +1744,7 @@ async def test_nested_self_join_generates(self, generator: SQLGenerator, orders_ orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="change(time_shift(revenue:sum, -1, 'year'))", name="x")], ) sql = await _generate(generator, query, orders_model) @@ -1755,7 +1755,7 @@ async def test_post_filter_on_computed_column(self, generator: SQLGenerator, ord orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="change(revenue:sum)", name="rev_change")], filters=["rev_change < 0"], ) @@ -1769,7 +1769,7 @@ async def test_inline_transform_filter(self, generator: SQLGenerator, orders_mod orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum")], filters=["last(change(revenue:sum)) < 0"], ) @@ -1783,7 +1783,7 @@ async def test_mixed_base_and_post_filters(self, generator: SQLGenerator, orders orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="change(revenue:sum)", name="rev_change")], filters=["status == 'completed'", "rev_change > 0"], ) @@ -2055,7 +2055,7 @@ async def test_rank_partition_by_time_dimension( source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH + dimension="created_at", granularity=TimeGranularity.MONTH ) ], measures=[ @@ -2188,7 +2188,7 @@ async def test_nested_cumsum_of_change_generates_stacked_ctes(self, generator: S orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="cumsum(change(revenue:sum))", name="delta"), @@ -2205,7 +2205,7 @@ async def test_change_of_cumsum_generates(self, generator: SQLGenerator, orders_ orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="change(cumsum(revenue:sum))", name="delta"), @@ -2220,7 +2220,7 @@ async def test_mixed_arithmetic_with_transform(self, generator: SQLGenerator, or orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ ModelMeasure(formula="*:count"), ModelMeasure(formula="revenue:sum"), @@ -2340,7 +2340,7 @@ async def test_date_trunc(self, dialect: str, orders_model: SlayerModel) -> None query = SlayerQuery( source_model="orders", measures=[ModelMeasure(formula="*:count")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], ) sql = await _generate(gen, query, orders_model) assert "COUNT(" in sql @@ -2368,7 +2368,7 @@ async def test_date_trunc_casts_unknown_typed_time_dim(self, dialect: str) -> No SlayerQuery( source_model="orders", measures=[ModelMeasure(formula="*:count")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="ts"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="ts", granularity=TimeGranularity.MONTH)], ), model, ) @@ -2380,7 +2380,7 @@ async def test_date_trunc_casts_unknown_typed_time_dim(self, dialect: str) -> No SlayerQuery( source_model="orders", measures=[ModelMeasure(formula="*:count")], - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], ), model, ) @@ -2394,7 +2394,7 @@ async def test_calendar_time_shift(self, dialect: str, orders_model: SlayerModel gen = SQLGenerator(dialect=dialect) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ModelMeasure(formula="revenue:sum"), ModelMeasure(formula="time_shift(revenue:sum, -1, 'year')", name="rev_prev_year")], ) sql = await _generate(gen, query, orders_model) @@ -2413,7 +2413,7 @@ async def test_window_measure_multi_unit_interval_dialect_correct( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), + TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="revenue:sum(window='1y2m3d')", @@ -2446,7 +2446,7 @@ async def test_window_measure_single_unit_interval_dialect_correct( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), + TimeDimension(dimension="created_at", granularity=TimeGranularity.DAY), ], measures=[ModelMeasure(formula="revenue:sum(window='7d')", @@ -2753,7 +2753,7 @@ async def test_time_dimension_sql_with_path_alias_infers_joins(self, storage) -> source_model="events", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="user_signup_date"), + dimension="user_signup_date", granularity=TimeGranularity.MONTH, ) ], @@ -3256,7 +3256,7 @@ async def test_post_transform_wrapping_path_aliased_measure( measures=[ModelMeasure(formula="cumsum(region_pop_sum:sum)")], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -4033,7 +4033,7 @@ async def test_filtered_last_masks_the_value_over_full_ranking( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], measures=[ModelMeasure(formula="completed_balance:last")], ) @@ -4058,7 +4058,7 @@ async def test_filtered_first_masks_the_value_over_full_ranking( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], measures=[ModelMeasure(formula="completed_balance:first")], ) @@ -4079,7 +4079,7 @@ async def test_unfiltered_last_unchanged( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], measures=[ModelMeasure(formula="balance:last")], ) @@ -4102,7 +4102,7 @@ async def test_mixed_filtered_and_unfiltered_last( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], measures=[ ModelMeasure(formula="balance:last"), @@ -4150,7 +4150,7 @@ async def _filtered_last_cross_model_sql(generator: SQLGenerator) -> str: query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], measures=[ModelMeasure(formula="active_balance:last")], ) @@ -4256,7 +4256,7 @@ async def test_two_filtered_lasts_same_source_different_filters_dont_collide( query = SlayerQuery( source_model="orders", time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], measures=[ ModelMeasure(formula="active_balance:last"), @@ -6485,7 +6485,7 @@ async def test_rerooted_with_time_dimension(self, generator, _models): source_model="policy", measures=[ModelMeasure(formula="policy_amount.total_policy_amount:sum")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="policy_amount.effective_date"), + dimension="policy_amount.effective_date", granularity=TimeGranularity.MONTH, )], ) @@ -6654,7 +6654,7 @@ async def test_order_by_custom_name_in_computed_query(self, generator: SQLGenera ], dimensions=[ColumnRef(name="status")], time_dimensions=[TimeDimension( - dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH, + dimension="created_at", granularity=TimeGranularity.MONTH, )], order=[OrderItem(column=ColumnRef(name="num_customers"), direction="desc")], ) @@ -7376,7 +7376,7 @@ async def test_isolated_last_no_ranked_subquery_in_base( measures=[ModelMeasure(formula="latest_payment:last")], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await self._sql(claim_amount_model_with_time, related_models, query) @@ -7402,7 +7402,7 @@ async def test_isolated_last_cte_has_valid_ranked_subquery( measures=[ModelMeasure(formula="latest_payment:last")], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await self._sql(claim_amount_model_with_time, related_models, query) @@ -7434,7 +7434,7 @@ async def test_mixed_isolated_and_local_first_last( ], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await self._sql(claim_amount_model_with_time, related_models, query) @@ -7477,7 +7477,7 @@ async def test_isolated_first_with_explicit_time_column( measures=[ModelMeasure(formula="earliest_reserve:first(updated_at)")], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await self._sql(claim_amount_model_with_time, related_models, query) @@ -7508,7 +7508,7 @@ async def test_multiple_isolated_first_last_separate_ctes( ], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], ) sql = await self._sql(claim_amount_model_with_time, related_models, query) @@ -7712,7 +7712,7 @@ async def test_post_phase_filter_not_routed_to_outer_combined_where( ModelMeasure(formula="cumsum(loss_payment_amt:sum)", name="cum_loss"), ], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], filters=["cumsum(loss_payment_amt:sum) > 0"], ) @@ -7751,7 +7751,7 @@ async def test_aggregate_and_post_filters_route_independently( ModelMeasure(formula="cumsum(loss_payment_amt:sum)", name="cum_loss"), ], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], filters=[ "loss_payment_amt:sum > 1000", @@ -7824,7 +7824,7 @@ async def test_filtered_local_parametric_last_in_order_by( ], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], order=[ OrderItem(column="latest_pmt", direction="desc"), @@ -7860,7 +7860,7 @@ async def test_filtered_local_parametric_last_in_having( measures=[ModelMeasure(formula="latest_payment:last(updated_at)", name="latest_pmt")], dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ - TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH), + TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH), ], filters=["latest_pmt > 500"], ) @@ -7931,7 +7931,7 @@ async def test_aggregate_filter_with_date_range_routes_correctly( dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=("2020-01-01", "2021-01-01"), ), @@ -8045,7 +8045,7 @@ async def test_local_first_last_with_routed_cross_model_filter( dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -8135,7 +8135,7 @@ async def test_first_last_with_host_filter_not_reapplied_outside_ranked_subquery dimensions=[ColumnRef(name="claim.claim_number")], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -8872,7 +8872,7 @@ async def test_windowed_sum_with_measure_type_wraps_in_cast( source_model="orders_for_window", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -8902,7 +8902,7 @@ async def test_windowed_sum_no_measure_type_casts_inferred_type( source_model="orders_for_window2", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -10100,7 +10100,7 @@ def orders_model(self) -> SlayerModel: async def test_windowed_non_sum_avg_raises(self, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:min(window='30d')", "name": "rev_w"}], ) # DEV-1915 lift: window= is no longer restricted to sum/avg; min renders. @@ -10140,7 +10140,7 @@ async def test_windowed_cross_model_raises(self, tmp_path) -> None: engine = SlayerQueryEngine(storage=storage) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "customers.revenue:sum(window='30d')", "name": "rev_w"}], ) # DEV-1836: fails closed with a precise attributability ValueError (the active time dimension is a host column, unreachable from customers). @@ -10151,7 +10151,7 @@ async def test_windowed_with_transform_raises(self, orders_model: SlayerModel) - orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[ {"formula": "revenue:sum(window='90d')", "name": "rev_w"}, {"formula": "time_shift(revenue:sum, -1)", "name": "rev_prev"}, @@ -10166,7 +10166,7 @@ async def test_windowed_as_transform_input_raises(self, orders_model: SlayerMode orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "cumsum(revenue:sum(window='90d'))", "name": "cum_w"}], ) # DEV-1835 lift: a windowed aggregate feeding a transform now renders instead of raising; the emitted SQL must be scope-closed and fully lowered (no ``__regroup__`` sentinel). @@ -10177,7 +10177,7 @@ async def test_windowed_as_transform_input_raises(self, orders_model: SlayerMode async def test_windowed_in_arithmetic_raises(self, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d') / 2", "name": "half_w"}], ) # DEV-1835 lift: a windowed aggregate inside scalar arithmetic now renders instead of raising; scope-closed and fully lowered. @@ -10188,7 +10188,7 @@ async def test_windowed_in_arithmetic_raises(self, orders_model: SlayerModel) -> async def test_windowed_filter_only_hidden_raises(self, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum", "name": "rev"}], filters=["revenue:sum(window='90d') > 100"], ) @@ -10200,7 +10200,7 @@ async def test_windowed_filter_only_hidden_raises(self, orders_model: SlayerMode async def test_windowed_mixed_aggregate_filter_raises(self, orders_model: SlayerModel) -> None: query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_w"}], filters=["revenue:sum(window='90d') > 100 and revenue:sum > 50"], ) @@ -10217,7 +10217,7 @@ async def test_windowed_malformed_duration_raises( """A malformed compact duration must raise a clear ``ValueError`` at plan time, not silently degrade.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": f"revenue:sum(window='{bad}')", "name": "rev_w"}], ) with pytest.raises(ValueError, match=r"Use syntax like '1y2m3w5d6h7min8s'"): @@ -10227,7 +10227,7 @@ async def test_windowed_empty_duration_raises(self, orders_model: SlayerModel) - """An empty window duration must raise the distinct empty-duration error.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='')", "name": "rev_w"}], ) with pytest.raises(ValueError, match="cannot be empty"): @@ -10237,7 +10237,7 @@ async def test_windowed_non_string_duration_raises(self, orders_model: SlayerMod """Codex C4: a non-string ``window`` value (e.g. ``window=90``) must be normalized to a ``ValueError`` — never a raw ``TypeError`` from feeding a number to the compact-duration regex.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window=90)", "name": "rev_w"}], ) with pytest.raises(ValueError): @@ -10257,7 +10257,7 @@ async def test_custom_aggregation_with_window_raises(self) -> None: ) query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "amount_col:myagg(window='90d')", "name": "w"}], ) sql = await _engine_generate(query=query, model=model) @@ -10271,7 +10271,7 @@ async def test_windowed_transform_input_precedence_not_selected( orders_model.default_time_dimension = "created_at" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "cumsum(revenue:sum(window='90d'))", "name": "cum_w"}], ) # DEV-1835: the G4/G6 guards are gone — a windowed aggregate nested in a transform now renders; the SQL must be scope-closed and fully lowered. @@ -10295,7 +10295,7 @@ def test_windowed_guards_fire_at_plan_time(self, case: str) -> None: # NOSONAR( created_at, Column(name="revenue", sql="amount", type=DataType.DOUBLE), ] - td = [TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)] + td = [TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)] def _plain() -> SlayerModel: return SlayerModel( @@ -10397,7 +10397,7 @@ async def test_windowed_mixed_row_filter_raises(self, orders_model: SlayerModel) """Codex round 5: a single filter mixing a windowed measure with a ROW predicate (not just a plain aggregate — G7's case) is reclassified whole to POST, so the row part would neither filter pre-aggregation nor resolve against an unprojected ``_base`` column. Reject it at plan time.""" query = SlayerQuery( source_model="orders", - time_dimensions=[TimeDimension(dimension=ColumnRef(name="created_at"), granularity=TimeGranularity.MONTH)], + time_dimensions=[TimeDimension(dimension="created_at", granularity=TimeGranularity.MONTH)], measures=[{"formula": "revenue:sum(window='90d')", "name": "rev_w"}], filters=["revenue:sum(window='90d') > 100 and status = 'active'"], ) diff --git a/tests/test_sqlite_memories_pk_migration.py b/tests/test_sqlite_memories_pk_migration.py index db69c9fd4..076999edb 100644 --- a/tests/test_sqlite_memories_pk_migration.py +++ b/tests/test_sqlite_memories_pk_migration.py @@ -180,7 +180,7 @@ async def test_legacy_int_rows_round_trip_through_v2_load( assert rows[0].id == "1" assert rows[0].learning == "legacy" assert rows[0].entities == ["mydb.orders"] - assert rows[0].version == 5 + assert rows[0].version == 6 @pytest.fixture(autouse=True) diff --git a/tests/test_stored_upgrade_backing_sql.py b/tests/test_stored_upgrade_backing_sql.py index 32f2982ef..b10b403e5 100644 --- a/tests/test_stored_upgrade_backing_sql.py +++ b/tests/test_stored_upgrade_backing_sql.py @@ -55,7 +55,7 @@ async def test_v14_model_loads_without_backing_sql(storage: StorageBackend) -> N assert model is not None assert "backing_query_sql" not in model.model_dump() doc = await raw_doc(storage, "rev") - assert doc["version"] == 16 + assert doc["version"] == 17 assert "backing_query_sql" not in doc diff --git a/tests/test_stored_upgrade_rank_orders.py b/tests/test_stored_upgrade_rank_orders.py index 1cb31c899..250efafde 100644 --- a/tests/test_stored_upgrade_rank_orders.py +++ b/tests/test_stored_upgrade_rank_orders.py @@ -68,9 +68,9 @@ def shop_ds(tmp_path): def test_current_versions(): - assert mig.CURRENT_VERSIONS["SlayerModel"] == 16 - assert mig.CURRENT_VERSIONS["SlayerQuery"] == 7 - assert mig.CURRENT_VERSIONS["Memory"] == 5 + assert mig.CURRENT_VERSIONS["SlayerModel"] == 17 + assert mig.CURRENT_VERSIONS["SlayerQuery"] == 8 + assert mig.CURRENT_VERSIONS["Memory"] == 6 for key in (("SlayerModel", 13), ("SlayerQuery", 5), ("Memory", 3)): assert key in mig._REGISTRY assert key in mig._STORED_ONLY diff --git a/tests/test_time_dimensions_filters.py b/tests/test_time_dimensions_filters.py index fb27953bb..5f855663c 100644 --- a/tests/test_time_dimensions_filters.py +++ b/tests/test_time_dimensions_filters.py @@ -28,7 +28,7 @@ from slayer.core.errors import AmbiguousReferenceError, UnknownReferenceError from slayer.core.keys import ArithmeticKey, ColumnKey, LiteralKey, Phase, walk_value_keys from slayer.core.models import Column, ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.ir.source_bundle import ResolvedSourceBundle from slayer.engine.bind_inputs import _resolve_main_time_dimension from slayer.engine.plan import plan_query @@ -116,7 +116,7 @@ def test_no_time_dimensions_returns_none(self) -> None: def test_single_td_returns_it(self) -> None: model = _orders_model() td = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery(source_model="orders", time_dimensions=[td]) @@ -126,11 +126,11 @@ def test_single_td_returns_it(self) -> None: def test_multi_td_no_disambiguator_returns_none(self) -> None: model = _orders_model() td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery(source_model="orders", time_dimensions=[td_a, td_b]) @@ -139,11 +139,11 @@ def test_multi_td_no_disambiguator_returns_none(self) -> None: def test_multi_td_main_time_dimension_matches_by_leaf(self) -> None: model = _orders_model() td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -157,11 +157,11 @@ def test_multi_td_main_time_dimension_matches_by_leaf(self) -> None: def test_multi_td_main_time_dimension_matches_by_full_name(self) -> None: model = _orders_with_customers_join() td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -195,11 +195,11 @@ def test_full_name_wins_over_leaf_match(self) -> None: ], ) td_local = TimeDimension( - dimension=ColumnRef(name="signed_up_at"), + dimension="signed_up_at", granularity=TimeGranularity.MONTH, ) td_joined = TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -218,7 +218,7 @@ def test_single_td_ignores_main_time_dimension(self) -> None: # is only consulted as a disambiguator for 2+ TDs. model = _orders_model() td = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -232,11 +232,11 @@ def test_single_td_ignores_main_time_dimension(self) -> None: def test_multi_td_default_time_dimension_falls_back_when_no_main(self) -> None: model = _orders_model(default_td="reviewed_at") td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery(source_model="orders", time_dimensions=[td_a, td_b]) @@ -246,11 +246,11 @@ def test_multi_td_default_time_dimension_falls_back_when_no_main(self) -> None: def test_query_main_td_wins_over_model_default(self) -> None: model = _orders_model(default_td="reviewed_at") td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -264,11 +264,11 @@ def test_query_main_td_wins_over_model_default(self) -> None: def test_unknown_main_time_dimension_raises(self) -> None: model = _orders_model() td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -305,11 +305,11 @@ def test_ambiguous_leaf_match_raises(self) -> None: ], ) td_a = TimeDimension( - dimension=ColumnRef(name="customers.created_at"), + dimension="customers.created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="payments.created_at"), + dimension="payments.created_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -334,7 +334,7 @@ def test_default_td_picks_host_local_over_joined(self) -> None: model = _orders_with_customers_join() model = model.model_copy(update={"default_time_dimension": "created_at"}) td_joined = TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.MONTH, ) # No local TD whose leaf matches "created_at" in the query — @@ -368,11 +368,11 @@ def test_default_td_picks_host_local_over_joined(self) -> None: default_time_dimension="created_at", # doesn't match any LOCAL TD ) td_local = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) td_joined2 = TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery( @@ -399,11 +399,11 @@ def test_default_td_matches_local_td(self) -> None: default_time_dimension="created_at", ) td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery(source_model="orders", time_dimensions=[td_a, td_b]) @@ -417,11 +417,11 @@ def test_unknown_default_td_does_not_raise_but_returns_none(self) -> None: # didn't include this query). model = _orders_model(default_td="not_in_query") td_a = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) td_b = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, ) q = SlayerQuery(source_model="orders", time_dimensions=[td_a, td_b]) @@ -467,7 +467,7 @@ def test_date_range_emits_filter_on_underlying_column(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), @@ -486,7 +486,7 @@ def test_date_operand_gets_date_literals(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03"], ), @@ -502,7 +502,7 @@ def test_date_range_filter_is_row_phase(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), @@ -518,7 +518,7 @@ def test_no_date_range_no_filter(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -531,7 +531,7 @@ def test_date_range_with_joined_td(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.DAY, date_range=["2024-01-01", "2024-03-01"], ), @@ -555,7 +555,7 @@ def test_user_filter_and_date_range_coexist(self) -> None: filters=["amount > 0"], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), @@ -584,12 +584,12 @@ def test_multiple_tds_each_with_date_range(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.MONTH, date_range=["2024-02-01", "2024-04-01"], ), @@ -611,7 +611,7 @@ def test_multiple_tds_each_with_date_range(self) -> None: assert and_created is not and_reviewed def test_empty_date_range_is_rejected_at_construction(self) -> None: - dimension = ColumnRef(name="created_at") + dimension = "created_at" with pytest.raises(pydantic.ValidationError): TimeDimension( dimension=dimension, @@ -624,7 +624,7 @@ def test_single_element_date_range_emits_one_period_filter(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01"], ), @@ -641,7 +641,7 @@ def test_date_range_filter_carries_expression(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), @@ -673,7 +673,7 @@ def test_raw_column_slot_materialized_hidden(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), @@ -711,7 +711,7 @@ def test_filter_does_not_reference_timetrunckey(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, date_range=["2024-01-01", "2024-03-01"], ), diff --git a/tests/test_time_dimensions_planner.py b/tests/test_time_dimensions_planner.py index 6bdc25126..1ecb094d1 100644 --- a/tests/test_time_dimensions_planner.py +++ b/tests/test_time_dimensions_planner.py @@ -30,7 +30,7 @@ from slayer.core.errors import TimeDimensionColumnError, UnknownReferenceError from slayer.core.keys import AggregateKey, ColumnKey, Phase, TimeTruncKey from slayer.core.models import Column, ModelJoin, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.core.scope import ModelScope from slayer.engine.binding import bind_time_dimension from slayer.engine.elaborate_env import check_time_dimension_column @@ -131,7 +131,7 @@ def _orders_with_derived_temporal() -> SlayerModel: """Host model with a derived (Column.sql) temporal column. Used to pin the 7b.3b rejection contract: derived TD columns route - through ``ColumnSqlKey`` and ``TimeTruncKey.column`` is typed as + through ``ColumnSqlKey`` and ``TimeTruncKey.source`` is typed as ``ColumnKey``, so the binder must raise rather than silently produce an ill-typed key. A later issue can widen TimeTruncKey if/when this case is needed. @@ -193,28 +193,28 @@ class TestBindTimeDimension: def test_local_td_yields_timetrunckey(self) -> None: model = _orders_model() td = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) bound = bind_time_dimension( td, scope=ModelScope(source_model=model), bundle=_bundle_local(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnKey(path=(), leaf="created_at") + assert bound.bound.value_key.source == ColumnKey(path=(), leaf="created_at") assert bound.bound.value_key.granularity == "month" assert bound.bound.phase == Phase.ROW def test_joined_td_path_is_populated(self) -> None: host = _orders_with_customers_join() td = TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.DAY, ) bound = bind_time_dimension( td, scope=ModelScope(source_model=host), bundle=_bundle_joined(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnKey( + assert bound.bound.value_key.source == ColumnKey( path=("customers",), leaf="signed_up_at", ) assert bound.bound.value_key.granularity == "day" @@ -224,13 +224,13 @@ def test_non_temporal_column_rejected(self) -> None: with pytest.raises(TimeDimensionColumnError, match="temporal"): check_time_dimension_column( name="status", column_type=DataType.TEXT, - upstream_granularity=None, requested_granularity=TimeGranularity.MONTH, + requested_granularity=TimeGranularity.MONTH, ) def test_unknown_column_raises_unknown_reference(self) -> None: model = _orders_model() td = TimeDimension( - dimension=ColumnRef(name="not_a_column"), + dimension="not_a_column", granularity=TimeGranularity.MONTH, ) scope = ModelScope(source_model=model) @@ -243,7 +243,7 @@ def test_unknown_column_raises_unknown_reference(self) -> None: def test_model_scope_without_source_model_rejected(self) -> None: td = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) scope = ModelScope(source_model=None) @@ -255,7 +255,7 @@ def test_date_column_accepted(self) -> None: # DATE is in the same temporal bucket as TIMESTAMP — should bind. model = _orders_model() td = TimeDimension( - dimension=ColumnRef(name="reviewed_at"), + dimension="reviewed_at", granularity=TimeGranularity.WEEK, ) bound = bind_time_dimension( @@ -264,7 +264,7 @@ def test_date_column_accepted(self) -> None: bundle=_bundle_local(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - col_key = bound.bound.value_key.column + col_key = bound.bound.value_key.source assert isinstance(col_key, ColumnKey) assert col_key.leaf == "reviewed_at" assert bound.bound.value_key.granularity == "week" @@ -274,7 +274,7 @@ def test_multi_hop_joined_td(self) -> None: # multi-hop path. The bound key carries the full path. host = _orders_with_customers_join() td = TimeDimension( - dimension=ColumnRef(name="customers.regions.opened_at"), + dimension="customers.regions.opened_at", granularity=TimeGranularity.MONTH, ) bound = bind_time_dimension( @@ -283,19 +283,19 @@ def test_multi_hop_joined_td(self) -> None: bundle=_bundle_multi_hop(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnKey( + assert bound.bound.value_key.source == ColumnKey( path=("customers", "regions"), leaf="opened_at", ) assert bound.bound.value_key.granularity == "month" def test_derived_column_sql_td_binds(self) -> None: # A derived (Column.sql) temporal column now - # binds to ``TimeTruncKey(column=ColumnSqlKey(...))`` — full support, + # binds to ``TimeTruncKey(source=ColumnSqlKey(...))`` — full support, # no NotImplementedError. The grain still rides on the TimeTruncKey. host = _orders_with_derived_temporal() td = TimeDimension( - dimension=ColumnRef(name="created_day"), + dimension="created_day", granularity=TimeGranularity.DAY, ) bound = bind_time_dimension( @@ -304,7 +304,7 @@ def test_derived_column_sql_td_binds(self) -> None: bundle=_bundle_derived(), ) assert isinstance(bound.bound.value_key, TimeTruncKey) - assert bound.bound.value_key.column == ColumnSqlKey( + assert bound.bound.value_key.source == ColumnSqlKey( path=(), model="orders", column_name="created_day", ) assert bound.bound.value_key.granularity == "day" @@ -319,7 +319,7 @@ def test_derived_column_sql_td_binds(self) -> None: class TestPlanningPrimitivesForTimeTruncKey: def test_iter_slot_deps_yields_timetrunckey(self) -> None: key = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) deps = list(_iter_slot_deps(key)) @@ -335,7 +335,7 @@ def test_iter_slot_deps_does_not_force_inner_column_slot(self) -> None: # adding a time dimension does not auto-add the raw column as a # separate output column.) col = ColumnKey(path=(), leaf="created_at") - key = TimeTruncKey(column=col, granularity=TimeGranularity.MONTH) + key = TimeTruncKey(source=col, granularity=TimeGranularity.MONTH) deps = list(_iter_slot_deps(key)) assert deps == [key] @@ -343,21 +343,21 @@ def test_canonical_name_local_no_grain_suffix(self) -> None: # Legacy result-key contract: alias is the column name only — # the granularity goes into the SQL DATE_TRUNC, not the alias. key = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) assert _canonical_name(key) == "created_at" def test_canonical_name_joined_uses_dunder_path(self) -> None: key = TimeTruncKey( - column=ColumnKey(path=("customers",), leaf="signed_up_at"), + source=ColumnKey(path=("customers",), leaf="signed_up_at"), granularity=TimeGranularity.DAY, ) assert _canonical_name(key) == "customers__signed_up_at" def test_canonical_name_multi_hop_path(self) -> None: key = TimeTruncKey( - column=ColumnKey(path=("customers", "regions"), leaf="opened_at"), + source=ColumnKey(path=("customers", "regions"), leaf="opened_at"), granularity=TimeGranularity.MONTH, ) assert _canonical_name(key) == "customers__regions__opened_at" @@ -372,11 +372,11 @@ class TestValueRegistryTimeTrunc: def test_same_column_same_grain_interns_once(self) -> None: reg = ValueRegistry() k1 = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) k2 = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) sid1 = reg.intern(key=k1, declared_name="created_at", phase=Phase.ROW) @@ -389,11 +389,11 @@ def test_different_grain_distinct_slots(self) -> None: # they share an underlying column. reg = ValueRegistry() k_month = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) k_day = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.DAY, ) sid_m = reg.intern( @@ -407,11 +407,11 @@ def test_different_grain_distinct_slots(self) -> None: def test_different_column_distinct_slots(self) -> None: reg = ValueRegistry() k_a = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) k_b = TimeTruncKey( - column=ColumnKey(path=(), leaf="reviewed_at"), + source=ColumnKey(path=(), leaf="reviewed_at"), granularity=TimeGranularity.MONTH, ) sid_a = reg.intern(key=k_a, declared_name="created_at", phase=Phase.ROW) @@ -428,7 +428,7 @@ class TestProjectionPlannerTimeDimensions: def test_td_declared_measure_is_public_not_hidden(self) -> None: model = _orders_model() td = TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ) bound = bind_time_dimension( @@ -469,7 +469,7 @@ def test_local_td_appears_in_projection(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -493,7 +493,7 @@ def test_label_propagates_to_stage_column(self) -> None: name="s1", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, label="Order date", ), @@ -517,7 +517,7 @@ def test_joined_td_stage_column_uses_flat_dunder_form(self) -> None: name="s1", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.DAY, ), ], @@ -534,7 +534,7 @@ def test_joined_td_label_propagates(self) -> None: name="s1", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="customers.signed_up_at"), + dimension="customers.signed_up_at", granularity=TimeGranularity.DAY, label="Customer signup", ), @@ -554,7 +554,7 @@ def test_multi_hop_td_in_stage_schema(self) -> None: name="s1", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="customers.regions.opened_at"), + dimension="customers.regions.opened_at", granularity=TimeGranularity.MONTH, ), ], @@ -572,7 +572,7 @@ def test_td_buckets_into_row_slots(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -592,7 +592,7 @@ def test_td_coexists_with_aggregate_measure(self) -> None: source_model="orders", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -611,7 +611,7 @@ def test_stage_schema_emits_one_td_column(self) -> None: name="s1", time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], diff --git a/tests/test_time_points_exec.py b/tests/test_time_points_exec.py index a770cc093..b863074bb 100644 --- a/tests/test_time_points_exec.py +++ b/tests/test_time_points_exec.py @@ -381,7 +381,6 @@ async def test_non_temporal_operand_rejected_in_measure(self, engine) -> None: @pytest.mark.parametrize("filt", [ "month() >= '2024-01-01'", "month(ts, d) >= '2024-01-01'", - "month(upper(code)) >= '2024-01-01'", "month(*) >= '2024-01-01'", "month(ts, granularity=2) >= '2024-01-01'", ]) @@ -391,7 +390,11 @@ async def test_wrong_shape_rejected(self, engine, filt) -> None: msg = str(ei.value) for gran in GRANULARITIES: assert gran in msg, f"error must name granularity {gran!r}: {msg}" - assert "(col" in msg, msg + assert "(expr" in msg, msg + + async def test_non_temporal_expression_operand_rejected(self, engine) -> None: + with pytest.raises(DateOperandTypeError, match=r"upper\(code\)"): + await ids(engine, "month(upper(code)) >= '2024-01-01'") class TestDateFunctionGrammar: diff --git a/tests/test_time_trunc_key.py b/tests/test_time_trunc_key.py index 9f76b695e..31dd759ae 100644 --- a/tests/test_time_trunc_key.py +++ b/tests/test_time_trunc_key.py @@ -11,7 +11,7 @@ - Different columns are distinct even with the same granularity. - Phase is ``ROW``: time truncation is a per-row transformation that belongs in the base SELECT / GROUP BY, not in a window / outer SELECT. -- ``ColumnKey.path`` is preserved through ``TimeTruncKey.column`` so +- ``ColumnKey.path`` is preserved through ``TimeTruncKey.source`` so cross-model time dimensions (``customers.signup_at``) still carry the join walk. @@ -40,8 +40,8 @@ class TestTimeTruncKeyConstruction: def test_constructs_from_columnkey_and_granularity_string(self) -> None: col = ColumnKey(leaf="ordered_at") - k = TimeTruncKey(column=col, granularity=TimeGranularity.MONTH) - assert k.column == col + k = TimeTruncKey(source=col, granularity=TimeGranularity.MONTH) + assert k.source == col assert k.granularity == "month" def test_accepts_timegranularity_enum_as_string(self) -> None: @@ -49,43 +49,44 @@ def test_accepts_timegranularity_enum_as_string(self) -> None: Accept the enum value directly.""" col = ColumnKey(leaf="ordered_at") k = TimeTruncKey.model_validate( - {"column": col, "granularity": TimeGranularity.MONTH.value} + {"source": col, "granularity": TimeGranularity.MONTH.value} ) assert k.granularity == "month" def test_preserves_columnkey_path_for_cross_model(self) -> None: col = ColumnKey(path=("customers",), leaf="signup_at") - k = TimeTruncKey(column=col, granularity=TimeGranularity.DAY) - assert k.column.path == ("customers",) - assert k.column.leaf == "signup_at" + k = TimeTruncKey(source=col, granularity=TimeGranularity.DAY) + assert isinstance(k.source, ColumnKey) + assert k.source.path == ("customers",) + assert k.source.leaf == "signup_at" class TestTimeTruncKeyIdentity: def test_same_column_and_granularity_intern_equal(self) -> None: - a = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) - b = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + a = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + b = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) assert a == b assert hash(a) == hash(b) def test_different_granularities_on_same_column_distinct(self) -> None: col = ColumnKey(leaf="ordered_at") - a = TimeTruncKey(column=col, granularity=TimeGranularity.DAY) - b = TimeTruncKey(column=col, granularity=TimeGranularity.MONTH) + a = TimeTruncKey(source=col, granularity=TimeGranularity.DAY) + b = TimeTruncKey(source=col, granularity=TimeGranularity.MONTH) assert a != b assert hash(a) != hash(b) def test_different_columns_with_same_granularity_distinct(self) -> None: - a = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) - b = TimeTruncKey(column=ColumnKey(leaf="shipped_at"), granularity=TimeGranularity.MONTH) + a = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + b = TimeTruncKey(source=ColumnKey(leaf="shipped_at"), granularity=TimeGranularity.MONTH) assert a != b def test_cross_model_paths_with_same_leaf_distinct(self) -> None: a = TimeTruncKey( - column=ColumnKey(path=("customers",), leaf="signup_at"), + source=ColumnKey(path=("customers",), leaf="signup_at"), granularity=TimeGranularity.MONTH, ) b = TimeTruncKey( - column=ColumnKey(leaf="signup_at"), + source=ColumnKey(leaf="signup_at"), granularity=TimeGranularity.MONTH, ) assert a != b @@ -95,28 +96,28 @@ def test_distinct_from_raw_columnkey(self) -> None: same column must not collide as ValueKey identities — they end up as separate slots in the ValueRegistry.""" col = ColumnKey(leaf="ordered_at") - k_trunc = TimeTruncKey(column=col, granularity=TimeGranularity.MONTH) + k_trunc = TimeTruncKey(source=col, granularity=TimeGranularity.MONTH) # Cannot be == across types; that would imply slot collision. assert col != k_trunc # Hashes may collide by chance but the registry uses dict-key # equality, so the strict check is __eq__. def test_usable_as_dict_key(self) -> None: - k = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + k = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) d = {k: "slot_1"} - same = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + same = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) assert d[same] == "slot_1" class TestTimeTruncKeyPhase: def test_phase_is_row(self) -> None: - k = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + k = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) assert k.phase == Phase.ROW class TestTimeTruncKeyImmutability: def test_is_frozen(self) -> None: - k = TimeTruncKey(column=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) + k = TimeTruncKey(source=ColumnKey(leaf="ordered_at"), granularity=TimeGranularity.MONTH) # TimeTruncKey is a frozen Pydantic v2 model — mutation raises # ValidationError, not the generic Exception. with pytest.raises(ValidationError): @@ -124,15 +125,15 @@ def test_is_frozen(self) -> None: class TestTimeTruncKeyWithColumnSqlKey: - """``TimeTruncKey.column`` is widened to accept + """``TimeTruncKey.source`` accepts ``ColumnSqlKey`` so a derived (``Column.sql`` set) temporal column can be a time dimension. Identity is structural over the (derived-column, grain) pair, exactly like the base-column case.""" def test_constructs_from_columnsqlkey(self) -> None: col = ColumnSqlKey(model="orders", column_name="effective_at") - k = TimeTruncKey(column=col, granularity=TimeGranularity.MONTH) - assert k.column == col + k = TimeTruncKey(source=col, granularity=TimeGranularity.MONTH) + assert k.source == col assert k.granularity == "month" assert k.phase == Phase.ROW @@ -140,17 +141,18 @@ def test_preserves_columnsqlkey_path_for_cross_model(self) -> None: col = ColumnSqlKey( path=("customers",), model="customers", column_name="effective_at", ) - k = TimeTruncKey(column=col, granularity=TimeGranularity.DAY) - assert k.column.path == ("customers",) - assert k.column.column_name == "effective_at" + k = TimeTruncKey(source=col, granularity=TimeGranularity.DAY) + assert isinstance(k.source, ColumnSqlKey) + assert k.source.path == ("customers",) + assert k.source.column_name == "effective_at" def test_same_derived_column_and_grain_intern_equal(self) -> None: a = TimeTruncKey( - column=ColumnSqlKey(model="orders", column_name="effective_at"), + source=ColumnSqlKey(model="orders", column_name="effective_at"), granularity=TimeGranularity.MONTH, ) b = TimeTruncKey( - column=ColumnSqlKey(model="orders", column_name="effective_at"), + source=ColumnSqlKey(model="orders", column_name="effective_at"), granularity=TimeGranularity.MONTH, ) assert a == b @@ -158,28 +160,28 @@ def test_same_derived_column_and_grain_intern_equal(self) -> None: def test_different_grain_on_derived_column_distinct(self) -> None: col = ColumnSqlKey(model="orders", column_name="effective_at") - a = TimeTruncKey(column=col, granularity=TimeGranularity.DAY) - b = TimeTruncKey(column=col, granularity=TimeGranularity.MONTH) + a = TimeTruncKey(source=col, granularity=TimeGranularity.DAY) + b = TimeTruncKey(source=col, granularity=TimeGranularity.MONTH) assert a != b def test_base_and_derived_columns_distinct(self) -> None: a = TimeTruncKey( - column=ColumnKey(leaf="effective_at"), granularity=TimeGranularity.MONTH, + source=ColumnKey(leaf="effective_at"), granularity=TimeGranularity.MONTH, ) b = TimeTruncKey( - column=ColumnSqlKey(model="orders", column_name="effective_at"), + source=ColumnSqlKey(model="orders", column_name="effective_at"), granularity=TimeGranularity.MONTH, ) assert a != b def test_usable_as_dict_key(self) -> None: k = TimeTruncKey( - column=ColumnSqlKey(model="orders", column_name="effective_at"), + source=ColumnSqlKey(model="orders", column_name="effective_at"), granularity=TimeGranularity.MONTH, ) d = {k: "slot_1"} same = TimeTruncKey( - column=ColumnSqlKey(model="orders", column_name="effective_at"), + source=ColumnSqlKey(model="orders", column_name="effective_at"), granularity=TimeGranularity.MONTH, ) assert d[same] == "slot_1" diff --git a/tests/test_transforms_planner.py b/tests/test_transforms_planner.py index 0bf774440..199394e72 100644 --- a/tests/test_transforms_planner.py +++ b/tests/test_transforms_planner.py @@ -45,7 +45,7 @@ ) from slayer.core.keys import Grain from slayer.core.models import Column, ModelMeasure, SlayerModel -from slayer.core.query import ColumnRef, SlayerQuery, TimeDimension +from slayer.core.query import SlayerQuery, TimeDimension from slayer.core.scope import ModelScope from slayer.engine.binding import bind_expr from slayer.engine.compile.projection import _iter_slot_deps @@ -331,7 +331,7 @@ def test_iter_slot_deps_time_truncated_time_key(self) -> None: # time_key can be a TimeTruncKey too — same dep yield rule. inner = AggregateKey(source=ColumnKey(path=(), leaf="amount"), agg="sum") tt = TimeTruncKey( - column=ColumnKey(path=(), leaf="created_at"), + source=ColumnKey(path=(), leaf="created_at"), granularity=TimeGranularity.MONTH, ) tk = TransformKey(op="cumsum", input=inner, time_key=tt) @@ -363,7 +363,7 @@ def test_partition_by_rejected_on_non_rank_transform(self) -> None: ], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -385,7 +385,7 @@ def test_cumsum_emits_transform_layer(self) -> None: measures=[{"formula": "cumsum(amount:sum)"}], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -412,7 +412,7 @@ def test_time_shift_emits_transform_layer(self) -> None: measures=[{"formula": "time_shift(amount:sum, periods=-1)"}], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -431,7 +431,7 @@ def test_change_desugars_to_time_shift_layer(self) -> None: measures=[{"formula": "change(amount:sum)"}], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -450,7 +450,7 @@ def test_nested_transforms_emit_in_dependency_order(self) -> None: measures=[{"formula": "cumsum(change(amount:sum))"}], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -475,7 +475,7 @@ def test_each_transform_slot_emits_its_own_layer(self) -> None: ], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -512,7 +512,7 @@ def test_change_preserves_inner_aggregate_identity(self) -> None: measures=[{"formula": "change(amount:sum)"}], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ], @@ -539,7 +539,7 @@ def test_change_and_explicit_amount_sum_share_one_aggregate_slot(self) -> None: ], time_dimensions=[ TimeDimension( - dimension=ColumnRef(name="created_at"), + dimension="created_at", granularity=TimeGranularity.MONTH, ), ],