Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/boring_semantic_layer/agents/backends/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
42 changes: 42 additions & 0 deletions src/boring_semantic_layer/agents/tests/test_semantic_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
10 changes: 9 additions & 1 deletion src/boring_semantic_layer/expr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -859,6 +861,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,
Expand All @@ -871,6 +874,7 @@ def query(
time_grains=time_grains,
time_range=time_range,
having=having,
strict_semantic_boundaries=strict_semantic_boundaries,
)

def compare_periods(
Expand All @@ -885,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,
Expand All @@ -898,6 +903,7 @@ def compare_periods(
time_grains=time_grains,
order_by=order_by,
limit=limit,
strict_semantic_boundaries=strict_semantic_boundaries,
)


Expand Down Expand Up @@ -1047,6 +1053,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,
Expand All @@ -1059,6 +1066,7 @@ def query(
time_grains=time_grains,
time_range=time_range,
having=having,
strict_semantic_boundaries=strict_semantic_boundaries,
)

def as_table(self) -> SemanticModel:
Expand Down
78 changes: 39 additions & 39 deletions src/boring_semantic_layer/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"],
)


Expand All @@ -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"],
)


Expand Down Expand Up @@ -4773,7 +4772,8 @@ def build_fragment(field_name: str) -> Any:
field_name,
field_name,
type_str,
weight_expr,
self.by,
all_roots,
)
)

Expand Down
11 changes: 7 additions & 4 deletions src/boring_semantic_layer/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from ibis import BaseBackend

from ._xorq import HAS_XORQ, Profile as XorqProfile

from .utils import read_yaml_file


Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading