Skip to content

Commit 66731ac

Browse files
committed
feat(api): Refactors node and edge models
1 parent e92be34 commit 66731ac

6 files changed

Lines changed: 80 additions & 142 deletions

File tree

src/lsst/cmservice/db/campaigns_v2.py

Lines changed: 7 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
"""ORM Models for v2 tables and objects."""
2+
13
from datetime import datetime
24
from typing import Any
35
from uuid import NAMESPACE_DNS, UUID, uuid5
46

5-
from pydantic import AliasChoices, ValidationInfo, model_validator
7+
from pydantic import AliasChoices
68
from sqlalchemy.dialects import postgresql
79
from sqlalchemy.ext.mutable import MutableDict, MutableList
810
from sqlalchemy.types import PickleType
@@ -43,15 +45,6 @@ def jsonb_column(name: str, aliases: list[str] | None = None) -> Any:
4345
)
4446

4547

46-
# NOTES
47-
# - model validation is not triggered when table=True
48-
# - Every object model needs to have three flavors:
49-
# 1. the declarative model of the object's database table
50-
# 2. the model of the manifest when creating a new object
51-
# 3. the model of the manifest when updating an object
52-
# 4. a response model for APIs related to the object
53-
54-
5548
class BaseSQLModel(SQLModel):
5649
__table_args__ = {"schema": config.db.table_schema}
5750
metadata = metadata
@@ -72,26 +65,7 @@ class CampaignBase(BaseSQLModel):
7265
configuration: dict = jsonb_column("configuration", aliases=["configuration", "data", "spec"])
7366

7467

75-
class CampaignModel(CampaignBase):
76-
"""model used for resource creation."""
77-
78-
@model_validator(mode="before")
79-
@classmethod
80-
def custom_model_validator(cls, data: Any, info: ValidationInfo) -> Any:
81-
"""Validates the model based on different types of raw inputs,
82-
where some default non-optional fields can be auto-populated.
83-
"""
84-
if isinstance(data, dict):
85-
if "name" not in data:
86-
raise ValueError("'name' must be specified.")
87-
if "namespace" not in data:
88-
data["namespace"] = _default_campaign_namespace
89-
if "id" not in data:
90-
data["id"] = uuid5(namespace=data["namespace"], name=data["name"])
91-
return data
92-
93-
94-
class Campaign(CampaignModel, table=True):
68+
class Campaign(CampaignBase, table=True):
9569
"""Model used for database operations involving campaigns_v2 table rows"""
9670

9771
__tablename__: str = "campaigns_v2" # type: ignore[misc]
@@ -127,25 +101,7 @@ class NodeBase(BaseSQLModel):
127101
configuration: dict = jsonb_column("configuration", aliases=["configuration", "data", "spec"])
128102

129103

130-
class NodeModel(NodeBase):
131-
"""model validating class for Nodes"""
132-
133-
@model_validator(mode="before")
134-
@classmethod
135-
def custom_model_validator(cls, data: Any, info: ValidationInfo) -> Any:
136-
if isinstance(data, dict):
137-
if "version" not in data:
138-
data["version"] = 1
139-
if "name" not in data:
140-
raise ValueError("'name' must be specified.")
141-
if "namespace" not in data:
142-
data["namespace"] = _default_campaign_namespace
143-
if "id" not in data:
144-
data["id"] = uuid5(namespace=data["namespace"], name=f"""{data["name"]}.{data["version"]}""")
145-
return data
146-
147-
148-
class Node(NodeModel, table=True):
104+
class Node(NodeBase, table=True):
149105
__tablename__: str = "nodes_v2" # type: ignore[misc]
150106

151107
machine: UUID | None = Field(foreign_key="machines_v2.id", default=None, ondelete="CASCADE")
@@ -163,28 +119,12 @@ class EdgeBase(BaseSQLModel):
163119
configuration: dict = jsonb_column("configuration", aliases=["configuration", "data", "spec"])
164120

165121

166-
class EdgeModel(EdgeBase):
167-
"""model validating class for Edges"""
168-
169-
@model_validator(mode="before")
170-
@classmethod
171-
def custom_model_validator(cls, data: Any, info: ValidationInfo) -> Any:
172-
if isinstance(data, dict):
173-
if "name" not in data:
174-
raise ValueError("'name' must be specified.")
175-
if "namespace" not in data:
176-
raise ValueError("Edges may only exist in a 'namespace'.")
177-
if "id" not in data:
178-
data["id"] = uuid5(namespace=data["namespace"], name=data["name"])
179-
return data
180-
181-
182-
class EdgeResponseModel(EdgeModel):
122+
class EdgeResponseModel(EdgeBase):
183123
source: Any
184124
target: Any
185125

186126

187-
class Edge(EdgeModel, table=True):
127+
class Edge(EdgeBase, table=True):
188128
__tablename__: str = "edges_v2" # type: ignore[misc]
189129

190130

@@ -216,24 +156,6 @@ class ManifestBase(BaseSQLModel):
216156
spec: dict = jsonb_column("spec", aliases=["spec", "configuration", "data"])
217157

218158

219-
class ManifestModel(ManifestBase):
220-
"""model validating class for Manifests"""
221-
222-
@model_validator(mode="before")
223-
@classmethod
224-
def custom_model_validator(cls, data: Any, info: ValidationInfo) -> Any:
225-
if isinstance(data, dict):
226-
if "version" not in data:
227-
data["version"] = 1
228-
if "name" not in data:
229-
raise ValueError("'name' must be specified.")
230-
if "namespace" not in data:
231-
data["namespace"] = _default_campaign_namespace
232-
if "id" not in data:
233-
data["id"] = uuid5(namespace=data["namespace"], name=f"""{data["name"]}.{data["version"]}""")
234-
return data
235-
236-
237159
class Manifest(ManifestBase, table=True):
238160
__tablename__: str = "manifests_v2" # type: ignore[misc]
239161

src/lsst/cmservice/db/manifests_v2.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from typing import Self
8+
from uuid import uuid4
89

910
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationInfo, model_validator
1011

@@ -99,3 +100,43 @@ def custom_model_validator(self, info: ValidationInfo) -> Self:
99100
raise ValueError("Campaigns may only be created from a <campaign> manifest")
100101

101102
return self
103+
104+
105+
class EdgeMetadata(ManifestMetadata):
106+
"""Metadata model for an Edge Manifest.
107+
108+
A default random alphanumeric 8-byte name is generated if no name provided.
109+
"""
110+
111+
name: str = Field(default_factory=lambda: uuid4().hex[:8])
112+
113+
114+
class EdgeSpec(ManifestSpec):
115+
"""Spec model for an Edge Manifest."""
116+
117+
source: str
118+
target: str
119+
120+
121+
class EdgeManifest(Manifest[EdgeMetadata, EdgeSpec]):
122+
"""validating model for Edges"""
123+
124+
@model_validator(mode="after")
125+
def custom_model_validator(self, info: ValidationInfo) -> Self:
126+
"""Validate an Edge Manifest after a model has been created."""
127+
if self.kind is not ManifestKind.edge:
128+
raise ValueError("Edges may only be created from an <edge> manifest")
129+
130+
return self
131+
132+
133+
class NodeManifest(Manifest[VersionedMetadata, ManifestSpec]):
134+
"""validating model for Nodes"""
135+
136+
@model_validator(mode="after")
137+
def custom_model_validator(self, info: ValidationInfo) -> Self:
138+
"""Validate a Node Manifest after a model has been created."""
139+
if self.kind is not ManifestKind.node:
140+
raise ValueError("Nodes may only be created from an <node> manifest")
141+
142+
return self

src/lsst/cmservice/routers/v2/edges.py

Lines changed: 19 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@
66

77
from collections.abc import Sequence
88
from typing import Annotated
9-
from uuid import UUID, uuid4, uuid5
9+
from uuid import UUID, uuid5
1010

1111
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
1212
from sqlmodel import select
1313
from sqlmodel.ext.asyncio.session import AsyncSession
1414

15-
from ...common.enums import ManifestKind
1615
from ...common.logging import LOGGER
1716
from ...db.campaigns_v2 import Campaign, Edge
18-
from ...db.manifests_v2 import ManifestWrapper
17+
from ...db.manifests_v2 import EdgeManifest
1918
from ...db.session import db_session_dependency
2019

2120
# TODO should probably bind a logger to the fastapi app or something
@@ -110,7 +109,7 @@ async def read_edge_resource(
110109
async def create_edge_resource(
111110
request: Request,
112111
response: Response,
113-
manifest: ManifestWrapper,
112+
manifest: EdgeManifest,
114113
session: Annotated[AsyncSession, Depends(db_session_dependency)],
115114
) -> Edge:
116115
"""Creates a new edge from a Manifest.
@@ -130,36 +129,23 @@ async def create_edge_resource(
130129
target: {ndoe name or id}
131130
```
132131
"""
133-
if manifest.kind is not ManifestKind.edge:
134-
raise HTTPException(status_code=422, detail="Edges may only be created from a 'edge' manifest")
135-
136-
edge_name: str
137-
if (edge_name := manifest.metadata_.pop("name", None)) is None:
138-
edge_name = uuid4().hex[:8]
139-
140-
# Validate manifest spec as an edge
141-
# TODO match edge with jsonschema and validate
142-
elif (source_node := manifest.spec.pop("source", None)) is None:
143-
raise HTTPException(status_code=400, detail="Edges must have a source node'")
144-
elif (target_node := manifest.spec.pop("target", None)) is None:
145-
raise HTTPException(status_code=400, detail="Edges must have a target node'")
132+
edge_name = manifest.metadata_.name
133+
source_node = manifest.spec.source
134+
target_node = manifest.spec.target
146135

147136
# A edge must exist in the namespace of an existing campaign
148-
edge_namespace: str = manifest.metadata_.pop("namespace", None)
149-
if edge_namespace is None:
150-
raise HTTPException(status_code=422, detail="Edges must be created in a campaign namespace.")
151-
else:
152-
try:
153-
edge_namespace_uuid: UUID | None = UUID(edge_namespace)
154-
except ValueError:
155-
# get the campaign ID by its name to use as a namespace
156-
edge_namespace_uuid = (
157-
await session.exec(select(Campaign.id).where(Campaign.name == edge_namespace))
158-
).one_or_none()
159-
160-
# it is an error if the provided namespace (campaign) does not exist
161-
if edge_namespace_uuid is None:
162-
raise HTTPException(status_code=422, detail="Requested campaign namespace does not exist.")
137+
edge_namespace: str = manifest.metadata_.namespace
138+
try:
139+
edge_namespace_uuid: UUID | None = UUID(edge_namespace)
140+
except ValueError:
141+
# get the campaign ID by its name to use as a namespace
142+
edge_namespace_uuid = (
143+
await session.exec(select(Campaign.id).where(Campaign.name == edge_namespace))
144+
).one_or_none()
145+
146+
# it is an error if the provided namespace (campaign) does not exist
147+
if edge_namespace_uuid is None:
148+
raise HTTPException(status_code=422, detail="Requested campaign namespace does not exist.")
163149

164150
# an edge may specify the source and target nodes by name and version,
165151
# which means the UUID of these nodes is deterministic, or we could go to
@@ -180,7 +166,7 @@ async def create_edge_resource(
180166
namespace=edge_namespace_uuid,
181167
source=uuid5(edge_namespace_uuid, source_node),
182168
target=uuid5(edge_namespace_uuid, target_node),
183-
configuration=manifest.spec,
169+
configuration=manifest.spec.model_dump(),
184170
)
185171

186172
# The merge operation is effectively an upsert should an edge matching the

src/lsst/cmservice/routers/v2/nodes.py

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
from sqlmodel import col, select
1313
from sqlmodel.ext.asyncio.session import AsyncSession
1414

15-
from ...common.enums import ManifestKind
1615
from ...common.jsonpatch import JSONPatch, JSONPatchError, apply_json_patch
1716
from ...common.logging import LOGGER
1817
from ...db.campaigns_v2 import Campaign, Node
19-
from ...db.manifests_v2 import ManifestWrapper
18+
from ...db.manifests_v2 import NodeManifest
2019
from ...db.session import db_session_dependency
2120

2221
# TODO should probably bind a logger to the fastapi app or something
@@ -103,23 +102,11 @@ async def read_node_resource(
103102
async def create_node_resource(
104103
request: Request,
105104
response: Response,
106-
manifest: ManifestWrapper,
105+
manifest: NodeManifest,
107106
session: Annotated[AsyncSession, Depends(db_session_dependency)],
108107
) -> Node:
109-
# Validate the input by checking the "kind" of manifest is a node
110-
if manifest.kind is not ManifestKind.node:
111-
raise HTTPException(status_code=422, detail="Nodes may only be created from a 'node' manifest")
112-
# and that the manifest includes any required fields, though this could
113-
# just as well be a try/except ValueError around `_.model_validate()`
114-
elif (node_name := manifest.metadata_.pop("name", None)) is None:
115-
raise HTTPException(status_code=400, detail="Nodes must have a name set in '.metadata.name'")
116-
117-
# A node must exist in the namespace of an existing campaign
118-
node_namespace: str = manifest.metadata_.pop("namespace", None)
119-
if node_namespace is None:
120-
raise HTTPException(
121-
status_code=400, detail="Nodes must have a campaign namespace set in '.metadata.namespace'"
122-
)
108+
node_name = manifest.metadata_.name
109+
node_namespace = manifest.metadata_.namespace
123110

124111
try:
125112
node_namespace_uuid: UUID | None = UUID(node_namespace)
@@ -136,7 +123,7 @@ async def create_node_resource(
136123

137124
# A node must be a new version if name+namespace already exists
138125
# - check db for node as name+namespace, get current version and increment
139-
node_version = int(manifest.metadata_.pop("version", 0))
126+
node_version = int(manifest.metadata_.version)
140127

141128
s = (
142129
select(Node)
@@ -154,7 +141,7 @@ async def create_node_resource(
154141
name=node_name,
155142
namespace=node_namespace_uuid,
156143
version=node_version,
157-
configuration=manifest.spec,
144+
configuration=manifest.spec.model_dump(),
158145
)
159146

160147
# Put the node in the database

tests/v2/test_edge_routes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async def test_edge_negative(aclient: AsyncClient) -> None:
4848
"cm-service/v2/edges",
4949
json={
5050
"kind": "edge",
51-
"metadata": {"name": uuid4().hex[8:]},
51+
"metadata": {"name": uuid4().hex[8:], "namespace": "test_campaign"},
5252
"spec": {
5353
"source": "START",
5454
},
@@ -60,7 +60,7 @@ async def test_edge_negative(aclient: AsyncClient) -> None:
6060
"cm-service/v2/edges",
6161
json={
6262
"kind": "edge",
63-
"metadata": {"name": uuid4().hex[8:]},
63+
"metadata": {"name": uuid4().hex[8:], "namespace": "test_campaign"},
6464
"spec": {
6565
"target": "END",
6666
},
@@ -78,7 +78,6 @@ async def test_edge_negative(aclient: AsyncClient) -> None:
7878
},
7979
)
8080
assert x.is_client_error
81-
assert "campaign namespace" in x.text
8281

8382
# negative: fail to delete an edge using a non-uuid string (like a name)
8483
x = await aclient.delete(
@@ -97,6 +96,7 @@ async def test_edge_lifecycle(aclient: AsyncClient) -> None:
9796
json={
9897
"kind": "campaign",
9998
"metadata": {"name": campaign_name},
99+
"spec": {},
100100
},
101101
)
102102
assert x.is_success

tests/v2/test_node_routes.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,7 @@ async def test_node_lifecycle(aclient: AsyncClient) -> None:
124124
assert x.is_success
125125
node = x.json()
126126

127-
# Edit a Node using RFCxxxx json-merge
128-
...
127+
# test that the updates have been made and the version has been bumped
128+
assert node["configuration"]["split"]["maxInFlight"] == 4
129+
assert node["configuration"]["query"][-1]["dimension"] == "fact"
130+
assert node["version"] == 2

0 commit comments

Comments
 (0)