Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/boring_semantic_layer/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -1935,18 +1935,35 @@ def aggregate(
**aliased,
):
aggs = {}
for item in measure_names:

def anonymous_name(position: int) -> str:
"""Name a positional measure by where it appears, not by ``id()``.

``_measure_{id(item)}`` derived the result column from a memory
address: the column name changed between runs, and because the
name reaches the xorq tag it changed the expression hash too, so
an identical query never hit the cache.
"""
candidate = f"_measure_{position}"
# ``aliased`` is merged in after this loop, so a keyword measure
# occupying the positional name would otherwise overwrite it.
while candidate in aggs or candidate in aliased:
position += 1
candidate = f"_measure_{position}"
return candidate

for index, item in enumerate(measure_names):
if _is_deferred(item):
try:
name = _normalize_to_name(item)
aggs[name] = make_bare_ref_lambda(name)
except TypeError:
# Complex Deferred (e.g. _.distance.sum()) — treat as callable
aggs[f"_measure_{id(item)}"] = item
aggs[anonymous_name(index)] = item
elif isinstance(item, str):
aggs[item] = make_bare_ref_lambda(item)
elif callable(item):
aggs[f"_measure_{id(item)}"] = item
aggs[anonymous_name(index)] = item
else:
raise TypeError(
f"measure_names must be strings, callables, or Deferred expressions, "
Expand Down
41 changes: 38 additions & 3 deletions src/boring_semantic_layer/predicate.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
"not_ilike": lambda x, y: ~x.ilike(y),
}

#: Comparisons whose right operand is a string pattern, never a literal date.
_PATTERN_OPS = frozenset({"like", "not_like", "ilike", "not_ilike"})

# JSON filter operator strings that map to a Compare node. Includes
# legacy aliases (``=``, ``equals``) accepted by the existing parser.
_DICT_COMPARE_OPS: dict[str, str] = {
Expand Down Expand Up @@ -208,7 +211,7 @@ def _reject_value_keys(spec: dict, op: str) -> None:
raise ValueError(f"Operator {op!r} should not have 'value' or 'values' fields")


def _convert_literal(value: Any, ibis_module) -> Any:
def _convert_literal(value: Any, ibis_module, column: Any = None) -> Any:
"""Convert complete ISO date/timestamp strings to typed ibis literals.

Backends like Athena require typed date literals or fail with
Expand All @@ -217,9 +220,16 @@ def _convert_literal(value: Any, ibis_module) -> Any:
or "12:30" with *today's* date, so coercing them would make results
depend on the day the query runs. Other strings pass through
unchanged.

Coercion also depends on *column*: a string column that happens to hold
ISO-looking text ("2024-01-01" as a batch label) is compared as text.
Without this check the comparison was rebuilt as string-vs-timestamp and
the backend rejected a perfectly valid filter.
"""
if not isinstance(value, str) or not _is_complete_iso_datetime(value):
return value
if column is not None and not _is_temporal_column(column):
return value
for dtype in ("timestamp", "date"):
try:
return ibis_module.literal(value, type=dtype)
Expand All @@ -228,6 +238,25 @@ def _convert_literal(value: Any, ibis_module) -> Any:
return value


def _is_temporal_column(column: Any) -> bool:
"""True when *column* holds dates/times, so a date literal is comparable.

Unknown dtypes answer True to preserve the coercion that backends like
Athena need; only a positively non-temporal column suppresses it.
"""
try:
dtype = column.type()
except Exception:
return True
for probe in ("is_temporal", "is_timestamp", "is_date", "is_time"):
check = getattr(dtype, probe, None)
if callable(check) and check():
return True
return not any(
callable(getattr(dtype, probe, None)) for probe in ("is_temporal", "is_string")
)


def _is_complete_iso_datetime(value: str) -> bool:
for parse in (datetime.date.fromisoformat, datetime.datetime.fromisoformat):
try:
Expand Down Expand Up @@ -349,7 +378,7 @@ def compile( # noqa: A001
post_agg=post_agg,
strict_qualified=strict_qualified,
)
values = [_convert_literal(v, ibis_module) for v in pred.values]
values = [_convert_literal(v, ibis_module, col) for v in pred.values]
return col.notin(values) if pred.negate else col.isin(values)
if isinstance(pred, Compare):
col = _field_accessor(
Expand All @@ -358,7 +387,13 @@ def compile( # noqa: A001
post_agg=post_agg,
strict_qualified=strict_qualified,
)
value = _convert_literal(pred.value, ibis_module)
# like/ilike are string pattern matches: a date literal can never be
# the right operand, whatever the column's type.
value = (
pred.value
if pred.op in _PATTERN_OPS
else _convert_literal(pred.value, ibis_module, col)
)
return _COMPARE_OPS[pred.op](col, value)
if isinstance(pred, Custom):
return pred.fn(table)
Expand Down
52 changes: 43 additions & 9 deletions src/boring_semantic_layer/serialization/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,38 @@ def _ensure_registered():
# ---------------------------------------------------------------------------


def _unwrap_or_raise(result: Result[dict, Exception], kind: str, model_name) -> dict:
"""Return the serialized fields, or explain which kind could not be written.

``value_or({})`` here meant that one unserializable field emptied the
whole set: a model with a measure holding, say, a Python ``set`` was
tagged with ``measures: ()``, reconstructed with no measures at all, and
only failed later as "Column 'total' is not found" — pointing at the
query rather than at the field that could not be serialized.
"""
if isinstance(result, Success):
return result.unwrap()
where = f" on model {model_name!r}" if model_name else ""
raise ValueError(
f"Cannot serialize the {kind}{where}: {result.failure()}. Tagging would "
f"otherwise drop every {kind} silently and the reconstructed model would "
"be missing them."
)


@_register_lazy("SemanticTableOp")
def _extract_semantic_table(op, context: BSLSerializationContext) -> dict[str, Any]:
dims_result = serialize_dimensions(op.get_dimensions())
meas_result = serialize_measures(op.get_measures())
calc_result = serialize_calc_measures(op.get_calculated_measures())
metadata: dict[str, Any] = {
"dimensions": dims_result.value_or({}),
"measures": meas_result.value_or({}),
"dimensions": _unwrap_or_raise(
serialize_dimensions(op.get_dimensions()), "dimensions", op.name
),
"measures": _unwrap_or_raise(
serialize_measures(op.get_measures()), "measures", op.name
),
}
calc_data = calc_result.value_or({})
calc_data = _unwrap_or_raise(
serialize_calc_measures(op.get_calculated_measures()), "calculated measures", op.name
)
if calc_data:
metadata["calc_measures"] = calc_data
if op.name:
Expand Down Expand Up @@ -348,7 +370,12 @@ def do_serialize():
case Success():
entry["expr_struct"] = struct_result.unwrap()
case _:
continue
# Skipping left the model looking complete while quietly
# missing this calc measure.
raise ValueError(
f"Calc measure {name!r}: failed to serialize expression "
f"({struct_result.failure()})"
)
description = getattr(calc, "description", None)
if description is not None:
entry["description"] = description
Expand Down Expand Up @@ -393,7 +420,11 @@ def deserialize_calc_measures(calc_data: Mapping[str, Any]) -> dict[str, Any]:
depends_on = frozenset()

if struct is None:
continue
raise ValueError(
f"Calc measure {name!r} has no serialized expression in this "
"payload; reconstructing without it would silently return a "
"model that is missing the measure."
)
# ``thaw`` converts the resolver tuple into a list of lists; the
# resolver deserializer expects nested tuples, so convert back.
struct = list_to_tuple(struct)
Expand All @@ -402,7 +433,10 @@ def deserialize_calc_measures(calc_data: Mapping[str, Any]) -> dict[str, Any]:
case Success():
expr = result.unwrap()
case _:
continue
raise ValueError(
f"Calc measure {name!r}: failed to deserialize expression "
f"({result.failure()})"
)
out[name] = CalcMeasure(
expr=expr,
description=description,
Expand Down
Loading