Skip to content

Refuse raw output from definition-side semantic expressions - #306

Merged
hachej merged 2 commits into
mainfrom
fix/refuse-raw-execute
Aug 24, 2026
Merged

Refuse raw output from definition-side semantic expressions#306
hachej merged 2 commits into
mainfrom
fix/refuse-raw-execute

Conversation

@hussainsultan

Copy link
Copy Markdown
Collaborator

Problem

Executing a bare semantic model returned the complete raw table: every physical column at row grain, undeclared columns included, computed dimensions missing. Nothing about that result is semantic. The same hole existed for every output sink (sql, to_pandas, to_csv, … — 14 in total) and for every pre-aggregation chain:

model.execute()                      # raw SELECT *
model.filter(...).execute()          # raw filtered rows
model.group_by("cust").execute()     # raw UNGROUPED rows — silent-wrong
model.query().execute()              # raw SELECT *

Contract

A semantic model is a definition, not a query (matching Malloy, where run: source without a query stage is a compile error). Output now requires a completed query; raw access stays one explicit call away:

model.group_by("cust").aggregate("revenue").execute()   # query — works
model.query(dimensions=[...], measures=[...]).execute() # query — works
model.to_untagged().execute()                           # explicit raw escape hatch
model.execute()                                         # QueryError with guidance
  • All 14 output sinks guard via _ensure_executable(); the error names the model, lists its declared dimensions/measures, and shows the intended spellings.
  • group_by without aggregate gets a dedicated error pointing at .aggregate(...) (an empty .aggregate() returns distinct grouped values).
  • query() with neither dimensions nor measures fails eagerly.

Post-aggregation mutate

Per the direction that post-aggregate row math belongs on to_untagged() (calc measures are the semantic mechanism), .mutate() chained after filter/order_by/limit on an aggregate now raises instead of materializing a pseudo-model:

agg.mutate(share=lambda t: t.revenue / t.all(t.revenue))  # measure path — still works
agg.order_by("x").mutate(...)                             # raises → use .to_untagged().mutate(...)

The _build_post_aggregate_model wrapper is deleted, along with the now-unreachable non-additive-total refusal machinery (_non_additive_result_columns, NonAdditiveTotalError) — the surviving measure path computes totals from the underlying rows, so it gets the mean case right (verified in test_totals_semantics.py) rather than needing to refuse it.

What stays executable

Results, not definitions: aggregates and their filter/order_by/limit chains, .index() results, as_table() over a completed query, and compare_periods() output — tracked via a new _materialized_result flag on SemanticTableOp that survives metadata rebuilds.

Docs

Agent help topics and the four bsl-query-expert doc copies now teach window functions via .to_untagged() (the old order_by().mutate() recipe raises) plus the two new gotchas; ADR 0001 carries an amendment note.

Tests

  • New test_execute_guard.py pins the contract (30 tests).
  • Existing tests that inspected raw rows (join alias soundness, unnest, derived-dim filters) now use the explicit .to_untagged() spelling; the post-agg mutate tests assert the refusal and verify the to_untagged equivalent produces the old values.
  • Full suite: 1334 passed + 13 integration (Malloy) passed.

Noted while testing (pre-existing, not addressed here)

agg.mutate(share=t.total / t.all(t.total)) over integer measures truncates to 0.0 on the memtable → canonical-backend (xorq/DataFusion) path, while the compiled SQL carries the float cast and duckdb executes it correctly — same family as the ibis/xorq flavor-audit defects. Pinned on duckdb in test_totals_semantics.py with a comment.

🤖 Generated with Claude Code

A semantic model is a definition, not a query. Executing one returned the
raw underlying table — every physical column at row grain, undeclared
columns included, computed dimensions missing — through every output sink
(execute/sql/to_pandas/to_csv/...) and every pre-aggregation chain
(filters, joins, order_by/limit, group_by without aggregate, empty
query()). Nothing about that result was semantic.

Output now requires a completed query:

- All 14 output sinks on SemanticTable call _ensure_executable(), which
  raises QueryError with the intended spellings (group_by/aggregate,
  query(), or .to_untagged() for explicit raw access) unless the chain
  contains an aggregation stage, an index, or a materialized result model.
- group_by without aggregate gets its own error pointing at .aggregate()
  (an empty .aggregate() returns distinct grouped values).
- query() with neither dimensions nor measures fails eagerly.
- .mutate() chained after filter/order_by/limit on an aggregate raises:
  a query result is a plain table, so row math over it is spelled
  .to_untagged().mutate(...); .mutate() directly on the aggregate (the
  measure-path desugar) and calc measures remain the semantic spellings.
  The _build_post_aggregate_model wrapper and the now-unreachable
  non-additive-total refusal machinery are removed.
- SemanticTableOp gains _materialized_result to distinguish result models
  (compare_periods output, as_table() over an aggregate — executable)
  from definition models; the flag survives with_dimensions/with_measures,
  filter-preserving rebuilds, and root-derived models.
- Agent help topics and the four bsl-query-expert doc copies now teach
  window functions via .to_untagged() and document the two new gotchas;
  ADR 0001 carries an amendment note.

test_execute_guard.py pins the contract (30 tests). Existing tests that
inspected raw rows now do so via the explicit .to_untagged() escape hatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… contract

CI surfaced consumers of the old behavior that the core test run missed:

- chart/md_parser executor: a doc fence ending with a bare semantic model
  now renders the "semantic table defined" box (the previously unreachable
  definition branch) by catching the guard's QueryError from the convert
  step, instead of erroring — no core privates imported, so the extras
  boundary ratchet stays green. New test pins the rendering.
- chart tests: rolling-window and chart-time-detection regressions rewritten
  to mutate on the aggregate (the measure path handles windows when the
  window flavor matches — the window carries its own ordering), then
  order_by/limit.
- examples/window_functions.py: windows over a filtered result drop to ibis
  via .to_untagged().mutate(...).
- docs: windowing.md fences and query-methods.md window example rewritten to
  the mutate-on-the-aggregate spelling (keyword .over(rows=..., order_by=...))
  with .to_untagged() for filtered/limited results; agent help topics and the
  bsl-query-expert copies teach the same spelling — it keeps .chart() and
  further order_by/limit working, and avoids constructing ibis.window()
  objects of the wrong flavor. Docs build now completes with zero query
  errors.
- ruff format for tests/test_query.py (lint job failure).

Full tree green: 1777 passed (incl. chart/, agents/, md_parser/ suites),
docs build clean, skills check clean, examples run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hussainsultan
hussainsultan marked this pull request as ready for review August 24, 2026 13:07
@hachej
hachej merged commit 5024933 into main Aug 24, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants