Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions python/src/ossie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ class OssieAIContextObject(BaseModel):
class OssieCustomExtension(BaseModel):
"""Vendor-specific metadata as a serialized JSON string."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

vendor_name: str
data: str
Expand All @@ -104,7 +104,7 @@ class OssieCustomExtension(BaseModel):
class OssieDialectExpression(BaseModel):
"""Expression in a specific dialect."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

dialect: OssieDialect
expression: str
Expand All @@ -113,23 +113,23 @@ class OssieDialectExpression(BaseModel):
class OssieExpression(BaseModel):
"""Expression definition with multi-dialect support."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

dialects: list[OssieDialectExpression] = Field(..., min_length=1)


class OssieDimension(BaseModel):
"""Dimension metadata on a field."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

is_time: Optional[bool] = None


class OssieField(BaseModel):
"""Row-level attribute for grouping, filtering, and metric expressions."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

name: str = Field(..., min_length=1)
expression: OssieExpression
Expand Down Expand Up @@ -157,7 +157,7 @@ def is_time_dimension(self) -> bool:
class OssieDataset(BaseModel):
"""Logical dataset representing a business entity (fact or dimension table)."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

name: str = Field(..., min_length=1)
source: str = Field(..., min_length=1)
Expand All @@ -172,7 +172,7 @@ class OssieDataset(BaseModel):
class OssieRelationship(BaseModel):
"""Foreign key relationship between datasets."""

model_config = ConfigDict(frozen=True, populate_by_name=True)
model_config = ConfigDict(frozen=True, extra="forbid", populate_by_name=True)

name: str = Field(..., min_length=1)
from_dataset: str = Field(..., alias="from", min_length=1)
Expand All @@ -186,7 +186,7 @@ class OssieRelationship(BaseModel):
class OssieMetric(BaseModel):
"""Quantitative measure defined on business data."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

name: str = Field(..., min_length=1)
expression: OssieExpression
Expand All @@ -199,7 +199,7 @@ class OssieMetric(BaseModel):
class OssieSemanticModel(BaseModel):
"""Semantic model: the datasets, relationships, and metrics for one business domain."""

model_config = ConfigDict(frozen=True)
model_config = ConfigDict(frozen=True, extra="forbid")

name: str = Field(..., min_length=1)
description: Optional[str] = None
Expand Down
132 changes: 126 additions & 6 deletions python/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from ossie import (
OssieAIContextObject,
OssieCustomExtension,
OssieDataset,
OssieDataType,
OssieDialect,
Expand Down Expand Up @@ -272,26 +273,145 @@ def test_document_is_a_semantic_model(document_data: dict) -> None:
document = OssieDocument.model_validate(document_data)

assert isinstance(document, OssieSemanticModel)
# `version` is document metadata, absent from the schema's SemanticModel node,
# so the embedded model is built from the same data with it removed.
del document_data["version"]
semantic_model = OssieSemanticModel.model_validate(document_data)
assert document.model_dump(exclude={"version"}) == semantic_model.model_dump()


def test_only_document_rejects_extra_fields(document_data: dict) -> None:
document_data["vendor_extension"] = "unknown"
@pytest.mark.parametrize(
("container_path", "unknown_key"),
[
((), "vendor_extension"),
(("datasets", 0), "datasetn"),
(("datasets", 0, "fields", 0), "descriptionn"),
(("datasets", 0, "fields", 0, "expression"), "dialect"),
(("datasets", 0, "fields", 0, "expression", "dialects", 0), "expressions"),
(("datasets", 0, "fields", 0, "dimension"), "is_tile"),
(("metrics", 0), "expresssion"),
],
ids=[
"document",
"dataset",
"field",
"expression",
"dialect_expression",
"dimension",
"metric",
],
)
def test_every_object_rejects_unknown_fields(
document_data: dict, container_path: tuple, unknown_key: str
) -> None:
"""The SDK must reject what the schema rejects, not drop it silently.

Every node of `ossie-schema.json` but `AIContext` sets
`additionalProperties: false`. A nested model left at Pydantic's default
`extra="ignore"` accepts a mistyped key and loses it on serialization, so a
document the SDK round-trips cleanly still fails `ossie validate`.
"""
container = document_data
for part in container_path:
container = container[part]
container[unknown_key] = "typo"

with pytest.raises(ValidationError) as error:
OssieDocument.model_validate(document_data)

assert any(
item["loc"] == (*container_path, unknown_key)
and item["type"] == "extra_forbidden"
for item in error.value.errors()
), error.value.errors()


def test_relationship_rejects_unknown_fields(document_data: dict) -> None:
document_data["relationships"] = [
{
"name": "orders_to_customers",
"from": "events",
"to": "orders",
"from_columns": ["id"],
"to_columns": ["id"],
"targeet": "typo",
}
]

with pytest.raises(ValidationError) as error:
OssieDocument.model_validate(document_data)

assert any(
item["loc"] == ("relationships", 0, "targeet")
and item["type"] == "extra_forbidden"
for item in error.value.errors()
)


embedded = OssieSemanticModel.model_validate(document_data)
assert not hasattr(embedded, "vendor_extension")
assert "vendor_extension" not in embedded.model_dump()
def test_custom_extension_rejects_unknown_fields(document_data: dict) -> None:
document_data["custom_extensions"] = [
{"vendor_name": "DBT", "data": "{}", "datta": "typo"}
]

with pytest.raises(ValidationError) as error:
OssieDocument.model_validate(document_data)

assert any(
item["loc"] == ("vendor_extension",) and item["type"] == "extra_forbidden"
item["loc"] == ("custom_extensions", 0, "datta")
and item["type"] == "extra_forbidden"
for item in error.value.errors()
)


def test_ai_context_keeps_unknown_fields(document_data: dict) -> None:
"""`AIContext` is the one open node in the schema, so extras stay allowed."""
document_data["datasets"][0]["ai_context"] = {"synonyms": ["events"], "glossary": "x"}

document = OssieDocument.model_validate(document_data)

assert document.datasets[0].ai_context.glossary == "x"
assert "glossary" in document.model_dump(by_alias=True, exclude_none=True, mode="json")[
"datasets"
][0]["ai_context"]


def test_unknown_field_names_match_the_schema_allowlist() -> None:
"""Each forbidding schema node needs a forbidding model, and vice versa.

Without this, a node added to the schema later can keep Pydantic's
`extra="ignore"` default and reintroduce the silent drop the SDK had before.
"""
schema_path = Path(__file__).parents[2] / "core-spec" / "ossie-schema.json"
schema = json.loads(schema_path.read_text())

models = {
"CustomExtension": OssieCustomExtension,
"DialectExpression": OssieDialectExpression,
"Expression": OssieExpression,
"Dimension": OssieDimension,
"Field": OssieField,
"Dataset": OssieDataset,
"Relationship": OssieRelationship,
"Metric": OssieMetric,
"SemanticModel": OssieSemanticModel,
}

forbidden_in_schema = {
name
for name, node in schema["$defs"].items()
if node.get("additionalProperties") is False
}
assert forbidden_in_schema == set(models), (
"the schema gained or dropped an additionalProperties: false node; "
"update the model map and configs together"
)
for name, model in models.items():
assert model.model_config.get("extra") == "forbid", f"{model.__name__} must forbid"

assert OssieAIContextObject.model_config.get("extra") == "allow"
assert schema["$defs"]["AIContext"].get("additionalProperties") is not False


def test_invalid_datatype_is_rejected(document_data: dict) -> None:
field = document_data["datasets"][0]["fields"][0]
field["datatype"] = "timestamp"
Expand Down
Loading