"""Date-range resolver for typed Filters. Given a `Filter` with `date_range="YYYY"` or `date_range="YYYY-Q[1-4]"`, the target column, and the active encoding map, build a SQL predicate node that evaluates correctly under the column's actual storage. Dispatched in two layers: 1. `Column.semantic_type` if it names an encoding in the effective map — coerce the raw column to a DATE via the encoding's `as_date` template, then compare. This is the extension point: BIRD's `date_yymmdd` ships as a default (see `api.composer.encodings`); a dataset can add or override encodings in its own `schema_docs.yaml`. 2. Otherwise `Column.sql_type` (`DATE`, `TIMESTAMP`, …) — compare the column directly. Returns an `exp.Between` node (inclusive on both ends) so it composes into the query tree the composer assembles; the composer is the single place a SQL string is produced. """ from __future__ import annotations import re import sqlglot from sqlglot import exp from api.composer.types import PickValidationError from api.recon.types import Column _YEAR_ONLY = re.compile(r"^\d{4}$") _YEAR_QUARTER = re.compile(r"^(\d{4})-Q([1-4])$") _QUARTER_BOUNDS = { 1: ("01-01", "03-31"), 2: ("04-01", "06-30"), 3: ("07-01", "09-30"), 4: ("10-01", "12-31"), } def _bounds(spec: str) -> tuple[str, str]: """Parse a date_range spec to (lo, hi) calendar dates as ISO strings.""" if _YEAR_ONLY.match(spec): year = spec return f"{year}-01-01", f"{year}-12-31" m = _YEAR_QUARTER.match(spec) if m: year, q = m.group(1), int(m.group(2)) lo_mmdd, hi_mmdd = _QUARTER_BOUNDS[q] return f"{year}-{lo_mmdd}", f"{year}-{hi_mmdd}" raise PickValidationError( f"unsupported date_range {spec!r}; expected 'YYYY' or 'YYYY-Q1..Q4'" ) def resolve_date_range( spec: str, column: Column, col_expr: exp.Expression, encodings: dict[str, dict[str, str]], ) -> exp.Between: """Build the ` BETWEEN '' AND ''` predicate for `spec`. `col_expr` is the (already alias-qualified) column reference node; `encodings` is the effective encoding map (see `api.composer.encodings`). """ lo, hi = _bounds(spec) bounds = (exp.convert(lo), exp.convert(hi)) semantic = (column.semantic_type or "").lower() enc = encodings.get(semantic) if semantic else None if enc and enc.get("as_date"): # Coerce the raw column to a DATE via the dataset/default template. template = enc["as_date"] coerced = sqlglot.parse_one( template.format(col=col_expr.sql(dialect="postgres", identify=True)), dialect="postgres", ) return exp.Between(this=coerced, low=bounds[0], high=bounds[1]) sql_type = column.sql_type.upper() if sql_type.startswith("DATE") or sql_type.startswith("TIMESTAMP"): return exp.Between(this=col_expr, low=bounds[0], high=bounds[1]) raise PickValidationError( f"date_range on column with sql_type={column.sql_type!r} and " f"semantic_type={column.semantic_type!r} is not supported " f"(no native date type and no matching encoding)" )