From 592db69018928246cc75309851094df30b12c92b Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Thu, 27 Aug 2026 08:43:34 -0700 Subject: [PATCH 01/10] feat: support dataset-scoped metrics Allow metrics to be declared on an individual dataset (datasets[].metrics) in addition to the semantic model (semantic_model.metrics), using the same metric structure in both placements. Dataset-scoped metrics are for aggregations that resolve entirely within a single dataset. They keep a metric next to the fields it depends on and make a dataset independently interpretable. Metrics that span datasets via relationships remain model-scoped. Scoping rules: - A dataset-scoped metric expression must only reference fields of its own dataset; it must not traverse relationships. - Names must be unique within their dataset. Two datasets may each declare a metric with the same local name. - A dataset-scoped name must not collide with any model-scoped metric name, keeping unqualified metric references unambiguous. - Referenced from outside the dataset as dataset_name.metric_name, mirroring how a dataset's fields are already referenced in metric expressions. Prior art: dbt MetricFlow (v1.12+) supports the same two-placement split, reserving in-model metrics for single-semantic-model metrics and top-level metrics for cross-model ones. Cube scopes measures to cubes with qualified cube_name.member references. Ossie follows Cube's scoped-uniqueness and qualified-reference convention because it matches how Ossie already treats fields. Dataset-scoped metrics reuse the existing Metric schema definition, so they inherit any future additions to the metric shape automatically. validate.py gains three checks that JSON Schema cannot express: duplicate metric names within a dataset, collisions with model-scoped metric names, and cross-dataset references in dataset-scoped expressions. Scope checking degrades gracefully for dialects sqlglot cannot parse (MDX, TABLEAU, MAQL) rather than reporting false positives. .... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code) Co-Authored-By: Cortex Code --- core-spec/ossie-schema.json | 9 ++- core-spec/spec.md | 120 +++++++++++++++++++++++++++++++++--- core-spec/spec.yaml | 25 +++++++- validation/validate.py | 105 ++++++++++++++++++++++++++++++- 4 files changed, 246 insertions(+), 13 deletions(-) diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json index edddac31..4af4d9cb 100644 --- a/core-spec/ossie-schema.json +++ b/core-spec/ossie-schema.json @@ -215,6 +215,13 @@ "$ref": "#/$defs/Field" } }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/$defs/Metric" + }, + "description": "Dataset-scoped metrics. Expressions must resolve entirely within this dataset and must not traverse relationships or reference fields of another dataset. Names must be unique within the dataset and must not collide with any model-scoped metric name. Referenced from outside the dataset as dataset_name.metric_name. Metrics that span datasets belong in semantic_model.metrics." + }, "custom_extensions": { "type": "array", "items": { @@ -336,7 +343,7 @@ "items": { "$ref": "#/$defs/Metric" }, - "description": "Quantifiable measures spanning datasets" + "description": "Model-scoped metrics. May span multiple datasets and traverse relationships. Names must be unique across the semantic model and must not collide with any dataset-scoped metric name. Metrics that resolve entirely within a single dataset may instead be defined on that dataset (datasets[].metrics)." }, "custom_extensions": { "type": "array", diff --git a/core-spec/spec.md b/core-spec/spec.md index 156cb1db..06702c3e 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -37,7 +37,8 @@ 4. [Relationships](#relationships) 5. [Fields](#fields) 6. [Metrics](#metrics) -7. [Examples](#examples) +7. [Metric Scoping](#metric-scoping) +8. [Examples](#examples) --- @@ -92,7 +93,7 @@ The top-level container that represents a complete semantic model, including dat | `ai_context` | string/object | No | Additional context for AI tools (e.g., custom instructions) | | `datasets` | array | Yes | Collection of logical datasets (fact and dimension tables) | | `relationships` | array | No | Defines how logical datasets are connected | -| `metrics` | array | No | Quantifiable measures defined as aggregate expressions on fields from logical datasets | +| `metrics` | array | No | Model-scoped metrics: aggregate expressions that may span multiple datasets and traverse relationships. See [Metric Scoping](#metric-scoping). | | `custom_extensions` | array | No | Vendor-specific attributes for extensibility | ### Example @@ -130,6 +131,7 @@ Logical datasets represent business entities or concepts (fact and dimension tab | `description` | string | No | Human-readable description | | `ai_context` | string/object | No | Additional context for AI tools (e.g., synonyms, common terms) | | `fields` | array | No | Row-level attributes for grouping, filtering, and metric expressions | +| `metrics` | array | No | Dataset-scoped metrics whose expressions resolve entirely within this dataset. See [Metric Scoping](#metric-scoping). | | `custom_extensions` | array | No | Vendor-specific attributes | ### Primary Key Examples @@ -358,7 +360,14 @@ Common combinations: ## Metrics -Quantitative measures defined on business data, representing key calculations like sums, averages, ratios, etc. Metrics are defined at the semantic model level and can span multiple datasets. +Quantitative measures defined on business data, representing key calculations like sums, averages, ratios, etc. + +Metrics may be defined in two placements, using the same structure in both: + +- **Model-scoped** (`semantic_model.metrics`) — may span multiple datasets and traverse relationships. +- **Dataset-scoped** (`datasets[].metrics`) — must resolve entirely within a single dataset. + +See [Metric Scoping](#metric-scoping) for the rules governing each. ### Schema @@ -415,6 +424,89 @@ expression: - "Order Average by customer" ``` +### Metric Scoping + +A metric may be defined at the semantic model level or on an individual dataset. Both placements use the identical metric structure; only the resolution rules differ. + +| | Model-scoped (`semantic_model.metrics`) | Dataset-scoped (`datasets[].metrics`) | +|---|---|---| +| May reference fields from | Any dataset in the model | Only its own dataset | +| May traverse relationships | Yes | No | +| Name uniqueness | Unique across the semantic model | Unique within its dataset | +| Referenced as | `metric_name` | `dataset_name.metric_name` | + +**Rules** + +1. A dataset-scoped metric's expression MUST only reference fields of the dataset that declares it. It MUST NOT traverse relationships or reference fields belonging to another dataset. A metric that needs to span datasets MUST be model-scoped. +2. Dataset-scoped metric names MUST be unique within their dataset. Two different datasets MAY each declare a metric with the same name (e.g. `orders.item_count` and `shipments.item_count`). +3. A dataset-scoped metric name MUST NOT collide with the name of any model-scoped metric in the same semantic model. This keeps an unqualified metric reference unambiguous. +4. Dataset-scoped metrics are referenced from outside their dataset using `dataset_name.metric_name`, mirroring how a dataset's fields are already referenced in metric expressions (e.g. `SUM(orders.amount)`). + +**Choosing a placement** + +Prefer dataset-scoped for simple aggregations that belong conceptually to one entity — they keep the metric next to the fields it depends on and make the dataset independently interpretable. Use model-scoped for anything requiring a join. + +**Example — dataset-scoped metrics** + +```yaml +datasets: + - name: orders + source: sales.public.orders + primary_key: [order_id] + fields: + - name: amount + expression: + dialects: + - dialect: ANSI_SQL + expression: amount + description: Order amount + + metrics: + # Valid: resolves entirely within the orders dataset + - name: total_amount + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) + description: Total order amount + datatype: Decimal + + # Also valid: unqualified reference to a field of the declaring dataset + - name: order_count + expression: + dialects: + - dialect: ANSI_SQL + expression: COUNT(order_id) + description: Number of orders + datatype: Integer +``` + +Referenced from a consumer as `orders.total_amount` and `orders.order_count`. + +**Example — invalid dataset-scoped metric** + +```yaml +datasets: + - name: orders + source: sales.public.orders + metrics: + # INVALID: references the customers dataset, so it must be model-scoped + - name: revenue_per_customer + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) / COUNT(DISTINCT customers.id) +``` + +**Prior art** + +This two-placement model follows established practice in comparable semantic layers: + +- **dbt MetricFlow** (v1.12+) supports the same split: metrics defined within a semantic model for those using dimensions from a single semantic model ("recommended for simple metrics"), and top-level metrics for those referencing metrics across different semantic models. MetricFlow explicitly disallows simple metrics at the top level. +- **Cube** defines measures only within cubes, requiring member names to be unique within their cube and referenced as `cube_name.member`. + +Ossie adopts Cube's scoped-uniqueness and qualified-reference convention because it matches how Ossie already treats fields: field names are unique within a dataset, and metric expressions already reference them as `dataset.field`. + --- ## Custom Extensions @@ -546,6 +638,16 @@ semantic_model: expression: amount description: Order amount + # Dataset-scoped: resolves entirely within the orders dataset + metrics: + - name: total_amount + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) + description: Total order amount + datatype: Decimal + - name: customers source: sales.public.customers primary_key: [id] @@ -573,16 +675,14 @@ semantic_model: to_columns: [id] metrics: - - name: total_revenue + # Model-scoped: spans orders and customers via the relationship + - name: revenue_per_customer expression: dialects: - dialect: ANSI_SQL - expression: SUM(orders.amount) - description: Total revenue from all orders - ai_context: - synonyms: - - "total sales" - - "revenue" + expression: SUM(orders.amount) / COUNT(DISTINCT customers.id) + description: Average revenue per customer + datatype: Decimal - name: customer_count expression: diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index 9b21b444..0bb5e03c 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -77,8 +77,10 @@ semantic_model: # See Relationships section below for detailed structure relationships: [] - # Optional: + # Optional: Model-scoped metrics # These metrics can span one or more logical datasets and use relationships + # Metric names must be unique across the semantic model, and must not collide + # with the name of any dataset-scoped metric # See Metrics section below for detailed structure metrics: [] @@ -134,6 +136,23 @@ datasets: # See Fields section below for detailed structure fields: [] + # Optional: Dataset-scoped metrics + # Metrics whose expressions resolve entirely within this dataset. Use these for + # simple aggregations over a single dataset's fields; use model-scoped metrics + # (semantic_model.metrics) for anything that spans datasets via relationships. + # + # Scoping rules: + # - The expression MUST only reference fields of this dataset. It MUST NOT + # traverse relationships or reference fields of another dataset. + # - Names must be unique within this dataset, and must not collide with the + # name of any model-scoped metric. + # - Referenced from outside the dataset as dataset_name.metric_name, mirroring + # how fields of a dataset are referenced (e.g., orders.total_revenue). + # + # Entries use the same structure as model-scoped metrics. + # See Metrics section below for detailed structure + metrics: [] + # Optional: Vendor-specific attributes for extensibility custom_extensions: - vendor_name: string # Free-form string identifying the vendor @@ -235,6 +254,10 @@ fields: # Metrics Schema # Quantitative measures defined on business data # Represents key calculations like sums, averages, ratios, etc. +# +# The same structure is used in two placements: +# - semantic_model.metrics (model-scoped): may span datasets via relationships +# - datasets[].metrics (dataset-scoped): must resolve within one dataset metrics: # Required: Unique identifier for the metric - name: string diff --git a/validation/validate.py b/validation/validate.py index 8757bdf1..346e9367 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -33,7 +33,8 @@ 1. JSON Schema (structure, types, enums) 2. Unique names (datasets, fields, metrics, relationships) 3. Valid relationship references -4. SQL syntax (using sqlglot) +4. Dataset-scoped metric scoping (must resolve within their own dataset) +5. SQL syntax (using sqlglot) Usage: python validation/validate.py @@ -55,6 +56,7 @@ try: import sqlglot + from sqlglot import exp from sqlglot.errors import ParseError, TokenError SQLGLOT_AVAILABLE = True except ImportError: @@ -120,6 +122,24 @@ def validate_unique_names(data: dict) -> list[str]: for dup in find_duplicates(metric_names): errors.append(f"[Unique] Duplicate metric name '{dup}' in model '{model_name}'") + # Check unique dataset-scoped metric names within each dataset, and that + # they do not collide with model-scoped metric names. + model_metric_names = set(metric_names) + for dataset in model.get("datasets", []): + dataset_name = dataset.get("name", "") + ds_metric_names = [ + m.get("name") for m in dataset.get("metrics", []) if m.get("name") + ] + for dup in find_duplicates(ds_metric_names): + errors.append( + f"[Unique] Duplicate metric name '{dup}' in dataset '{dataset_name}'" + ) + for name in set(ds_metric_names) & model_metric_names: + errors.append( + f"[Unique] Dataset-scoped metric '{dataset_name}.{name}' collides with " + f"model-scoped metric '{name}' in model '{model_name}'" + ) + # Check unique relationship names rel_names = [r.get("name") for r in model.get("relationships", []) if r.get("name")] for dup in find_duplicates(rel_names): @@ -167,6 +187,75 @@ def validate_references(data: dict) -> list[str]: return errors +def _expression_qualifiers(expr: str, dialect: str) -> set[str] | None: + """Returns the set of table qualifiers used in an expression. + + For example, "SUM(orders.amount) / COUNT(customers.id)" yields + {"orders", "customers"}. Unqualified columns contribute nothing. + + Returns None when the qualifiers cannot be determined (sqlglot unavailable, + unsupported dialect, or unparseable expression) so callers can skip the check + rather than report a false positive. + """ + if not SQLGLOT_AVAILABLE or dialect in SKIP_SQL_VALIDATION: + return None + + sqlglot_dialect = DIALECT_MAP.get(dialect) + + for candidate in (expr, f"SELECT {expr}"): + try: + tree = sqlglot.parse_one(candidate, dialect=sqlglot_dialect) + except (ParseError, TokenError): + continue + if tree is None: + continue + return {col.table for col in tree.find_all(exp.Column) if col.table} + + return None + + +def validate_metric_scoping(data: dict) -> list[str]: + """Validate that dataset-scoped metrics resolve within their own dataset. + + A dataset-scoped metric may only reference fields of the dataset that declares + it. Referencing another dataset requires traversing a relationship, which is + only permitted for model-scoped metrics. + """ + errors = [] + + for model in data.get("semantic_model", []): + model_name = model.get("name", "") + + for dataset in model.get("datasets", []): + dataset_name = dataset.get("name", "") + + for metric in dataset.get("metrics", []): + metric_name = metric.get("name", "") + expression = metric.get("expression") or {} + + for dialect_expr in expression.get("dialects", []): + dialect = dialect_expr.get("dialect", "ANSI_SQL") + expr = dialect_expr.get("expression", "") + if not expr: + continue + + qualifiers = _expression_qualifiers(expr, dialect) + if qualifiers is None: + continue + + foreign = sorted(q for q in qualifiers if q != dataset_name) + if foreign: + errors.append( + f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " + f"in model '{model_name}' ({dialect}) references " + f"dataset(s) {', '.join(repr(f) for f in foreign)}. " + f"Dataset-scoped metrics must resolve within their own dataset; " + f"move this metric to semantic_model.metrics." + ) + + return errors + + def validate_sql_expression(expr: str, dialect: str, context: str) -> str | None: """Validate a single SQL expression. Returns error message or None if valid.""" if not SQLGLOT_AVAILABLE: @@ -221,6 +310,19 @@ def validate_sql(data: dict) -> list[str]: if error: errors.append(error) + # Validate dataset-scoped metric expressions + for metric in dataset.get("metrics", []): + metric_name = metric.get("name", "") + expression = metric.get("expression", {}) + for dialect_expr in expression.get("dialects", []): + dialect = dialect_expr.get("dialect", "ANSI_SQL") + expr = dialect_expr.get("expression", "") + if expr: + context = f"Metric '{dataset_name}.{metric_name}' in model '{model_name}' ({dialect})" + error = validate_sql_expression(expr, dialect, context) + if error: + errors.append(error) + # Validate metric expressions for metric in model.get("metrics", []): metric_name = metric.get("name", "") @@ -280,6 +382,7 @@ def main(): if data.get("semantic_model"): errors.extend(validate_unique_names(data)) errors.extend(validate_references(data)) + errors.extend(validate_metric_scoping(data)) errors.extend(validate_sql(data)) # Report results From 499cb354ba601d7afc987a647fdd69ee4263fc85 Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Thu, 27 Aug 2026 08:50:51 -0700 Subject: [PATCH 02/10] docs: expand metric scoping prior art Add Snowflake semantic views and Databricks Unity Catalog metric views to the prior-art comparison, alongside dbt MetricFlow and Cube. Snowflake is the closest analogue: table-level metrics scoped to a logical table plus top-level derived metrics that combine metrics across tables, with qualified table.metric references. Databricks takes a different approach, with a single flat scope per metric view and joins declared inside the view. Also records that this proposal is deliberately stricter than Snowflake on scope enforcement: Snowflake permits table-level metrics to traverse relationships via using_relationships, whereas dataset-scoped metrics here must resolve within their own dataset. Notes the rationale (a strict boundary is legible and can be relaxed compatibly later) and leaves the question open for the community. .... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code) Co-Authored-By: Cortex Code --- core-spec/spec.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/core-spec/spec.md b/core-spec/spec.md index 06702c3e..b8dbbb9d 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -500,12 +500,29 @@ datasets: **Prior art** -This two-placement model follows established practice in comparable semantic layers: +Scoping metrics to an entity is established practice across comparable semantic layers, though they differ in how names resolve and how strictly scope is enforced. -- **dbt MetricFlow** (v1.12+) supports the same split: metrics defined within a semantic model for those using dimensions from a single semantic model ("recommended for simple metrics"), and top-level metrics for those referencing metrics across different semantic models. MetricFlow explicitly disallows simple metrics at the top level. -- **Cube** defines measures only within cubes, requiring member names to be unique within their cube and referenced as `cube_name.member`. +| System | Placement model | Name uniqueness | Reference form | +|---|---|---|---| +| **Snowflake semantic views** | `tables[].metrics` (table-level) and top-level `metrics` (derived, view-level) | Scoped to the logical table | Qualified — `table.metric` | +| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model, and top-level metrics | Globally unique across the project | Bare name | +| **Cube** | Measures only within cubes | Scoped to the cube | Qualified — `cube.member` | +| **Databricks Unity Catalog metric views** | Single flat scope per metric view; joins declared within the view | Unique within the metric view | `MEASURE(name)` | -Ossie adopts Cube's scoped-uniqueness and qualified-reference convention because it matches how Ossie already treats fields: field names are unique within a dataset, and metric expressions already reference them as `dataset.field`. +Notes on each: + +- **Snowflake semantic views** are the closest analogue. Table-level metrics are "scoped to a specific logical table, aggregating data within that table," while top-level derived metrics are "view-level metrics not tied to a specific table" that "combine metrics from multiple tables." Derived metrics reference table-scoped ones with qualified names, e.g. `orders.total_revenue / customers.customer_count`. +- **dbt MetricFlow** reserves in-model metrics for those using dimensions from a single semantic model ("recommended for simple metrics") and top-level metrics for those spanning semantic models — it explicitly disallows simple metrics at the top level. Its namespace is flat, so metric names must be globally unique. +- **Cube** has no model-level measure concept at all; every measure belongs to a cube, and members must be unique within their cube. +- **Databricks Unity Catalog metric views** take a different approach: a metric view has one `source` plus optional `joins`, and all measures live in that single flat scope. Cross-table access happens through joins declared inside the view rather than through a separate cross-entity placement. + +**How this proposal relates** + +Ossie adopts the Snowflake/Cube convention of scoped uniqueness with qualified `dataset.metric` references, rather than MetricFlow's flat global namespace. This matches how Ossie already treats fields: field names are unique within a dataset, and metric expressions already reference them as `dataset.field` (e.g. `SUM(orders.amount)`). + +On scope enforcement, this proposal is deliberately stricter than Snowflake. Snowflake permits a table-level metric to traverse relationships — it provides `using_relationships` specifically to disambiguate when multiple join paths exist between two logical tables. This proposal instead requires that a dataset-scoped metric resolve entirely within its own dataset, and reserves traversal for model-scoped metrics. + +The rationale is that a strict boundary makes the guarantee legible: a dataset-scoped metric is verifiably self-contained, so a dataset plus its metrics can be reasoned about, reused, or exchanged without resolving the surrounding join graph. Relaxing this later would be backward compatible; tightening it would not. Whether Ossie should eventually follow Snowflake in allowing declared traversal from dataset-scoped metrics is left as an open question for the community. --- From 22f6d8aca18195fea5778044f6009bdc999f205a Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Thu, 27 Aug 2026 09:02:47 -0700 Subject: [PATCH 03/10] docs: add AtScale SML to prior art and consumer flattening guidance Add AtScale SML as a fifth reference point. SML demonstrates a third placement pattern: a standalone, globally-named metric object that declares its binding by property (dataset and column are both required), with cross-entity calculations as a separate metric_calc object type. This positions the proposal between the extremes rather than at one end: SML binds a plain metric to a single column with a single aggregation method; Snowflake permits table-level metrics to traverse relationships; Ossie permits an arbitrary expression over the declaring dataset but no traversal. Also adds consumer guidance on flattening to a single metric namespace. Because a dataset-scoped expression resolves within its declaring dataset, it is already valid as a model-scoped metric, so consumers that support only model-level metrics can hoist without rewriting expressions. Notes the two real caveats: flattening requires name qualification, and consumers reading only semantic_model.metrics will not observe dataset-scoped metrics. .... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code) Co-Authored-By: Cortex Code --- core-spec/spec.md | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/core-spec/spec.md b/core-spec/spec.md index b8dbbb9d..d553d958 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -500,29 +500,41 @@ datasets: **Prior art** -Scoping metrics to an entity is established practice across comparable semantic layers, though they differ in how names resolve and how strictly scope is enforced. +Separating an aggregation anchored to a single entity from a calculation that spans entities is established practice, though systems differ in where the single-entity metric lives, how names resolve, and how strictly scope is enforced. -| System | Placement model | Name uniqueness | Reference form | -|---|---|---|---| -| **Snowflake semantic views** | `tables[].metrics` (table-level) and top-level `metrics` (derived, view-level) | Scoped to the logical table | Qualified — `table.metric` | -| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model, and top-level metrics | Globally unique across the project | Bare name | -| **Cube** | Measures only within cubes | Scoped to the cube | Qualified — `cube.member` | -| **Databricks Unity Catalog metric views** | Single flat scope per metric view; joins declared within the view | Unique within the metric view | `MEASURE(name)` | +| System | Single-entity metric | Cross-entity mechanism | Name uniqueness | Reference form | +|---|---|---|---|---| +| **Snowflake semantic views** | `tables[].metrics` | Top-level `metrics` (derived) | Per logical table | Qualified — `table.metric` | +| **AtScale SML** | Standalone `metric` object bound to one `dataset` + `column` | Separate `metric_calc` object type | Global across all repositories | Bare `unique_name` | +| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model | Top-level `metrics` | Global across the project | Bare name | +| **Cube** | Measures within cubes | Calculated measures referencing other measures | Per cube | Qualified — `cube.member` | +| **Databricks UC metric views** | Measures in the view (one flat scope) | Joins declared inside the view | Per metric view | `MEASURE(name)` | Notes on each: -- **Snowflake semantic views** are the closest analogue. Table-level metrics are "scoped to a specific logical table, aggregating data within that table," while top-level derived metrics are "view-level metrics not tied to a specific table" that "combine metrics from multiple tables." Derived metrics reference table-scoped ones with qualified names, e.g. `orders.total_revenue / customers.customer_count`. -- **dbt MetricFlow** reserves in-model metrics for those using dimensions from a single semantic model ("recommended for simple metrics") and top-level metrics for those spanning semantic models — it explicitly disallows simple metrics at the top level. Its namespace is flat, so metric names must be globally unique. -- **Cube** has no model-level measure concept at all; every measure belongs to a cube, and members must be unique within their cube. -- **Databricks Unity Catalog metric views** take a different approach: a metric view has one `source` plus optional `joins`, and all measures live in that single flat scope. Cross-table access happens through joins declared inside the view rather than through a separate cross-entity placement. +- **Snowflake semantic views** are the closest structural analogue. Table-level metrics are "scoped to a specific logical table, aggregating data within that table," while top-level derived metrics are "view-level metrics not tied to a specific table" that "combine metrics from multiple tables," referenced with qualified names such as `orders.total_revenue / customers.customer_count`. +- **AtScale SML** demonstrates a third pattern: a metric is a standalone, globally-named object that nonetheless declares its binding by property. Both `dataset` and `column` are required, so a plain SML metric is a single `calculation_method` over a single column of a single fact dataset. Anything combining metrics is a distinct object type (`metric_calc`) with an `expression` and no dataset binding at all. +- **dbt MetricFlow** reserves in-model metrics for those using dimensions from a single semantic model ("recommended for simple metrics") and top-level metrics for those spanning semantic models — it explicitly disallows simple metrics at the top level. Its namespace is flat, so names must be globally unique. +- **Cube** has no model-level measure concept; every measure belongs to a cube and must be unique within it. +- **Databricks UC metric views** take a different approach: one `source` plus optional `joins`, with all measures in a single flat scope. Cross-table access happens through joins declared inside the view rather than a separate cross-entity placement. **How this proposal relates** -Ossie adopts the Snowflake/Cube convention of scoped uniqueness with qualified `dataset.metric` references, rather than MetricFlow's flat global namespace. This matches how Ossie already treats fields: field names are unique within a dataset, and metric expressions already reference them as `dataset.field` (e.g. `SUM(orders.amount)`). +Four of the five systems above structurally distinguish a single-entity aggregation from a cross-entity calculation. Ossie currently provides only the cross-entity placement, which is the gap this section addresses. + +On naming, Ossie follows Snowflake and Cube — scoped uniqueness with qualified `dataset.metric` references — rather than the global flat namespace used by SML and MetricFlow. This matches how Ossie already treats fields: field names are unique within a dataset, and metric expressions already reference them as `dataset.field` (e.g. `SUM(orders.amount)`). + +On strictness, this proposal sits between the two extremes. SML is more restrictive: a plain metric binds to exactly one column with one aggregation method. Snowflake is more permissive: a table-level metric may traverse relationships, and `using_relationships` exists specifically to disambiguate when multiple join paths connect two logical tables. Ossie permits an arbitrary expression over the declaring dataset's fields, but no traversal. + +The rationale for disallowing traversal is that a strict boundary makes the guarantee legible: a dataset-scoped metric is verifiably self-contained, so a dataset together with its metrics can be reasoned about, reused, or exchanged without resolving the surrounding join graph. Relaxing this later would be backward compatible; tightening it would not. Whether Ossie should eventually adopt a `using_relationships` equivalent is left as an open question. + +**Consumer guidance: flattening to a single metric namespace** + +Consumers whose native model has only model-level metrics do not need to represent the two placements separately. Because a dataset-scoped metric's expression resolves entirely within its declaring dataset, that expression is already valid as a model-scoped metric — hoisting requires no expression rewriting. -On scope enforcement, this proposal is deliberately stricter than Snowflake. Snowflake permits a table-level metric to traverse relationships — it provides `using_relationships` specifically to disambiguate when multiple join paths exist between two logical tables. This proposal instead requires that a dataset-scoped metric resolve entirely within its own dataset, and reserves traversal for model-scoped metrics. +The one concern when flattening is naming. Two datasets may each declare a metric with the same local name, so a flat target namespace requires qualification; use the canonical `dataset_name.metric_name` form, or an equivalent encoding if the target namespace disallows dots. -The rationale is that a strict boundary makes the guarantee legible: a dataset-scoped metric is verifiably self-contained, so a dataset plus its metrics can be reasoned about, reused, or exchanged without resolving the surrounding join graph. Relaxing this later would be backward compatible; tightening it would not. Whether Ossie should eventually follow Snowflake in allowing declared traversal from dataset-scoped metrics is left as an open question for the community. +Consumers that read only `semantic_model.metrics` remain valid, but will not observe dataset-scoped metrics. Producers requiring maximum compatibility with such consumers may continue declaring all metrics at the model level. --- From 3b81ec0e1a4e840afc1615e97a5ed7db6e661842 Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Thu, 27 Aug 2026 09:38:01 -0700 Subject: [PATCH 04/10] feat: demonstrate dataset-scoped metrics in TPC-DS example Move the three single-dataset metrics in the TPC-DS example into store_sales.metrics, leaving the two that genuinely span datasets at model level. The example now demonstrates the placement decision rather than contradicting the guidance in spec.md. Dataset-scoped (expressions resolve within store_sales): - total_sales, total_profit, sales_by_brand Model-scoped (span datasets via relationships): - customer_lifetime_value (store_sales + customer) - store_productivity (store_sales + store) Referencing these three changes from total_sales to store_sales.total_sales. Also clarifies in spec.md and spec.yaml that the scoping rule constrains a metric's expression, not how it may be queried. A dataset-scoped metric can still be grouped by or filtered on dimensions of other datasets reached through relationships, since grouping dimensions are supplied by the consumer at query time. The existing sales_by_brand metric is exactly this case: its expression touches only store_sales, but its description notes it "requires grouping by item.i_brand". Without this clarification the rule is easy to misread as forbidding cross-dataset grouping, which would make the feature appear far more limited than it is. .... Generated with [Cortex Code](https://docs.snowflake.com/en/user-guide/cortex-code/cortex-code) Co-Authored-By: Cortex Code --- core-spec/spec.md | 10 +++- core-spec/spec.yaml | 4 ++ examples/tpcds_semantic_model.yaml | 87 ++++++++++++++++-------------- 3 files changed, 60 insertions(+), 41 deletions(-) diff --git a/core-spec/spec.md b/core-spec/spec.md index d553d958..30a32ae5 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -430,8 +430,8 @@ A metric may be defined at the semantic model level or on an individual dataset. | | Model-scoped (`semantic_model.metrics`) | Dataset-scoped (`datasets[].metrics`) | |---|---|---| -| May reference fields from | Any dataset in the model | Only its own dataset | -| May traverse relationships | Yes | No | +| Expression may reference fields from | Any dataset in the model | Only its own dataset | +| Expression may traverse relationships | Yes | No | | Name uniqueness | Unique across the semantic model | Unique within its dataset | | Referenced as | `metric_name` | `dataset_name.metric_name` | @@ -442,6 +442,12 @@ A metric may be defined at the semantic model level or on an individual dataset. 3. A dataset-scoped metric name MUST NOT collide with the name of any model-scoped metric in the same semantic model. This keeps an unqualified metric reference unambiguous. 4. Dataset-scoped metrics are referenced from outside their dataset using `dataset_name.metric_name`, mirroring how a dataset's fields are already referenced in metric expressions (e.g. `SUM(orders.amount)`). +**Scope restricts the expression, not the query** + +Rule 1 constrains only what a metric's *expression* may reference. It does not restrict how the metric may be queried. A dataset-scoped metric can still be grouped by, or filtered on, dimensions from other datasets reached through relationships — grouping dimensions are supplied by the consumer at query time and are not part of the metric definition. + +For example, a metric declared on `store_sales` as `SUM(store_sales.ss_ext_sales_price)` is dataset-scoped because its expression touches only `store_sales`, yet it remains valid to group that metric by `item.i_brand` or `store.s_state` via the model's relationships. Only a metric whose own expression must reach into another dataset — such as `SUM(store_sales.amount) / COUNT(DISTINCT customer.id)` — needs to be model-scoped. + **Choosing a placement** Prefer dataset-scoped for simple aggregations that belong conceptually to one entity — they keep the metric next to the fields it depends on and make the dataset independently interpretable. Use model-scoped for anything requiring a join. diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index 0bb5e03c..a5478fb1 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -144,6 +144,10 @@ datasets: # Scoping rules: # - The expression MUST only reference fields of this dataset. It MUST NOT # traverse relationships or reference fields of another dataset. + # - This constrains the EXPRESSION only, not the query. A dataset-scoped + # metric may still be grouped by or filtered on dimensions of other + # datasets reached through relationships, since grouping dimensions are + # supplied by the consumer at query time. # - Names must be unique within this dataset, and must not collide with the # name of any model-scoped metric. # - Referenced from outside the dataset as dataset_name.metric_name, mirroring diff --git a/examples/tpcds_semantic_model.yaml b/examples/tpcds_semantic_model.yaml index f192c189..7dbc816b 100644 --- a/examples/tpcds_semantic_model.yaml +++ b/examples/tpcds_semantic_model.yaml @@ -148,6 +148,50 @@ semantic_model: - "profit" - "margin" + # Dataset-scoped metrics: expressions resolve entirely within + # store_sales. These may still be grouped by dimensions of other + # datasets (for example item.i_brand or store.s_state) through the + # model's relationships. Referenced as store_sales.. + metrics: + - name: total_sales + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.ss_ext_sales_price) + description: Total sales revenue across all transactions + datatype: Decimal + ai_context: + synonyms: + - "total revenue" + - "gross sales" + - "sales amount" + + - name: total_profit + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.ss_net_profit) + description: Total net profit from store sales + datatype: Decimal + ai_context: + synonyms: + - "net profit" + - "total earnings" + - "profit" + + - name: sales_by_brand + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.ss_ext_sales_price) + description: Total sales by brand (requires grouping by item.i_brand) + datatype: Decimal + ai_context: + synonyms: + - "brand sales" + - "brand performance" + - "brand revenue" + # Dimension table: Date - name: date_dim source: tpcds.public.date_dim @@ -543,33 +587,11 @@ semantic_model: - "where sale occurred" # Semantic model-level metrics spanning multiple datasets + # Model-scoped metrics: these span multiple datasets via relationships and + # therefore cannot be dataset-scoped. Simple aggregations that resolve + # within a single dataset are declared on that dataset instead — see + # store_sales.metrics above. metrics: - - name: total_sales - expression: - dialects: - - dialect: ANSI_SQL - expression: SUM(store_sales.ss_ext_sales_price) - description: Total sales revenue across all transactions - datatype: Decimal - ai_context: - synonyms: - - "total revenue" - - "gross sales" - - "sales amount" - - - name: total_profit - expression: - dialects: - - dialect: ANSI_SQL - expression: SUM(store_sales.ss_net_profit) - description: Total net profit from store sales - datatype: Decimal - ai_context: - synonyms: - - "net profit" - - "total earnings" - - "profit" - - name: customer_lifetime_value expression: dialects: @@ -584,19 +606,6 @@ semantic_model: - "customer value" - "lifetime revenue" - - name: sales_by_brand - expression: - dialects: - - dialect: ANSI_SQL - expression: SUM(store_sales.ss_ext_sales_price) - description: Total sales by brand (requires grouping by item.i_brand) - datatype: Decimal - ai_context: - synonyms: - - "brand sales" - - "brand performance" - - "brand revenue" - - name: store_productivity expression: dialects: From 20b4b8f95f53d362c1c032f99c027d6773c68cf6 Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Sat, 29 Aug 2026 11:22:29 -0400 Subject: [PATCH 05/10] address review: unqualified expression namespace, warn/error split Incorporates review from jbonofre, christianeu-db and khush-bhatia on #343. Two substantive changes: - Dataset-scoped metric expressions now reference fields by unqualified name (SUM(amount), not SUM(orders.amount)), matching how a field's own expression is written. Raised by christianeu-db. - Name-collision rules are split into errors and warnings. A model-scoped metric reusing a dataset-scoped metric's name now warns instead of failing, and the warning is attributed to the model rather than the dataset, since a dataset may be authored independently and reused across models. Also reframes the placement throughout: a dataset-scoped metric aggregates data held by its dataset, and is still joined and grouped through the model's relationships like any other metric. The earlier "no traversal" wording implied a restriction on how the metric could be queried, which was wrong. Validator fixes, each reported with a reproduction: - Case-fold qualifier comparison, so SUM(ORDERS.AMOUNT) on dataset orders is no longer rejected against a dataset that does not exist - Use Column.parts[0] rather than Column.table, so STRUCT and VARIANT paths are not read as dataset references - Cross-check qualifiers against declared dataset names, so local aliases, CTEs and subquery sources are not reported as cross-dataset references - Extract a single cached _parse_expression helper, replacing two duplicated parse paths that had begun to diverge - Sort collision output for determinism under hash randomisation - Handle an explicitly null expression without raising AttributeError Spec and docs: - Metric Scoping promoted to a top-level section so its TOC entry resolves - Aggregation grain stated, and explicitly not dependent on primary_key - New rule: a dataset-scoped metric name must not collide with a field name of the same dataset - Namespace model documented, so the permitted repetitions are stated rather than left for implementations to constrain differently - docs/index.md and converters/README.md corrected; both said metrics are model-level only - Consumer guidance strengthened: ignoring datasets[].metrics is a lossy conversion, not a valid one The TPC-DS example is reverted to its state on main. No converter reads datasets[].metrics yet, so a flagship example using the placement would be lossy through every converter. Assisted-by: Cortex Code --- converters/README.md | 16 ++- core-spec/ossie-schema.json | 4 +- core-spec/spec.md | 134 +++++++++++++---------- core-spec/spec.yaml | 44 ++++---- docs/index.md | 6 +- examples/tpcds_semantic_model.yaml | 87 +++++++-------- python/src/ossie/models.py | 32 +++--- python/tests/test_models.py | 43 ++++++++ validation/validate.py | 166 ++++++++++++++++++++--------- 9 files changed, 340 insertions(+), 192 deletions(-) diff --git a/converters/README.md b/converters/README.md index 417d7663..c6b71236 100644 --- a/converters/README.md +++ b/converters/README.md @@ -94,7 +94,7 @@ The top-level container. Maps to the root object in each vendor's format. | `ai_context` | Instructions, synonyms for AI tools | Map if vendor supports AI/LLM annotations | | `datasets` | Logical datasets (fact/dimension tables) | See dataset mapping below | | `relationships` | Foreign key connections | See relationship mapping below | -| `metrics` | Aggregate measures | See metric mapping below | +| `metrics` | Model-scoped aggregate measures | See metric mapping below | | `custom_extensions` | Vendor-specific metadata | Extract extensions matching the target vendor | ### Datasets @@ -108,6 +108,7 @@ Datasets represent logical tables (fact or dimension tables). They contain field | `primary_key` | Single or composite primary key | Map to vendor's PK syntax; note composite keys use arrays like `[order_id, line_number]` | | `unique_keys` | Alternative unique identifiers | Map if vendor supports unique constraints | | `fields` | Row-level attributes (columns) | See field mapping below | +| `metrics` | Dataset-scoped aggregate measures whose expressions resolve entirely within this dataset | See metric mapping below. A converter that reads only `semantic_model.metrics` will silently drop these | | `ai_context` | Synonyms and context for AI | Map if vendor supports semantic annotations | | `custom_extensions` | Vendor-specific metadata | Extract extensions matching the target vendor | @@ -166,7 +167,18 @@ means `from.product_id = to.id AND from.variant_id = to.variant_id`. The convert ### Metrics -Metrics are aggregate measures defined at the semantic model level. They can span multiple datasets via relationships. +Metrics are aggregate measures. They appear in two places, and **a converter must read both**: + +- `semantic_model.metrics` — model-scoped. May span multiple datasets via relationships. Expressions reference fields by qualified name (`SUM(orders.amount)`). +- `datasets[].metrics` — dataset-scoped. The expression resolves entirely within the declaring dataset and references fields by unqualified name (`SUM(amount)`). + +Both placements use the identical metric structure, so the field mapping below applies to each. + +A converter that reads only `semantic_model.metrics` produces an incomplete model. That is a lossy conversion, not a valid one: it SHOULD warn, naming the metrics it dropped, and MUST NOT present the output as a faithful representation of the source. + +When the target format has only one metric namespace, hoist dataset-scoped metrics to the model level. This requires two changes: qualify the expression's field references with the dataset name, and qualify the metric's own name as `dataset_name.metric_name`, since two datasets may each declare a metric with the same local name. If the target namespace disallows dots, use an equivalent encoding. + +See [Metric Scoping](../core-spec/spec.md#metric-scoping) for the full rules. | Ossie Field | Description | Converter Consideration | |-----------|-------------|------------------------| diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json index 4af4d9cb..14d16b0e 100644 --- a/core-spec/ossie-schema.json +++ b/core-spec/ossie-schema.json @@ -220,7 +220,7 @@ "items": { "$ref": "#/$defs/Metric" }, - "description": "Dataset-scoped metrics. Expressions must resolve entirely within this dataset and must not traverse relationships or reference fields of another dataset. Names must be unique within the dataset and must not collide with any model-scoped metric name. Referenced from outside the dataset as dataset_name.metric_name. Metrics that span datasets belong in semantic_model.metrics." + "description": "Metrics that aggregate data held by this dataset, at this dataset's grain. The expression must aggregate only fields of this dataset, must not reference a field of another dataset, and must reference fields by unqualified name (SUM(amount), not SUM(orders.amount)). This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." }, "custom_extensions": { "type": "array", @@ -343,7 +343,7 @@ "items": { "$ref": "#/$defs/Metric" }, - "description": "Model-scoped metrics. May span multiple datasets and traverse relationships. Names must be unique across the semantic model and must not collide with any dataset-scoped metric name. Metrics that resolve entirely within a single dataset may instead be defined on that dataset (datasets[].metrics)." + "description": "Model-scoped metrics. Expressions reference fields by qualified name (dataset.field). Names must be unique across the semantic model; reusing the name of a dataset-scoped metric should produce a warning rather than an error. An unqualified metric reference resolves to a model-scoped metric. See core-spec/spec.md#metric-scoping." }, "custom_extensions": { "type": "array", diff --git a/core-spec/spec.md b/core-spec/spec.md index 30a32ae5..b7f48b49 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -93,7 +93,7 @@ The top-level container that represents a complete semantic model, including dat | `ai_context` | string/object | No | Additional context for AI tools (e.g., custom instructions) | | `datasets` | array | Yes | Collection of logical datasets (fact and dimension tables) | | `relationships` | array | No | Defines how logical datasets are connected | -| `metrics` | array | No | Model-scoped metrics: aggregate expressions that may span multiple datasets and traverse relationships. See [Metric Scoping](#metric-scoping). | +| `metrics` | array | No | Model-scoped metrics: for expressions that must combine fields from more than one dataset. See [Metric Scoping](#metric-scoping). | | `custom_extensions` | array | No | Vendor-specific attributes for extensibility | ### Example @@ -364,8 +364,8 @@ Quantitative measures defined on business data, representing key calculations li Metrics may be defined in two placements, using the same structure in both: -- **Model-scoped** (`semantic_model.metrics`) — may span multiple datasets and traverse relationships. -- **Dataset-scoped** (`datasets[].metrics`) — must resolve entirely within a single dataset. +- **Model-scoped** (`semantic_model.metrics`) — for expressions that must combine fields from more than one dataset. +- **Dataset-scoped** (`datasets[].metrics`) — aggregates data held by one dataset. Still joined and grouped through the model's relationships like any other metric. See [Metric Scoping](#metric-scoping) for the rules governing each. @@ -424,33 +424,55 @@ expression: - "Order Average by customer" ``` -### Metric Scoping +--- + +## Metric Scoping A metric may be defined at the semantic model level or on an individual dataset. Both placements use the identical metric structure; only the resolution rules differ. | | Model-scoped (`semantic_model.metrics`) | Dataset-scoped (`datasets[].metrics`) | |---|---|---| | Expression may reference fields from | Any dataset in the model | Only its own dataset | -| Expression may traverse relationships | Yes | No | -| Name uniqueness | Unique across the semantic model | Unique within its dataset | +| Expression namespace | Qualified — `dataset.field` | Unqualified — `field` | +| Aggregation grain | Determined by the expression | The declaring dataset's grain | +| Name uniqueness | Unique across the semantic model | Unique within its dataset, and distinct from that dataset's field names | | Referenced as | `metric_name` | `dataset_name.metric_name` | **Rules** -1. A dataset-scoped metric's expression MUST only reference fields of the dataset that declares it. It MUST NOT traverse relationships or reference fields belonging to another dataset. A metric that needs to span datasets MUST be model-scoped. -2. Dataset-scoped metric names MUST be unique within their dataset. Two different datasets MAY each declare a metric with the same name (e.g. `orders.item_count` and `shipments.item_count`). -3. A dataset-scoped metric name MUST NOT collide with the name of any model-scoped metric in the same semantic model. This keeps an unqualified metric reference unambiguous. -4. Dataset-scoped metrics are referenced from outside their dataset using `dataset_name.metric_name`, mirroring how a dataset's fields are already referenced in metric expressions (e.g. `SUM(orders.amount)`). +1. A dataset-scoped metric's expression MUST aggregate only fields of the dataset that declares it, and MUST NOT reference a field of another dataset. Model-scoped metrics carry no equivalent restriction. +2. A dataset-scoped metric's expression MUST reference fields by unqualified name: `SUM(ss_ext_sales_price)`, not `SUM(store_sales.ss_ext_sales_price)`. +3. Dataset-scoped metric names MUST be unique within their dataset. Two datasets MAY each declare a metric with the same name. +4. A dataset-scoped metric name MUST NOT collide with the name of a field of the same dataset, which under rule 6 would leave `orders.amount` ambiguous. +5. A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric in the same semantic model. Validators SHOULD warn, and SHOULD attribute the warning to the model rather than to the dataset. +6. An unqualified metric reference resolves to a model-scoped metric. A dataset-scoped metric is referenced as `dataset_name.metric_name`. + +Rule 1 means declared fields, not any column the dataset's `source` exposes: a column used by a dataset-scoped metric must be declared as a field. This is not checked automatically, because an expression may also contain function names, literals, and struct or variant paths. + +Rule 1 places no limit on the complexity of the aggregation. Any expression that resolves within the declaring dataset is eligible. + +**Scope describes the aggregation, not the query** -**Scope restricts the expression, not the query** +A dataset-scoped metric aggregates data held by its own dataset. That is all the placement asserts. It does not limit how the metric may be queried: the metric is joined and grouped like any other, using the relationships declared in the model, so it can be sliced by dimensions of any dataset the model connects. Rules 1 and 2 constrain what an expression may reference, never what may be joined to the result. -Rule 1 constrains only what a metric's *expression* may reference. It does not restrict how the metric may be queried. A dataset-scoped metric can still be grouped by, or filtered on, dimensions from other datasets reached through relationships — grouping dimensions are supplied by the consumer at query time and are not part of the metric definition. +Presenting many datasets and their metrics through one queryable interface is the concern of a layer above this one. This section defines only where an aggregation is anchored. -For example, a metric declared on `store_sales` as `SUM(store_sales.ss_ext_sales_price)` is dataset-scoped because its expression touches only `store_sales`, yet it remains valid to group that metric by `item.i_brand` or `store.s_state` via the model's relationships. Only a metric whose own expression must reach into another dataset — such as `SUM(store_sales.amount) / COUNT(DISTINCT customer.id)` — needs to be model-scoped. +**Aggregation grain** -**Choosing a placement** +A dataset-scoped metric aggregates at its dataset's grain. That grain does not depend on a declared `primary_key`: it is the grain of the rows the `source` produces. `primary_key` and `unique_keys` let consumers reason about fan-out when the dataset participates in relationships, but are not a prerequisite. -Prefer dataset-scoped for simple aggregations that belong conceptually to one entity — they keep the metric next to the fields it depends on and make the dataset independently interpretable. Use model-scoped for anything requiring a join. +**Which names may repeat** + +Names are addressed in three ways, so a name may repeat across them without ambiguity: + +| Kind | Addressed as | +|---|---| +| Model-scoped metric | Bare — `revenue` | +| Dataset-scoped metric | Qualified — `orders.revenue` | +| Field | Qualified — `orders.revenue` | +| Dataset | Only ever as a qualifier | + +A model-scoped metric MAY therefore share a name with a field, or with a dataset. Rule 4 is an error because a field and a metric of one dataset share the same qualified namespace, so `orders.revenue` would resolve two ways. Rule 5 is a warning because the two names remain separately addressable, and because a dataset may be authored independently of the model that includes it. **Example — dataset-scoped metrics** @@ -460,6 +482,12 @@ datasets: source: sales.public.orders primary_key: [order_id] fields: + - name: order_id + expression: + dialects: + - dialect: ANSI_SQL + expression: order_id + description: Order identifier - name: amount expression: dialects: @@ -468,16 +496,14 @@ datasets: description: Order amount metrics: - # Valid: resolves entirely within the orders dataset - name: total_amount expression: dialects: - dialect: ANSI_SQL - expression: SUM(orders.amount) + expression: SUM(amount) description: Total order amount datatype: Decimal - # Also valid: unqualified reference to a field of the declaring dataset - name: order_count expression: dialects: @@ -489,60 +515,55 @@ datasets: Referenced from a consumer as `orders.total_amount` and `orders.order_count`. -**Example — invalid dataset-scoped metric** +**Example — invalid dataset-scoped metrics** ```yaml datasets: - name: orders source: sales.public.orders metrics: - # INVALID: references the customers dataset, so it must be model-scoped + # INVALID (rule 1): references a field of the customers dataset - name: revenue_per_customer expression: dialects: - dialect: ANSI_SQL - expression: SUM(orders.amount) / COUNT(DISTINCT customers.id) + expression: SUM(amount) / COUNT(DISTINCT customers.id) + + # INVALID (rule 2): qualifies a field with the declaring dataset's own name + - name: total_amount + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.amount) ``` **Prior art** -Separating an aggregation anchored to a single entity from a calculation that spans entities is established practice, though systems differ in where the single-entity metric lives, how names resolve, and how strictly scope is enforced. +Systems in this space differ in where a single-entity aggregation lives, how names resolve, and how strictly scope is enforced. | System | Single-entity metric | Cross-entity mechanism | Name uniqueness | Reference form | |---|---|---|---|---| -| **Snowflake semantic views** | `tables[].metrics` | Top-level `metrics` (derived) | Per logical table | Qualified — `table.metric` | | **AtScale SML** | Standalone `metric` object bound to one `dataset` + `column` | Separate `metric_calc` object type | Global across all repositories | Bare `unique_name` | -| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model | Top-level `metrics` | Global across the project | Bare name | | **Cube** | Measures within cubes | Calculated measures referencing other measures | Per cube | Qualified — `cube.member` | | **Databricks UC metric views** | Measures in the view (one flat scope) | Joins declared inside the view | Per metric view | `MEASURE(name)` | +| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model | Top-level `metrics` | Global across the project | Bare name | +| **Snowflake semantic views** | `tables[].metrics` | Top-level `metrics` (derived) | Per logical table | Qualified — `table.metric` | -Notes on each: - -- **Snowflake semantic views** are the closest structural analogue. Table-level metrics are "scoped to a specific logical table, aggregating data within that table," while top-level derived metrics are "view-level metrics not tied to a specific table" that "combine metrics from multiple tables," referenced with qualified names such as `orders.total_revenue / customers.customer_count`. -- **AtScale SML** demonstrates a third pattern: a metric is a standalone, globally-named object that nonetheless declares its binding by property. Both `dataset` and `column` are required, so a plain SML metric is a single `calculation_method` over a single column of a single fact dataset. Anything combining metrics is a distinct object type (`metric_calc`) with an `expression` and no dataset binding at all. -- **dbt MetricFlow** reserves in-model metrics for those using dimensions from a single semantic model ("recommended for simple metrics") and top-level metrics for those spanning semantic models — it explicitly disallows simple metrics at the top level. Its namespace is flat, so names must be globally unique. -- **Cube** has no model-level measure concept; every measure belongs to a cube and must be unique within it. -- **Databricks UC metric views** take a different approach: one `source` plus optional `joins`, with all measures in a single flat scope. Cross-table access happens through joins declared inside the view rather than a separate cross-entity placement. - -**How this proposal relates** +Four of the five distinguish the two structurally. Ossie has so far provided only the model-level placement, which is the gap this section addresses. -Four of the five systems above structurally distinguish a single-entity aggregation from a cross-entity calculation. Ossie currently provides only the cross-entity placement, which is the gap this section addresses. +Two points of comparison worth recording. Databricks UC metric views are closest to the namespace rules above: a metric view has one `source` plus optional `joins`, sources are named so a column is addressed as `source_name.column_name`, and the metric view itself exposes a flat schema. Snowflake semantic views let a table-scoped metric's own expression reach through a relationship, so the boundary between their two placements is softer than rule 1. -On naming, Ossie follows Snowflake and Cube — scoped uniqueness with qualified `dataset.metric` references — rather than the global flat namespace used by SML and MetricFlow. This matches how Ossie already treats fields: field names are unique within a dataset, and metric expressions already reference them as `dataset.field` (e.g. `SUM(orders.amount)`). +Scoped uniqueness with qualified references, rather than the flat global namespace used by AtScale SML and dbt MetricFlow, follows Ossie's existing convention: field names are already scoped to a dataset. Rule 2 follows the same convention, since a field's own expression is written without naming its dataset. -On strictness, this proposal sits between the two extremes. SML is more restrictive: a plain metric binds to exactly one column with one aggregation method. Snowflake is more permissive: a table-level metric may traverse relationships, and `using_relationships` exists specifically to disambiguate when multiple join paths connect two logical tables. Ossie permits an arbitrary expression over the declaring dataset's fields, but no traversal. +Rule 1 is the conservative choice. It keeps a dataset-scoped metric verifiably anchored to one dataset, so a dataset and its metrics can be exchanged without resolving the surrounding join graph. Relaxing it later would be backward compatible; tightening it would not. Whether a dataset-scoped metric's expression should be permitted to reach through an explicitly declared path is left open. -The rationale for disallowing traversal is that a strict boundary makes the guarantee legible: a dataset-scoped metric is verifiably self-contained, so a dataset together with its metrics can be reasoned about, reused, or exchanged without resolving the surrounding join graph. Relaxing this later would be backward compatible; tightening it would not. Whether Ossie should eventually adopt a `using_relationships` equivalent is left as an open question. +Choosing between multiple relationship paths, where more than one connects the same pair of datasets, is a separate gap that Ossie does not currently address. It applies to model-scoped expressions and consumer queries alike. **Consumer guidance: flattening to a single metric namespace** -Consumers whose native model has only model-level metrics do not need to represent the two placements separately. Because a dataset-scoped metric's expression resolves entirely within its declaring dataset, that expression is already valid as a model-scoped metric — hoisting requires no expression rewriting. - -The one concern when flattening is naming. Two datasets may each declare a metric with the same local name, so a flat target namespace requires qualification; use the canonical `dataset_name.metric_name` form, or an equivalent encoding if the target namespace disallows dots. +Hoisting a dataset-scoped metric to the model level requires qualifying its field references with the dataset name, and qualifying the metric's own name as `dataset_name.metric_name` since two datasets may reuse a local name. Use an equivalent encoding if the target namespace disallows dots. -Consumers that read only `semantic_model.metrics` remain valid, but will not observe dataset-scoped metrics. Producers requiring maximum compatibility with such consumers may continue declaring all metrics at the model level. - ---- +A consumer that reads only `semantic_model.metrics` produces an incomplete representation of the model. This is a lossy conversion rather than a valid one: such a consumer SHOULD warn, naming the metrics it dropped, and MUST NOT present the result as a faithful representation of the source. Producers requiring maximum compatibility may continue declaring all metrics at the model level. ## Custom Extensions @@ -679,7 +700,7 @@ semantic_model: expression: dialects: - dialect: ANSI_SQL - expression: SUM(orders.amount) + expression: SUM(amount) description: Total order amount datatype: Decimal @@ -702,6 +723,20 @@ semantic_model: expression: email description: Customer email + # Dataset-scoped: resolves entirely within the customers dataset + metrics: + - name: customer_count + expression: + dialects: + - dialect: ANSI_SQL + expression: COUNT(DISTINCT id) + description: Total number of customers + datatype: Integer + ai_context: + synonyms: + - "total customers" + - "customer base" + relationships: - name: orders_to_customers from: orders @@ -719,17 +754,6 @@ semantic_model: description: Average revenue per customer datatype: Decimal - - name: customer_count - expression: - dialects: - - dialect: ANSI_SQL - expression: COUNT(DISTINCT customers.id) - description: Total number of customers - ai_context: - synonyms: - - "total customers" - - "customer base" - custom_extensions: - vendor_name: SNOWFLAKE data: '{"warehouse": "ANALYTICS_WH"}' diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index a5478fb1..9b37f666 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -78,9 +78,15 @@ semantic_model: relationships: [] # Optional: Model-scoped metrics - # These metrics can span one or more logical datasets and use relationships - # Metric names must be unique across the semantic model, and must not collide - # with the name of any dataset-scoped metric + # Expressions reference fields by qualified name (e.g., SUM(orders.amount)), + # unlike dataset-scoped metrics, which use unqualified names + # Names MUST be unique across the semantic model + # A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric; + # validators warn rather than fail, and attribute the warning to the model + # An unqualified metric reference resolves to a model-scoped metric + # + # Whether a metric expression may reference another metric is not yet defined + # by this specification and is being handled as a separate proposal # See Metrics section below for detailed structure metrics: [] @@ -137,24 +143,24 @@ datasets: fields: [] # Optional: Dataset-scoped metrics - # Metrics whose expressions resolve entirely within this dataset. Use these for - # simple aggregations over a single dataset's fields; use model-scoped metrics - # (semantic_model.metrics) for anything that spans datasets via relationships. + # Metrics that aggregate data held by this dataset. Same structure as + # model-scoped metrics; see the Metrics section below. # - # Scoping rules: - # - The expression MUST only reference fields of this dataset. It MUST NOT - # traverse relationships or reference fields of another dataset. - # - This constrains the EXPRESSION only, not the query. A dataset-scoped - # metric may still be grouped by or filtered on dimensions of other - # datasets reached through relationships, since grouping dimensions are - # supplied by the consumer at query time. - # - Names must be unique within this dataset, and must not collide with the - # name of any model-scoped metric. - # - Referenced from outside the dataset as dataset_name.metric_name, mirroring - # how fields of a dataset are referenced (e.g., orders.total_revenue). + # Rules (see core-spec/spec.md#metric-scoping): + # - The expression MUST aggregate only fields of this dataset, and MUST NOT + # reference a field of another dataset. + # - The expression MUST reference fields by unqualified name: + # SUM(amount), not SUM(orders.amount). + # - Aggregates at this dataset's grain, which does not depend on a declared + # primary_key. + # - Names MUST be unique within this dataset and MUST NOT collide with a + # field name of this dataset. + # - Referenced from outside the dataset as dataset_name.metric_name. An + # unqualified metric name refers to a model-scoped metric. # - # Entries use the same structure as model-scoped metrics. - # See Metrics section below for detailed structure + # These rules constrain the EXPRESSION only, not the query. The metric is + # joined and grouped like any other, using the model's relationships, so it + # can be sliced by dimensions of any dataset the model connects. metrics: [] # Optional: Vendor-specific attributes for extensibility diff --git a/docs/index.md b/docs/index.md index 3836092d..89617462 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,7 +55,7 @@ The Ossie core specification (current version: **0.2.0.dev0**, latest released: | **Datasets** | Logical datasets representing business entities (fact and dimension tables), with fields, primary keys, and unique keys. | | **Fields** | Row-level attributes for grouping, filtering, and metric expressions. Fields support multiple SQL dialects for cross-platform compatibility. | | **Relationships** | Foreign key connections between datasets, supporting both simple and composite keys. | -| **Metrics** | Quantitative measures (sums, averages, ratios, etc.) defined at the model level, capable of spanning multiple datasets. | +| **Metrics** | Quantitative measures (sums, averages, ratios, etc.). A model-scoped metric's expression may combine fields from several datasets; a dataset-scoped metric aggregates data held by one dataset. Either is joined and grouped through the model's relationships. | | **Custom Extensions** | Vendor-specific metadata stored as JSON, allowing platforms to carry additional information without breaking core compatibility. | | **AI Context** | Optional annotations at every level (model, dataset, field, relationship, metric) to help AI tools understand business meaning — including instructions, synonyms, and example queries. | @@ -248,7 +248,7 @@ YAML is more human-readable and easier to author by hand, which is important for Through `custom_extensions`. Each vendor can store arbitrary JSON metadata in extension blocks tagged with their vendor name. This metadata is preserved during round-trip conversions and ignored by tools that don't understand it — ensuring that no information is lost. **Can metrics reference multiple datasets?** -Yes. Metrics are defined at the semantic model level (not within a dataset) and can reference fields from multiple datasets. +Any metric can be *grouped and filtered* by fields from other datasets, using the relationships declared in the model. The placement affects only what the metric's own **expression** may reference. A model-scoped metric (`semantic_model.metrics`) may combine fields from several datasets in one expression, for example `SUM(orders.amount) / COUNT(DISTINCT customers.id)`. A dataset-scoped metric (`datasets[].metrics`) aggregates data held by a single dataset, and is still sliceable by any dataset the model connects. See [Metric Scoping](https://github.com/apache/ossie/blob/main/core-spec/spec.md#metric-scoping). **What SQL dialects are supported?** The current specification supports `ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`, `MDX`, and `TABLEAU`. New dialects can be proposed through the standard specification change process. @@ -318,7 +318,7 @@ A practical guide for organizations looking to adopt Ossie. | **Dataset** | A logical representation of a business entity, typically corresponding to a fact table or dimension table in a data warehouse. | | **Field** | A row-level attribute within a dataset, used for grouping, filtering, or as part of metric expressions. Fields can be simple column references or computed expressions. A field's logical data type is declared by the optional top-level `datatype` field (one of `String`, `Integer`, `Decimal`, `Float`, `Boolean`, `Date`, `Time`, `DateTime`, `DateTimeTz`, or `Opaque`). | | **Dimension** | A categorical attribute used to slice and filter data (e.g., region, product category, date). In Ossie, dimensions are represented as fields with optional metadata such as `is_time`. | -| **Metric** | A quantitative measure computed by aggregating data across one or more datasets (e.g., total revenue, average order value). Metrics are defined at the semantic model level. | +| **Metric** | A quantitative measure computed by aggregating data across one or more datasets (e.g., total revenue, average order value). Metrics may be defined at the semantic model level, or on an individual dataset when the expression resolves entirely within it. | | **Relationship** | A foreign key connection between two datasets, defining how they can be joined. Relationships are always many-to-one (from the referencing dataset to the referenced dataset). | | **Dialect** | A specific SQL or expression language variant (e.g., `ANSI_SQL`, `SNOWFLAKE`, `DATABRICKS`). Ossie supports multiple dialects so expressions can be tailored to each platform. | | **Custom Extension** | Vendor-specific metadata attached to any Ossie construct as a JSON string. Extensions allow platforms to carry additional information without modifying the core specification. | diff --git a/examples/tpcds_semantic_model.yaml b/examples/tpcds_semantic_model.yaml index 7dbc816b..f192c189 100644 --- a/examples/tpcds_semantic_model.yaml +++ b/examples/tpcds_semantic_model.yaml @@ -148,50 +148,6 @@ semantic_model: - "profit" - "margin" - # Dataset-scoped metrics: expressions resolve entirely within - # store_sales. These may still be grouped by dimensions of other - # datasets (for example item.i_brand or store.s_state) through the - # model's relationships. Referenced as store_sales.. - metrics: - - name: total_sales - expression: - dialects: - - dialect: ANSI_SQL - expression: SUM(store_sales.ss_ext_sales_price) - description: Total sales revenue across all transactions - datatype: Decimal - ai_context: - synonyms: - - "total revenue" - - "gross sales" - - "sales amount" - - - name: total_profit - expression: - dialects: - - dialect: ANSI_SQL - expression: SUM(store_sales.ss_net_profit) - description: Total net profit from store sales - datatype: Decimal - ai_context: - synonyms: - - "net profit" - - "total earnings" - - "profit" - - - name: sales_by_brand - expression: - dialects: - - dialect: ANSI_SQL - expression: SUM(store_sales.ss_ext_sales_price) - description: Total sales by brand (requires grouping by item.i_brand) - datatype: Decimal - ai_context: - synonyms: - - "brand sales" - - "brand performance" - - "brand revenue" - # Dimension table: Date - name: date_dim source: tpcds.public.date_dim @@ -587,11 +543,33 @@ semantic_model: - "where sale occurred" # Semantic model-level metrics spanning multiple datasets - # Model-scoped metrics: these span multiple datasets via relationships and - # therefore cannot be dataset-scoped. Simple aggregations that resolve - # within a single dataset are declared on that dataset instead — see - # store_sales.metrics above. metrics: + - name: total_sales + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.ss_ext_sales_price) + description: Total sales revenue across all transactions + datatype: Decimal + ai_context: + synonyms: + - "total revenue" + - "gross sales" + - "sales amount" + + - name: total_profit + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.ss_net_profit) + description: Total net profit from store sales + datatype: Decimal + ai_context: + synonyms: + - "net profit" + - "total earnings" + - "profit" + - name: customer_lifetime_value expression: dialects: @@ -606,6 +584,19 @@ semantic_model: - "customer value" - "lifetime revenue" + - name: sales_by_brand + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(store_sales.ss_ext_sales_price) + description: Total sales by brand (requires grouping by item.i_brand) + datatype: Decimal + ai_context: + synonyms: + - "brand sales" + - "brand performance" + - "brand revenue" + - name: store_productivity expression: dialects: diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py index 7ea515c3..a8423cd9 100644 --- a/python/src/ossie/models.py +++ b/python/src/ossie/models.py @@ -147,6 +147,24 @@ def is_time_dimension(self) -> bool: return self.datatype in _TEMPORAL_DATA_TYPES +class OssieMetric(BaseModel): + """Quantitative measure defined on business data. + + Used for both model-scoped metrics (OssieSemanticModel.metrics) and + dataset-scoped metrics (OssieDataset.metrics); the two placements share an + identical structure and differ only in their resolution rules. + """ + + model_config = ConfigDict(frozen=True) + + name: str + expression: OssieExpression + description: Optional[str] = None + datatype: Optional[OssieDataType] = None + ai_context: Optional[OssieAIContext] = None + custom_extensions: Optional[list[OssieCustomExtension]] = None + + class OssieDataset(BaseModel): """Logical dataset representing a business entity (fact or dimension table).""" @@ -159,6 +177,7 @@ class OssieDataset(BaseModel): description: Optional[str] = None ai_context: Optional[OssieAIContext] = None fields: Optional[list[OssieField]] = None + metrics: Optional[list[OssieMetric]] = None custom_extensions: Optional[list[OssieCustomExtension]] = None @@ -176,19 +195,6 @@ class OssieRelationship(BaseModel): custom_extensions: Optional[list[OssieCustomExtension]] = None -class OssieMetric(BaseModel): - """Quantitative measure defined on business data.""" - - model_config = ConfigDict(frozen=True) - - name: str - expression: OssieExpression - description: Optional[str] = None - datatype: Optional[OssieDataType] = None - ai_context: Optional[OssieAIContext] = None - custom_extensions: Optional[list[OssieCustomExtension]] = None - - class OssieSemanticModel(BaseModel): """Top-level container representing a complete semantic model.""" diff --git a/python/tests/test_models.py b/python/tests/test_models.py index 74d2a320..0c978aa2 100644 --- a/python/tests/test_models.py +++ b/python/tests/test_models.py @@ -135,3 +135,46 @@ def test_effective_time_dimension_role( ) assert field.is_time_dimension() is expected + + +def test_every_schema_property_exists_on_its_model() -> None: + """Guard against structural drift between the JSON Schema and these models. + + Pydantic's default ``extra='ignore'`` means a property present in the schema + but absent from the corresponding model is silently discarded on load, with + no validation error. That is how ``OssieDataset.metrics`` came to drop + dataset-scoped metrics. Comparing the enum lists alone does not catch it, so + walk every ``$defs`` entry that declares ``properties`` and assert each one + is representable. + """ + import ossie.models as models + + schema_path = Path(__file__).parents[2] / "core-spec" / "ossie-schema.json" + schema = json.loads(schema_path.read_text()) + + checked = 0 + for def_name, definition in schema["$defs"].items(): + properties = definition.get("properties") + if not properties: + continue + + model_name = f"Ossie{def_name}" + model = getattr(models, model_name, None) + assert model is not None, ( + f"schema defines $defs.{def_name} with properties but there is no " + f"{model_name} model to represent it" + ) + + # Compare against aliases too: `from` is a Python keyword, so + # OssieRelationship exposes it under an alias. + representable = { + field.alias or name for name, field in model.model_fields.items() + } + missing = sorted(set(properties) - representable) + assert not missing, ( + f"{model_name} cannot represent {missing} from $defs.{def_name}; " + f"these would be silently dropped on load" + ) + checked += 1 + + assert checked >= 9, f"expected to check at least 9 models, checked {checked}" diff --git a/validation/validate.py b/validation/validate.py index 346e9367..b10ead22 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -33,7 +33,8 @@ 1. JSON Schema (structure, types, enums) 2. Unique names (datasets, fields, metrics, relationships) 3. Valid relationship references -4. Dataset-scoped metric scoping (must resolve within their own dataset) +4. Dataset-scoped metric scoping (must resolve within their own dataset, using + unqualified field references) 5. SQL syntax (using sqlglot) Usage: @@ -44,6 +45,7 @@ import json import sys +from functools import lru_cache from pathlib import Path try: @@ -123,21 +125,40 @@ def validate_unique_names(data: dict) -> list[str]: errors.append(f"[Unique] Duplicate metric name '{dup}' in model '{model_name}'") # Check unique dataset-scoped metric names within each dataset, and that - # they do not collide with model-scoped metric names. + # they do not shadow a field of the same dataset. A model-scoped metric + # reusing the name is reported separately, as a warning against the model. model_metric_names = set(metric_names) for dataset in model.get("datasets", []): dataset_name = dataset.get("name", "") ds_metric_names = [ m.get("name") for m in dataset.get("metrics", []) if m.get("name") ] + ds_field_names = { + f.get("name") for f in dataset.get("fields", []) if f.get("name") + } for dup in find_duplicates(ds_metric_names): errors.append( f"[Unique] Duplicate metric name '{dup}' in dataset '{dataset_name}'" ) - for name in set(ds_metric_names) & model_metric_names: + for name in sorted(set(ds_metric_names) & ds_field_names): errors.append( f"[Unique] Dataset-scoped metric '{dataset_name}.{name}' collides with " - f"model-scoped metric '{name}' in model '{model_name}'" + f"field '{dataset_name}.{name}'; '{dataset_name}.{name}' would be " + f"ambiguous between a row-level field and an aggregate" + ) + for name in sorted(set(ds_metric_names) & model_metric_names): + # Reported as a warning, and attributed to the model rather than + # the dataset. A dataset may be authored independently and reused + # across models, so it must not become invalid because of a name + # the surrounding model happens to introduce. References stay + # unambiguous either way: '' is the model-scoped metric and + # '.' is the dataset-scoped one. + errors.append( + f"[Unique] Warning: model-scoped metric '{name}' in model " + f"'{model_name}' shadows dataset-scoped metric " + f"'{dataset_name}.{name}'. Both remain addressable, but " + f"consumers resolving the name '{name}' cannot tell which was " + f"intended; consider renaming the model-scoped metric." ) # Check unique relationship names @@ -187,47 +208,87 @@ def validate_references(data: dict) -> list[str]: return errors -def _expression_qualifiers(expr: str, dialect: str) -> set[str] | None: - """Returns the set of table qualifiers used in an expression. +@lru_cache(maxsize=2048) +def _parse_expression(expr: str, dialect: str): + """Parse an expression, trying it bare and then wrapped in SELECT. - For example, "SUM(orders.amount) / COUNT(customers.id)" yields - {"orders", "customers"}. Unqualified columns contribute nothing. + Returns ``(tree, error)``. Exactly one of the two is meaningful: - Returns None when the qualifiers cannot be determined (sqlglot unavailable, - unsupported dialect, or unparseable expression) so callers can skip the check - rather than report a false positive. + * ``(tree, None)`` - parsed successfully. + * ``(None, message)`` - sqlglot rejected the expression in both forms. + * ``(None, None)`` - parsing was skipped, either because sqlglot is not + installed or because the dialect is one sqlglot cannot parse. Callers + must treat this as "unknown" rather than as a failure. + + Results are cached because several checks run over the same expressions. + The returned tree is shared between callers and MUST NOT be mutated. """ if not SQLGLOT_AVAILABLE or dialect in SKIP_SQL_VALIDATION: - return None + return None, None sqlglot_dialect = DIALECT_MAP.get(dialect) + error = None for candidate in (expr, f"SELECT {expr}"): try: tree = sqlglot.parse_one(candidate, dialect=sqlglot_dialect) - except (ParseError, TokenError): - continue - if tree is None: + except (ParseError, TokenError) as exc: + if error is None: + error = str(exc).split(chr(10))[0] continue - return {col.table for col in tree.find_all(exp.Column) if col.table} + if tree is not None: + return tree, None + + return None, error - return None + +def _leading_qualifiers(tree) -> set[str]: + """The leading name of every qualified column path in an expression. + + ``orders.amount`` yields ``{"orders"}``. For a three-part path such as + ``payload.attrs.value``, sqlglot puts the middle part in ``Column.table`` + and the first in ``Column.db``, so reading ``table`` alone would report + ``attrs``. Taking ``parts[0]`` gives the outermost name in every case. + Unqualified columns contribute nothing. + """ + names = set() + for col in tree.find_all(exp.Column): + parts = [part.name for part in col.parts] + if len(parts) > 1 and parts[0]: + names.add(parts[0]) + return names def validate_metric_scoping(data: dict) -> list[str]: - """Validate that dataset-scoped metrics resolve within their own dataset. + """Validate the expression rules for dataset-scoped metrics. + + A dataset-scoped metric's expression must aggregate only fields of the + dataset that declares it, and must reference them by unqualified name. An + expression that combines fields from more than one dataset belongs in + semantic_model.metrics. - A dataset-scoped metric may only reference fields of the dataset that declares - it. Referencing another dataset requires traversing a relationship, which is - only permitted for model-scoped metrics. + This checks the expression only. It says nothing about how the metric may be + queried: a dataset-scoped metric is joined and grouped like any other, using + the model's relationships. + + A qualifier that names neither a dataset nor a field of the declaring + dataset is left alone: it is a local alias, CTE, or subquery source rather + than a dataset reference. """ errors = [] for model in data.get("semantic_model", []): model_name = model.get("name", "") - for dataset in model.get("datasets", []): + datasets = model.get("datasets", []) + all_dataset_names = { + d["name"].casefold() for d in datasets if d.get("name") + } + + for dataset in datasets: dataset_name = dataset.get("name", "") + own_name = dataset_name.casefold() + other_dataset_names = all_dataset_names - {own_name} for metric in dataset.get("metrics", []): metric_name = metric.get("name", "") @@ -239,46 +300,51 @@ def validate_metric_scoping(data: dict) -> list[str]: if not expr: continue - qualifiers = _expression_qualifiers(expr, dialect) - if qualifiers is None: + tree, _ = _parse_expression(expr, dialect) + if tree is None: continue - foreign = sorted(q for q in qualifiers if q != dataset_name) + foreign = set() + self_qualified = False + + for qualifier in _leading_qualifiers(tree): + folded = qualifier.casefold() + if folded == own_name: + # Qualifying a field with the declaring dataset's own + # name. Valid SQL, but not the required spelling. + self_qualified = True + elif folded in other_dataset_names: + foreign.add(qualifier) + # Anything else is a field of this dataset (struct or + # variant access) or a local alias, so it is not a + # dataset reference and needs no report. + if foreign: errors.append( f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " f"in model '{model_name}' ({dialect}) references " - f"dataset(s) {', '.join(repr(f) for f in foreign)}. " + f"dataset(s) {', '.join(repr(f) for f in sorted(foreign))}. " f"Dataset-scoped metrics must resolve within their own dataset; " f"move this metric to semantic_model.metrics." ) + if self_qualified: + errors.append( + f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " + f"in model '{model_name}' ({dialect}) qualifies a field with its " + f"own dataset name. Dataset-scoped metric expressions reference " + f"fields by unqualified name; drop the '{dataset_name}.' prefix." + ) + return errors def validate_sql_expression(expr: str, dialect: str, context: str) -> str | None: """Validate a single SQL expression. Returns error message or None if valid.""" - if not SQLGLOT_AVAILABLE: - return None - - if dialect in SKIP_SQL_VALIDATION: - return None - - sqlglot_dialect = DIALECT_MAP.get(dialect) - - try: - # Try parsing as expression first (for field expressions like "column_name") - sqlglot.parse_one(expr, dialect=sqlglot_dialect) - return None - except (ParseError, TokenError): - pass - - try: - # Try wrapping in SELECT for simple column references - sqlglot.parse_one(f"SELECT {expr}", dialect=sqlglot_dialect) - return None - except (ParseError, TokenError) as e: - return f"[SQL] {context}: {str(e).split(chr(10))[0]}" + _, error = _parse_expression(expr, dialect) + if error: + return f"[SQL] {context}: {error}" + return None def validate_sql(data: dict) -> list[str]: @@ -300,7 +366,7 @@ def validate_sql(data: dict) -> list[str]: dataset_name = dataset.get("name", "") for field in dataset.get("fields", []): field_name = field.get("name", "") - expression = field.get("expression", {}) + expression = field.get("expression") or {} for dialect_expr in expression.get("dialects", []): dialect = dialect_expr.get("dialect", "ANSI_SQL") expr = dialect_expr.get("expression", "") @@ -313,7 +379,7 @@ def validate_sql(data: dict) -> list[str]: # Validate dataset-scoped metric expressions for metric in dataset.get("metrics", []): metric_name = metric.get("name", "") - expression = metric.get("expression", {}) + expression = metric.get("expression") or {} for dialect_expr in expression.get("dialects", []): dialect = dialect_expr.get("dialect", "ANSI_SQL") expr = dialect_expr.get("expression", "") @@ -326,7 +392,7 @@ def validate_sql(data: dict) -> list[str]: # Validate metric expressions for metric in model.get("metrics", []): metric_name = metric.get("name", "") - expression = metric.get("expression", {}) + expression = metric.get("expression") or {} for dialect_expr in expression.get("dialects", []): dialect = dialect_expr.get("dialect", "ANSI_SQL") expr = dialect_expr.get("expression", "") From 75118a7e9251b2c48bc0445bfb8bc2f85b05f878 Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Sat, 29 Aug 2026 11:27:11 -0400 Subject: [PATCH 06/10] test: cover metric scoping and metric name rules Extends the validator test suite added in #330 with cases for the metric scoping and metric name checks. Each test under "reported in review" corresponds to a defect found in review of #343 and fails against the validator as it stood before that review: raw-cased qualifier comparison, three-part STRUCT paths read as dataset references, local aliases and subquery sources reported as cross-dataset references, a traceback on an explicitly null expression, non-deterministic collision output, and the missing field/metric name collision check. Also covers the deliberately permitted cases, so a later change does not constrain them by accident: two datasets may reuse a metric name, and a model-scoped metric may take the name of a field or of a dataset. Follows the module-loading and importorskip pattern established by the existing tests. sqlglot is skipped rather than asserted, since the scoping checks no-op without it and would otherwise pass without asserting anything. Assisted-by: Cortex Code --- validation/tests/test_validate.py | 250 ++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/validation/tests/test_validate.py b/validation/tests/test_validate.py index 30806f18..92540f31 100644 --- a/validation/tests/test_validate.py +++ b/validation/tests/test_validate.py @@ -161,3 +161,253 @@ def test_skips_malformed_flat_unique_keys() -> None: ) assert errors == [] + + +# --------------------------------------------------------------------------- +# Metric scoping and metric name uniqueness. +# +# Every test under "reported in review" corresponds to a defect found in review +# of apache/ossie#343 and fails against the validator as it stood before it. +# --------------------------------------------------------------------------- + +# The metric-scoping checks no-op without sqlglot, which would let the whole +# scoping section pass without asserting anything. Skip visibly instead. +pytest.importorskip("sqlglot") + +validate_metric_scoping = _VALIDATE.validate_metric_scoping +validate_unique_names = _VALIDATE.validate_unique_names +validate_sql = _VALIDATE.validate_sql + + +def _expr(sql: str, dialect: str = "ANSI_SQL") -> dict: + return {"dialects": [{"dialect": dialect, "expression": sql}]} + + +def _field(name: str) -> dict: + return {"name": name, "expression": _expr(name)} + + +def _metric(name: str, sql: str, dialect: str = "ANSI_SQL") -> dict: + return {"name": name, "expression": _expr(sql, dialect)} + + +def _metric_document( + dataset_metrics: list[dict] | None = None, + fields: list[dict] | None = None, + extra_datasets: list[dict] | None = None, + model_metrics: list[dict] | None = None, +) -> dict: + orders = { + "name": "orders", + "source": "db.s.orders", + "fields": fields if fields is not None else [_field("amount")], + "metrics": dataset_metrics or [], + } + return { + "version": "0.2.0.dev0", + "semantic_model": [ + { + "name": "m", + "datasets": [orders] + (extra_datasets or []), + "metrics": model_metrics or [], + } + ], + } + + +_CUSTOMERS_WITH_ID = { + "name": "customers", + "source": "db.s.customers", + "fields": [_field("id")], +} + + +# --- scoping rules --------------------------------------------------------- + + +def test_dataset_scoped_metric_accepts_unqualified_field_reference() -> None: + doc = _metric_document([_metric("total", "SUM(amount)")]) + + assert validate_metric_scoping(doc) == [] + + +def test_dataset_scoped_metric_rejects_its_own_dataset_as_a_qualifier() -> None: + errors = validate_metric_scoping( + _metric_document([_metric("total", "SUM(orders.amount)")]) + ) + + assert any("own dataset name" in error for error in errors) + + +def test_dataset_scoped_metric_rejects_another_dataset() -> None: + doc = _metric_document( + [_metric("bad", "SUM(amount) / COUNT(DISTINCT customers.id)")], + extra_datasets=[_CUSTOMERS_WITH_ID], + ) + errors = validate_metric_scoping(doc) + + assert any("references dataset(s) 'customers'" in error for error in errors) + + +def test_dataset_scope_does_not_limit_aggregation_complexity() -> None: + # Rule 1 limits which fields an expression may reach, not its complexity. + doc = _metric_document( + [_metric("odd", "SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) / NULLIF(COUNT(*), 0)")] + ) + + assert validate_metric_scoping(doc) == [] + + +# --- reported in review ---------------------------------------------------- + + +def test_uppercase_qualifier_is_not_reported_as_a_missing_dataset() -> None: + # SQL identifiers are case-insensitive, but the qualifier was compared + # against the raw YAML name, so SUM(ORDERS.AMOUNT) on dataset `orders` was + # rejected while naming a dataset that does not exist. + errors = validate_metric_scoping( + _metric_document([_metric("total", "SUM(ORDERS.AMOUNT)", "SNOWFLAKE")]) + ) + + assert not any("references dataset(s)" in error for error in errors) + assert any("own dataset name" in error for error in errors) + + +def test_struct_path_is_not_reported_as_a_dataset() -> None: + # sqlglot places the middle element of a three-part path in Column.table, + # so reading `table` alone reported the struct field as a dataset. + doc = _metric_document( + [_metric("total", "SUM(payload.amount)")], fields=[_field("payload")] + ) + + assert validate_metric_scoping(doc) == [] + + +def test_three_part_path_reports_the_dataset_not_the_struct_field() -> None: + errors = validate_metric_scoping( + _metric_document([_metric("total", "SUM(orders.payload.amount)")]) + ) + + assert any("own dataset name" in error for error in errors) + assert not any("'payload'" in error for error in errors) + + +def test_local_alias_is_not_reported_as_a_dataset() -> None: + doc = _metric_document([_metric("total", "SUM(o.amount)")]) + + assert validate_metric_scoping(doc) == [] + + +def test_subquery_source_is_not_reported_as_a_dataset() -> None: + doc = _metric_document( + [_metric("total", "(SELECT SUM(x) FROM other WHERE other.id = amount)")] + ) + + assert validate_metric_scoping(doc) == [] + + +def test_collision_report_order_is_deterministic() -> None: + # Set iteration order varies between processes under hash randomisation. + names = ("a", "b", "c", "d") + doc = _metric_document( + [_metric(name, "SUM(amount)") for name in names], + model_metrics=[_metric(name, "SUM(orders.amount)") for name in names], + ) + + assert len({tuple(validate_unique_names(doc)) for _ in range(8)}) == 1 + + +def test_null_expression_does_not_raise() -> None: + # `.get(key, {})` returns None when the key is present but null, so a + # truncated hand-edit produced a traceback instead of the schema error. + doc = { + "version": "0.2.0.dev0", + "semantic_model": [ + { + "name": "m", + "datasets": [ + { + "name": "orders", + "source": "db.s.orders", + "metrics": [{"name": "total", "expression": None}], + } + ], + "metrics": [{"name": "other", "expression": None}], + } + ], + } + + validate_sql(doc) + validate_metric_scoping(doc) + + +def test_expression_is_parsed_once_across_checks() -> None: + _VALIDATE._parse_expression.cache_clear() + doc = _metric_document([_metric("total", "SUM(amount)")]) + before = _VALIDATE._parse_expression.cache_info().hits + + validate_metric_scoping(doc) + validate_sql(doc) + + assert _VALIDATE._parse_expression.cache_info().hits > before + + +# --- metric name rules ----------------------------------------------------- + + +def test_metric_may_not_take_the_name_of_a_field_of_its_dataset() -> None: + # Both occupy `orders.amount`, so the reference would resolve two ways. + errors = validate_unique_names(_metric_document([_metric("amount", "SUM(amount)")])) + + assert any("collides with field" in error for error in errors) + assert not any("Warning:" in error for error in errors) + + +def test_duplicate_metric_name_within_a_dataset_is_an_error() -> None: + doc = _metric_document( + [_metric("total", "SUM(amount)"), _metric("total", "COUNT(*)")] + ) + errors = validate_unique_names(doc) + + assert any("Duplicate metric name 'total'" in error for error in errors) + + +def test_two_datasets_may_reuse_a_metric_name() -> None: + # Dataset-scoped names are scoped to their dataset. + shipments = { + "name": "shipments", + "source": "db.s.shipments", + "fields": [_field("qty")], + "metrics": [_metric("item_count", "COUNT(*)")], + } + doc = _metric_document( + [_metric("item_count", "COUNT(*)")], extra_datasets=[shipments] + ) + + assert validate_unique_names(doc) == [] + + +def test_model_metric_shadowing_a_dataset_metric_only_warns() -> None: + # A dataset may be authored independently and reused across models, so it + # must not become invalid because of a name the surrounding model adds. + doc = _metric_document( + [_metric("total_sales", "SUM(amount)")], + model_metrics=[_metric("total_sales", "SUM(orders.amount)")], + ) + errors = validate_unique_names(doc) + + assert errors == [ + "[Unique] Warning: model-scoped metric 'total_sales' in model 'm' shadows " + "dataset-scoped metric 'orders.total_sales'. Both remain addressable, but " + "consumers resolving the name 'total_sales' cannot tell which was " + "intended; consider renaming the model-scoped metric." + ] + + +@pytest.mark.parametrize("name", ["amount", "orders"]) +def test_model_metric_may_reuse_a_field_or_dataset_name(name: str) -> None: + # Bare and qualified names never collide, and a model-scoped metric usually + # takes the name of the column it aggregates, so this is left permitted. + doc = _metric_document(model_metrics=[_metric(name, "SUM(orders.amount)")]) + + assert validate_unique_names(doc) == [] From 9a0bc55d61104b3475724135bb94ad063c8150ad Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Sat, 29 Aug 2026 18:14:38 -0400 Subject: [PATCH 07/10] Remove the metric placement rule and the aggregation grain claim Rule 1 originally read "a dataset-scoped metric's expression MUST NOT reference a field of another dataset", justified on the grounds that such a metric could then be exchanged without resolving the surrounding join graph. That justification is a claim about evaluation, and this specification does not define evaluation semantics: there is nothing in core-spec/ about how a metric is computed, how grain is resolved, or how fan-out is handled. Constraining authors on the strength of a benefit the spec never specifies overreaches. Raised by willpugh in review. Restating it as a placement rule removed the overreach but left the rule redundant. It is entailed by the unqualified-reference rule: an expression restricted to unqualified field names cannot reach another dataset, because a cross-dataset reference requires a qualifier. The rule stated a consequence and then needed two paragraphs to walk back the evaluation reading it invited, so it is now dropped and the consequence folded into rule 1 as a single clause. The remaining rules are renumbered 1 to 5. Also drops the aggregation grain paragraph and the corresponding comparison table row, for the same reason the rule 1 rationale went: grain is an evaluation concept, and this spec defines structure and naming rather than evaluation semantics. No check changes behaviour. Both previously documented INVALID snippets are still rejected; they are now both rule 1 violations. Assisted-by: Cortex Code --- core-spec/ossie-schema.json | 2 +- core-spec/spec.md | 53 +++++++------------------------------ core-spec/spec.yaml | 9 +++---- validation/validate.py | 17 ++++++------ 4 files changed, 23 insertions(+), 58 deletions(-) diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json index 14d16b0e..146585fc 100644 --- a/core-spec/ossie-schema.json +++ b/core-spec/ossie-schema.json @@ -220,7 +220,7 @@ "items": { "$ref": "#/$defs/Metric" }, - "description": "Metrics that aggregate data held by this dataset, at this dataset's grain. The expression must aggregate only fields of this dataset, must not reference a field of another dataset, and must reference fields by unqualified name (SUM(amount), not SUM(orders.amount)). This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." + "description": "Metrics that aggregate data held by this dataset. The expression must reference fields of this dataset by unqualified name (SUM(amount), not SUM(orders.amount)); an expression referencing fields of more than one dataset is model-scoped and belongs in semantic_model.metrics. This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." }, "custom_extensions": { "type": "array", diff --git a/core-spec/spec.md b/core-spec/spec.md index b7f48b49..fed9bfc5 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -434,32 +434,20 @@ A metric may be defined at the semantic model level or on an individual dataset. |---|---|---| | Expression may reference fields from | Any dataset in the model | Only its own dataset | | Expression namespace | Qualified — `dataset.field` | Unqualified — `field` | -| Aggregation grain | Determined by the expression | The declaring dataset's grain | | Name uniqueness | Unique across the semantic model | Unique within its dataset, and distinct from that dataset's field names | | Referenced as | `metric_name` | `dataset_name.metric_name` | **Rules** -1. A dataset-scoped metric's expression MUST aggregate only fields of the dataset that declares it, and MUST NOT reference a field of another dataset. Model-scoped metrics carry no equivalent restriction. -2. A dataset-scoped metric's expression MUST reference fields by unqualified name: `SUM(ss_ext_sales_price)`, not `SUM(store_sales.ss_ext_sales_price)`. -3. Dataset-scoped metric names MUST be unique within their dataset. Two datasets MAY each declare a metric with the same name. -4. A dataset-scoped metric name MUST NOT collide with the name of a field of the same dataset, which under rule 6 would leave `orders.amount` ambiguous. -5. A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric in the same semantic model. Validators SHOULD warn, and SHOULD attribute the warning to the model rather than to the dataset. -6. An unqualified metric reference resolves to a model-scoped metric. A dataset-scoped metric is referenced as `dataset_name.metric_name`. +1. A dataset-scoped metric's expression MUST reference fields of its declaring dataset by unqualified name: `SUM(ss_ext_sales_price)`, not `SUM(store_sales.ss_ext_sales_price)`. An expression that references fields of more than one dataset MUST be declared in `semantic_model.metrics`. +2. Dataset-scoped metric names MUST be unique within their dataset. Two datasets MAY each declare a metric with the same name. +3. A dataset-scoped metric name MUST NOT collide with the name of a field of the same dataset, which under rule 5 would leave `orders.amount` ambiguous. +4. A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric in the same semantic model. Validators SHOULD warn, and SHOULD attribute the warning to the model rather than to the dataset. +5. An unqualified metric reference resolves to a model-scoped metric. A dataset-scoped metric is referenced as `dataset_name.metric_name`. Rule 1 means declared fields, not any column the dataset's `source` exposes: a column used by a dataset-scoped metric must be declared as a field. This is not checked automatically, because an expression may also contain function names, literals, and struct or variant paths. -Rule 1 places no limit on the complexity of the aggregation. Any expression that resolves within the declaring dataset is eligible. - -**Scope describes the aggregation, not the query** - -A dataset-scoped metric aggregates data held by its own dataset. That is all the placement asserts. It does not limit how the metric may be queried: the metric is joined and grouped like any other, using the relationships declared in the model, so it can be sliced by dimensions of any dataset the model connects. Rules 1 and 2 constrain what an expression may reference, never what may be joined to the result. - -Presenting many datasets and their metrics through one queryable interface is the concern of a layer above this one. This section defines only where an aggregation is anchored. - -**Aggregation grain** - -A dataset-scoped metric aggregates at its dataset's grain. That grain does not depend on a declared `primary_key`: it is the grain of the rows the `source` produces. `primary_key` and `unique_keys` let consumers reason about fan-out when the dataset participates in relationships, but are not a prerequisite. +Rule 1 places no limit on the complexity of the aggregation, and constrains the expression rather than the query. Any expression that resolves within the declaring dataset is eligible, and a dataset-scoped metric is joined and grouped like any other using the relationships declared in the model, so a metric declared on `store_sales` as `SUM(ss_ext_sales_price)` may still be grouped by `item.i_brand` or `store.s_state`. **Which names may repeat** @@ -472,7 +460,7 @@ Names are addressed in three ways, so a name may repeat across them without ambi | Field | Qualified — `orders.revenue` | | Dataset | Only ever as a qualifier | -A model-scoped metric MAY therefore share a name with a field, or with a dataset. Rule 4 is an error because a field and a metric of one dataset share the same qualified namespace, so `orders.revenue` would resolve two ways. Rule 5 is a warning because the two names remain separately addressable, and because a dataset may be authored independently of the model that includes it. +A model-scoped metric MAY therefore share a name with a field, or with a dataset. Rule 3 is an error because a field and a metric of one dataset share the same qualified namespace; rule 4 is a warning because the two names remain separately addressable. **Example — dataset-scoped metrics** @@ -522,14 +510,15 @@ datasets: - name: orders source: sales.public.orders metrics: - # INVALID (rule 1): references a field of the customers dataset + # INVALID (rule 1): references a field of the customers dataset, so this + # metric is model-scoped and belongs in semantic_model.metrics - name: revenue_per_customer expression: dialects: - dialect: ANSI_SQL expression: SUM(amount) / COUNT(DISTINCT customers.id) - # INVALID (rule 2): qualifies a field with the declaring dataset's own name + # INVALID (rule 1): qualifies a field with the declaring dataset's own name - name: total_amount expression: dialects: @@ -537,28 +526,6 @@ datasets: expression: SUM(orders.amount) ``` -**Prior art** - -Systems in this space differ in where a single-entity aggregation lives, how names resolve, and how strictly scope is enforced. - -| System | Single-entity metric | Cross-entity mechanism | Name uniqueness | Reference form | -|---|---|---|---|---| -| **AtScale SML** | Standalone `metric` object bound to one `dataset` + `column` | Separate `metric_calc` object type | Global across all repositories | Bare `unique_name` | -| **Cube** | Measures within cubes | Calculated measures referencing other measures | Per cube | Qualified — `cube.member` | -| **Databricks UC metric views** | Measures in the view (one flat scope) | Joins declared inside the view | Per metric view | `MEASURE(name)` | -| **dbt MetricFlow** (v1.12+) | Metrics inside a semantic model | Top-level `metrics` | Global across the project | Bare name | -| **Snowflake semantic views** | `tables[].metrics` | Top-level `metrics` (derived) | Per logical table | Qualified — `table.metric` | - -Four of the five distinguish the two structurally. Ossie has so far provided only the model-level placement, which is the gap this section addresses. - -Two points of comparison worth recording. Databricks UC metric views are closest to the namespace rules above: a metric view has one `source` plus optional `joins`, sources are named so a column is addressed as `source_name.column_name`, and the metric view itself exposes a flat schema. Snowflake semantic views let a table-scoped metric's own expression reach through a relationship, so the boundary between their two placements is softer than rule 1. - -Scoped uniqueness with qualified references, rather than the flat global namespace used by AtScale SML and dbt MetricFlow, follows Ossie's existing convention: field names are already scoped to a dataset. Rule 2 follows the same convention, since a field's own expression is written without naming its dataset. - -Rule 1 is the conservative choice. It keeps a dataset-scoped metric verifiably anchored to one dataset, so a dataset and its metrics can be exchanged without resolving the surrounding join graph. Relaxing it later would be backward compatible; tightening it would not. Whether a dataset-scoped metric's expression should be permitted to reach through an explicitly declared path is left open. - -Choosing between multiple relationship paths, where more than one connects the same pair of datasets, is a separate gap that Ossie does not currently address. It applies to model-scoped expressions and consumer queries alike. - **Consumer guidance: flattening to a single metric namespace** Hoisting a dataset-scoped metric to the model level requires qualifying its field references with the dataset name, and qualifying the metric's own name as `dataset_name.metric_name` since two datasets may reuse a local name. Use an equivalent encoding if the target namespace disallows dots. diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index 9b37f666..c9a0c84c 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -147,12 +147,9 @@ datasets: # model-scoped metrics; see the Metrics section below. # # Rules (see core-spec/spec.md#metric-scoping): - # - The expression MUST aggregate only fields of this dataset, and MUST NOT - # reference a field of another dataset. - # - The expression MUST reference fields by unqualified name: - # SUM(amount), not SUM(orders.amount). - # - Aggregates at this dataset's grain, which does not depend on a declared - # primary_key. + # - The expression MUST reference fields of this dataset by unqualified name: + # SUM(amount), not SUM(orders.amount). An expression referencing fields of + # more than one dataset belongs in semantic_model.metrics. # - Names MUST be unique within this dataset and MUST NOT collide with a # field name of this dataset. # - Referenced from outside the dataset as dataset_name.metric_name. An diff --git a/validation/validate.py b/validation/validate.py index b10ead22..97d83af0 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -33,8 +33,8 @@ 1. JSON Schema (structure, types, enums) 2. Unique names (datasets, fields, metrics, relationships) 3. Valid relationship references -4. Dataset-scoped metric scoping (must resolve within their own dataset, using - unqualified field references) +4. Metric scoping (a dataset-scoped metric's expression references fields of its + own dataset by unqualified name) 5. SQL syntax (using sqlglot) Usage: @@ -262,10 +262,10 @@ def _leading_qualifiers(tree) -> set[str]: def validate_metric_scoping(data: dict) -> list[str]: """Validate the expression rules for dataset-scoped metrics. - A dataset-scoped metric's expression must aggregate only fields of the - dataset that declares it, and must reference them by unqualified name. An - expression that combines fields from more than one dataset belongs in - semantic_model.metrics. + A dataset-scoped metric's expression must reference fields of the dataset + that declares it by unqualified name. A qualifier is therefore always an + error: it either names another dataset, in which case the metric belongs in + semantic_model.metrics, or it names the declaring dataset redundantly. This checks the expression only. It says nothing about how the metric may be queried: a dataset-scoped metric is joined and grouped like any other, using @@ -324,8 +324,9 @@ def validate_metric_scoping(data: dict) -> list[str]: f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " f"in model '{model_name}' ({dialect}) references " f"dataset(s) {', '.join(repr(f) for f in sorted(foreign))}. " - f"Dataset-scoped metrics must resolve within their own dataset; " - f"move this metric to semantic_model.metrics." + f"Dataset-scoped metrics aggregate fields of one dataset; " + f"a metric spanning datasets is model-scoped and belongs in " + f"semantic_model.metrics." ) if self_qualified: From 56c425e7f44b18668183ddd6f9afdf3ba0b7423f Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Sat, 29 Aug 2026 18:21:03 -0400 Subject: [PATCH 08/10] spec: use colons instead of em dashes in the metric scoping section Matches the rest of spec.md, which uses one em dash in total. Assisted-by: Cortex Code --- core-spec/spec.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/core-spec/spec.md b/core-spec/spec.md index fed9bfc5..72c2b2df 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -364,8 +364,8 @@ Quantitative measures defined on business data, representing key calculations li Metrics may be defined in two placements, using the same structure in both: -- **Model-scoped** (`semantic_model.metrics`) — for expressions that must combine fields from more than one dataset. -- **Dataset-scoped** (`datasets[].metrics`) — aggregates data held by one dataset. Still joined and grouped through the model's relationships like any other metric. +- **Model-scoped** (`semantic_model.metrics`): for expressions that must combine fields from more than one dataset. +- **Dataset-scoped** (`datasets[].metrics`): aggregates data held by one dataset. Still joined and grouped through the model's relationships like any other metric. See [Metric Scoping](#metric-scoping) for the rules governing each. @@ -433,7 +433,7 @@ A metric may be defined at the semantic model level or on an individual dataset. | | Model-scoped (`semantic_model.metrics`) | Dataset-scoped (`datasets[].metrics`) | |---|---|---| | Expression may reference fields from | Any dataset in the model | Only its own dataset | -| Expression namespace | Qualified — `dataset.field` | Unqualified — `field` | +| Expression namespace | Qualified: `dataset.field` | Unqualified: `field` | | Name uniqueness | Unique across the semantic model | Unique within its dataset, and distinct from that dataset's field names | | Referenced as | `metric_name` | `dataset_name.metric_name` | @@ -455,14 +455,14 @@ Names are addressed in three ways, so a name may repeat across them without ambi | Kind | Addressed as | |---|---| -| Model-scoped metric | Bare — `revenue` | -| Dataset-scoped metric | Qualified — `orders.revenue` | -| Field | Qualified — `orders.revenue` | +| Model-scoped metric | Bare: `revenue` | +| Dataset-scoped metric | Qualified: `orders.revenue` | +| Field | Qualified: `orders.revenue` | | Dataset | Only ever as a qualifier | A model-scoped metric MAY therefore share a name with a field, or with a dataset. Rule 3 is an error because a field and a metric of one dataset share the same qualified namespace; rule 4 is a warning because the two names remain separately addressable. -**Example — dataset-scoped metrics** +**Example: dataset-scoped metrics** ```yaml datasets: @@ -503,7 +503,7 @@ datasets: Referenced from a consumer as `orders.total_amount` and `orders.order_count`. -**Example — invalid dataset-scoped metrics** +**Example: invalid dataset-scoped metrics** ```yaml datasets: From 74a6d8b212d0aa206175b18bf571fba06cfd4b02 Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Sat, 29 Aug 2026 18:36:41 -0400 Subject: [PATCH 09/10] Dataset-scoped metric expressions reference source columns, not fields Rule 1 said a dataset-scoped metric's expression MUST reference fields of its declaring dataset. That forces an author to declare a field for a column they only want to aggregate, and then hide it. The expression is written against the dataset's source and references its columns, so nothing needs declaring first. This is not a new rule: it is how a field's own expression already works. The spec declares a field customer_id with expression customer_id, which refers to the source column rather than to the field itself, since referring to the field would be circular. That also settles a resolution question the old wording left open. A declared field does not shadow a source column of the same name, so if a dataset declares a field foo whose expression is not simply foo, then SUM(foo) in a metric of that dataset still refers to the source column. Reusing a declared field's expression inside a metric is the same composability question as a metric referencing another metric, and is left to a separate proposal. No check changes behaviour. validate_metric_scoping keys on dataset names and never on whether a leaf is a declared field, so permitting undeclared columns costs nothing mechanically. Two tests lock the documented behaviour in; both also pass against the previous validator, because it did not check field declarations either. Assisted-by: Cortex Code --- converters/README.md | 4 ++-- core-spec/ossie-schema.json | 2 +- core-spec/spec.md | 15 ++++++++------- core-spec/spec.yaml | 8 +++++--- validation/tests/test_validate.py | 25 +++++++++++++++++++++++++ validation/validate.py | 22 ++++++++++++---------- 6 files changed, 53 insertions(+), 23 deletions(-) diff --git a/converters/README.md b/converters/README.md index c6b71236..b4800971 100644 --- a/converters/README.md +++ b/converters/README.md @@ -169,8 +169,8 @@ means `from.product_id = to.id AND from.variant_id = to.variant_id`. The convert Metrics are aggregate measures. They appear in two places, and **a converter must read both**: -- `semantic_model.metrics` — model-scoped. May span multiple datasets via relationships. Expressions reference fields by qualified name (`SUM(orders.amount)`). -- `datasets[].metrics` — dataset-scoped. The expression resolves entirely within the declaring dataset and references fields by unqualified name (`SUM(amount)`). +- `semantic_model.metrics`: model-scoped. May span multiple datasets via relationships. Expressions reference fields by qualified name (`SUM(orders.amount)`). +- `datasets[].metrics`: dataset-scoped. The expression is written against the declaring dataset's `source` and references its columns by unqualified name (`SUM(amount)`). Both placements use the identical metric structure, so the field mapping below applies to each. diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json index 146585fc..69b38011 100644 --- a/core-spec/ossie-schema.json +++ b/core-spec/ossie-schema.json @@ -220,7 +220,7 @@ "items": { "$ref": "#/$defs/Metric" }, - "description": "Metrics that aggregate data held by this dataset. The expression must reference fields of this dataset by unqualified name (SUM(amount), not SUM(orders.amount)); an expression referencing fields of more than one dataset is model-scoped and belongs in semantic_model.metrics. This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." + "description": "Metrics that aggregate data held by this dataset. The expression is written against this dataset's source and must reference its columns by unqualified name (SUM(amount), not SUM(orders.amount)); a column used only inside a metric does not need to be declared as a field. An expression referencing columns of more than one dataset is model-scoped and belongs in semantic_model.metrics. This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." }, "custom_extensions": { "type": "array", diff --git a/core-spec/spec.md b/core-spec/spec.md index 72c2b2df..f058feec 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -432,22 +432,23 @@ A metric may be defined at the semantic model level or on an individual dataset. | | Model-scoped (`semantic_model.metrics`) | Dataset-scoped (`datasets[].metrics`) | |---|---|---| -| Expression may reference fields from | Any dataset in the model | Only its own dataset | -| Expression namespace | Qualified: `dataset.field` | Unqualified: `field` | +| Expression references | Fields of any dataset in the model, qualified: `dataset.field` | Columns of its own dataset's `source`, unqualified: `column` | | Name uniqueness | Unique across the semantic model | Unique within its dataset, and distinct from that dataset's field names | | Referenced as | `metric_name` | `dataset_name.metric_name` | **Rules** -1. A dataset-scoped metric's expression MUST reference fields of its declaring dataset by unqualified name: `SUM(ss_ext_sales_price)`, not `SUM(store_sales.ss_ext_sales_price)`. An expression that references fields of more than one dataset MUST be declared in `semantic_model.metrics`. +1. A dataset-scoped metric's expression is written against its dataset's `source` and MUST reference columns by unqualified name: `SUM(ss_ext_sales_price)`, not `SUM(store_sales.ss_ext_sales_price)`. An expression that references columns of more than one dataset MUST be declared in `semantic_model.metrics`. 2. Dataset-scoped metric names MUST be unique within their dataset. Two datasets MAY each declare a metric with the same name. 3. A dataset-scoped metric name MUST NOT collide with the name of a field of the same dataset, which under rule 5 would leave `orders.amount` ambiguous. 4. A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric in the same semantic model. Validators SHOULD warn, and SHOULD attribute the warning to the model rather than to the dataset. 5. An unqualified metric reference resolves to a model-scoped metric. A dataset-scoped metric is referenced as `dataset_name.metric_name`. -Rule 1 means declared fields, not any column the dataset's `source` exposes: a column used by a dataset-scoped metric must be declared as a field. This is not checked automatically, because an expression may also contain function names, literals, and struct or variant paths. +Rule 1 refers to the columns the dataset's `source` exposes, not to its declared fields. A column used only inside a metric does not need to be declared as a field first. This matches how a field's own expression is written: the field `customer_id` with expression `customer_id` refers to the source column, not to itself. -Rule 1 places no limit on the complexity of the aggregation, and constrains the expression rather than the query. Any expression that resolves within the declaring dataset is eligible, and a dataset-scoped metric is joined and grouped like any other using the relationships declared in the model, so a metric declared on `store_sales` as `SUM(ss_ext_sales_price)` may still be grouped by `item.i_brand` or `store.s_state`. +A declared field therefore does not shadow a source column of the same name. If a dataset declares a field `foo` whose expression is not simply `foo`, then `SUM(foo)` in a metric of that dataset still refers to the source column `foo`. Reusing a declared field's expression inside a metric is the same composability question as a metric referencing another metric, and is left to a separate proposal. + +Rule 1 places no limit on the complexity of the aggregation, and constrains the expression rather than the query. A dataset-scoped metric is joined and grouped like any other using the relationships declared in the model, so a metric declared on `store_sales` as `SUM(ss_ext_sales_price)` may still be grouped by `item.i_brand` or `store.s_state`. **Which names may repeat** @@ -510,7 +511,7 @@ datasets: - name: orders source: sales.public.orders metrics: - # INVALID (rule 1): references a field of the customers dataset, so this + # INVALID (rule 1): references a column of the customers dataset, so this # metric is model-scoped and belongs in semantic_model.metrics - name: revenue_per_customer expression: @@ -518,7 +519,7 @@ datasets: - dialect: ANSI_SQL expression: SUM(amount) / COUNT(DISTINCT customers.id) - # INVALID (rule 1): qualifies a field with the declaring dataset's own name + # INVALID (rule 1): qualifies a column with the declaring dataset's own name - name: total_amount expression: dialects: diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index c9a0c84c..a56db78a 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -147,9 +147,11 @@ datasets: # model-scoped metrics; see the Metrics section below. # # Rules (see core-spec/spec.md#metric-scoping): - # - The expression MUST reference fields of this dataset by unqualified name: - # SUM(amount), not SUM(orders.amount). An expression referencing fields of - # more than one dataset belongs in semantic_model.metrics. + # - The expression is written against this dataset's source and MUST + # reference its columns by unqualified name: SUM(amount), not + # SUM(orders.amount). A column used only inside a metric does not need to + # be declared as a field. An expression referencing columns of more than + # one dataset belongs in semantic_model.metrics. # - Names MUST be unique within this dataset and MUST NOT collide with a # field name of this dataset. # - Referenced from outside the dataset as dataset_name.metric_name. An diff --git a/validation/tests/test_validate.py b/validation/tests/test_validate.py index 92540f31..1f1c73e4 100644 --- a/validation/tests/test_validate.py +++ b/validation/tests/test_validate.py @@ -411,3 +411,28 @@ def test_model_metric_may_reuse_a_field_or_dataset_name(name: str) -> None: doc = _metric_document(model_metrics=[_metric(name, "SUM(orders.amount)")]) assert validate_unique_names(doc) == [] + + +def test_metric_may_reference_an_undeclared_source_column() -> None: + # A dataset-scoped metric's expression is written against the dataset's + # source, so a column used only inside a metric need not be declared as a + # field first. Here 'tax' is not in fields. + doc = _metric_document( + [_metric("total_tax", "SUM(tax)")], + fields=[_field("amount")], + ) + + assert validate_metric_scoping(doc) == [] + + +def test_declared_field_does_not_shadow_a_source_column() -> None: + # A field named 'foo' whose expression is not simply 'foo' does not change + # how 'foo' resolves inside a metric of the same dataset: it is still the + # source column, so the metric stays valid and unqualified. + shadowing_field = {"name": "foo", "expression": _expr("UPPER(bar)")} + doc = _metric_document( + [_metric("foo_total", "SUM(foo)")], + fields=[shadowing_field], + ) + + assert validate_metric_scoping(doc) == [] diff --git a/validation/validate.py b/validation/validate.py index 97d83af0..76e55780 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -33,8 +33,8 @@ 1. JSON Schema (structure, types, enums) 2. Unique names (datasets, fields, metrics, relationships) 3. Valid relationship references -4. Metric scoping (a dataset-scoped metric's expression references fields of its - own dataset by unqualified name) +4. Metric scoping (a dataset-scoped metric's expression references columns of its + own dataset's source by unqualified name) 5. SQL syntax (using sqlglot) Usage: @@ -262,10 +262,11 @@ def _leading_qualifiers(tree) -> set[str]: def validate_metric_scoping(data: dict) -> list[str]: """Validate the expression rules for dataset-scoped metrics. - A dataset-scoped metric's expression must reference fields of the dataset - that declares it by unqualified name. A qualifier is therefore always an - error: it either names another dataset, in which case the metric belongs in - semantic_model.metrics, or it names the declaring dataset redundantly. + A dataset-scoped metric's expression is written against its dataset's + source and must reference the source's columns by unqualified name. A + qualifier is therefore always an error: it either names another dataset, in + which case the metric belongs in semantic_model.metrics, or it names the + declaring dataset redundantly. This checks the expression only. It says nothing about how the metric may be queried: a dataset-scoped metric is joined and grouped like any other, using @@ -324,17 +325,18 @@ def validate_metric_scoping(data: dict) -> list[str]: f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " f"in model '{model_name}' ({dialect}) references " f"dataset(s) {', '.join(repr(f) for f in sorted(foreign))}. " - f"Dataset-scoped metrics aggregate fields of one dataset; " - f"a metric spanning datasets is model-scoped and belongs in " + f"Dataset-scoped metrics aggregate columns of one " + f"dataset's source; a metric spanning datasets is " + f"model-scoped and belongs in " f"semantic_model.metrics." ) if self_qualified: errors.append( f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " - f"in model '{model_name}' ({dialect}) qualifies a field with its " + f"in model '{model_name}' ({dialect}) qualifies a column with its " f"own dataset name. Dataset-scoped metric expressions reference " - f"fields by unqualified name; drop the '{dataset_name}.' prefix." + f"columns by unqualified name; drop the '{dataset_name}.' prefix." ) return errors From 3edd0a92280bd5fa147c89c5efe34ee8e3b40cd0 Mon Sep 17 00:00:00 2001 From: Josh Klahr Date: Sat, 29 Aug 2026 19:00:45 -0400 Subject: [PATCH 10/10] Dataset-scoped metrics reach both declared fields and source columns Rule 1 said a dataset-scoped metric's expression references fields of its dataset. That forces an author to declare a field for a column they only want to aggregate, and then hide it. It also narrowed something the spec already states: the Fields section describes fields as "row-level attributes that can be used for grouping, filtering, and in metric expressions". The expression now reaches both namespaces, with a distinct spelling for each. A declared field is written dataset_name.field_name, which is how a metric reuses a field's expression instead of repeating it. A column of the source is written unqualified. Two spellings rather than one shared namespace avoids a shadowing rule. A field and a source column may share a name without ambiguity, so declaring a field named after an existing column does not change the meaning of an expression already using the bare name. This replaces the hard error on self-qualification, since orders.amount now means the declared field amount, with a check that a qualified reference names a declared field of the declaring dataset. That is verifiable from the model, so SUM(orders.tax) is reported and points the author at SUM(tax). Whether a bare name is a real column stays unchecked, because it needs catalog metadata the model does not carry, and nothing else in validate.py checks a field's expression against real columns either. validate.py: _leading_qualifiers becomes _qualified_references, returning (qualifier, name) pairs so the name a qualifier introduces can be resolved against the dataset's field list. spec.md: the worked example now demonstrates both spellings on a derived field rather than two identity fields, and the second INVALID case is a qualified reference to an undeclared name instead of a self-qualifier. Tests: 21 functions added overall, collecting 32 cases with the 10 from #330. Reverting only validate.py to 592db69 fails 11 of the added functions and none of the 10 pre-existing ones. Assisted-by: Cortex Code --- converters/README.md | 4 +- core-spec/ossie-schema.json | 2 +- core-spec/spec.md | 54 +++++++++++++------ core-spec/spec.yaml | 13 +++-- validation/tests/test_validate.py | 55 ++++++++++++++------ validation/validate.py | 86 ++++++++++++++++++------------- 6 files changed, 137 insertions(+), 77 deletions(-) diff --git a/converters/README.md b/converters/README.md index b4800971..ae260071 100644 --- a/converters/README.md +++ b/converters/README.md @@ -170,13 +170,13 @@ means `from.product_id = to.id AND from.variant_id = to.variant_id`. The convert Metrics are aggregate measures. They appear in two places, and **a converter must read both**: - `semantic_model.metrics`: model-scoped. May span multiple datasets via relationships. Expressions reference fields by qualified name (`SUM(orders.amount)`). -- `datasets[].metrics`: dataset-scoped. The expression is written against the declaring dataset's `source` and references its columns by unqualified name (`SUM(amount)`). +- `datasets[].metrics`: dataset-scoped. The expression may reference this dataset's declared fields, written `dataset.field` (`SUM(orders.net_amount)`), and the columns of its `source`, written unqualified (`SUM(tax)`). A qualified reference must name a declared field. Both placements use the identical metric structure, so the field mapping below applies to each. A converter that reads only `semantic_model.metrics` produces an incomplete model. That is a lossy conversion, not a valid one: it SHOULD warn, naming the metrics it dropped, and MUST NOT present the output as a faithful representation of the source. -When the target format has only one metric namespace, hoist dataset-scoped metrics to the model level. This requires two changes: qualify the expression's field references with the dataset name, and qualify the metric's own name as `dataset_name.metric_name`, since two datasets may each declare a metric with the same local name. If the target namespace disallows dots, use an equivalent encoding. +When the target format has only one metric namespace, hoist dataset-scoped metrics to the model level. This requires two changes: qualify the expression's unqualified column references with the dataset name, since references to declared fields are already qualified, and qualify the metric's own name as `dataset_name.metric_name`, since two datasets may each declare a metric with the same local name. If the target namespace disallows dots, use an equivalent encoding. See [Metric Scoping](../core-spec/spec.md#metric-scoping) for the full rules. diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json index 69b38011..d5b70bf3 100644 --- a/core-spec/ossie-schema.json +++ b/core-spec/ossie-schema.json @@ -220,7 +220,7 @@ "items": { "$ref": "#/$defs/Metric" }, - "description": "Metrics that aggregate data held by this dataset. The expression is written against this dataset's source and must reference its columns by unqualified name (SUM(amount), not SUM(orders.amount)); a column used only inside a metric does not need to be declared as a field. An expression referencing columns of more than one dataset is model-scoped and belongs in semantic_model.metrics. This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." + "description": "Metrics that aggregate data held by this dataset. The expression may reference this dataset's declared fields, written dataset_name.field_name, and the columns of its source, written unqualified; a column used only inside a metric does not need to be declared as a field. A qualified reference must name a declared field of this dataset. An expression referencing another dataset is model-scoped and belongs in semantic_model.metrics. This constrains the expression only, not the query: the metric is joined and grouped like any other, using the model's relationships. Names must be unique within the dataset and must not collide with a field name of this dataset. Referenced from outside the dataset as dataset_name.metric_name. See core-spec/spec.md#metric-scoping." }, "custom_extensions": { "type": "array", diff --git a/core-spec/spec.md b/core-spec/spec.md index f058feec..c26d60fd 100644 --- a/core-spec/spec.md +++ b/core-spec/spec.md @@ -432,21 +432,23 @@ A metric may be defined at the semantic model level or on an individual dataset. | | Model-scoped (`semantic_model.metrics`) | Dataset-scoped (`datasets[].metrics`) | |---|---|---| -| Expression references | Fields of any dataset in the model, qualified: `dataset.field` | Columns of its own dataset's `source`, unqualified: `column` | +| Expression references | Fields of any dataset in the model, qualified: `dataset.field` | Fields of its own dataset, qualified: `dataset.field`; columns of its `source`, unqualified: `column` | | Name uniqueness | Unique across the semantic model | Unique within its dataset, and distinct from that dataset's field names | | Referenced as | `metric_name` | `dataset_name.metric_name` | **Rules** -1. A dataset-scoped metric's expression is written against its dataset's `source` and MUST reference columns by unqualified name: `SUM(ss_ext_sales_price)`, not `SUM(store_sales.ss_ext_sales_price)`. An expression that references columns of more than one dataset MUST be declared in `semantic_model.metrics`. +1. A dataset-scoped metric's expression MAY reference the declared fields of its dataset and the columns of its `source`. A declared field is written `dataset_name.field_name`; a column of the `source` is written unqualified: `SUM(orders.net_amount) / COUNT(DISTINCT tax_id)`. A qualified reference MUST name a declared field of the declaring dataset. An expression that references another dataset MUST be declared in `semantic_model.metrics`. 2. Dataset-scoped metric names MUST be unique within their dataset. Two datasets MAY each declare a metric with the same name. 3. A dataset-scoped metric name MUST NOT collide with the name of a field of the same dataset, which under rule 5 would leave `orders.amount` ambiguous. 4. A model-scoped metric SHOULD NOT reuse the name of a dataset-scoped metric in the same semantic model. Validators SHOULD warn, and SHOULD attribute the warning to the model rather than to the dataset. 5. An unqualified metric reference resolves to a model-scoped metric. A dataset-scoped metric is referenced as `dataset_name.metric_name`. -Rule 1 refers to the columns the dataset's `source` exposes, not to its declared fields. A column used only inside a metric does not need to be declared as a field first. This matches how a field's own expression is written: the field `customer_id` with expression `customer_id` refers to the source column, not to itself. +A column used only inside a metric does not need to be declared as a field. Declaring one is how a metric reuses a field's expression instead of repeating it. -A declared field therefore does not shadow a source column of the same name. If a dataset declares a field `foo` whose expression is not simply `foo`, then `SUM(foo)` in a metric of that dataset still refers to the source column `foo`. Reusing a declared field's expression inside a metric is the same composability question as a metric referencing another metric, and is left to a separate proposal. +Because the two are spelled differently, a field and a source column MAY share a name without ambiguity. In a metric of `orders`, `orders.foo` is the declared field and `foo` is the source column, so declaring a field named after an existing column does not change the meaning of an expression already using the bare name. + +Whether a bare name is a real column of the `source` is not checked, because that requires catalog metadata the model does not carry. A qualified reference is checked, because the field list is in the model. Rule 1 places no limit on the complexity of the aggregation, and constrains the expression rather than the query. A dataset-scoped metric is joined and grouped like any other using the relationships declared in the model, so a metric declared on `store_sales` as `SUM(ss_ext_sales_price)` may still be grouped by `item.i_brand` or `store.s_state`. @@ -477,20 +479,31 @@ datasets: - dialect: ANSI_SQL expression: order_id description: Order identifier - - name: amount + - name: net_amount expression: dialects: - dialect: ANSI_SQL - expression: amount - description: Order amount + expression: amount - discount + description: Order amount after discount metrics: - - name: total_amount + # Qualified, so this references the declared field net_amount + - name: total_net_amount + expression: + dialects: + - dialect: ANSI_SQL + expression: SUM(orders.net_amount) + description: Total order amount after discount + datatype: Decimal + + # Unqualified, so this references tax, a column of the source that is + # not declared as a field + - name: total_tax expression: dialects: - dialect: ANSI_SQL - expression: SUM(amount) - description: Total order amount + expression: SUM(tax) + description: Total tax collected datatype: Decimal - name: order_count @@ -502,7 +515,7 @@ datasets: datatype: Integer ``` -Referenced from a consumer as `orders.total_amount` and `orders.order_count`. +Referenced from a consumer as `orders.total_net_amount`, `orders.total_tax` and `orders.order_count`. **Example: invalid dataset-scoped metrics** @@ -510,21 +523,28 @@ Referenced from a consumer as `orders.total_amount` and `orders.order_count`. datasets: - name: orders source: sales.public.orders + fields: + - name: net_amount + expression: + dialects: + - dialect: ANSI_SQL + expression: amount - discount metrics: - # INVALID (rule 1): references a column of the customers dataset, so this - # metric is model-scoped and belongs in semantic_model.metrics + # INVALID (rule 1): references the customers dataset, so this metric is + # model-scoped and belongs in semantic_model.metrics - name: revenue_per_customer expression: dialects: - dialect: ANSI_SQL - expression: SUM(amount) / COUNT(DISTINCT customers.id) + expression: SUM(net_amount) / COUNT(DISTINCT customers.id) - # INVALID (rule 1): qualifies a column with the declaring dataset's own name - - name: total_amount + # INVALID (rule 1): a qualified reference must name a declared field, and + # tax is only a column of the source. Write it unqualified as SUM(tax) + - name: total_tax expression: dialects: - dialect: ANSI_SQL - expression: SUM(orders.amount) + expression: SUM(orders.tax) ``` **Consumer guidance: flattening to a single metric namespace** diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml index a56db78a..2e96028f 100644 --- a/core-spec/spec.yaml +++ b/core-spec/spec.yaml @@ -147,11 +147,14 @@ datasets: # model-scoped metrics; see the Metrics section below. # # Rules (see core-spec/spec.md#metric-scoping): - # - The expression is written against this dataset's source and MUST - # reference its columns by unqualified name: SUM(amount), not - # SUM(orders.amount). A column used only inside a metric does not need to - # be declared as a field. An expression referencing columns of more than - # one dataset belongs in semantic_model.metrics. + # - The expression MAY reference this dataset's declared fields and the + # columns of its source. A declared field is written + # dataset_name.field_name; a source column is written unqualified: + # SUM(orders.net_amount) / COUNT(DISTINCT tax_id). A column used only + # inside a metric does not need to be declared as a field. + # - A qualified reference MUST name a declared field of this dataset. + # - An expression referencing another dataset belongs in + # semantic_model.metrics. # - Names MUST be unique within this dataset and MUST NOT collide with a # field name of this dataset. # - Referenced from outside the dataset as dataset_name.metric_name. An diff --git a/validation/tests/test_validate.py b/validation/tests/test_validate.py index 1f1c73e4..5d9fdb3a 100644 --- a/validation/tests/test_validate.py +++ b/validation/tests/test_validate.py @@ -225,18 +225,28 @@ def _metric_document( # --- scoping rules --------------------------------------------------------- -def test_dataset_scoped_metric_accepts_unqualified_field_reference() -> None: +def test_dataset_scoped_metric_accepts_unqualified_column_reference() -> None: doc = _metric_document([_metric("total", "SUM(amount)")]) assert validate_metric_scoping(doc) == [] -def test_dataset_scoped_metric_rejects_its_own_dataset_as_a_qualifier() -> None: +def test_dataset_scoped_metric_accepts_a_qualified_declared_field() -> None: + # A qualifier names a declared field, which is how a metric reuses a + # field's expression rather than repeating it. `amount` is declared here. + doc = _metric_document([_metric("total", "SUM(orders.amount)")]) + + assert validate_metric_scoping(doc) == [] + + +def test_dataset_scoped_metric_rejects_a_qualified_undeclared_name() -> None: + # `tax` is a column of the source, not a declared field, so the qualified + # spelling is wrong: it should be written SUM(tax). errors = validate_metric_scoping( - _metric_document([_metric("total", "SUM(orders.amount)")]) + _metric_document([_metric("total", "SUM(orders.tax)")]) ) - assert any("own dataset name" in error for error in errors) + assert any("MUST name a declared field" in error for error in errors) def test_dataset_scoped_metric_rejects_another_dataset() -> None: @@ -250,7 +260,7 @@ def test_dataset_scoped_metric_rejects_another_dataset() -> None: def test_dataset_scope_does_not_limit_aggregation_complexity() -> None: - # Rule 1 limits which fields an expression may reach, not its complexity. + # Rule 1 limits what an expression may reach, not its complexity. doc = _metric_document( [_metric("odd", "SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) / NULLIF(COUNT(*), 0)")] ) @@ -264,13 +274,13 @@ def test_dataset_scope_does_not_limit_aggregation_complexity() -> None: def test_uppercase_qualifier_is_not_reported_as_a_missing_dataset() -> None: # SQL identifiers are case-insensitive, but the qualifier was compared # against the raw YAML name, so SUM(ORDERS.AMOUNT) on dataset `orders` was - # rejected while naming a dataset that does not exist. + # rejected while naming a dataset that does not exist. Both the qualifier + # and the field name it qualifies must be matched case-insensitively. errors = validate_metric_scoping( _metric_document([_metric("total", "SUM(ORDERS.AMOUNT)", "SNOWFLAKE")]) ) - assert not any("references dataset(s)" in error for error in errors) - assert any("own dataset name" in error for error in errors) + assert errors == [] def test_struct_path_is_not_reported_as_a_dataset() -> None: @@ -284,12 +294,24 @@ def test_struct_path_is_not_reported_as_a_dataset() -> None: def test_three_part_path_reports_the_dataset_not_the_struct_field() -> None: + # sqlglot places the middle element of a three-part path in Column.table, + # so reading `table` alone reported the struct field as a dataset. The + # qualifier must read as `orders` and the name it qualifies as `payload`, + # which is undeclared here. errors = validate_metric_scoping( _metric_document([_metric("total", "SUM(orders.payload.amount)")]) ) - assert any("own dataset name" in error for error in errors) - assert not any("'payload'" in error for error in errors) + assert any("'orders.payload'" in error for error in errors) + assert not any("references dataset(s)" in error for error in errors) + + +def test_three_part_path_to_a_declared_struct_field_is_valid() -> None: + doc = _metric_document( + [_metric("total", "SUM(orders.payload.amount)")], fields=[_field("payload")] + ) + + assert validate_metric_scoping(doc) == [] def test_local_alias_is_not_reported_as_a_dataset() -> None: @@ -425,13 +447,16 @@ def test_metric_may_reference_an_undeclared_source_column() -> None: assert validate_metric_scoping(doc) == [] -def test_declared_field_does_not_shadow_a_source_column() -> None: - # A field named 'foo' whose expression is not simply 'foo' does not change - # how 'foo' resolves inside a metric of the same dataset: it is still the - # source column, so the metric stays valid and unqualified. +def test_declared_field_and_source_column_may_share_a_name() -> None: + # A field `foo` whose expression is not simply `foo` does not shadow the + # source column `foo`. The two spellings keep them separately reachable: + # `orders.foo` is the field, bare `foo` is the column. shadowing_field = {"name": "foo", "expression": _expr("UPPER(bar)")} doc = _metric_document( - [_metric("foo_total", "SUM(foo)")], + [ + _metric("via_field", "COUNT(DISTINCT orders.foo)"), + _metric("via_column", "COUNT(DISTINCT foo)"), + ], fields=[shadowing_field], ) diff --git a/validation/validate.py b/validation/validate.py index 76e55780..4e3a9da8 100644 --- a/validation/validate.py +++ b/validation/validate.py @@ -33,8 +33,8 @@ 1. JSON Schema (structure, types, enums) 2. Unique names (datasets, fields, metrics, relationships) 3. Valid relationship references -4. Metric scoping (a dataset-scoped metric's expression references columns of its - own dataset's source by unqualified name) +4. Metric scoping (a dataset-scoped metric's expression references its dataset's + declared fields as dataset.field, and its source's columns unqualified) 5. SQL syntax (using sqlglot) Usage: @@ -242,39 +242,44 @@ def _parse_expression(expr: str, dialect: str): return None, error -def _leading_qualifiers(tree) -> set[str]: - """The leading name of every qualified column path in an expression. +def _qualified_references(tree) -> set[tuple[str, str]]: + """Every qualified column path in an expression, as (qualifier, name). - ``orders.amount`` yields ``{"orders"}``. For a three-part path such as - ``payload.attrs.value``, sqlglot puts the middle part in ``Column.table`` - and the first in ``Column.db``, so reading ``table`` alone would report - ``attrs``. Taking ``parts[0]`` gives the outermost name in every case. - Unqualified columns contribute nothing. + ``orders.amount`` yields ``{("orders", "amount")}``. For a three-part path + such as ``payload.attrs.value``, sqlglot puts the middle part in + ``Column.table`` and the first in ``Column.db``, so reading ``table`` alone + would report ``attrs``. Taking ``parts[0]`` and ``parts[1]`` gives the + outermost qualifier and the name it qualifies in every case. Unqualified + columns contribute nothing. """ - names = set() + refs = set() for col in tree.find_all(exp.Column): parts = [part.name for part in col.parts] - if len(parts) > 1 and parts[0]: - names.add(parts[0]) - return names + if len(parts) > 1 and parts[0] and parts[1]: + refs.add((parts[0], parts[1])) + return refs def validate_metric_scoping(data: dict) -> list[str]: """Validate the expression rules for dataset-scoped metrics. - A dataset-scoped metric's expression is written against its dataset's - source and must reference the source's columns by unqualified name. A - qualifier is therefore always an error: it either names another dataset, in - which case the metric belongs in semantic_model.metrics, or it names the - declaring dataset redundantly. + A dataset-scoped metric's expression may reference the declared fields of + its dataset, written dataset_name.field_name, and the columns of its + source, written unqualified. Two things are errors: a qualifier naming + another dataset, since such a metric belongs in semantic_model.metrics, and + a qualifier naming the declaring dataset followed by a name that is not one + of its declared fields. + + Whether a bare name is a real column of the source is not checked, because + that needs catalog metadata the model does not carry. A qualified reference + is checked, because the field list is in the model. This checks the expression only. It says nothing about how the metric may be queried: a dataset-scoped metric is joined and grouped like any other, using the model's relationships. - A qualifier that names neither a dataset nor a field of the declaring - dataset is left alone: it is a local alias, CTE, or subquery source rather - than a dataset reference. + A qualifier that names neither the declaring dataset nor another dataset is + left alone: it is a local alias, CTE, or subquery source. """ errors = [] @@ -290,6 +295,11 @@ def validate_metric_scoping(data: dict) -> list[str]: dataset_name = dataset.get("name", "") own_name = dataset_name.casefold() other_dataset_names = all_dataset_names - {own_name} + own_field_names = { + f["name"].casefold() + for f in dataset.get("fields", []) + if f.get("name") + } for metric in dataset.get("metrics", []): metric_name = metric.get("name", "") @@ -306,37 +316,39 @@ def validate_metric_scoping(data: dict) -> list[str]: continue foreign = set() - self_qualified = False + undeclared = set() - for qualifier in _leading_qualifiers(tree): + for qualifier, referenced in _qualified_references(tree): folded = qualifier.casefold() if folded == own_name: - # Qualifying a field with the declaring dataset's own - # name. Valid SQL, but not the required spelling. - self_qualified = True + # Qualifying with the declaring dataset's own name + # is a reference to one of its declared fields. + if referenced.casefold() not in own_field_names: + undeclared.add(f"{qualifier}.{referenced}") elif folded in other_dataset_names: foreign.add(qualifier) - # Anything else is a field of this dataset (struct or - # variant access) or a local alias, so it is not a - # dataset reference and needs no report. + # Anything else is a struct or variant path, or a local + # alias, so it is not a dataset reference and needs no + # report. if foreign: errors.append( f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " f"in model '{model_name}' ({dialect}) references " f"dataset(s) {', '.join(repr(f) for f in sorted(foreign))}. " - f"Dataset-scoped metrics aggregate columns of one " - f"dataset's source; a metric spanning datasets is " - f"model-scoped and belongs in " - f"semantic_model.metrics." + f"Dataset-scoped metrics aggregate one dataset; a " + f"metric spanning datasets is model-scoped and " + f"belongs in semantic_model.metrics." ) - if self_qualified: + if undeclared: errors.append( f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' " - f"in model '{model_name}' ({dialect}) qualifies a column with its " - f"own dataset name. Dataset-scoped metric expressions reference " - f"columns by unqualified name; drop the '{dataset_name}.' prefix." + f"in model '{model_name}' ({dialect}) references " + f"{', '.join(repr(u) for u in sorted(undeclared))}. " + f"A qualified reference MUST name a declared field " + f"of dataset '{dataset_name}'; reference a column of " + f"its source by unqualified name instead." ) return errors