From e39e5a06cd27d8fa08d4ada01791485e95499629 Mon Sep 17 00:00:00 2001 From: boringdata Date: Tue, 23 Jun 2026 18:18:03 +0000 Subject: [PATCH 1/2] Fix plain ibis index and semantic boundaries --- src/boring_semantic_layer/expr.py | 4 + src/boring_semantic_layer/ops.py | 78 ++++++------- src/boring_semantic_layer/profile.py | 11 +- src/boring_semantic_layer/query.py | 63 ++++++++++- .../tests/test_issue_273_275.py | 107 ++++++++++++++++++ 5 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 src/boring_semantic_layer/tests/test_issue_273_275.py diff --git a/src/boring_semantic_layer/expr.py b/src/boring_semantic_layer/expr.py index db599052..3d5eaff6 100644 --- a/src/boring_semantic_layer/expr.py +++ b/src/boring_semantic_layer/expr.py @@ -859,6 +859,7 @@ def query( time_grains: dict[str, str] | None = None, time_range: dict[str, str] | None = None, having: list | None = None, + strict_semantic_boundaries: bool = False, ): return build_query( semantic_table=self, @@ -871,6 +872,7 @@ def query( time_grains=time_grains, time_range=time_range, having=having, + strict_semantic_boundaries=strict_semantic_boundaries, ) def compare_periods( @@ -1047,6 +1049,7 @@ def query( time_grains: dict[str, str] | None = None, time_range: dict[str, str] | None = None, having: list | None = None, + strict_semantic_boundaries: bool = False, ): return build_query( semantic_table=self, @@ -1059,6 +1062,7 @@ def query( time_grains=time_grains, time_range=time_range, having=having, + strict_semantic_boundaries=strict_semantic_boundaries, ) def as_table(self) -> SemanticModel: diff --git a/src/boring_semantic_layer/ops.py b/src/boring_semantic_layer/ops.py index bb9fda16..e5753faa 100644 --- a/src/boring_semantic_layer/ops.py +++ b/src/boring_semantic_layer/ops.py @@ -4542,15 +4542,22 @@ def _get_weight_expr( all_roots: list, is_string: bool, ) -> Any: - from ._xorq import api as xo - if not by_measure: - return xo._.count() + return base_tbl.count() merged_measures = _get_merged_fields(all_roots, "measures") - return ( - merged_measures[by_measure](base_tbl) if by_measure in merged_measures else xo._.count() - ) + if by_measure in merged_measures: + return merged_measures[by_measure](base_tbl) + return base_tbl.count() + + +def _literal_for_table(table: Any, value: Any) -> Any: + if type(table).__module__.startswith("xorq."): + from ._xorq import api as xo + + return xo.literal(value) + + return ibis.literal(value) def _build_string_index_fragment( @@ -4561,18 +4568,13 @@ def _build_string_index_fragment( type_str: str, weight_expr: Any, ) -> Any: - from ._xorq import api as xo - - return ( - base_tbl.group_by(field_expr.name("value")) - .aggregate(weight=weight_expr) - .select( - fieldName=xo.literal(field_name.split(".")[-1]), - fieldPath=xo.literal(field_path), - fieldType=xo.literal(type_str), - fieldValue=xo._["value"].cast("string"), - weight=xo._["weight"], - ) + aggregated = base_tbl.group_by(field_expr.name("value")).aggregate(weight=weight_expr) + return aggregated.select( + fieldName=_literal_for_table(aggregated, field_name.split(".")[-1]), + fieldPath=_literal_for_table(aggregated, field_path), + fieldType=_literal_for_table(aggregated, type_str), + fieldValue=aggregated["value"].cast("string"), + weight=aggregated["weight"], ) @@ -4582,27 +4584,24 @@ def _build_numeric_index_fragment( field_name: str, field_path: str, type_str: str, - weight_expr: Any, + by_measure: str | None, + all_roots: list, ) -> Any: - from ._xorq import api as xo - - return ( - base_tbl.select(field_expr.name("value")) - .filter(xo._["value"].notnull()) - .aggregate( - min_val=xo._["value"].min(), - max_val=xo._["value"].max(), - weight=weight_expr, - ) - .select( - fieldName=xo.literal(field_name.split(".")[-1]), - fieldPath=xo.literal(field_path), - fieldType=xo.literal(type_str), - fieldValue=( - xo._["min_val"].cast("string") + " to " + xo._["max_val"].cast("string") - ), - weight=xo._["weight"], - ) + values = base_tbl.mutate(value=field_expr).filter(lambda t: t["value"].notnull()) + weight_expr = _get_weight_expr(values, by_measure, all_roots, is_string=False) + aggregated = values.aggregate( + min_val=values["value"].min(), + max_val=values["value"].max(), + weight=weight_expr, + ) + return aggregated.select( + fieldName=_literal_for_table(aggregated, field_name.split(".")[-1]), + fieldPath=_literal_for_table(aggregated, field_path), + fieldType=_literal_for_table(aggregated, type_str), + fieldValue=( + aggregated["min_val"].cast("string") + " to " + aggregated["max_val"].cast("string") + ), + weight=aggregated["weight"], ) @@ -4773,7 +4772,8 @@ def build_fragment(field_name: str) -> Any: field_name, field_name, type_str, - weight_expr, + self.by, + all_roots, ) ) diff --git a/src/boring_semantic_layer/profile.py b/src/boring_semantic_layer/profile.py index b48cfb8f..c25a3613 100644 --- a/src/boring_semantic_layer/profile.py +++ b/src/boring_semantic_layer/profile.py @@ -7,7 +7,6 @@ from ibis import BaseBackend from ._xorq import HAS_XORQ, Profile as XorqProfile - from .utils import read_yaml_file @@ -170,12 +169,16 @@ def _create_connection_from_config(config: dict) -> BaseBackend: if HAS_XORQ: # Try xorq first (handles env var substitution automatically) + kwargs_tuple = tuple(sorted((k, v) for k, v in config.items() if k != "type")) try: - kwargs_tuple = tuple(sorted((k, v) for k, v in config.items() if k != "type")) xorq_profile = XorqProfile(con_name=conn_type, kwargs_tuple=kwargs_tuple) - connection = xorq_profile.get_con() - except AssertionError: + except (AssertionError, ValueError): connection = _connect_plain_ibis(ibis, config, conn_type) + else: + try: + connection = xorq_profile.get_con() + except AssertionError: + connection = _connect_plain_ibis(ibis, config, conn_type) else: connection = _connect_plain_ibis(ibis, config, conn_type) diff --git a/src/boring_semantic_layer/query.py b/src/boring_semantic_layer/query.py index 22ab522c..8c27308f 100644 --- a/src/boring_semantic_layer/query.py +++ b/src/boring_semantic_layer/query.py @@ -304,6 +304,50 @@ def _normalize_order_by( ] +def _raise_unknown_semantic_fields(kind: str, fields: set[str], allowed: set[str]) -> None: + unknown = sorted(fields - allowed) + if unknown: + raise ValueError( + f"Unknown semantic {kind}: {', '.join(unknown)}. " + f"Allowed fields: {', '.join(sorted(allowed)) or 'none'}", + ) + + +def _filter_semantic_fields(filter_spec: Any) -> set[str]: + """Return dict-filter field references that can be boundary-checked.""" + raw = filter_spec.filter if isinstance(filter_spec, Filter) else filter_spec + return _extract_filter_fields(raw) if isinstance(raw, dict) else set() + + +def _validate_semantic_boundaries( + *, + dimensions: Sequence[str], + measures: Sequence[str] | None, + filters: Sequence[Any], + having: Sequence[Any], + order_by: Sequence[tuple[str, str]] | None, + known_dimensions: set[str], + known_measures: set[str], + model_name: str | None = None, +) -> None: + """Ensure structured query fields do not escape the declared semantic model.""" + semantic_fields = known_dimensions | known_measures + _raise_unknown_semantic_fields("dimensions", set(dimensions), known_dimensions) + if measures is not None: + _raise_unknown_semantic_fields("measures", set(measures), known_measures) + if order_by: + order_fields = {field for field, _ in order_by} + _raise_unknown_semantic_fields("order_by fields", order_fields, semantic_fields) + + filter_fields: set[str] = set() + for filter_spec in [*filters, *having]: + filter_fields.update( + _normalize_field_name(field, semantic_fields, model_name) + for field in _filter_semantic_fields(filter_spec) + ) + _raise_unknown_semantic_fields("filter fields", filter_fields, semantic_fields) + + def _extract_filter_fields(filter_spec: dict) -> set[str]: """Extract all field names referenced by a dict filter (including compound).""" from . import predicate as pred_mod @@ -588,6 +632,7 @@ def query( time_grains: Mapping[str, TimeGrain] | None = None, time_range: Mapping[str, str] | None = None, having: Sequence[dict[str, Any] | str | Callable | Filter] | None = None, + strict_semantic_boundaries: bool = False, ) -> Any: # Returns SemanticModel or SemanticAggregate """ Query semantic table using parameter-based interface with time dimension support. @@ -611,6 +656,9 @@ def query( having: Optional list of post-aggregation filters. These are always applied after group-by/aggregate regardless of field type. Use this for callable/lambda filters that reference measures. + strict_semantic_boundaries: When True, structured dimensions, measures, + order_by fields, and dict/Filter filters must reference declared + dimensions, measures, or calculated measures only. Returns: SemanticAggregate or SemanticTable ready for execution @@ -673,6 +721,19 @@ def query( ) order_by = _normalize_order_by(order_by, known_order_fields, expected_prefix=model_name) filters = list(filters or []) # Copy to avoid mutating input + having = list(having or []) + + if strict_semantic_boundaries: + _validate_semantic_boundaries( + dimensions=dimensions, + measures=measures, + filters=filters, + having=having, + order_by=order_by, + known_dimensions=known_dimensions, + known_measures=known_measures, + model_name=model_name, + ) # Step 0: Add time_range as a filter if specified if time_range: @@ -741,7 +802,7 @@ def query( # Step 2: Apply filters — separate pre-agg (dimension) from post-agg (measure) pre_agg_filters = [] - post_agg_filters = list(having or []) + post_agg_filters = list(having) for filter_spec in filters: _split_filter(filter_spec, known_measures, model_name, pre_agg_filters, post_agg_filters) diff --git a/src/boring_semantic_layer/tests/test_issue_273_275.py b/src/boring_semantic_layer/tests/test_issue_273_275.py new file mode 100644 index 00000000..84fd339a --- /dev/null +++ b/src/boring_semantic_layer/tests/test_issue_273_275.py @@ -0,0 +1,107 @@ +import ibis +import pytest +from ibis import _ + +from boring_semantic_layer import to_semantic_table + + +def _opportunities_model(): + source = ibis.memtable( + { + "status": ["open", "closed", "open"], + "private_amount": [100, 200, 300], + } + ) + return ( + to_semantic_table(source, name="opportunities") + .with_dimensions(status=_.status) + .with_measures( + opportunity_count=_.count(), + pipeline_amount=_.private_amount.sum(), + ) + ) + + +def test_strict_semantic_boundaries_reject_raw_dimension(): + opportunities = _opportunities_model() + + with pytest.raises(ValueError, match="private_amount"): + opportunities.query( + dimensions=["private_amount"], + measures=["opportunity_count"], + strict_semantic_boundaries=True, + ) + + +def test_strict_semantic_boundaries_reject_raw_filter_field(): + opportunities = _opportunities_model() + + with pytest.raises(ValueError, match="private_amount"): + opportunities.query( + dimensions=["status"], + measures=["opportunity_count"], + filters=[{"field": "private_amount", "operator": ">=", "value": 200}], + strict_semantic_boundaries=True, + ) + + +def test_strict_semantic_boundaries_allow_model_prefixed_declared_filter(): + opportunities = _opportunities_model() + + result = opportunities.query( + dimensions=["opportunities.status"], + measures=["opportunity_count"], + filters=[{"field": "opportunities.status", "operator": "=", "value": "open"}], + strict_semantic_boundaries=True, + ).execute() + + assert list(result["status"]) == ["open"] + + +def test_index_uses_plain_table_expressions_for_weight_and_value_refs(): + opportunities = _opportunities_model() + + result = opportunities.index("status").execute() + + assert set(result["fieldValue"]) == {"open", "closed"} + assert result.set_index("fieldValue").loc["open", "weight"] == 2 + + +def test_profile_constructor_value_error_falls_back_to_plain_ibis(monkeypatch): + import ibis as ibis_module + + import boring_semantic_layer.profile as profile_module + + class RaisingXorqProfile: + def __init__(self, *args, **kwargs): + raise ValueError("unsupported by xorq") + + class FakeBackend: + @staticmethod + def connect(**kwargs): + return {"connected_with": kwargs} + + monkeypatch.setattr(profile_module, "XorqProfile", RaisingXorqProfile) + monkeypatch.setattr(ibis_module, "fakebackend", FakeBackend, raising=False) + + connection = profile_module._create_connection_from_config( + {"type": "fakebackend", "token": "${FAKE_TOKEN}"} + ) + + assert connection == {"connected_with": {"token": "${FAKE_TOKEN}"}} + + +def test_profile_connection_value_error_is_not_swallowed(monkeypatch): + import boring_semantic_layer.profile as profile_module + + class RaisingXorqProfile: + def __init__(self, *args, **kwargs): + pass + + def get_con(self): + raise ValueError("invalid connection option") + + monkeypatch.setattr(profile_module, "XorqProfile", RaisingXorqProfile) + + with pytest.raises(ValueError, match="invalid connection option"): + profile_module._create_connection_from_config({"type": "duckdb"}) From 65b5cdd241485cdcaf260c8e3d69d47065041019 Mon Sep 17 00:00:00 2001 From: boringdata Date: Tue, 30 Jun 2026 20:25:51 +0000 Subject: [PATCH 2/2] Enforce strict boundaries on agent query surfaces --- .../agents/backends/mcp.py | 2 + .../agents/tests/test_semantic_mcp.py | 42 ++++++++++++ src/boring_semantic_layer/expr.py | 6 +- src/boring_semantic_layer/query.py | 68 ++++++++++++++++++- src/boring_semantic_layer/server/api.py | 2 + .../tests/test_issue_273_275.py | 62 ++++++++++++++++- .../tests/test_server_api.py | 58 +++++++++++++++- 7 files changed, 231 insertions(+), 9 deletions(-) diff --git a/src/boring_semantic_layer/agents/backends/mcp.py b/src/boring_semantic_layer/agents/backends/mcp.py index 5b50d100..5bc414bd 100644 --- a/src/boring_semantic_layer/agents/backends/mcp.py +++ b/src/boring_semantic_layer/agents/backends/mcp.py @@ -255,6 +255,7 @@ def query_model( time_grain=time_grain, time_grains=time_grains, time_range=time_range, + strict_semantic_boundaries=True, ) return generate_chart_with_data( @@ -391,6 +392,7 @@ def compare_periods( time_grains=time_grains, order_by=order_by, limit=limit, + strict_semantic_boundaries=True, ) return generate_chart_with_data( diff --git a/src/boring_semantic_layer/agents/tests/test_semantic_mcp.py b/src/boring_semantic_layer/agents/tests/test_semantic_mcp.py index 3cf65a92..1fef11f9 100644 --- a/src/boring_semantic_layer/agents/tests/test_semantic_mcp.py +++ b/src/boring_semantic_layer/agents/tests/test_semantic_mcp.py @@ -283,6 +283,48 @@ async def test_query_with_time_grain(self, sample_models): assert result.content[0].text is not None assert "flight_date" in result.content[0].text + @pytest.mark.asyncio + async def test_query_with_prefixed_time_grains(self, sample_models): + """Test query with model-prefixed per-dimension time grains.""" + mcp = MCPSemanticModel(models=sample_models) + + async with Client(mcp) as client: + result = await client.call_tool( + "query_model", + { + "model_name": "flights", + "dimensions": ["flights.flight_date"], + "measures": ["flight_count"], + "time_grains": {"flights.flight_date": "month"}, + "get_chart": False, + }, + ) + + data = json.loads(result.content[0].text) + assert "flight_date" in data["columns"] + + @pytest.mark.asyncio + async def test_compare_periods_with_prefixed_time_grains(self, sample_models): + """Test compare_periods with model-prefixed per-dimension time grains.""" + mcp = MCPSemanticModel(models=sample_models) + + async with Client(mcp) as client: + result = await client.call_tool( + "compare_periods", + { + "model_name": "flights", + "dimensions": ["flights.flight_date"], + "measures": ["flight_count"], + "current_time_range": {"start": "2024-01-11", "end": "2024-01-20"}, + "previous_time_range": {"start": "2024-01-01", "end": "2024-01-10"}, + "time_grains": {"flights.flight_date": "month"}, + "get_chart": False, + }, + ) + + data = json.loads(result.content[0].text) + assert "flight_date" in data["columns"] + @pytest.mark.asyncio async def test_query_with_time_range(self, sample_models): """Test query with time range.""" diff --git a/src/boring_semantic_layer/expr.py b/src/boring_semantic_layer/expr.py index 3d5eaff6..b78794ca 100644 --- a/src/boring_semantic_layer/expr.py +++ b/src/boring_semantic_layer/expr.py @@ -11,12 +11,14 @@ from ibis.expr.types.groupby import GroupedTable as IbisGroupedTable from ibis.expr.types.relations import Table as IbisTable from returns.result import Success, safe + from ._xorq import ( Column as XorqColumn, +) +from ._xorq import ( GroupedTable, Table, ) - from .chart import chart as create_chart from .measure_scope import MeasureScope from .ops import ( @@ -887,6 +889,7 @@ def compare_periods( time_grains: dict[str, str] | None = None, order_by: Sequence[tuple[str, str]] | None = None, limit: int | None = None, + strict_semantic_boundaries: bool = False, ): return build_compare_periods( semantic_table=self, @@ -900,6 +903,7 @@ def compare_periods( time_grains=time_grains, order_by=order_by, limit=limit, + strict_semantic_boundaries=strict_semantic_boundaries, ) diff --git a/src/boring_semantic_layer/query.py b/src/boring_semantic_layer/query.py index 8c27308f..79fa8f54 100644 --- a/src/boring_semantic_layer/query.py +++ b/src/boring_semantic_layer/query.py @@ -304,6 +304,20 @@ def _normalize_order_by( ] +def _normalize_time_grains( + time_grains: Mapping[str, TimeGrain] | None, + known_dimensions: set[str], + expected_prefix: str | None = None, +) -> dict[str, TimeGrain]: + """Normalize per-dimension time grain keys against known semantic dimensions.""" + if not time_grains: + return {} + return { + _normalize_field_name(dim, known_dimensions, expected_prefix): grain + for dim, grain in time_grains.items() + } + + def _raise_unknown_semantic_fields(kind: str, fields: set[str], allowed: set[str]) -> None: unknown = sorted(fields - allowed) if unknown: @@ -511,6 +525,7 @@ def compare_periods( time_grains: Mapping[str, TimeGrain] | None = None, order_by: Sequence[tuple[str, str]] | None = None, limit: int | None = None, + strict_semantic_boundaries: bool = False, ) -> Any: """Compare two time ranges and return current/previous/delta columns.""" from .api import to_semantic_table @@ -533,6 +548,7 @@ def compare_periods( dimensions = _normalize_fields(dimensions, known_dimensions, expected_prefix=model_name) measures = _normalize_fields(measures, known_measures, expected_prefix=model_name) + time_grains = _normalize_time_grains(time_grains, known_dimensions, model_name) resolved_time_dimension = time_dimension if resolved_time_dimension is not None: @@ -551,21 +567,68 @@ def compare_periods( "or pass time_dimension explicitly." ) + if strict_semantic_boundaries: + comparison_order_fields = set(dimensions) + for measure in measures: + comparison_order_fields.update( + { + f"{measure}_current", + f"{measure}_previous", + f"{measure}_delta", + f"{measure}_pct_change", + } + ) + _validate_semantic_boundaries( + dimensions=dimensions, + measures=measures, + filters=filters, + having=[], + order_by=None, + known_dimensions=known_dimensions, + known_measures=known_measures, + model_name=model_name, + ) + if time_dimension is not None: + _raise_unknown_semantic_fields( + "time_dimension", + {resolved_time_dimension}, + known_dimensions, + ) + if order_by: + normalized_order_by = _normalize_order_by( + order_by, + comparison_order_fields, + expected_prefix=model_name, + ) + _raise_unknown_semantic_fields( + "order_by fields", + {field for field, _ in normalized_order_by}, + comparison_order_fields, + ) + current_result = query( semantic_table=semantic_table, dimensions=dimensions, measures=measures, - filters=[*filters, *_build_time_range_filters(semantic_table, resolved_time_dimension, current_time_range)], + filters=[ + *filters, + *_build_time_range_filters(semantic_table, resolved_time_dimension, current_time_range), + ], time_grain=time_grain, time_grains=time_grains, + strict_semantic_boundaries=strict_semantic_boundaries, ) previous_result = query( semantic_table=semantic_table, dimensions=dimensions, measures=measures, - filters=[*filters, *_build_time_range_filters(semantic_table, resolved_time_dimension, previous_time_range)], + filters=[ + *filters, + *_build_time_range_filters(semantic_table, resolved_time_dimension, previous_time_range), + ], time_grain=time_grain, time_grains=time_grains, + strict_semantic_boundaries=strict_semantic_boundaries, ) current_tbl = current_result.as_table().table.rename( @@ -759,6 +822,7 @@ def query( # Build per-dimension grain mapping: either from time_grains directly, # or by expanding time_grain to all time dimensions in the query. grain_map: dict[str, str] = {} + time_grains = _normalize_time_grains(time_grains, known_dimensions, model_name) if time_grains: grain_map = {dim: _normalize_grain(g) for dim, g in time_grains.items()} elif time_grain: diff --git a/src/boring_semantic_layer/server/api.py b/src/boring_semantic_layer/server/api.py index 9bfef517..0a60b8de 100644 --- a/src/boring_semantic_layer/server/api.py +++ b/src/boring_semantic_layer/server/api.py @@ -297,6 +297,7 @@ def query_model(payload: QueryRequest, request: Request) -> dict[str, Any]: time_grain=payload.time_grain, time_grains=payload.time_grains, time_range=payload.time_range, + strict_semantic_boundaries=True, ) response = json.loads( generate_chart_with_data( @@ -329,6 +330,7 @@ def compare_periods(payload: ComparePeriodsRequest, request: Request) -> dict[st time_grains=payload.time_grains, order_by=payload.order_by, limit=payload.limit, + strict_semantic_boundaries=True, ) response = json.loads( generate_chart_with_data( diff --git a/src/boring_semantic_layer/tests/test_issue_273_275.py b/src/boring_semantic_layer/tests/test_issue_273_275.py index 84fd339a..a9461ee4 100644 --- a/src/boring_semantic_layer/tests/test_issue_273_275.py +++ b/src/boring_semantic_layer/tests/test_issue_273_275.py @@ -9,12 +9,20 @@ def _opportunities_model(): source = ibis.memtable( { "status": ["open", "closed", "open"], + "created_at": ["2024-01-01", "2024-01-02", "2024-02-01"], "private_amount": [100, 200, 300], } - ) + ).mutate(created_at=_.created_at.cast("timestamp")) return ( to_semantic_table(source, name="opportunities") - .with_dimensions(status=_.status) + .with_dimensions( + status=_.status, + created_at={ + "expr": _.created_at, + "is_time_dimension": True, + "smallest_time_grain": "day", + }, + ) .with_measures( opportunity_count=_.count(), pipeline_amount=_.private_amount.sum(), @@ -58,6 +66,50 @@ def test_strict_semantic_boundaries_allow_model_prefixed_declared_filter(): assert list(result["status"]) == ["open"] +def test_query_normalizes_model_prefixed_time_grains(): + opportunities = _opportunities_model() + + result = opportunities.query( + dimensions=["opportunities.created_at"], + measures=["opportunity_count"], + time_grains={"opportunities.created_at": "month"}, + strict_semantic_boundaries=True, + ).execute() + + assert "created_at" in result.columns + assert result["opportunity_count"].sum() == 3 + + +def test_compare_periods_normalizes_model_prefixed_time_grains(): + opportunities = _opportunities_model() + + result = opportunities.compare_periods( + dimensions=["opportunities.created_at"], + measures=["opportunity_count"], + current_time_range={"start": "2024-02-01", "end": "2024-02-28"}, + previous_time_range={"start": "2024-01-01", "end": "2024-01-31"}, + time_grains={"opportunities.created_at": "month"}, + strict_semantic_boundaries=True, + ).execute() + + assert "created_at" in result.columns + assert "opportunity_count_current" in result.columns + + +def test_compare_periods_strict_semantic_boundaries_reject_raw_filter_field(): + opportunities = _opportunities_model() + + with pytest.raises(ValueError, match="private_amount"): + opportunities.compare_periods( + dimensions=["status"], + measures=["opportunity_count"], + current_time_range={"start": "2024-02-01", "end": "2024-02-28"}, + previous_time_range={"start": "2024-01-01", "end": "2024-01-31"}, + filters=[{"field": "private_amount", "operator": ">=", "value": 200}], + strict_semantic_boundaries=True, + ) + + def test_index_uses_plain_table_expressions_for_weight_and_value_refs(): opportunities = _opportunities_model() @@ -83,17 +135,21 @@ def connect(**kwargs): monkeypatch.setattr(profile_module, "XorqProfile", RaisingXorqProfile) monkeypatch.setattr(ibis_module, "fakebackend", FakeBackend, raising=False) + monkeypatch.setenv("FAKE_TOKEN", "secret") connection = profile_module._create_connection_from_config( {"type": "fakebackend", "token": "${FAKE_TOKEN}"} ) - assert connection == {"connected_with": {"token": "${FAKE_TOKEN}"}} + assert connection == {"connected_with": {"token": "secret"}} def test_profile_connection_value_error_is_not_swallowed(monkeypatch): import boring_semantic_layer.profile as profile_module + if not profile_module.HAS_XORQ: + pytest.skip("xorq connection errors only apply when xorq is installed") + class RaisingXorqProfile: def __init__(self, *args, **kwargs): pass diff --git a/src/boring_semantic_layer/tests/test_server_api.py b/src/boring_semantic_layer/tests/test_server_api.py index 59a3f7b1..f59a5977 100644 --- a/src/boring_semantic_layer/tests/test_server_api.py +++ b/src/boring_semantic_layer/tests/test_server_api.py @@ -186,6 +186,22 @@ def test_query_uses_core_bsl_interface(client): assert "chart" not in data +def test_query_enforces_strict_semantic_boundaries(client): + response = client.post( + "/query", + json={ + "model_name": "flights", + "dimensions": ["carrier"], + "measures": ["flight_count"], + "filters": [{"field": "dep_delay", "operator": ">=", "value": 5}], + "get_chart": False, + }, + ) + + assert response.status_code == 400 + assert "dep_delay" in response.json()["detail"] + + def test_query_supports_time_grain_and_time_range(client): response = client.post( "/query", @@ -211,11 +227,11 @@ def test_query_supports_per_dimension_time_grains(client): "/query", json={ "model_name": "flights", - "dimensions": ["flight_date", "arrival_date"], + "dimensions": ["flights.flight_date", "flights.arrival_date"], "measures": ["flight_count"], "time_grains": { - "flight_date": "month", - "arrival_date": "month", + "flights.flight_date": "month", + "flights.arrival_date": "month", }, "time_range": {"start": "2024-01-01", "end": "2024-01-31"}, "get_chart": False, @@ -257,6 +273,42 @@ def test_compare_periods_endpoint(client): assert aa_row["flight_count_delta"] == -1 +def test_compare_periods_supports_prefixed_time_grains(client): + response = client.post( + "/compare-periods", + json={ + "model_name": "flights", + "dimensions": ["flights.flight_date"], + "measures": ["flight_count"], + "current_time_range": {"start": "2024-01-11", "end": "2024-01-20"}, + "previous_time_range": {"start": "2024-01-01", "end": "2024-01-10"}, + "time_grains": {"flights.flight_date": "month"}, + "get_chart": False, + }, + ) + + assert response.status_code == 200 + assert "flight_date" in response.json()["columns"] + + +def test_compare_periods_enforces_strict_semantic_boundaries(client): + response = client.post( + "/compare-periods", + json={ + "model_name": "flights", + "dimensions": ["carrier"], + "measures": ["flight_count"], + "current_time_range": {"start": "2024-01-11", "end": "2024-01-20"}, + "previous_time_range": {"start": "2024-01-01", "end": "2024-01-10"}, + "filters": [{"field": "dep_delay", "operator": ">=", "value": 5}], + "get_chart": False, + }, + ) + + assert response.status_code == 400 + assert "dep_delay" in response.json()["detail"] + + def test_query_rejects_both_time_grain_and_time_grains(client): """Specifying both time_grain and time_grains is an error.""" response = client.post(