You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ossie.apache.org by ce...@apache.org on 2026/10/22 06:48:37 UTC
(ossie) branch main updated: feat(core-spec): support dataset-scoped metrics (#591)
This is an automated email from the ASF dual-hosted git repository.
christianeu-db pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ossie.git
The following commit(s) were added to refs/heads/main by this push:
new fae8f416 feat(core-spec): support dataset-scoped metrics (#591)
fae8f416 is described below
commit fae8f416babfe9c996591bccf9515ecac482cc7e
Author: Chris Eubank <10...@users.noreply.github.com>
AuthorDate: Wed Oct 21 23:48:30 2026 -0700
feat(core-spec): support dataset-scoped metrics (#591)
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* 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 <no...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
* [ceubank extension] Refine dataset-scoped vs model-scoped metric semantics
Split the two placements by where a measure belongs and tighten the
dataset-scoped reference model:
- Dataset-scoped metrics semantically belong to one dataset. Their
expressions reference the dataset's declared fields by single-part
(unqualified) name and resolve entirely within it, evaluated at the
dataset's row level. Referencing a source column that is not a declared
field is called out as a future extension.
- Model-scoped metrics are for aggregations that need cross-dataset
modeling: multi-part dataset.field references that combine fields across
datasets via relationships.
- Collapse the uniqueness rules to the one case this feature introduces:
a dataset's field names and metric names must not collide.
- Drop the consumer-flattening guidance, the name-repeat table, and the
converter read-both / hoisting prose.
- Reconcile the stale semantic_model.metrics references to the wrapper-less
model root.
Co-authored-by: Isaac <no...@databricks.com>
* [ceubank extension] Validate dataset-scoped metric uniqueness and scoping
Teach the reference validator about dataset-scoped metrics:
- validate_unique_names: a dataset's field names and dataset-scoped metric
names share one namespace. Reject duplicate dataset-scoped metric names and
any name used by both a field and a metric of the same dataset.
- validate_metric_scoping (new): a dataset-scoped metric expression must
reference the dataset's declared fields by single-part (unqualified) name.
Reject qualified references (dataset.field) -- a self-qualification or a
cross-dataset reference, which belong in the model-root metrics -- and
references to any undeclared name (e.g. a raw source column). COUNT(*) and
literals reference no field and are fine. Degrades gracefully for dialects
sqlglot cannot parse.
- validate_sql: run dataset-scoped metric expressions through the existing
SQL-syntax check, alongside fields and model-scoped metrics.
Tests cover each case in validation/tests/test_validate.py.
Co-authored-by: Isaac <no...@databricks.com>
* fix(validation): address kayemkim review on dataset-scoped metrics (#591)
Addresses the review feedback from @kayemkim on apache/ossie#591.
Relocate the dataset-scoped metric example off the duplicated flights
example pair onto the standalone tpcds model, cover the new dataset
metrics property in the serialization fixture, and consolidate the SQL
expression parse so the scoping check reuses one parser.
- examples: remove AIRCRAFT.total_seats from flights.semantic_model.yaml
so the split file again matches its embedded copy in flights.yaml, and
add store_sales.total_quantity (SUM(ss_quantity)) to
tpcds_semantic_model.yaml, which no test duplicates. This fixes
test_split_flights_examples_match_embedded_example.
- test: set datasets[].metrics in _fully_populated_document() so the
OssieDataset.metrics property is exercised. This fixes
test_the_sample_document_sets_every_declared_field.
- validation: add _parse_sql_expression, which parses an expression once
and returns the tree and any error. validate_sql_expression is now a
thin wrapper over it, and validate_metric_scoping reuses it instead of
a second bare-then-SELECT parse. This removes _parse_metric_expression.
Co-authored-by: Isaac <no...@databricks.com>
---------
Co-authored-by: Josh Klahr <jo...@snowflake.com>
Co-authored-by: Isaac <no...@databricks.com>
---
core-spec/ossie-schema.json | 12 ++-
core-spec/spec.md | 145 ++++++++++++++++++++++++++++++-----
core-spec/spec.yaml | 28 ++++++-
docs/index.md | 6 +-
examples/tpcds_semantic_model.yaml | 12 +++
python/src/ossie/models.py | 29 +++----
python/tests/test_serialization.py | 10 +++
validation/tests/test_validate.py | 124 ++++++++++++++++++++++++++++++
validation/validate.py | 152 +++++++++++++++++++++++++++++++++----
9 files changed, 464 insertions(+), 54 deletions(-)
diff --git a/core-spec/ossie-schema.json b/core-spec/ossie-schema.json
index 6cc259e9..174b985c 100644
--- a/core-spec/ossie-schema.json
+++ b/core-spec/ossie-schema.json
@@ -233,7 +233,15 @@
"type": "array",
"items": {
"$ref": "#/$defs/Field"
- }
+ },
+ "description": "The dataset's declared fields. Within the dataset, field names and metric names share one namespace and must all be unique across the two."
+ },
+ "metrics": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/Metric"
+ },
+ "description": "Dataset-scoped metrics: aggregations evaluated at this dataset's row level, for measures that semantically belong to the dataset. The expression references this dataset's declared fields by single-part (unqualified) name and must resolve entirely within this dataset; an expression that must reference another dataset belongs in the model-scoped metrics instead. Referencing a column of the source that is not a declared field is a future extension. Within the datas [...]
},
"custom_extensions": {
"type": "array",
@@ -361,7 +369,7 @@
"items": {
"$ref": "#/$defs/Metric"
},
- "description": "Quantifiable measures spanning datasets"
+ "description": "Model-scoped metrics: aggregations that span datasets or need to be aware of cross-dataset concepts. Expressions reference fields by multi-part name (dataset.field) and may combine fields from more than one dataset. Names must be unique across the model. See core-spec/spec.md#metric-scoping."
},
"custom_extensions": {
"type": "array",
diff --git a/core-spec/spec.md b/core-spec/spec.md
index 99a39e32..b1f06e35 100644
--- a/core-spec/spec.md
+++ b/core-spec/spec.md
@@ -109,7 +109,7 @@ not define a bundle format or cross-model references.
| `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: for aggregations that span datasets or need to be aware of cross-dataset concepts. See [Metric Scoping](#metric-scoping). |
| `custom_extensions` | array | No | Vendor-specific attributes for extensibility |
### Example
@@ -178,6 +178,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 that semantically belong to this dataset; expressions reference its declared fields by single-part name and resolve entirely within it. See [Metric Scoping](#metric-scoping). |
| `custom_extensions` | array | No | Vendor-specific attributes |
### Primary Key Examples
@@ -274,6 +275,8 @@ Relationships define how logical datasets are connected through foreign key cons
Fields represent row-level attributes that can be used for grouping, filtering, and in metric expressions. They can be simple column references or computed expressions.
+Within a dataset, field names and dataset-scoped metric names share one namespace, so a field name MUST NOT collide with a dataset-scoped metric name of the same dataset (see [Metric Scoping](#metric-scoping)).
+
### Schema
| Field | Type | Required | Description |
@@ -406,7 +409,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.
+
+There are two types of metrics:
+
+- **Dataset-scoped** (`datasets[].metrics`): aggregations that belong to a single dataset. The expression references that dataset's declared fields by single-part name and resolves entirely within it.
+- **Model-scoped** (the model-root `metrics`): aggregations that span datasets across the model. The expression references fields by multi-part `dataset.field` name and may combine fields across datasets.
+
+See [Metric Scoping](#metric-scoping) for the rules governing each.
### Schema
@@ -463,6 +473,90 @@ expression:
- "Order Average by customer"
```
+### Metric Scoping
+
+A metric may be defined on an individual dataset or at the model level. Both placements use the identical metric structure; they differ in what the expression may reference and in what the metric is for:
+
+- A **dataset-scoped** metric (`datasets[].metrics`) belongs to a single dataset: it aggregates that dataset's own fields, at the dataset's row level, so the dataset carries its own measures and stays queryable on its own.
+- A **model-scoped** metric (the model-root `metrics`) spans datasets across the model: it combines fields from more than one dataset.
+
+| | Dataset-scoped (`datasets[].metrics`) | Model-scoped (model-root `metrics`) |
+|---|---|---|
+| Expression references | This dataset's declared fields, single-part: `field` | Fields of any dataset, multi-part: `dataset.field` |
+| Spans datasets | No | Yes, via relationships |
+| Referenced as | `dataset_name.metric_name` | `metric_name` |
+
+**Rules**
+
+1. A dataset-scoped metric's expression MUST reference only the declared fields of the dataset that declares it, written by single-part (unqualified) name: `SUM(net_amount)`. It MUST NOT reference a field of another dataset; a metric that must do so is model-scoped and MUST be declared in the model-root `metrics`.
+2. Within a dataset, declared field names and dataset-scoped metric names share one namespace and MUST all be unique across the two.
+3. A model-scoped metric's expression references fields by multi-part name, `dataset.field`, and MAY combine fields from more than one dataset.
+
+> Referencing a column of a dataset's `source` that is not a declared field is a future extension. Today a dataset-scoped expression reaches only the dataset's declared fields.
+
+**Example: dataset-scoped metrics**
+
+```yaml
+datasets:
+ - name: orders
+ source: sales.public.orders
+ primary_key: [order_id]
+ fields:
+ - name: order_id
+ expression:
+ dialects:
+ - dialect: OSSIE_SQL_2026
+ expression: order_id
+ description: Order identifier
+ - name: net_amount
+ expression:
+ dialects:
+ - dialect: OSSIE_SQL_2026
+ expression: amount - discount
+ description: Order amount after discount
+
+ metrics:
+ - name: total_net_amount
+ expression:
+ dialects:
+ - dialect: OSSIE_SQL_2026
+ expression: SUM(net_amount)
+ description: Total order amount after discount
+ datatype: Decimal
+
+ - name: order_count
+ expression:
+ dialects:
+ - dialect: OSSIE_SQL_2026
+ expression: COUNT(order_id)
+ description: Number of orders
+ datatype: Integer
+```
+
+Referenced from a consumer as `orders.total_net_amount` and `orders.order_count`.
+
+**Example: invalid dataset-scoped metric**
+
+```yaml
+datasets:
+ - name: orders
+ source: sales.public.orders
+ fields:
+ - name: net_amount
+ expression:
+ dialects:
+ - dialect: OSSIE_SQL_2026
+ expression: amount - discount
+ metrics:
+ # INVALID (rule 1): references the customers dataset, so this metric is
+ # model-scoped and belongs in the model-root metrics
+ - name: revenue_per_customer
+ expression:
+ dialects:
+ - dialect: OSSIE_SQL_2026
+ expression: SUM(net_amount) / COUNT(DISTINCT customers.id)
+```
+
---
## Custom Extensions
@@ -597,6 +691,16 @@ datasets:
expression: amount
description: Order amount
+ # Dataset-scoped: resolves entirely within the orders dataset
+ metrics:
+ - name: total_amount
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: SUM(amount)
+ description: Total order amount
+ datatype: Decimal
+
- name: customers
source: sales.public.customers
primary_key: [id]
@@ -616,6 +720,20 @@ datasets:
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
@@ -624,27 +742,14 @@ relationships:
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"
-
- - 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"
+ expression: SUM(orders.amount) / COUNT(DISTINCT customers.id)
+ description: Average revenue per customer
+ datatype: Decimal
custom_extensions:
- vendor_name: SNOWFLAKE
diff --git a/core-spec/spec.yaml b/core-spec/spec.yaml
index 887ee86d..a32bd035 100644
--- a/core-spec/spec.yaml
+++ b/core-spec/spec.yaml
@@ -91,8 +91,11 @@ datasets: []
# See Relationships section below for detailed structure
relationships: []
-# Optional:
-# These metrics can span one or more logical datasets and use relationships
+# Optional: Model-scoped metrics
+# For aggregations that span datasets or need to be aware of cross-dataset concepts.
+# Expressions reference fields by multi-part name (e.g., SUM(orders.amount)) and
+# may combine fields from more than one dataset.
+# Names MUST be unique across the model.
# See Metrics section below for detailed structure
metrics: []
@@ -148,6 +151,23 @@ datasets:
# See Fields section below for detailed structure
fields: []
+ # Optional: Dataset-scoped metrics
+ # Metrics that semantically belong to this dataset, aggregating its own
+ # fields at the dataset's row level. Same structure as model-scoped metrics;
+ # see the Metrics section below.
+ #
+ # Rules (see core-spec/spec.md#metric-scoping):
+ # - The expression MUST reference only this dataset's declared fields,
+ # written by single-part (unqualified) name: SUM(net_amount).
+ # - It MUST NOT reference another dataset; a metric that must do so is
+ # model-scoped and belongs in the model-root metrics.
+ # - Referencing a column of the source that is not a declared field is a
+ # future extension.
+ # - Field names and metric names share one namespace; every name MUST be
+ # unique across the two.
+ # - Referenced from outside the dataset as dataset_name.metric_name.
+ metrics: []
+
# Optional: Vendor-specific attributes for extensibility
custom_extensions:
- vendor_name: string # Free-form string identifying the vendor
@@ -253,6 +273,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:
+# - the model-root metrics (model-scoped): may span datasets
+# - datasets[].metrics (dataset-scoped): must resolve within one dataset
metrics:
# Required: Unique identifier for the metric
- name: string
diff --git a/docs/index.md b/docs/index.md
index 7fe00b87..0e67d847 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 one dataset's own fields at its row level. |
| **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.
+In the expression, only a model-scoped metric can — it uses multi-part `dataset.field` references and combines datasets. A dataset-scoped metric's expression stays within its own dataset. 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 fields (e.g., total revenue, average order value). A dataset-scoped metric is defined on an individual dataset, for a measure that semantically belongs to it and is evaluated at the dataset's row level; a model-scoped metric is defined at the model root and may span datasets. |
| **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 e2578f75..0aa290c7 100644
--- a/examples/tpcds_semantic_model.yaml
+++ b/examples/tpcds_semantic_model.yaml
@@ -147,6 +147,18 @@ datasets:
- "profit"
- "margin"
+ # Dataset-scoped metric: aggregates this dataset's own declared field by
+ # single-part (unqualified) name; resolves within store_sales. Contrast with
+ # the model-root `metrics:` block below, whose expressions span datasets.
+ metrics:
+ - name: total_quantity
+ expression:
+ dialects:
+ - dialect: ANSI_SQL
+ expression: SUM(ss_quantity)
+ description: Total quantity sold across all store sales
+ datatype: Integer
+
# Dimension table: Date
- name: date_dim
source: tpcds.public.date_dim
diff --git a/python/src/ossie/models.py b/python/src/ossie/models.py
index 2344d062..cb9a6b57 100644
--- a/python/src/ossie/models.py
+++ b/python/src/ossie/models.py
@@ -265,6 +265,19 @@ class OssieField(BaseModel):
return self.datatype in _TEMPORAL_DATA_TYPES
+class OssieMetric(BaseModel):
+ """Quantitative measure defined on business data."""
+
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ name: str = Field(..., min_length=1)
+ 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)."""
@@ -281,6 +294,8 @@ class OssieDataset(BaseModel):
description: Optional[str] = None
ai_context: Optional[OssieAIContext] = None
fields: Optional[list[OssieField]] = None
+ # Dataset-scoped: expressions reference this dataset's declared fields by single-part (unqualified) name.
+ metrics: Optional[list[OssieMetric]] = None
custom_extensions: Optional[list[OssieCustomExtension]] = None
@@ -298,19 +313,6 @@ class OssieRelationship(BaseModel):
custom_extensions: Optional[list[OssieCustomExtension]] = None
-class OssieMetric(BaseModel):
- """Quantitative measure defined on business data."""
-
- model_config = ConfigDict(frozen=True, extra="forbid")
-
- name: str = Field(..., min_length=1)
- expression: OssieExpression
- description: Optional[str] = None
- datatype: Optional[OssieDataType] = None
- ai_context: Optional[OssieAIContext] = None
- custom_extensions: Optional[list[OssieCustomExtension]] = None
-
-
class OssieSemanticModel(BaseModel):
"""Semantic model: the datasets, relationships, and metrics for one business domain."""
@@ -321,6 +323,7 @@ class OssieSemanticModel(BaseModel):
ai_context: Optional[OssieAIContext] = None
datasets: list[OssieDataset] = Field(..., min_length=1)
relationships: Optional[list[OssieRelationship]] = None
+ # Model-scoped: expressions reference fields by multi-part name (dataset.field) and may span datasets.
metrics: Optional[list[OssieMetric]] = None
custom_extensions: Optional[list[OssieCustomExtension]] = None
diff --git a/python/tests/test_serialization.py b/python/tests/test_serialization.py
index 62d8e219..a7c2b46d 100644
--- a/python/tests/test_serialization.py
+++ b/python/tests/test_serialization.py
@@ -314,6 +314,16 @@ def _fully_populated_document() -> dict:
"custom_extensions": list(extensions),
}
],
+ "metrics": [
+ {
+ "name": "total_amount",
+ "expression": dict(expression),
+ "description": "d",
+ "datatype": "Decimal",
+ "ai_context": dict(ai_context),
+ "custom_extensions": list(extensions),
+ }
+ ],
}
],
"relationships": [
diff --git a/validation/tests/test_validate.py b/validation/tests/test_validate.py
index edf2f67e..5081c5b2 100644
--- a/validation/tests/test_validate.py
+++ b/validation/tests/test_validate.py
@@ -1171,3 +1171,127 @@ def test_validate_document_runs_semantic_checks_on_a_valid_shape(core_schema, of
errors = _VALIDATE.validate_document(_document([_CUSTOMERS, _CUSTOMERS], []), core_schema)
assert any(error.startswith("[Unique]") for error in errors)
+
+
+# ---------------------------------------------------------------------------
+# Dataset-scoped metrics: field/metric name uniqueness and single-part scoping.
+# ---------------------------------------------------------------------------
+
+
+def _ds_expr(sql: str, dialect: str = "OSSIE_SQL_2026") -> dict:
+ return {"dialects": [{"dialect": dialect, "expression": sql}]}
+
+
+def _orders_with(fields=None, metrics=None) -> dict:
+ dataset = {"name": "orders", "source": "db.s.orders"}
+ if fields is not None:
+ dataset["fields"] = fields
+ if metrics is not None:
+ dataset["metrics"] = metrics
+ return _document([dataset, _CUSTOMERS], [])
+
+
+def test_dataset_scoped_metric_name_may_not_collide_with_a_field() -> None:
+ doc = _orders_with(
+ fields=[{"name": "amount", "expression": _ds_expr("amount")}],
+ metrics=[{"name": "amount", "expression": _ds_expr("SUM(amount)")}],
+ )
+
+ errors = _VALIDATE.validate_unique_names(doc)
+
+ assert any("used by both a field" in error for error in errors)
+
+
+def test_duplicate_dataset_scoped_metric_names_are_rejected() -> None:
+ doc = _orders_with(
+ metrics=[
+ {"name": "total", "expression": _ds_expr("SUM(amount)")},
+ {"name": "total", "expression": _ds_expr("COUNT(amount)")},
+ ],
+ )
+
+ errors = _VALIDATE.validate_unique_names(doc)
+
+ assert any("Duplicate metric name 'total' in dataset 'orders'" in error for error in errors)
+
+
+def test_field_and_dataset_metric_with_distinct_names_are_unique() -> None:
+ doc = _orders_with(
+ fields=[{"name": "amount", "expression": _ds_expr("amount")}],
+ metrics=[{"name": "total_amount", "expression": _ds_expr("SUM(amount)")}],
+ )
+
+ assert _VALIDATE.validate_unique_names(doc) == []
+
+
+def test_dataset_scoped_metric_accepts_single_part_declared_field() -> None:
+ doc = _orders_with(
+ fields=[{"name": "amount", "expression": _ds_expr("amount")}],
+ metrics=[{"name": "total_amount", "expression": _ds_expr("SUM(amount)")}],
+ )
+
+ assert _VALIDATE.validate_metric_scoping(doc) == []
+
+
+def test_dataset_scoped_metric_rejects_a_self_qualified_reference() -> None:
+ doc = _orders_with(
+ metrics=[{"name": "total_amount", "expression": _ds_expr("SUM(orders.amount)")}],
+ )
+
+ errors = _VALIDATE.validate_metric_scoping(doc)
+
+ assert any(error.startswith("[Scope]") and "'orders'" in error for error in errors)
+
+
+def test_dataset_scoped_metric_rejects_a_cross_dataset_reference() -> None:
+ doc = _orders_with(
+ fields=[{"name": "amount", "expression": _ds_expr("amount")}],
+ metrics=[{"name": "bad", "expression": _ds_expr("SUM(amount) / COUNT(customers.id)")}],
+ )
+
+ errors = _VALIDATE.validate_metric_scoping(doc)
+
+ assert any(error.startswith("[Scope]") and "'customers'" in error for error in errors)
+
+
+def test_dataset_scoped_metric_rejects_an_undeclared_field() -> None:
+ doc = _orders_with(
+ fields=[{"name": "amount", "expression": _ds_expr("amount")}],
+ metrics=[{"name": "total_qty", "expression": _ds_expr("SUM(quantity)")}],
+ )
+
+ errors = _VALIDATE.validate_metric_scoping(doc)
+
+ assert any(error.startswith("[Scope]") and "'quantity'" in error for error in errors)
+
+
+def test_dataset_scoped_metric_count_star_references_no_field() -> None:
+ doc = _orders_with(metrics=[{"name": "n", "expression": _ds_expr("COUNT(*)")}])
+
+ assert _VALIDATE.validate_metric_scoping(doc) == []
+
+
+def test_dataset_scoped_metric_scoping_skips_dialects_sqlglot_cannot_parse() -> None:
+ doc = _orders_with(
+ metrics=[{"name": "total", "expression": _ds_expr("SUM(orders.amount)", "MDX")}],
+ )
+
+ assert _VALIDATE.validate_metric_scoping(doc) == []
+
+
+def test_qualified_dataset_scoped_metric_fails_document_validation(core_schema, offline) -> None:
+ # Conformance test for the qualified-reference error case, per the dev@ossie
+ # "[DISCUSS] Dataset-scoped metrics" thread: a qualified reference in a
+ # dataset-scoped metric is a hard error (not a warning), so the whole
+ # document fails validation.
+ orders = {
+ "name": "orders",
+ "source": "db.s.orders",
+ "metrics": [{"name": "total", "expression": _ds_expr("SUM(orders.amount)")}],
+ }
+
+ errors = _VALIDATE.validate_document(_document([orders, _CUSTOMERS], []), core_schema)
+
+ scope_errors = [error for error in errors if error.startswith("[Scope]")]
+ assert scope_errors
+ assert all(not isinstance(error, _VALIDATE.ValidationWarning) for error in scope_errors)
diff --git a/validation/validate.py b/validation/validate.py
index 548bf127..f3a69486 100644
--- a/validation/validate.py
+++ b/validation/validate.py
@@ -75,6 +75,7 @@ except ImportError:
try:
import sqlglot
+ from sqlglot import exp
from sqlglot.errors import ParseError, TokenError
SQLGLOT_AVAILABLE = True
except ImportError:
@@ -261,13 +262,27 @@ def validate_unique_names(data: dict) -> list[str]:
for dup in find_duplicates(dataset_names):
errors.append(f"[Unique] Duplicate dataset name '{dup}' in model '{model_name}'")
- # Check unique field names within each dataset
+ # Check unique field names within each dataset, and that dataset-scoped
+ # metric names do not duplicate one another or collide with a field name of
+ # the same dataset: a dataset's fields and dataset-scoped metrics share one
+ # namespace.
for dataset in model.get("datasets", []):
dataset_name = dataset.get("name", "<unnamed>")
field_names = [f.get("name") for f in dataset.get("fields", []) if f.get("name")]
for dup in find_duplicates(field_names):
errors.append(f"[Unique] Duplicate field name '{dup}' in dataset '{dataset_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 sorted(set(field_names) & set(ds_metric_names)):
+ errors.append(
+ f"[Unique] Name '{name}' in dataset '{dataset_name}' is used by both a field "
+ f"and a dataset-scoped metric; a dataset's fields and metrics share one namespace"
+ )
+
# Check unique metric names
metric_names = [m.get("name") for m in model.get("metrics", []) if m.get("name")]
for dup in find_duplicates(metric_names):
@@ -358,13 +373,27 @@ def validate_relationship_column_arity(data: dict) -> list[str]:
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
+def _parse_sql_expression(
+ expr: str, dialect: str, context: str
+) -> tuple[object | None, str | None]:
+ """Parse a SQL expression once and return its tree and any error.
- if dialect in SKIP_SQL_VALIDATION:
- return None
+ The expression is tried bare first (so a field expression like `column_name`
+ parses), then wrapped in `SELECT {expr}` for bare column references. The parsed
+ sqlglot tree is returned together with a validation error message; each may be
+ None. A valid expression yields `(tree, None)`; a syntax, empty, or
+ multi-statement error yields `(None, error)`.
+
+ Callers that only surface errors read the error (see `validate_sql_expression`);
+ callers that inspect the parsed columns read the tree (see
+ `validate_metric_scoping`). Both go through this single parse, so the statement
+ handling below and any later parser fix reach both without a second copy.
+
+ Returns `(None, None)` when there is nothing to parse: sqlglot is unavailable or
+ the dialect is one sqlglot cannot parse.
+ """
+ if not SQLGLOT_AVAILABLE or dialect in SKIP_SQL_VALIDATION:
+ return None, None
sqlglot_dialect = DIALECT_MAP.get(dialect)
@@ -408,27 +437,33 @@ def validate_sql_expression(expr: str, dialect: str, context: str) -> str | None
try:
# Try parsing as expression first (for field expressions like "column_name")
- sqlglot.parse_one(expr, dialect=sqlglot_dialect)
+ tree = sqlglot.parse_one(expr, dialect=sqlglot_dialect)
except (ParseError, TokenError, RecursionError):
# A bare column reference fails to parse alone; retry it wrapped in
# SELECT below. RecursionError (deeply nested input) is included so the
# retry reports it instead of crashing, while genuine errors such as a
# non-string expr raising TypeError still surface.
if holds_no_statement(expr):
- return f"[SQL] {context}: expression is empty"
+ return None, f"[SQL] {context}: expression is empty"
else:
- return statement_count_error(expr)
+ return tree, statement_count_error(expr)
try:
# Try wrapping in SELECT for simple column references
- sqlglot.parse_one(f"SELECT {expr}", dialect=sqlglot_dialect)
+ tree = sqlglot.parse_one(f"SELECT {expr}", dialect=sqlglot_dialect)
except (ParseError, TokenError) as e:
- return f"[SQL] {context}: {str(e).split(chr(10))[0]}"
+ return None, f"[SQL] {context}: {str(e).split(chr(10))[0]}"
except RecursionError:
# Deeply nested input exhausts the recursion limit rather than raising a
# parser error; report it instead of letting it abort validation.
- return f"[SQL] {context}: expression is too deeply nested to parse"
- return statement_count_error(f"SELECT {expr}")
+ return None, f"[SQL] {context}: expression is too deeply nested to parse"
+ return tree, statement_count_error(f"SELECT {expr}")
+
+
+def validate_sql_expression(expr: str, dialect: str, context: str) -> str | None:
+ """Validate a single SQL expression. Returns error message or None if valid."""
+ _tree, error = _parse_sql_expression(expr, dialect, context)
+ return error
def validate_sql(data: dict) -> list[str]:
@@ -463,6 +498,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", "<unnamed>")
+ 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 is not None:
+ 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", "<unnamed>")
@@ -479,6 +527,81 @@ def validate_sql(data: dict) -> list[str]:
return errors
+def validate_metric_scoping(data: dict) -> list[str]:
+ """Validate the field references in each dataset-scoped metric expression.
+
+ A dataset-scoped metric references the declaring dataset's declared fields by
+ single-part (unqualified) name. Two rules are enforced:
+
+ * No qualified reference (`dataset.field`): that is model-scoped and belongs
+ in the model-root metrics.
+ * Every field a dataset-scoped metric references must be a declared field of
+ the dataset; referencing an undeclared name (e.g. a raw source column) is
+ not yet allowed. A bare name is compared case-insensitively; `COUNT(*)` and
+ literals reference no field and are fine.
+
+ Checking the expression needs sqlglot; dialects it cannot parse are skipped
+ rather than reported.
+ """
+ if not isinstance(data, dict) or "datasets" not in data:
+ return []
+
+ model = data
+ errors = []
+ model_name = model.get("name", "<unnamed>")
+
+ for dataset in model.get("datasets", []):
+ dataset_name = dataset.get("name", "<unnamed>")
+ 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", "<unnamed>")
+ 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
+ tree, _ = _parse_sql_expression(
+ expr,
+ dialect,
+ f"Metric '{dataset_name}.{metric_name}' in model '{model_name}'",
+ )
+ if tree is None:
+ continue
+
+ qualifiers = set()
+ undeclared = set()
+ for col in tree.find_all(exp.Column):
+ parts = [part.name for part in col.parts]
+ if len(parts) > 1 and parts[0]:
+ qualifiers.add(parts[0])
+ elif parts and parts[0].casefold() not in field_names:
+ undeclared.add(parts[0])
+
+ prefix = (
+ f"[Scope] Dataset-scoped metric '{dataset_name}.{metric_name}' in model "
+ f"'{model_name}' ({dialect})"
+ )
+ if qualifiers:
+ names = ", ".join(repr(q) for q in sorted(qualifiers))
+ errors.append(
+ f"{prefix} uses qualified reference(s) {names}. A dataset-scoped metric "
+ f"expression must reference the dataset's fields by single-part "
+ f"(unqualified) name; a metric that must reference another dataset "
+ f"belongs in the model-root metrics."
+ )
+ if undeclared:
+ names = ", ".join(repr(n) for n in sorted(undeclared))
+ errors.append(
+ f"{prefix} references undeclared field(s) {names}. A dataset-scoped "
+ f"metric may reference only the dataset's declared fields."
+ )
+
+ return errors
+
+
def as_list(value: object) -> list:
"""The items of a list, or nothing for shapes that already failed the schema."""
return value if isinstance(value, list) else []
@@ -680,6 +803,7 @@ def validate_document(data, schema: dict) -> list[str]:
errors.extend(validate_unique_names(data))
errors.extend(validate_references(data))
errors.extend(validate_relationship_column_arity(data))
+ errors.extend(validate_metric_scoping(data))
errors.extend(validate_sql(data))
if not errors and isinstance(data, dict) and "ontology" in data:
errors.extend(validate_ontology(data))