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
6 changes: 6 additions & 0 deletions sidemantic/man/sidemantic.1
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,12 @@ Use HTTP transport instead of stdio
.TP
\fB\-\-port, \-p INTEGER\fR
Port for HTTP server
.TP
\fB\-\-user\-attrs\-file PATH\fR
Path to a JSON user\-attributes object applied to every MCP query
.TP
\fB\-\-enforce\-visibility\fR
Hide and reject fields declared public: false
.SH "SIDEMANTIC SERVER POSTGRES"
.SS SYNOPSIS
.B
Expand Down
3 changes: 2 additions & 1 deletion sidemantic/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,11 +737,12 @@ def get_semantic_graph() -> dict[str, Any]:
model_info: dict[str, Any] = {
"name": model_name,
"table": model.table,
"primary_key": _visible_dimension_name(model, model.primary_key, layer.enforce_visibility),
"dimensions": [d.name for d in model.dimensions if not layer.enforce_visibility or d.public],
"metrics": [m.name for m in model.metrics if not layer.enforce_visibility or m.public],
"relationships": [{"name": r.name, "type": r.type} for r in model.relationships],
}
if primary_key := _visible_dimension_name(model, model.primary_key, layer.enforce_visibility):
model_info["primary_key"] = primary_key
if model.description:
model_info["description"] = model.description
visible_segments = [s.name for s in model.segments if not layer.enforce_visibility or s.public]
Expand Down
67 changes: 66 additions & 1 deletion sidemantic/sql/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,49 @@ def collect_models_from_metric(metric_ref: str):

return models

def _find_aggregate_metric_models(self, metric_refs: list[str]) -> set[str]:
"""Find models whose entity keys can de-duplicate selected aggregate leaves."""
models: set[str] = set()
visited: set[int] = set()

def resolve(reference: str, model_context: str | None):
if "." not in reference and model_context:
local_metric = self.graph.get_model(model_context).get_metric(reference)
if local_metric is not None:
return model_context, local_metric
try:
return self.graph.resolve_metric_reference(reference)
except KeyError:
return None

def collect(metric, model_context: str | None) -> None:
if id(metric) in visited:
return
visited.add(id(metric))

if metric.agg or getattr(metric, "sql_is_complete", False):
if model_context:
models.add(model_context)
return

dependencies: list[str] = []
if metric.type == "ratio":
dependencies = [ref for ref in (metric.numerator, metric.denominator) if ref]
elif metric.type == "derived" or (not metric.type and metric.sql):
dependencies = metric.get_dependencies(self.graph, model_context)

for dependency in dependencies:
resolved = resolve(dependency, model_context)
if resolved is not None:
collect(resolved[1], resolved[0] or model_context)

for metric_ref in metric_refs:
resolved = resolve(metric_ref, None)
if resolved is not None:
collect(resolved[1], resolved[0])

return models

def _classify_filters_for_pushdown(
self, filters: list[str], all_models: set[str]
) -> tuple[dict[str, list[str]], list[str], dict[str, list[str]]]:
Expand Down Expand Up @@ -1974,6 +2017,10 @@ def _build_model_cte(
self._ensure_sql_model(model_name, model)
all_models = all_models or {model_name}
needs_keyed_joins = self._model_needs_keyed_join_columns(model_name, all_models)
metric_models = self._find_aggregate_metric_models(metrics)
needs_cross_fanout_key = model_name in metric_models and self._model_has_cross_join_in_query(
model_name, all_models
)

# Find which dimensions are actually needed
needed_dimensions = self._find_needed_dimensions(
Expand All @@ -1999,7 +2046,9 @@ def add_passthrough_column(column: str) -> None:
select_cols.append(f"{self._quote_identifier(column)} AS {self._quote_alias(column)}")
columns_added.add(column)

include_primary_keys = needs_keyed_joins or ungrouped or bool(model.sql)
# Cross joins do not need keys for the join predicate, but exact fan-out aggregation
# still de-duplicates metric rows by their entity key after the Cartesian product.
include_primary_keys = needs_keyed_joins or needs_cross_fanout_key or ungrouped or bool(model.sql)
if include_primary_keys:
for pk_col in model.primary_key_columns:
add_passthrough_column(pk_col)
Expand Down Expand Up @@ -2432,6 +2481,22 @@ def _model_needs_keyed_join_columns(self, model_name: str, all_models: set[str])

return False

def _model_has_cross_join_in_query(self, model_name: str, all_models: set[str]) -> bool:
"""Return whether this model participates in a cross join in this query."""
if len(all_models) <= 1:
return False

model = self.graph.get_model(model_name)
if any(rel.name in all_models and rel.type == "cross" for rel in model.relationships):
return True

return any(
rel.name == model_name and rel.type == "cross"
for other_model_name, other_model in self.graph.models.items()
if other_model_name in all_models
for rel in other_model.relationships
)

def _has_fanout_joins(self, base_model_name: str, other_models: list[str]) -> dict[str, bool]:
"""Determine which models need symmetric aggregates due to fan-out.

Expand Down
2 changes: 1 addition & 1 deletion tests/adapters/bsl/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1451,7 +1451,7 @@ def test_cross_join_compiles(self):
skip_default_time_dimensions=True,
)
assert "CROSS JOIN facts_cte" in sql
assert "id AS id" not in sql
assert "id AS id" in sql

conn = duckdb.connect(":memory:")
conn.execute("CREATE TABLE calendar(day DATE)")
Expand Down
5 changes: 4 additions & 1 deletion tests/adapters/cube/test_correctness_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,8 @@ def test_cross_cube_trailing_column_ref_translated_to_member():
cubes:
- name: orders
sql_table: orders
dimensions:
- {name: id, type: number, sql: id, primary_key: true}
measures:
- {name: amount, type: sum, sql: amt}
- name: line_items
Expand All @@ -1434,12 +1436,13 @@ def test_cross_cube_trailing_column_ref_translated_to_member():
)
m = graph.get_model("line_items").get_metric("derived_x")
assert m.sql == "orders.amount * 2"
assert graph.get_model("orders").primary_key == "id"
# It compiles to valid SQL (joins orders, references its measure) -- not a ${...} struct literal.
layer = SemanticLayer()
layer.graph = graph
compiled = layer.compile(metrics=["line_items.derived_x"])
assert "${orders}" not in compiled and "{'orders'" not in compiled
assert "SUM(orders_cte.amount_raw)" in compiled
assert "SUM(__sidemantic_dedup." in compiled


def test_rollup_with_only_unmaterializable_measures_is_rejected():
Expand Down
6 changes: 6 additions & 0 deletions tests/core/test_pure_rust_python_test_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ class PythonTestParityCase:


EXPECTED_GAPS = {
"tests.test_validation::test_query_validation_reports_ambiguous_join_routes": (
"Rust query validation does not yet reject ambiguous join routes"
),
"tests.queries.test_ungrouped_queries::test_with_totals_single_dimension": (
"Rust adapter does not yet support the with_totals (GROUPING SETS grand-total) compile kwarg"
),
Expand Down Expand Up @@ -228,6 +231,9 @@ class PythonTestParityCase:
"tests.optimizations.test_pre_aggregations::test_ungrouped_rollup_without_pk_falls_to_raw": (
"Rust adapter does not yet support Python pre-aggregation routing (ungrouped drill-to-detail)"
),
"tests.optimizations.test_pre_aggregations::test_ungrouped_keyless_model_falls_to_raw": (
"Rust adapter does not yet support Python pre-aggregation routing (ungrouped drill-to-detail)"
),
"tests.optimizations.test_pre_aggregations::test_lambda_preaggregation_unions_batch_rollup_with_fresh_source": (
"Rust adapter does not yet support Python pre-aggregation routing (lambda union)"
),
Expand Down
8 changes: 7 additions & 1 deletion tests/core/test_security_advisor_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ def _balance_layer():
def test_semi_additive_month_grain_uses_last_snapshot():
layer = _balance_layer()
sql = layer.compile(metrics=["bal.total_balance"], dimensions=["bal.day__month"])
assert "QUALIFY" in sql, "coarse grain must keep the semi-additive QUALIFY"
normalized_sql = " ".join(sql.split())
# The portable rewrite nulls out rows before the last snapshot, then aggregates
# the surviving values. This must remain a MAX window scoped to the output grain.
assert "CASE WHEN" in normalized_sql
assert " = MAX(" in normalized_sql
assert " OVER (PARTITION BY " in normalized_sql
assert " ELSE NULL END" in normalized_sql
# Correct: last day-of-month per account, summed = 110 + 210 = 320 (NOT naive 620).
assert layer.query(metrics=["bal.total_balance"], dimensions=["bal.day__month"]).fetchall() == [
(datetime.date(2026, 1, 1), 320)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ def test_get_semantic_graph(demo_layer):
assert "metrics" in model
assert "segments" in model
assert "completed_orders" in model["segments"]
assert model["primary_key"] is None
assert "primary_key" not in model


def test_get_models_enriched(demo_layer):
Expand All @@ -563,7 +563,7 @@ def test_get_models_enriched(demo_layer):
model = result["models"][0]

# Check new fields
assert model["primary_key"] is None
assert "primary_key" not in model
assert model["description"] == "All customer orders"

# Check segments are included
Expand Down
Loading