"""Typed Filter + Parameter control wrappers (L.1.9).
Each control binds to a typed source by object reference: parameter
controls take a ``ParameterDeclLike`` (the parameter declaration node
they read/write); filter controls take a ``FilterLike`` (the inner
filter their UI drives). At emit time the wrapper reads
``parameter.name`` or ``filter.filter_id`` to populate the underlying
``models.SourceParameterName`` / ``SourceFilterId``.
Naming convention: same as L.1.6 — tree wrappers use a clean,
unsuffixed name that doesn't collide with the underlying
``models.*Control`` classes (``Parameter*Control``, ``Filter*Control``).
User code reads:
from recon_gen.common.tree import (
ParameterDropdown, ParameterSlider, FilterDropdown, ...,
)
``LinkedValues`` / ``StaticValues`` typed wrappers replace the
existing dict-shaped ``SelectableValues`` argument — ``LinkedValues``
takes a typed ``Dataset`` ref + column name (catches "dropdown
populated from undeclared dataset" via the App's dependency graph
walk).
Auto-IDs (L.1.8.5 extension): ``control_id`` fields are Optional;
the App walker assigns position-indexed IDs at emit time
(``pc-{kind}-s{sheet_idx}-{control_idx}`` for parameter controls,
``fc-{kind}-s{sheet_idx}-{control_idx}`` for filter controls).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, ClassVar, Literal, Protocol, runtime_checkable
from recon_gen.common.models import (
FilterControl,
ParameterControl,
)
from recon_gen.common.models import (
FilterCrossSheetControl as ModelFilterCrossSheetControl,
)
from recon_gen.common.models import (
FilterDateTimePickerControl as ModelFilterDateTimePickerControl,
)
from recon_gen.common.models import (
FilterDropDownControl as ModelFilterDropDownControl,
)
from recon_gen.common.models import (
FilterSliderControl as ModelFilterSliderControl,
)
from recon_gen.common.models import (
ParameterDateTimePickerControl as ModelParameterDateTimePickerControl,
)
from recon_gen.common.models import (
ParameterDropDownControl as ModelParameterDropDownControl,
)
from recon_gen.common.models import (
ParameterSliderControl as ModelParameterSliderControl,
)
from recon_gen.common.tree._helpers import AUTO, AutoResolved, _AutoSentinel
from recon_gen.common.tree.datasets import Column, Dataset
from recon_gen.common.tree.filters import FilterLike
from recon_gen.common.tree.parameters import ParameterDeclLike
# ---------------------------------------------------------------------------
# Selectable values wrappers — typed alternatives to the dict-shaped
# SelectableValues argument used by the dropdown controls.
# ---------------------------------------------------------------------------
[docs]
@dataclass(frozen=True)
class StaticValues:
"""Restrict the dropdown to a fixed list of options."""
values: list[str]
[docs]
def emit(self) -> dict[str, Any]:
return {"Values": list(self.values)}
[docs]
@dataclass(frozen=True)
class LinkedValues:
"""Auto-populate the dropdown's options from a dataset column.
Construct via the factory methods (L.1.22 — the canonical fields
are ``dataset`` + ``column_name``; the factories normalize the two
legitimate construction forms into that pair, eliminating the
dual-form ``__post_init__`` validation):
- ``LinkedValues.from_column(ds["col"])`` — typed Column form. The
Column carries its own dataset, so the factory derives ``dataset``
from the Column. Preferred — the contract validates the column
name at the wiring site.
- ``LinkedValues.from_string(dataset=ds, column_name="col")`` —
bare-string escape hatch for datasets without a registered
``DatasetContract``. Dataset must be passed explicitly.
The Dataset participates in the L.1.7 dependency-graph walk via
the control's ``datasets()`` method.
"""
dataset: Dataset
column_name: str
[docs]
@classmethod
def from_column(cls, column: Column) -> "LinkedValues":
"""Linked values pulled from a typed Column. The Column's
dataset is the source dataset."""
return cls(dataset=column.dataset, column_name=column.name)
[docs]
@classmethod
def from_string(
cls, *, dataset: Dataset, column_name: str,
) -> "LinkedValues":
"""Linked values pulled from a bare-string column name on the
explicitly-passed dataset. Use when the dataset has no
registered ``DatasetContract``."""
return cls(dataset=dataset, column_name=column_name)
[docs]
def emit(self) -> dict[str, Any]:
return {
"LinkToDataSetColumn": {
"DataSetIdentifier": self.dataset.identifier,
"ColumnName": self.column_name,
},
}
SelectableValues = StaticValues | LinkedValues
# ---------------------------------------------------------------------------
# Control protocols — Sheet.add_parameter_control + add_filter_control
# accept any node satisfying these structural types.
# ---------------------------------------------------------------------------
[docs]
@runtime_checkable
class ParameterControlLike(Protocol):
"""Tree-level parameter control nodes.
``datasets()`` participates in the L.1.7 dependency-graph walk —
controls with ``LinkedValues`` populate from a ``Dataset``, and
that's a dep. Controls with static values return an empty set.
"""
control_id: str | AutoResolved
[docs]
def emit(self) -> ParameterControl: ...
[docs]
def datasets(self) -> set[Dataset]: ...
[docs]
@runtime_checkable
class FilterControlLike(Protocol):
"""Tree-level filter control nodes.
``datasets()`` participates in the L.1.7 dependency-graph walk —
same shape as ``ParameterControlLike.datasets()``.
"""
control_id: str | AutoResolved
[docs]
def emit(self) -> FilterControl: ...
[docs]
def datasets(self) -> set[Dataset]: ...
# ---------------------------------------------------------------------------
# Parameter controls
# ---------------------------------------------------------------------------
[docs]
@dataclass(eq=False)
class ParameterDropdown:
"""Dropdown control bound to a ``ParameterDeclLike`` parameter.
``parameter`` is the typed parameter declaration the control
reads/writes — at emit time, the control's ``SourceParameterName``
becomes ``parameter.name``. The type checker catches "control
bound to a parameter that doesn't exist" at the wiring site.
``selectable_values`` accepts a ``StaticValues(["a", "b"])`` for a
fixed option list or ``LinkedValues(dataset, column)`` for an
auto-populated list. The ``LinkedValues.dataset`` ref participates
in the App's dependency graph.
``hidden_select_all=True`` suppresses the "Select all" entry —
needed for SINGLE_SELECT dropdowns where empty/All semantics don't
apply (e.g. a Sankey anchor that needs exactly one value).
``cascade_source`` makes this dropdown depend on another dropdown:
when ``cascade_source`` changes value, QS refreshes THIS dropdown's
options. Required for cascading filters even when the source
dataset's params are bridged via ``MappedDataSetParameters`` —
QS won't refresh the dropdown widget without explicit UI-level
cascade wiring (M.3.10c finding).
"""
parameter: ParameterDeclLike
title: str
selectable_values: SelectableValues
type: Literal["SINGLE_SELECT", "MULTI_SELECT"] = "SINGLE_SELECT"
hidden_select_all: bool = False
cascade_source: "ParameterDropdown | None" = None
# Column-match for the cascade: when the source control's value
# changes, QS filters THIS dropdown's source dataset to rows
# where this column equals the source value, then re-distincts
# the dropdown's options. Required when cascade_source is set.
cascade_match_column: "Column | None" = None
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "dropdown"
[docs]
def datasets(self) -> set[Dataset]:
"""Datasets this control references (via LinkedValues if any)."""
if isinstance(self.selectable_values, LinkedValues):
ds = self.selectable_values.dataset
return {ds}
return set()
[docs]
def emit(self) -> ParameterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
display_options: dict[str, Any] | None = None
if self.hidden_select_all:
display_options = {
"SelectAllOptions": {"Visibility": "HIDDEN"},
}
cascading_config = None
if self.cascade_source is not None:
from recon_gen.common.models import (
CascadingControlConfiguration,
CascadingControlSource,
ColumnIdentifier,
)
assert not isinstance(self.cascade_source.control_id, _AutoSentinel), (
"cascade_source's control_id wasn't resolved before this "
"control's emit — auto-ID resolution must visit the source "
"control first."
)
assert self.cascade_match_column is not None, (
"cascade_source set without cascade_match_column — QS "
"needs to know which column on this dropdown's dataset "
"to filter by the source control's selected value."
)
cascading_config = CascadingControlConfiguration(
SourceControls=[CascadingControlSource(
SourceSheetControlId=self.cascade_source.control_id,
ColumnToMatch=ColumnIdentifier(
DataSetIdentifier=self.cascade_match_column.dataset.identifier,
ColumnName=self.cascade_match_column.name,
),
)],
)
return ParameterControl(
Dropdown=ModelParameterDropDownControl(
ParameterControlId=self.control_id,
Title=self.title,
SourceParameterName=self.parameter.name,
Type=self.type,
SelectableValues=self.selectable_values.emit(),
DisplayOptions=display_options,
CascadingControlConfiguration=cascading_config,
),
)
[docs]
@dataclass(eq=False)
class ParameterSlider:
"""Slider control bound to a numeric parameter."""
parameter: ParameterDeclLike
title: str
minimum_value: float
maximum_value: float
step_size: float
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "slider"
[docs]
def datasets(self) -> set[Dataset]:
return set()
[docs]
def emit(self) -> ParameterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
return ParameterControl(
Slider=ModelParameterSliderControl(
ParameterControlId=self.control_id,
Title=self.title,
SourceParameterName=self.parameter.name,
MinimumValue=self.minimum_value,
MaximumValue=self.maximum_value,
StepSize=self.step_size,
),
)
[docs]
@dataclass(eq=False)
class ParameterDateTimePicker:
"""Date/time picker control bound to a DateTime parameter."""
parameter: ParameterDeclLike
title: str
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "datetime"
[docs]
def datasets(self) -> set[Dataset]:
return set()
[docs]
def emit(self) -> ParameterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
return ParameterControl(
DateTimePicker=ModelParameterDateTimePickerControl(
ParameterControlId=self.control_id,
Title=self.title,
SourceParameterName=self.parameter.name,
),
)
[docs]
@dataclass(eq=False)
class ParameterTextField:
"""Free-text input control bound to a string parameter.
Right shape when the parameter's option universe is unbounded /
unknown at deploy time, or when the LinkedValues / StaticValues
paths are unavailable. The analyst types a value; QS writes it to
the bound parameter verbatim. No sample-values fetch — sidesteps
the X.1.b ``Sample values not found`` failure mode entirely.
Y.1.m: rejects ``parameter.multi_valued=True`` at construction.
QS text-field controls cannot commit non-empty values into a
multi-valued parameter — the commit silently reverts both this
parameter AND any sibling parameters back to their analysis-level
defaults. Empty-string commits work (special case); any other
string trips the state-reset bug. Reproduced live against L2FT
Rails on 2026-05-06 — the metadata cascade was 100% broken in
production for this reason. Construction-time error here prevents
the whole class of regression at the wiring site.
"""
parameter: ParameterDeclLike
title: str
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "text"
def __post_init__(self) -> None:
if getattr(self.parameter, "multi_valued", False):
raise ValueError(
f"ParameterTextField bound to multi-valued parameter "
f"{self.parameter.name!r}. QS text-field controls cannot "
f"commit non-empty values to multi-valued parameters; "
f"the commit silently reverts the parameter to its "
f"analysis-level default (broken cascade). Either set "
f"the parameter's multi_valued=False, or use a different "
f"control type (ParameterDropdown with LinkedValues / "
f"StaticValues)."
)
[docs]
def datasets(self) -> set[Dataset]:
return set()
[docs]
def emit(self) -> ParameterControl:
from recon_gen.common.models import ParameterTextFieldControl
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
return ParameterControl(
TextField=ParameterTextFieldControl(
ParameterControlId=self.control_id,
Title=self.title,
SourceParameterName=self.parameter.name,
),
)
# ---------------------------------------------------------------------------
# Filter controls
# ---------------------------------------------------------------------------
[docs]
@dataclass(eq=False)
class FilterDropdown:
"""Dropdown control bound to an inner filter (``CategoryFilter``).
``filter`` is the typed inner filter the dropdown drives — at
emit time, the control's ``SourceFilterId`` becomes
``filter.filter_id``. The filter must be inside a ``FilterGroup``
that's been registered on the analysis.
"""
filter: FilterLike
title: str
type: Literal["SINGLE_SELECT", "MULTI_SELECT"] = "MULTI_SELECT"
selectable_values: SelectableValues | None = None
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "dropdown"
[docs]
def datasets(self) -> set[Dataset]:
if isinstance(self.selectable_values, LinkedValues):
ds = self.selectable_values.dataset
assert ds is not None # LinkedValues.__post_init__ guarantees
return {ds}
return set()
[docs]
def emit(self) -> FilterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
assert not isinstance(self.filter.filter_id, _AutoSentinel), (
"inner filter's filter_id wasn't resolved — App.resolve_auto_ids() must run."
)
return FilterControl(
Dropdown=ModelFilterDropDownControl(
FilterControlId=self.control_id,
Title=self.title,
SourceFilterId=self.filter.filter_id,
Type=self.type,
SelectableValues=(
self.selectable_values.emit()
if self.selectable_values is not None else None
),
),
)
[docs]
@dataclass(eq=False)
class FilterSlider:
"""Slider control bound to a NumericRangeFilter."""
filter: FilterLike
title: str
minimum_value: float
maximum_value: float
step_size: float
type: Literal["SINGLE_POINT", "RANGE"] = "RANGE"
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "slider"
[docs]
def datasets(self) -> set[Dataset]:
return set()
[docs]
def emit(self) -> FilterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
assert not isinstance(self.filter.filter_id, _AutoSentinel), (
"inner filter's filter_id wasn't resolved — App.resolve_auto_ids() must run."
)
return FilterControl(
Slider=ModelFilterSliderControl(
FilterControlId=self.control_id,
Title=self.title,
SourceFilterId=self.filter.filter_id,
MinimumValue=self.minimum_value,
MaximumValue=self.maximum_value,
StepSize=self.step_size,
Type=self.type,
),
)
[docs]
@dataclass(eq=False)
class FilterDateTimePicker:
"""Date/time picker control bound to a TimeRangeFilter."""
filter: FilterLike
title: str
type: Literal["SINGLE_VALUED", "DATE_RANGE"] = "DATE_RANGE"
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "datetime"
[docs]
def datasets(self) -> set[Dataset]:
return set()
[docs]
def emit(self) -> FilterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
assert not isinstance(self.filter.filter_id, _AutoSentinel), (
"inner filter's filter_id wasn't resolved — App.resolve_auto_ids() must run."
)
return FilterControl(
DateTimePicker=ModelFilterDateTimePickerControl(
FilterControlId=self.control_id,
Title=self.title,
SourceFilterId=self.filter.filter_id,
Type=self.type,
),
)
[docs]
@dataclass(eq=False)
class FilterCrossSheet:
"""Cross-sheet filter control — surfaces the filter on multiple
sheets via the same bound filter.
No title; the Cross-Sheet control inherits its UI from the
underlying filter's primary control.
"""
filter: FilterLike
control_id: str | AutoResolved = AUTO
_AUTO_KIND: ClassVar[str] = "crosssheet"
[docs]
def datasets(self) -> set[Dataset]:
return set()
[docs]
def emit(self) -> FilterControl:
assert not isinstance(self.control_id, _AutoSentinel), (
"control_id wasn't resolved — App.resolve_auto_ids() must run."
)
assert not isinstance(self.filter.filter_id, _AutoSentinel), (
"inner filter's filter_id wasn't resolved — App.resolve_auto_ids() must run."
)
return FilterControl(
CrossSheet=ModelFilterCrossSheetControl(
FilterControlId=self.control_id,
SourceFilterId=self.filter.filter_id,
),
)