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
29 changes: 22 additions & 7 deletions docs/advanced-features.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -254,15 +254,30 @@ metrics:
non_additive_dimension: "date" # Don't sum averages across time!
```

### Default Dimensions
### Default Time Dimension

Specify default time dimension and granularity:
Specify default time dimension and granularity at the model level. When querying metrics from this model without explicitly selecting a time dimension, the default will be auto-included:

```yaml
metrics:
- name: daily_revenue
agg: sum
sql: amount
models:
- name: orders
table: orders_table
default_time_dimension: "order_date"
default_grain: "day"
default_grain: "month"
dimensions:
- name: order_date
type: time
granularity: day
metrics:
- name: revenue
agg: sum
sql: amount
```

```python
# Auto-includes orders.order_date__month
layer.compile(metrics=["orders.revenue"])

# Override with different granularity
layer.compile(metrics=["orders.revenue"], dimensions=["orders.order_date__week"])
```
2 changes: 2 additions & 0 deletions docs/models.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ orders = Model(
- **relationships**: Relationships to other models (see [Relationships](relationships.qmd))
- **dimensions**: Attributes for grouping and filtering
- **metrics**: Model-level aggregations
- **default_time_dimension**: Default time dimension to auto-include in queries
- **default_grain**: Default granularity for the time dimension (`hour`, `day`, `week`, `month`, `quarter`, `year`)

## Dimensions

Expand Down
4 changes: 0 additions & 4 deletions docs/python-api.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,6 @@ formatted_revenue = Metric(
value_format_name="usd",
drill_fields=["order_id", "customer_id", "order_date"],
non_additive_dimension="customer_id",
default_time_dimension="order_date",
default_grain="day"
)

# With inheritance
Expand Down Expand Up @@ -386,8 +384,6 @@ extended_revenue = Metric(
- **value_format_name**: Named format (e.g., `"usd"`, `"percent"`)
- **drill_fields**: List of field names for drill-down
- **non_additive_dimension**: Dimension this metric cannot be summed across
- **default_time_dimension**: Default time dimension for this metric
- **default_grain**: Default time granularity (`hour`, `day`, `week`, `month`, `quarter`, `year`)

#### Inheritance

Expand Down
24 changes: 14 additions & 10 deletions sidemantic/adapters/metricflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ def _parse_semantic_model(self, model_def: dict) -> Model | None:
# Parse inheritance
extends = meta.get("extends")

# Parse default time dimension (MetricFlow uses defaults.agg_time_dimension)
defaults = model_def.get("defaults", {})
default_time_dimension = defaults.get("agg_time_dimension")
default_grain = meta.get("default_grain")

return Model(
name=name,
table=table,
Expand All @@ -169,6 +174,8 @@ def _parse_semantic_model(self, model_def: dict) -> Model | None:
metrics=measures,
segments=segments,
extends=extends,
default_time_dimension=default_time_dimension,
default_grain=default_grain,
)

def _parse_dimension(self, dim_def: dict) -> Dimension | None:
Expand Down Expand Up @@ -258,8 +265,6 @@ def _parse_measure(self, measure_def: dict) -> Metric | None:
format_str = meta.get("format")
value_format_name = meta.get("value_format_name")
drill_fields = meta.get("drill_fields")
default_time_dimension = meta.get("default_time_dimension")
default_grain = meta.get("default_grain")

# Parse non_additive_dimension
non_additive = measure_def.get("non_additive_dimension")
Expand All @@ -282,8 +287,6 @@ def _parse_measure(self, measure_def: dict) -> Metric | None:
value_format_name=value_format_name,
drill_fields=drill_fields,
non_additive_dimension=non_additive_dimension,
default_time_dimension=default_time_dimension,
default_grain=default_grain,
)

def _parse_metric(self, metric_def: dict) -> Metric | None:
Expand Down Expand Up @@ -543,15 +546,16 @@ def _export_semantic_model(self, model: Model) -> dict:
measure_def["meta"]["drill_fields"] = measure.drill_fields
if measure.non_additive_dimension:
measure_def["non_additive_dimension"] = {"name": measure.non_additive_dimension}
if measure.default_time_dimension:
measure_def["meta"] = measure_def.get("meta", {})
measure_def["meta"]["default_time_dimension"] = measure.default_time_dimension
if measure.default_grain:
measure_def["meta"] = measure_def.get("meta", {})
measure_def["meta"]["default_grain"] = measure.default_grain

result["measures"].append(measure_def)

# Export model-level default_time_dimension
if model.default_time_dimension:
result["defaults"] = {"agg_time_dimension": model.default_time_dimension}
if model.default_grain:
result["meta"] = result.get("meta", {})
result["meta"]["default_grain"] = model.default_grain

# Export segments (as meta since MetricFlow doesn't have native segment support)
if model.segments:
result["meta"] = result.get("meta", {})
Expand Down
5 changes: 2 additions & 3 deletions sidemantic/adapters/omni.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,8 @@ def _export_view(self, model: Model) -> dict[str, Any]:
time_offset = metric.time_offset or self._comparison_type_to_offset(metric.comparison_type)

# Find the time dimension to apply the offset to
# Typically this would be the model's default time dimension
# For now, use a generic name - user may need to adjust
time_field = metric.default_time_dimension or "created_at"
# Use the model's default time dimension
time_field = model.default_time_dimension or "created_at"

measure_def["filters"] = {
time_field: {
Expand Down
14 changes: 8 additions & 6 deletions sidemantic/adapters/sidemantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,6 @@ def _parse_model(self, model_def: dict) -> Model | None:
value_format_name=measure_def.get("value_format_name"),
drill_fields=measure_def.get("drill_fields"),
non_additive_dimension=measure_def.get("non_additive_dimension"),
default_time_dimension=measure_def.get("default_time_dimension"),
default_grain=measure_def.get("default_grain"),
)
measures.append(measure)

Expand Down Expand Up @@ -313,6 +311,8 @@ def _parse_model(self, model_def: dict) -> Model | None:
metrics=measures,
segments=segments,
pre_aggregations=pre_aggregations,
default_time_dimension=model_def.get("default_time_dimension"),
default_grain=model_def.get("default_grain"),
)

def _parse_metric(self, metric_def: dict) -> Metric | None:
Expand Down Expand Up @@ -424,12 +424,14 @@ def _export_model(self, model: Model) -> dict:
measure_def["drill_fields"] = measure.drill_fields
if measure.non_additive_dimension:
measure_def["non_additive_dimension"] = measure.non_additive_dimension
if measure.default_time_dimension:
measure_def["default_time_dimension"] = measure.default_time_dimension
if measure.default_grain:
measure_def["default_grain"] = measure.default_grain
result["metrics"].append(measure_def)

# Export model-level default_time_dimension
if model.default_time_dimension:
result["default_time_dimension"] = model.default_time_dimension
if model.default_grain:
result["default_grain"] = model.default_grain

# Export segments
if model.segments:
result["segments"] = []
Expand Down
6 changes: 0 additions & 6 deletions sidemantic/core/metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,6 @@ def handle_expr_and_parse_agg(cls, data):
description="Dimension across which this metric cannot be summed (e.g., time for averages)",
)

# Defaults
default_time_dimension: str | None = Field(None, description="Default time dimension for this metric")
default_grain: Literal["hour", "day", "week", "month", "quarter", "year"] | None = Field(
None, description="Default time granularity for this metric"
)

def __hash__(self) -> int:
return hash((self.name, self.agg, self.sql))

Expand Down
10 changes: 10 additions & 0 deletions sidemantic/core/model.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Model definitions."""

from typing import Literal

from pydantic import BaseModel, Field

from sidemantic.core.dimension import Dimension
Expand Down Expand Up @@ -35,6 +37,14 @@ class Model(BaseModel):
default_factory=list, description="Pre-aggregation definitions for query optimization"
)

# Default time dimension for all metrics in this model
default_time_dimension: str | None = Field(
None, description="Default time dimension for metrics (auto-included in queries)"
)
default_grain: Literal["hour", "day", "week", "month", "quarter", "year"] | None = Field(
None, description="Default time granularity when using default_time_dimension"
)

def __init__(self, **data):
super().__init__(**data)

Expand Down
55 changes: 54 additions & 1 deletion sidemantic/sql/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,56 @@ def _date_trunc(self, granularity: str, column_expr: str) -> str:
date_trunc = exp.DateTrunc(this=col, unit=exp.Literal.string(granularity))
return date_trunc.sql(dialect=self.dialect)

def _apply_default_time_dimensions(self, metrics: list[str], dimensions: list[str]) -> list[str]:
"""Auto-include default_time_dimension from models if not already present.

If a model has default_time_dimension set and no time dimension from that
model is already in the dimensions list, add it with the default_grain.

Args:
metrics: List of metric references
dimensions: List of dimension references

Returns:
Updated dimensions list with default time dimensions added
"""
# Extract which models already have time dimensions in the query
models_with_time_dims = set()
for dim_ref in dimensions:
if "." in dim_ref:
model_name, dim_part = dim_ref.split(".", 1)
# Strip granularity suffix if present
dim_name = dim_part.split("__")[0]
model = self.graph.get_model(model_name)
if model:
dim = model.get_dimension(dim_name)
if dim and dim.type == "time":
models_with_time_dims.add(model_name)

# Check each model referenced by metrics for default_time_dimension
added_dims = []
models_checked = set()
for metric_ref in metrics:
if "." in metric_ref:
model_name, _ = metric_ref.split(".")
if model_name in models_checked:
continue
models_checked.add(model_name)

model = self.graph.get_model(model_name)
if model and model.default_time_dimension:
# Only add if this model doesn't already have a time dimension
if model_name not in models_with_time_dims:
time_dim_ref = f"{model_name}.{model.default_time_dimension}"
# Apply default_grain if specified
if model.default_grain:
time_dim_ref = f"{time_dim_ref}__{model.default_grain}"
if time_dim_ref not in dimensions and time_dim_ref not in added_dims:
added_dims.append(time_dim_ref)
models_with_time_dims.add(model_name)

return dimensions + added_dims

def generate_view(
self,
view_name: str,
Expand Down Expand Up @@ -114,12 +164,15 @@ def generate(
SQL query string
"""
metrics = metrics or []
dimensions = dimensions or []
dimensions = list(dimensions) if dimensions else []
filters = filters or []
segments = segments or []
parameters = parameters or {}
aliases = aliases or {}

# Auto-include default_time_dimension from metrics if not already present
dimensions = self._apply_default_time_dimensions(metrics, dimensions)

# Resolve segments to SQL filters
segment_filters = self._resolve_segments(segments)
filters = filters + segment_filters
Expand Down
15 changes: 10 additions & 5 deletions tests/adapters/test_metadata_adapter_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ def test_metadata_roundtrip_sidemantic_adapter():
table="orders_table",
primary_key="order_id",
description="Order data",
default_time_dimension="created_at",
default_grain="day",
dimensions=[
Dimension(
name="status",
Expand Down Expand Up @@ -44,8 +46,6 @@ def test_metadata_roundtrip_sidemantic_adapter():
format="$#,##0.00",
value_format_name="usd",
drill_fields=["status", "customer_id"],
default_time_dimension="created_at",
default_grain="day",
),
Metric(
name="avg_order_value",
Expand Down Expand Up @@ -119,8 +119,10 @@ def test_metadata_roundtrip_sidemantic_adapter():
assert revenue.format == "$#,##0.00"
assert revenue.value_format_name == "usd"
assert revenue.drill_fields == ["status", "customer_id"]
assert revenue.default_time_dimension == "created_at"
assert revenue.default_grain == "day"

# Verify model-level default_time_dimension
assert imported.default_time_dimension == "created_at"
assert imported.default_grain == "day"

avg_value = imported.get_metric("avg_order_value")
assert avg_value is not None
Expand Down Expand Up @@ -219,7 +221,10 @@ def test_empty_metadata_roundtrip():
user_count = imported.get_metric("user_count")
assert user_count.format is None
assert user_count.drill_fields is None
assert user_count.default_grain is None

# default_time_dimension is now on model, not metric
assert imported.default_time_dimension is None
assert imported.default_grain is None

status = imported.get_dimension("status")
assert status.format is None
Expand Down
6 changes: 1 addition & 5 deletions tests/core/test_sql_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ def test_parse_metric_all_fields():
format '$#,##0.00',
filters status = 'completed',
fill_nulls_with 0,
non_additive_dimension time,
default_time_dimension order_date,
default_grain day
non_additive_dimension time
);
"""

Expand All @@ -79,8 +77,6 @@ def test_parse_metric_all_fields():
assert metric.filters == ["status = 'completed'"]
assert metric.fill_nulls_with == 0
assert metric.non_additive_dimension == "time"
assert metric.default_time_dimension == "order_date"
assert metric.default_grain == "day"


def test_parse_ratio_metric():
Expand Down
Loading