"""Field-well leaf nodes — ``Dim`` + ``Measure`` typed wrappers.
Every visual's field wells contain a mix of ``DimensionField`` and
``MeasureField`` entries (source / target columns, group-by fields,
aggregated values). These tree nodes wrap them with typed factories
(``Dim.date(...)``, ``Measure.sum(...)``) so construction-time typing
drives what the visual gets, rather than hand-wiring the underlying
models every time.
Auto field_id (L.1.16): both ``Dim`` and ``Measure`` accept an
optional ``field_id`` keyword. When omitted, the App walker assigns
``f-{visual_kind}-s{sheet_idx}-v{visual_idx}-{role}{slot_idx}`` at
emit time. Authors typically pass ``Dim(ds, "column_name")`` and
reference the leaf via Python variable for sort / drill plumbing
(both accept ``Dim`` / ``Measure`` object refs in addition to bare
field-id strings).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from recon_gen.common.models import (
CategoricalDimensionField,
CategoricalMeasureField,
ColumnIdentifier,
CurrencyDisplayFormatConfiguration,
DateDimensionField,
DecimalPlacesConfiguration,
DimensionField,
MeasureField,
NumberFormatConfiguration,
NumericalAggregationFunction,
NumericalDimensionField,
NumericalMeasureField,
NumericFormatConfiguration,
SeparatorConfiguration,
ThousandSeparatorOptions,
)
from recon_gen.common.tree._helpers import (
AUTO,
AutoResolved,
TimeGranularity,
_AutoSentinel,
)
from recon_gen.common.tree.calc_fields import (
CalcField,
ColumnRef,
calc_field_in,
resolve_column,
)
from recon_gen.common.tree.datasets import Dataset
DimKind = Literal["categorical", "date", "numerical"]
# BL.1 — kind="count" Measures emit ``NumericalMeasureField(SUM)`` over
# an auto-registered CalcField with ``Expression="1"`` (one per
# referenced ``Dataset``). The convention name carries the dataset
# identifier so two datasets in the same Analysis don't collide on the
# global ``Analysis.calc_fields`` name registry. ``App.resolve_auto_ids``
# is the registrar; ``Measure.emit`` reads through to the convention
# name.
#
# Why this shape rather than ``CategoricalMeasureField(COUNT)``: QS's
# CategoricalMeasureField COUNT silently renders DISTINCT when the
# column also appears as a Dim elsewhere on the same visual / sheet
# (BL.1 bug). NumericalMeasureField(SUM) over a literal-1 CalcField
# is a pure row count with no quirky distinct behavior. App2's
# ``_visual_sql`` translates ``kind="count"`` → ``SUM(1)``; the two
# renderers stay symmetric (both compute SUM(1) over the dataset).
ROW_ONE_CALC_PREFIX = "_row_one_"
[docs]
def row_one_calc_name(dataset: Dataset) -> str:
"""Convention name for the literal-1 CalcField backing
``Measure.kind == "count"`` row-count semantics on ``dataset``.
Returns ``"_row_one_<sanitized-dataset-id>"``. Dashes in the
dataset identifier are replaced with underscores so the name is
QS-safe (QS calc field names accept underscores; dashes are
allowed too but underscores stay closer to convention).
"""
return f"{ROW_ONE_CALC_PREFIX}{dataset.identifier.replace('-', '_')}"
[docs]
@dataclass(eq=False)
class Dim:
"""One dimension field-well entry — typed wrapper that emits a
``DimensionField`` of the appropriate kind.
``dataset`` is a ``Dataset`` object ref — the locked L.1.7 hard
switch. The dataset must be registered on the parent ``App`` (via
``app.add_dataset()``) for the analysis to emit.
``column`` accepts either a bare ``str`` (a real column on the
dataset) or a ``CalcField`` object ref (an analysis-level
calculated field). The CalcField ref carries the calc-field
identity through the type checker — the App's emit-time
validation catches references to unregistered calc fields.
Default kind is ``categorical`` (the most common); use the
``date()`` / ``numerical()`` classmethods for the other variants.
``field_id`` is keyword-only and Optional (L.1.16 auto-ID). When
omitted, the App walker assigns one based on the leaf's tree
position. Pass an explicit ``field_id="..."`` only when external
consumers (browser e2e selectors, etc.) need a stable id —
cross-reference plumbing (sort_by, drill writes) accepts the
leaf object directly.
Identity-keyed (``eq=False``) so the auto-id resolver can mutate
the field_id at emit time. Dim leaves stay hashable via the
default object identity hash, which lets the dependency graph
set-membership check work.
"""
dataset: Dataset
column: ColumnRef
kind: DimKind = "categorical"
date_granularity: TimeGranularity | None = field(default=None, kw_only=True)
field_id: str | AutoResolved = field(default=AUTO, kw_only=True)
# Q.1.a.7 — currency=True emits a USD CurrencyDisplayFormatConfiguration
# on the underlying NumericalDimensionField (row-level money columns
# in tables typically use Dim.numerical, not Measure.sum, since they
# show the raw value rather than an aggregate). Only valid for
# ``kind="numerical"`` — money never makes sense as a categorical
# axis or a date axis. Asserted at emit time.
currency: bool = field(default=False, kw_only=True)
[docs]
@classmethod
def date(
cls, dataset: Dataset, column: ColumnRef,
*,
date_granularity: TimeGranularity | None = "DAY",
field_id: str | AutoResolved = AUTO,
) -> Dim:
"""Date dimension. ``date_granularity`` defaults to ``"DAY"`` —
QuickSight's most common bucket for daily series. Pass ``None``
to omit the granularity (the renderer falls back to its default,
which can shift bucketing on day-vs-month dashboards)."""
return cls(
dataset=dataset, column=column, kind="date",
date_granularity=date_granularity, field_id=field_id,
)
[docs]
@classmethod
def numerical(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO, currency: bool = False,
) -> Dim:
return cls(
dataset=dataset, column=column, kind="numerical",
field_id=field_id, currency=currency,
)
[docs]
def calc_field(self) -> CalcField | None:
"""The CalcField this Dim references, or None if it points at
a real dataset column. Used by the dependency-graph walk."""
return calc_field_in(self.column)
[docs]
def emit(self) -> DimensionField:
assert not isinstance(self.field_id, _AutoSentinel), (
"field_id wasn't resolved — App.resolve_auto_ids() must run "
"before Dim.emit()."
)
col = ColumnIdentifier(
DataSetIdentifier=self.dataset.identifier,
ColumnName=resolve_column(self.column),
)
if self.kind == "date":
return DimensionField(
DateDimensionField=DateDimensionField(
FieldId=self.field_id, Column=col,
DateGranularity=self.date_granularity,
),
)
if self.kind == "numerical":
return DimensionField(
NumericalDimensionField=NumericalDimensionField(
FieldId=self.field_id, Column=col,
FormatConfiguration=_USD_FORMAT if self.currency else None,
),
)
assert not self.currency, (
f"Dim(currency=True) is only valid for kind='numerical', not "
f"{self.kind!r} — money values aren't categorical or date axes."
)
return DimensionField(
CategoricalDimensionField=CategoricalDimensionField(
FieldId=self.field_id, Column=col,
),
)
[docs]
def emit_unaggregated_field(self) -> dict[str, object]:
"""Emit the raw ``UnaggregatedField`` dict shape used inside
``TableUnaggregatedFieldWells.Values``. The model layer types
that field as ``list[dict[str, Any]]`` rather than a typed
union, so the tree emits it as a dict directly.
Q.1.a.7 — When ``currency=True`` is set on a numerical Dim, the
same USD ``FormatConfiguration`` that ``emit()`` wires onto a
NumericalDimensionField is also folded into the unaggregated
field shape so table cells render with "$" + thousands
separator + 2 decimals. Without this, currency=True only took
effect when the Dim was used as a chart axis or KPI value, not
when it was used as a table column (the by-far common case).
"""
assert not isinstance(self.field_id, _AutoSentinel), (
"field_id wasn't resolved — App.resolve_auto_ids() must run "
"before Dim.emit_unaggregated_field()."
)
out: dict[str, object] = {
"FieldId": self.field_id,
"Column": {
"DataSetIdentifier": self.dataset.identifier,
"ColumnName": resolve_column(self.column),
},
}
if self.currency:
assert self.kind == "numerical", (
f"Dim(currency=True) is only valid for kind='numerical', "
f"not {self.kind!r} — money values aren't categorical or "
f"date axes."
)
from dataclasses import asdict
from recon_gen.common.models import _strip_nones
# UnaggregatedField.FormatConfiguration is a discriminated
# union of String/Number/DateTime — pick the NumberFormatConfiguration
# branch and place the existing _USD_FORMAT shape under it.
# (NumericalMeasureField's FormatConfiguration drops the
# discriminator since the field type is already known to be
# numeric; the unaggregated field stays generic over the
# column type and so needs the extra level.)
out["FormatConfiguration"] = {
"NumberFormatConfiguration": _strip_nones(asdict(_USD_FORMAT)),
}
return out
# Aggregation kinds split into "categorical" (COUNT, DISTINCT_COUNT —
# read off any column type) and "numerical" (SUM, MAX, MIN, AVERAGE —
# require a numeric column). The split mirrors the underlying
# ``CategoricalMeasureField`` vs ``NumericalMeasureField`` distinction.
MeasureKind = Literal[
"sum", "max", "min", "average", # → NumericalMeasureField
"count", "distinct_count", # → CategoricalMeasureField
]
_NUMERICAL_AGG = {
"sum": "SUM", "max": "MAX", "min": "MIN", "average": "AVERAGE",
}
_CATEGORICAL_AGG = {
"count": "COUNT", "distinct_count": "DISTINCT_COUNT",
}
# v11.24.1 — QS rejects ``NumericalMeasureField`` over non-numeric
# columns at analysis-create time with:
# "Object NumericalMeasureField can only refer to columns of types
# [INTEGER, DECIMAL], but the column <name> is of type <type>."
# Before v11.24.1 this only surfaced in CI's deploy probe (BO.12's
# ``ds_postings["posting"].max()`` over a DATETIME column took out the
# L2 Flow Tracing analysis + dashboard in v11.24.0). Catching it here
# at JSON-emit time fails the unit + json layers FAST so deploys never
# burn on this class of typo. The QS-numerical column types — kept as
# a tight whitelist that mirrors the QS error.
_QS_NUMERICAL_COLUMN_TYPES: frozenset[str] = frozenset({"INTEGER", "DECIMAL"})
def _assert_numerical_column_type(
dataset: Dataset, column: ColumnRef, kind: str,
) -> None:
"""Fail-fast guard: numerical aggregations (sum/max/min/average)
require an INTEGER/DECIMAL column at the contract level.
Permissive on the inputs the contract can't reason about:
- ``CalcField`` refs (analysis-level calculated columns — their
expression's type is opaque to the dataset contract).
- Missing contract (``KeyError`` from ``get_contract``) — only
possible in narrow test-harness paths where the contract didn't
register; production datasets always register at module import.
- Missing column on the contract (``KeyError`` from
``contract.column``) — leaves the existing L.1.17 validator to
catch the typo at construction time.
Loud on the case that bit v11.24.0 — a Column / str ref whose
contract declares a non-numeric type used under a numerical
aggregation.
"""
from recon_gen.common.dataset_contract import get_contract # noqa: PLC0415
if calc_field_in(column) is not None:
return
try:
contract = get_contract(dataset.identifier)
except KeyError:
return
name = resolve_column(column)
try:
col_spec = contract.column(name)
except KeyError:
return
if col_spec.type in _QS_NUMERICAL_COLUMN_TYPES:
return
raise AssertionError(
f"Measure.{kind}() on dataset {dataset.identifier!r} column "
f"{name!r} fails QS validation: numerical aggregations require "
f"INTEGER or DECIMAL columns, but {name!r} is declared as "
f"{col_spec.type!r} on the contract. QS rejects this at "
f"analysis-create time with: "
f'"Object NumericalMeasureField can only refer to columns of '
f"types [INTEGER, DECIMAL], but the column {name} is of type "
f'{col_spec.type}.\" Either change the column type at the '
f"dataset boundary or drop the aggregation (a DATETIME freshness "
f"signal lives more naturally on a Table column than a KPI)."
)
[docs]
@dataclass(eq=False)
class Measure:
"""One value field-well entry — typed wrapper that emits a
``MeasureField`` with the appropriate aggregation shape.
``dataset`` is a ``Dataset`` object ref (L.1.7 hard switch). The
dataset must be registered on the parent ``App`` for the analysis
to emit.
``field_id`` is keyword-only and Optional (L.1.16 auto-ID). When
omitted, the App walker assigns one based on the leaf's tree
position.
Use the classmethod factories for ergonomic construction:
``Measure.sum(...)``, ``Measure.distinct_count(...)``, etc.
Aggregation kind determines which underlying model class is
emitted (numerical aggregations on numeric columns,
categorical on count-style aggregations).
"""
dataset: Dataset
column: ColumnRef
kind: MeasureKind
field_id: str | AutoResolved = field(default=AUTO, kw_only=True)
# Q.1.a — currency=True emits a USD CurrencyDisplayFormatConfiguration
# on the underlying NumericalMeasureField (2 decimal places, comma
# thousands separator, "$" prefix per QS's USD rendering). Only
# valid for numerical aggregations (sum/max/min/average) — count /
# distinct_count don't aggregate money. The emit-time assert below
# catches the misuse loud rather than silently dropping the format.
currency: bool = field(default=False, kw_only=True)
# v11.22.1 cold-read finding #18 (2026-05-26) — when QS sees an
# AVERAGE aggregation with no FormatConfiguration it renders 3
# decimals by default ("2.000"). For count-of-things averages
# (Avg Daily Volume = avg(transfer_count_per_day)) the right
# rendering is an integer or 1-decimal. Setting decimals=N on a
# non-currency Measure emits a NumberDisplayFormatConfiguration with
# DecimalPlaces=N + comma thousands separator. Mutually exclusive
# with currency=True (currency already pins 2 decimals).
decimals: int | None = field(default=None, kw_only=True)
[docs]
@classmethod
def sum(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO, currency: bool = False,
decimals: int | None = None,
) -> Measure:
return cls(
dataset=dataset, column=column, kind="sum",
field_id=field_id, currency=currency, decimals=decimals,
)
[docs]
@classmethod
def max(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO, currency: bool = False,
decimals: int | None = None,
) -> Measure:
return cls(
dataset=dataset, column=column, kind="max",
field_id=field_id, currency=currency, decimals=decimals,
)
[docs]
@classmethod
def min(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO, currency: bool = False,
decimals: int | None = None,
) -> Measure:
return cls(
dataset=dataset, column=column, kind="min",
field_id=field_id, currency=currency, decimals=decimals,
)
[docs]
@classmethod
def average(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO, currency: bool = False,
decimals: int | None = None,
) -> Measure:
return cls(
dataset=dataset, column=column, kind="average",
field_id=field_id, currency=currency, decimals=decimals,
)
[docs]
@classmethod
def count(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO,
) -> Measure:
return cls(dataset=dataset, column=column, kind="count", field_id=field_id)
[docs]
@classmethod
def distinct_count(
cls, dataset: Dataset, column: ColumnRef,
*, field_id: str | AutoResolved = AUTO,
) -> Measure:
return cls(
dataset=dataset, column=column, kind="distinct_count",
field_id=field_id,
)
[docs]
def calc_field(self) -> CalcField | None:
"""The CalcField this Measure references, or None if it points
at a real dataset column."""
return calc_field_in(self.column)
[docs]
def emit(self) -> MeasureField:
assert not isinstance(self.field_id, _AutoSentinel), (
"field_id wasn't resolved — App.resolve_auto_ids() must run "
"before Measure.emit()."
)
col = ColumnIdentifier(
DataSetIdentifier=self.dataset.identifier,
ColumnName=resolve_column(self.column),
)
if self.kind == "count":
# BL.1 — read through to the literal-1 CalcField. The
# CalcField itself is registered on the Analysis by
# ``App.resolve_auto_ids`` (one per ``Dataset`` referenced
# by a count Measure); here we just emit the
# NumericalMeasureField(SUM) pointing at that CalcField's
# convention name.
assert not self.currency, (
f"Measure(currency=True) is only valid for numerical "
f"aggregations (sum/max/min/average), not "
f"{self.kind!r} — count returns row counts, never money."
)
row_one_col = ColumnIdentifier(
DataSetIdentifier=self.dataset.identifier,
ColumnName=row_one_calc_name(self.dataset),
)
return MeasureField(
NumericalMeasureField=NumericalMeasureField(
FieldId=self.field_id,
Column=row_one_col,
AggregationFunction=NumericalAggregationFunction(
SimpleNumericalAggregation="SUM",
),
),
)
if self.kind in _CATEGORICAL_AGG:
assert not self.currency, (
f"Measure(currency=True) is only valid for numerical "
f"aggregations (sum/max/min/average), not "
f"{self.kind!r} — count/distinct_count return row "
f"counts, never money."
)
return MeasureField(
CategoricalMeasureField=CategoricalMeasureField(
FieldId=self.field_id,
Column=col,
AggregationFunction=_CATEGORICAL_AGG[self.kind],
),
)
assert not (self.currency and self.decimals is not None), (
"Measure cannot set both currency=True and decimals=N — "
"currency already pins 2 decimals via _USD_FORMAT. Drop "
"decimals= or drop currency=True."
)
_assert_numerical_column_type(self.dataset, self.column, self.kind)
fmt: NumberFormatConfiguration | None
if self.currency:
fmt = _USD_FORMAT
elif self.decimals is not None:
fmt = _integer_format(self.decimals)
else:
fmt = None
return MeasureField(
NumericalMeasureField=NumericalMeasureField(
FieldId=self.field_id,
Column=col,
AggregationFunction=NumericalAggregationFunction(
SimpleNumericalAggregation=_NUMERICAL_AGG[self.kind],
),
FormatConfiguration=fmt,
),
)
# USD currency format — the only supported currency for now (Q.1.a).
# Extracted as a module-level constant so identity equality holds across
# every currency=True Measure (callers can compare-via-`is` if they need
# to detect "this measure was format-tagged"). When a future phase adds
# multi-currency support, swap this for a per-instance lookup.
_USD_FORMAT = NumberFormatConfiguration(
FormatConfiguration=NumericFormatConfiguration(
CurrencyDisplayFormatConfiguration=CurrencyDisplayFormatConfiguration(
Symbol="USD",
DecimalPlacesConfiguration=DecimalPlacesConfiguration(DecimalPlaces=2),
SeparatorConfiguration=SeparatorConfiguration(
ThousandsSeparator=ThousandSeparatorOptions(
Symbol="COMMA", Visibility="VISIBLE",
),
),
),
),
)
# v11.22.1 cold-read finding #18 (2026-05-26) — per-Measure integer /
# fixed-decimal format. Constructed per (decimals,) so the resulting
# wire shape is stable across emits and JSON pin tests don't churn.
# NumberDisplayFormatConfiguration is the QS NumericFormatConfiguration
# branch for plain numbers (vs the Currency / Percentage branches).
def _integer_format(decimals: int) -> NumberFormatConfiguration:
assert decimals >= 0, (
f"Measure.decimals must be >= 0, got {decimals!r}"
)
return NumberFormatConfiguration(
FormatConfiguration=NumericFormatConfiguration(
NumberDisplayFormatConfiguration={
"DecimalPlacesConfiguration": {"DecimalPlaces": decimals},
"SeparatorConfiguration": {
"ThousandsSeparator": {
"Symbol": "COMMA",
"Visibility": "VISIBLE",
},
},
},
),
)
# Type alias used everywhere a sort/drill plumbing slot accepts either
# a leaf object ref or a bare field_id string. Object refs are the
# preferred form (the tree resolves the field_id at emit time so
# auto-IDed leaves work without exposing the synthesized id).
FieldRef = Dim | Measure | str
[docs]
def resolve_field_id(ref: FieldRef) -> str:
"""Read the resolved field_id off a Dim / Measure / bare string."""
if isinstance(ref, str):
return ref
assert not isinstance(ref.field_id, _AutoSentinel), (
"field_id wasn't resolved — App.resolve_auto_ids() must run "
"before resolve_field_id."
)
return ref.field_id