refactor composer to use sqlalchemy, avoid string concatenations and follow conventions

This commit is contained in:
2026-06-03 12:10:30 -03:00
parent cbc7df8c60
commit be09fcde2c
16 changed files with 546 additions and 273 deletions

View File

@@ -1,22 +1,29 @@
"""Date-range resolver for typed Filters.
Given a `Filter` with `date_range="YYYY"` or `date_range="YYYY-Q[1-4]"` and
the target column, render a SQL fragment that evaluates correctly under the
column's actual storage type. Dispatched in two layers:
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 set (extension point — e.g. `"date_yymmdd"` for
datasets that ship YYMMDD-encoded integers).
2. Otherwise `Column.sql_type` (`DATE`, `TIMESTAMP`, …) — the default path
for warehouses that store dates as real date columns.
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.
Inclusive on both ends. Bounds quoted with single quotes (Postgres parses
date literals from strings).
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
@@ -47,27 +54,37 @@ def _bounds(spec: str) -> tuple[str, str]:
)
def resolve_date_range(spec: str, column: Column, qualified_col: str) -> str:
"""Render the WHERE fragment for `<qualified_col> BETWEEN ...`.
def resolve_date_range(
spec: str,
column: Column,
col_expr: exp.Expression,
encodings: dict[str, dict[str, str]],
) -> exp.Between:
"""Build the `<comparable> BETWEEN '<lo>' AND '<hi>'` predicate for `spec`.
`qualified_col` is the already-quoted reference the composer wants in
the SQL (e.g. `"l"."date"`). Returns just the predicate, no `WHERE`.
`col_expr` is the (already alias-qualified) column reference node;
`encodings` is the effective encoding map (see `api.composer.encodings`).
"""
lo, hi = _bounds(spec)
semantic = (column.semantic_type or "").lower()
bounds = (exp.convert(lo), exp.convert(hi))
if semantic == "date_yymmdd":
# Column stores YYMMDD as integer (some BIRD datasets ship this way).
return (
f"TO_DATE(LPAD({qualified_col}::text, 6, '0'), 'YYMMDD') "
f"BETWEEN '{lo}' AND '{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 f"{qualified_col} BETWEEN '{lo}' AND '{hi}'"
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"semantic_type={column.semantic_type!r} is not supported "
f"(no native date type and no matching encoding)"
)