Skip to content
4 changes: 3 additions & 1 deletion .claude/skills/slayer-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ Each entry in `measures` is either a bare formula string or a `{"formula": ...,
"last(revenue:sum)",
"time_shift(revenue:sum, -1, 'year')",
"lag(revenue:sum, 1)",
"rank(revenue:sum)"
"rank(revenue:sum)",
"round(revenue:sum, 2)",
"abs(revenue:sum - cost:sum)"
]
```

Expand Down
12 changes: 12 additions & 0 deletions docs/concepts/formulas.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,18 @@ Inside `Column.sql`, `ModelMeasure.formula`, or any `Aggregation.formula`, you c
| `exp(x)` | 1 | `e^x` |
| `sqrt(x)` | 1 | Square root |
| `pow(x, n)` / `power(x, n)` | 2 | `x^n`. Both spellings are accepted (sqlglot may emit either depending on origin dialect). |
| `round(x[, ndigits])` | 1–2 | Round to `ndigits` decimal places (default 0). `ndigits` must be an integer literal. |
| `abs(x)` | 1 | Absolute value. |

Unlike the other scalar functions above — which pass through only when embedded in a larger `Column.sql` or arithmetic expression — `round` and `abs` are also valid as the **top-level** form of a query measure or `ModelMeasure.formula`:

```python
{"formula": "round(revenue:sum, 2)"} # round an aggregate
{"formula": "abs(revenue:sum - cost:sum)"} # absolute difference
{"formula": "round(revenue:sum / *:count, 2)"}
```

On Postgres, 2-argument `round` over a floating-point value is automatically cast to `numeric` so it executes (Postgres has no `round(double precision, integer)` overload). SQLite and DuckDB round `DOUBLE` natively.

These are native on Postgres / DuckDB / MySQL / ClickHouse. SQLite doesn't have most of them in the standard build, so SLayer registers Python implementations on every connection (see `slayer/sql/dialects/sqlite.py`). NULL inputs always return NULL. Math-domain errors (`ln(0)`, `sqrt(-1)`, `pow(0, -1)`) propagate as `sqlite3.OperationalError` — matching Postgres's strict semantics rather than SQLite ≥3.35's silent-NULL built-in `log()`.

Expand Down
35 changes: 35 additions & 0 deletions slayer/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,41 @@ class JoinType(StrEnum):
"corr", "covar_samp", "covar_pop",
})

# DEV-1576: unambiguous aggregation-name aliases that LLM agents routinely
# emit. ``normalize_aggregation_name`` lowercases the incoming token and maps
# it through this table; the result is only adopted when it lands in
# ``BUILTIN_AGGREGATIONS``. ``stddev``/``var``/``variance`` map to the *sample*
# variants, matching Postgres' bare ``stddev``/``variance`` defaults.
AGGREGATION_ALIASES: dict[str, str] = {
"countd": "count_distinct",
"countdistinct": "count_distinct", # also matches "countDistinct" once lowercased
"stddev": "stddev_samp",
"var": "var_samp",
"variance": "var_samp",
}


def normalize_aggregation_name(name: str) -> str:
"""Coerce an aggregation token to its canonical SLayer spelling.

Lowercases the token and applies :data:`AGGREGATION_ALIASES`. The
normalized form is only adopted when it is a real built-in aggregation;
otherwise the **original** string is returned unchanged so genuinely
unknown names still raise downstream (and custom aggregation names keep
their exact casing). Examples::

"countd" -> "count_distinct"
"countDistinct" -> "count_distinct"
"stddev" -> "stddev_samp"
"SUM" -> "sum"
"myCustomAgg" -> "myCustomAgg" (unchanged — not a builtin)
"bogus" -> "bogus" (unchanged — still raises later)
"""
lowered = name.lower()
candidate = AGGREGATION_ALIASES.get(lowered, lowered)
return candidate if candidate in BUILTIN_AGGREGATIONS else name


# Built-in aggregation SQL formulas (for aggregations that use a template).
# {value} = measure's SQL expression; {param_name} = parameter values.
# Note: percentile is dialect-dependent (no single template works on
Expand Down
149 changes: 128 additions & 21 deletions slayer/core/formula.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from pydantic import BaseModel, Field

from slayer.core.enums import BUILTIN_AGGREGATIONS
from slayer.core.enums import BUILTIN_AGGREGATIONS, normalize_aggregation_name
from slayer.core.refs import (
AGG_REF_RE as _AGG_REF_RE,
IDENT_OR_PATH_RE as _IDENT_OR_PATH_RE,
Expand Down Expand Up @@ -70,7 +70,15 @@
"concat",
})

CallCategory = Literal["transform", "hygiene", "like_internal", "unknown"]
# DEV-1576: scalar post-aggregation functions accepted as top-level formula
# calls. They are recognised case-insensitively (agents overwhelmingly emit
# ``ROUND``) and pass through to the emitted SQL via the arithmetic-passthrough
# machinery (``_parse_mixed_arithmetic``). The list is intentionally tiny — the
# existing inside-arithmetic passthrough already accepts arbitrary functions,
# but top-level calls stay gated so genuinely unknown functions keep raising.
SCALAR_FUNCTIONS = frozenset({"round", "abs"})

CallCategory = Literal["transform", "scalar", "hygiene", "like_internal", "unknown"]

_LIKE_INTERNAL_NAMES = frozenset({"__like__", "__notlike__"})

Expand All @@ -84,13 +92,65 @@ def _classify_call_name(name: str) -> CallCategory:
"""
if name in ALL_TRANSFORMS:
return "transform"
if name.lower() in SCALAR_FUNCTIONS:
return "scalar"
if name in STRING_HYGIENE_OPS:
return "hygiene"
if name in _LIKE_INTERNAL_NAMES:
return "like_internal"
return "unknown"


def _validate_scalar_call(node: ast.Call, original: str) -> None:
"""Validate arity for a top-level ``round`` / ``abs`` call (DEV-1576).

``abs`` takes exactly one argument; ``round`` takes one or two
(``expression[, ndigits]``) where ``ndigits`` must be an integer literal
(negative allowed). Neither accepts keyword arguments.
"""
name = node.func.id.lower()
if node.keywords:
raise ValueError(
f"'{name}' does not accept keyword arguments. Formula: {original!r}"
)
if name == "abs":
if len(node.args) != 1:
raise ValueError(
f"'abs' requires exactly one argument (the expression). "
f"Formula: {original!r}"
)
return
# round
if len(node.args) not in (1, 2):
raise ValueError(
f"'round' accepts 1 or 2 arguments (expression[, ndigits]). "
f"Formula: {original!r}"
)
if len(node.args) == 2 and not _is_integer_literal(node.args[1]):
raise ValueError(
f"'round' ndigits (2nd argument) must be an integer literal. "
f"Formula: {original!r}"
)


def _is_integer_literal(node: ast.AST) -> bool:
"""True for an integer constant or a negated integer constant (``-2``).

Booleans are rejected — ``True``/``False`` are ``int`` subclasses but are
not meaningful ndigits values.
"""
if isinstance(node, ast.Constant):
return isinstance(node.value, int) and not isinstance(node.value, bool)
if (
isinstance(node, ast.UnaryOp)
and isinstance(node.op, ast.USub)
and isinstance(node.operand, ast.Constant)
):
v = node.operand.value
return isinstance(v, int) and not isinstance(v, bool)
return False


class AggregatedMeasureRef(BaseModel):
"""A measure reference with explicit aggregation (new colon syntax).

Expand Down Expand Up @@ -344,31 +404,66 @@ def _split_args(s: str) -> list[str]:
# ---------------------------------------------------------------------------


def _preprocess_agg_refs(formula: str) -> tuple[str, Dict[str, AggregatedMeasureRef]]:
def _split_agg_arglist(args_str: Optional[str]) -> tuple[list[str], dict[str, str]]:
"""Parse a colon-aggregation ``(...)`` arglist into (positional, keyword).

``args_str`` is the raw ``(...)`` capture (with parens) or ``None``.
``price:weighted_avg(weight=quantity)`` → ``([], {"weight": "quantity"})``;
``revenue:last(ordered_at)`` → ``(["ordered_at"], {})``.
"""
agg_args: list[str] = []
agg_kwargs: dict[str, str] = {}
if not args_str:
return agg_args, agg_kwargs
inner = args_str[1:-1].strip()
if not inner:
return agg_args, agg_kwargs
for part in inner.split(","):
part = part.strip()
if "=" in part:
key, val = part.split("=", 1)
agg_kwargs[key.strip()] = val.strip()
else:
agg_args.append(part)
return agg_args, agg_kwargs


def _preprocess_agg_refs(
formula: str,
custom_agg_names: frozenset[str] = frozenset(),
) -> tuple[str, Dict[str, AggregatedMeasureRef]]:
"""Replace colon-syntax aggregated measure refs with placeholder identifiers.

Returns (preprocessed_formula, {placeholder: AggregatedMeasureRef}).

``custom_agg_names`` are the reachable custom-aggregation names (the source
model plus its join graph); a colon token matching one exactly is left
un-normalized so a custom aggregation takes precedence over alias/casing
healing (DEV-1576). This is parse-time and model-set-scoped, not per-ref
target-scoped: in a mixed-model graph a custom name on one model suppresses
healing of that same token on a different model. The failure mode is benign
— the un-healed token simply resolves (custom agg) or raises an explicit
"Unknown aggregation" at enrichment; it never silently picks the wrong
aggregation. Per-ref scoping would require deferring the heal past parse,
which would desync the canonical filter/ORDER-BY aliases built here.
"""
refs: Dict[str, AggregatedMeasureRef] = {}
counter = [0]

def _replace(match: re.Match) -> str:
measure_name = match.group(1)
agg_name = match.group(2)
args_str = match.group(3)

agg_args: list[str] = []
agg_kwargs: dict[str, str] = {}
if args_str:
inner = args_str[1:-1].strip()
if inner:
for part in inner.split(","):
part = part.strip()
if "=" in part:
key, val = part.split("=", 1)
agg_kwargs[key.strip()] = val.strip()
else:
agg_args.append(part)
# DEV-1576: heal aggregation-name aliases / casing at the single colon-
# syntax chokepoint (shared by parse_formula and parse_filter). Unknown
# names pass through unchanged so the §3 enrichment error still fires.
# A model-level custom aggregation named like an alias key / builtin
# casing wins — skip healing for an exact custom-name match so it still
# resolves at enrichment.
raw_agg = match.group(2)
agg_name = (
raw_agg if raw_agg in custom_agg_names
else normalize_aggregation_name(raw_agg)
)
agg_args, agg_kwargs = _split_agg_arglist(match.group(3))

placeholder = f"__agg{counter[0]}__"
counter[0] += 1
Expand Down Expand Up @@ -510,7 +605,9 @@ def parse_formula(
# Rewrite function-style aggregations (e.g., sum(revenue) → revenue:sum)
formula = _rewrite_funcstyle_aggregations(formula, extra_agg_names)
# Preprocess colon syntax into ast-parseable placeholders
processed, agg_refs = _preprocess_agg_refs(formula)
processed, agg_refs = _preprocess_agg_refs(
formula=formula, custom_agg_names=extra_agg_names or frozenset()
)

try:
tree = ast.parse(processed, mode="eval")
Expand Down Expand Up @@ -554,7 +651,15 @@ def _parse_node(
raise ValueError(f"Unsupported function call in formula: {original!r}")

func_name = node.func.id
if _classify_call_name(func_name) != "transform":
category = _classify_call_name(func_name)
if category == "scalar":
# DEV-1576: round()/abs() are passthrough scalar functions. Route
# through the arithmetic-passthrough path, which preserves the call
# in the emitted SQL and registers any inner aggregated refs (and
# extracts any nested transform as a sub-transform).
_validate_scalar_call(node, original)
return _parse_mixed_arithmetic(node, original, agg_refs)
if category != "transform":
raise ValueError(
f"Unknown transform function '{func_name}'. "
f"Supported: {', '.join(sorted(ALL_TRANSFORMS))}"
Expand Down Expand Up @@ -1035,7 +1140,9 @@ def parse_filter(
# Include agg args/kwargs in the canonical name so e.g.
# ``revenue:sum(window='90d') > 100`` matches the windowed measure's alias
# ``orders.revenue_sum_window_90d`` and not the bare ``orders.revenue_sum``.
processed, agg_refs = _preprocess_agg_refs(processed)
processed, agg_refs = _preprocess_agg_refs(
formula=processed, custom_agg_names=extra_agg_names or frozenset()
)
agg_canonical = {
ph: canonical_agg_name(
measure_name=ref.measure_name,
Expand Down
25 changes: 24 additions & 1 deletion slayer/engine/enrichment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
transformation step in the query pipeline.
"""

import difflib
import re
from typing import Any, Dict, List, Mapping, Optional, Set, Tuple

Expand Down Expand Up @@ -409,6 +410,26 @@ async def _ensure_aggregated_measure(
raise ValueError(
f"Column '{measure_name}' not found in model '{model.name}'"
)
# DEV-1576 §3: distinguish "unknown aggregation name" from "known
# but not allowed for this column type". The name check runs BEFORE
# the PK / whitelist / type gates so an unknown name never gets
# mislabelled as a type restriction (a perfectly aggregatable DOUBLE
# column with a misspelled agg should say "Unknown aggregation",
# not "not applicable to DOUBLE column").
known_aggregations = BUILTIN_AGGREGATIONS | {
a.name for a in model.aggregations
}
if aggregation_name not in known_aggregations:
suggestion = difflib.get_close_matches(
word=aggregation_name,
possibilities=sorted(known_aggregations),
n=1,
)
hint = f" Did you mean '{suggestion[0]}'?" if suggestion else ""
raise ValueError(
f"Unknown aggregation '{aggregation_name}'.{hint} "
f"Known: {sorted(known_aggregations)}."
)
# Apply aggregation eligibility gates per the v2 contract:
# 1. Primary-key columns are always restricted to count/count_distinct
# (regardless of type or any explicit whitelist).
Expand Down Expand Up @@ -2772,7 +2793,9 @@ def extract_filter_transforms(
preprocessed = _preprocess_concat(preprocessed)
preprocessed = _preprocess_like(preprocessed)
# Preprocess colon syntax (e.g., "order_total:sum") into ast-safe placeholders
preprocessed, agg_refs = _preprocess_agg_refs(preprocessed)
preprocessed, agg_refs = _preprocess_agg_refs(
formula=preprocessed, custom_agg_names=extra_agg_names or frozenset()
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Build reverse map: placeholder → original colon form
_agg_reverse = {
ph: (
Expand Down
17 changes: 17 additions & 0 deletions slayer/sql/dialects/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,23 @@ def rewrite_parsed_ast(self, tree: exp.Expression) -> exp.Expression:
the function-call form (DEV-1331)."""
return tree

def rewrite_target_ast(self, tree: exp.Expression) -> exp.Expression:
"""Default: identity. Target-keyed AST rewrite (DEV-1576).

Applied in ``SQLGenerator._parse`` using the generator's **target**
dialect (``self._dialect``), independent of the parse dialect. This is
the place for output-shaping a dialect needs that the input-side
``rewrite_parsed_ast`` cannot do: formula/measure expressions are
canonically parsed as Postgres regardless of target, so a
``rewrite_parsed_ast`` override would fire for every backend.

``PostgresDialect`` overrides this to wrap the first argument of a
2-arg ``ROUND`` in a numeric ``CAST`` (Postgres has no
``round(double precision, integer)`` — only ``round(numeric, int)``).
SQLite / DuckDB round ``DOUBLE`` natively, so they keep the identity.
"""
return tree

def emit_outer_wrap(
self,
*,
Expand Down
Loading
Loading