Skip to content

Commit abf48d2

Browse files
dmealingclaude
andcommitted
feat(python-codegen): generated FastAPI router stamps @autoset (ADR-0045, #203)
The generated Python router delegated raw DTOs to the consumer repository seam and did no @autoset stamping — #203's Python stamping lives only in the ObjectManager runtime, BELOW that seam. So an adopter who wires their own persistence into the generated router got a deployed API that silently dropped the timestamp-stamping FR. Per ADR-0045 (the generated API surface owns metamodel write semantics), the router now stamps inline, ABOVE the repo seam, with no runtime dependency: - create stamps onCreate + onUpdate from ONE captured base instant (a fresh row's createdAt == updatedAt), ignoring any caller-supplied value; - update/patch bumps onUpdate on EVERY request (a server-inserted present key, compatible with the PATCH present-key tristate) and never rewrites onCreate; - the now()-expression is keyed off the column temporal type (date/time/timestamp; @localTime → naive), mirroring ObjectManager._auto_set_stamp; - byte-identical output for entities with no @autoset field (regression-pinned: render_router callers 26/26 green). The ObjectManager keeps stamping for non-HTTP writes (defense-in-depth). TPH-router @autoset stamping is a documented follow-up (not exercised by the corpus). This is the Python leg of the ADR-0045 rollout (Kotlin leg: 46d8e54). The shared api-contract @autoset gate follows; no scenario lands here, so no lane reds. Refs #203, #229. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent 46d8e54 commit abf48d2

2 files changed

Lines changed: 165 additions & 0 deletions

File tree

server/python/src/metaobjects/codegen/generators/router_generator.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from metaobjects.codegen.type_map import PyType, py_type_for
5353
from metaobjects.meta.core.field import field_constants as fc
5454
from metaobjects.meta.core.field.meta_field import MetaField
55+
from metaobjects.meta.persistence.db import db_constants as dbc
5556
from metaobjects.meta.core.identity.identity_constants import (
5657
IDENTITY_ATTR_FIELDS,
5758
IDENTITY_REFERENCE_ATTR_REFERENCES,
@@ -98,6 +99,60 @@ def _required_field_names(entity: MetaObject) -> list[str]:
9899
return [f.name for f in entity.fields() if is_required(f)]
99100

100101

102+
# --- #203 / ADR-0045: @autoSet stamping in the generated router (above the repo seam) ----------
103+
104+
_AUTO_SET_TEMPORAL_SUBTYPES = (
105+
fc.FIELD_SUBTYPE_DATE,
106+
fc.FIELD_SUBTYPE_TIME,
107+
fc.FIELD_SUBTYPE_TIMESTAMP,
108+
)
109+
110+
111+
def _auto_set_split(entity: MetaObject) -> tuple[list[MetaField], list[MetaField]]:
112+
"""The entity's ``@autoSet`` temporal fields split into ``(onCreate, onUpdate)``.
113+
114+
Reads the RESOLVING ``@autoSet`` (ADR-0039) so an inherited
115+
``BaseEntity.createdAt/updatedAt`` is honored; a non-temporal field is skipped.
116+
Both lists are empty for an entity that declares none, keeping its router
117+
byte-identical to the pre-#203 output."""
118+
on_create: list[MetaField] = []
119+
on_update: list[MetaField] = []
120+
for f in entity.fields():
121+
if f.sub_type not in _AUTO_SET_TEMPORAL_SUBTYPES:
122+
continue
123+
mode = f.get_meta_attr(fc.FIELD_ATTR_AUTO_SET) # ADR-0039 resolving: may be inherited via extends
124+
if mode == fc.AUTO_SET_ON_CREATE:
125+
on_create.append(f)
126+
elif mode == fc.AUTO_SET_ON_UPDATE:
127+
on_update.append(f)
128+
return on_create, on_update
129+
130+
131+
def _auto_set_stamp_expr(field: MetaField, base_var: str) -> str:
132+
"""The inline now()-expression for an ``@autoSet`` *field*, derived from one shared
133+
tz-aware *base_var* datetime so every column stamped in one op is equal (a fresh
134+
row's created == updated). Keyed off the COLUMN's temporal type: ``field.date`` →
135+
``.date()``; ``field.time`` → ``.time()``; ``field.timestamp`` → the tz-aware base
136+
(``@localTime`` → a naive wall-clock value). Mirrors ObjectManager._auto_set_stamp."""
137+
sub = field.sub_type
138+
if sub == fc.FIELD_SUBTYPE_DATE:
139+
return f"{base_var}.date()"
140+
if sub == fc.FIELD_SUBTYPE_TIME:
141+
return f"{base_var}.time()"
142+
is_tz = field.get_meta_attr(dbc.FIELD_ATTR_LOCAL_TIME) is not True # ADR-0039 resolving
143+
return base_var if is_tz else f"{base_var}.replace(tzinfo=None)"
144+
145+
146+
def _auto_set_stamp_lines(fields: list[MetaField]) -> list[str]:
147+
"""Router-handler body lines stamping *fields* into ``dto`` from ONE captured base
148+
instant (so all same-op columns are equal). Empty list for no fields."""
149+
if not fields:
150+
return []
151+
lines = [" _asnow = _dt.datetime.now(_dt.timezone.utc)"]
152+
lines += [f' dto["{f.name}"] = {_auto_set_stamp_expr(f, "_asnow")}' for f in fields]
153+
return lines
154+
155+
101156
def _py_set_literal(names: list[str], *, frozen: bool = False) -> str:
102157
"""A Python set/frozenset literal from field names, matching the generated
103158
allowlist idiom (one quoted name per line). Empty → ``frozenset()`` / ``set()``."""
@@ -259,6 +314,8 @@ def _emit_route_handler(
259314
ops_const: str,
260315
model_name: str,
261316
patch_model: str,
317+
create_autoset: list[str] = (),
318+
update_autoset: list[str] = (),
262319
) -> list[str]:
263320
"""One CRUD route handler block, dispatched by *name*
264321
(``list`` / ``get`` / ``create`` / ``update`` / ``delete``). Override to
@@ -318,6 +375,11 @@ def _emit_route_handler(
318375
f" {model_name}(**dto)",
319376
" except ValidationError:",
320377
' return JSONResponse(status_code=400, content={"error": "validation"})',
378+
# #203/ADR-0045: the generated router (the API surface) stamps @autoSet columns
379+
# ABOVE the consumer repo seam — onCreate + onUpdate both stamped from one instant
380+
# (a fresh row's createdAt == updatedAt), ignoring any caller-supplied value.
381+
*([" # #203/ADR-0045: stamp @autoSet columns (server-owned; caller ignored)."]
382+
+ list(create_autoset) if create_autoset else []),
321383
" return repo.create(dto)",
322384
]
323385
if name == "update":
@@ -341,6 +403,11 @@ def _emit_route_handler(
341403
f" {patch_model}(**dto)",
342404
" except ValidationError:",
343405
' return JSONResponse(status_code=400, content={"error": "validation"})',
406+
# #203/ADR-0045: bump onUpdate @autoSet columns (server-owned) on EVERY patch,
407+
# above the repo seam — a server-inserted present key (compatible with the PATCH
408+
# present-key tristate); onCreate columns are never touched here.
409+
*([" # #203/ADR-0045: bump onUpdate @autoSet column(s) (server-owned)."]
410+
+ list(update_autoset) if update_autoset else []),
344411
f" saved = repo.update({pk_param}, dto)",
345412
" if saved is None:",
346413
' return JSONResponse(status_code=404, content={"error": "not_found"})',
@@ -684,13 +751,25 @@ def render_router(
684751
# is a 400 — the update handler guards these before the repo call.
685752
required_set_body = _py_set_literal(_required_field_names(entity), frozen=True)
686753

754+
# #203/ADR-0045: @autoSet stamp lines for the create/update handlers (empty for a
755+
# non-@autoSet entity → byte-identical output). insert stamps onCreate+onUpdate from one
756+
# instant; a patch bumps onUpdate only (createdAt stays immutable).
757+
_on_create_auto, _on_update_auto = _auto_set_split(entity)
758+
create_autoset = _auto_set_stamp_lines(_on_create_auto + _on_update_auto)
759+
update_autoset = _auto_set_stamp_lines(_on_update_auto)
760+
has_autoset = bool(_on_create_auto or _on_update_auto)
761+
687762
parts: list[str] = []
688763
parts.append(
689764
generated_header(short_name, _effective_fqn(entity)).rstrip() + "\n"
690765
+ f'"""GENERATED — REST router for {short_name} entity. Implements the cross-port API contract."""\n'
691766
)
692767
parts.append("from __future__ import annotations")
693768
parts.append("")
769+
if has_autoset:
770+
# #203/ADR-0045: the router stamps @autoSet columns inline (no runtime dependency).
771+
parts.append("import datetime as _dt")
772+
parts.append("")
694773
for import_line in sorted(pk.imports):
695774
parts.append(import_line)
696775
if pk.imports:
@@ -762,6 +841,8 @@ def render_router(
762841
ops_const=ops_const,
763842
model_name=f"{short_name}Create",
764843
patch_model=f"{short_name}Patch",
844+
create_autoset=create_autoset,
845+
update_autoset=update_autoset,
765846
)
766847
for i, hname in enumerate(("list", "get", "create", "update", "delete")):
767848
if i > 0:
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""#203 / ADR-0045 — the generated FastAPI router (the deployed API surface) stamps
2+
``@autoSet`` timestamps ABOVE the consumer repository seam, so an adopter who wires
3+
their own persistence into the generated router still gets the shipped stamping
4+
semantics. (The ObjectManager keeps stamping for non-HTTP writes — defense-in-depth,
5+
not the wire-tier guarantee.) A non-``@autoSet`` entity's router stays byte-identical."""
6+
7+
from __future__ import annotations
8+
9+
import shutil
10+
import tempfile
11+
from pathlib import Path
12+
13+
from metaobjects import MetaDataLoader
14+
from metaobjects.codegen.generators.m2m_codegen import build_object_index
15+
from metaobjects.codegen.generators.router_generator import render_router
16+
from metaobjects.meta.core.object.meta_object import MetaObject
17+
from metaobjects.shared.base_types import TYPE_OBJECT
18+
19+
# Event declares both @autoSet policies on field.timestamp columns; Note declares none
20+
# (the byte-identical baseline).
21+
_AUTOSET_FIXTURE = """{
22+
"metadata.root": { "package": "acme::events", "children": [
23+
{ "object.entity": { "name": "Event", "children": [
24+
{ "source.rdb": { "@table": "events" } },
25+
{ "field.long": { "name": "id" } },
26+
{ "field.string": { "name": "name", "@required": true, "@maxLength": 120 } },
27+
{ "field.timestamp": { "name": "createdAt", "@autoSet": "onCreate" } },
28+
{ "field.timestamp": { "name": "updatedAt", "@autoSet": "onUpdate" } },
29+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
30+
] } },
31+
{ "object.entity": { "name": "Note", "children": [
32+
{ "source.rdb": { "@table": "notes" } },
33+
{ "field.long": { "name": "id" } },
34+
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200 } },
35+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
36+
] } }
37+
] }
38+
}"""
39+
40+
41+
def _load(fixture: str) -> dict[str, MetaObject]:
42+
tmp = Path(tempfile.mkdtemp(prefix="router-autoset-"))
43+
try:
44+
(tmp / "meta.events.json").write_text(fixture)
45+
result = MetaDataLoader.from_directory(str(tmp))
46+
assert not result.errors, [f"{e.code}: {e.message}" for e in result.errors]
47+
return {
48+
c.name: c
49+
for c in result.root.children()
50+
if c.type == TYPE_OBJECT and isinstance(c, MetaObject)
51+
}
52+
finally:
53+
shutil.rmtree(tmp, ignore_errors=True)
54+
55+
56+
_ENTITIES = _load(_AUTOSET_FIXTURE)
57+
_INDEX = build_object_index(list(_ENTITIES.values()))
58+
59+
60+
def test_router_create_stamps_both_autoset_columns_from_one_instant() -> None:
61+
src = render_router(_ENTITIES["Event"], _INDEX)
62+
assert src is not None
63+
assert "import datetime as _dt" in src
64+
# ONE captured base instant, assigned to BOTH columns → a fresh row's createdAt == updatedAt.
65+
assert "_asnow = _dt.datetime.now(_dt.timezone.utc)" in src
66+
assert 'dto["createdAt"] = _asnow' in src
67+
assert 'dto["updatedAt"] = _asnow' in src
68+
69+
70+
def test_router_update_bumps_only_onupdate() -> None:
71+
src = render_router(_ENTITIES["Event"], _INDEX)
72+
assert src is not None
73+
update_body = src.split("def update_")[1].split("def delete_")[0]
74+
# the update handler bumps updatedAt; createdAt is never stamped on update (immutable).
75+
assert 'dto["updatedAt"] = _asnow' in update_body
76+
assert 'dto["createdAt"]' not in update_body
77+
78+
79+
def test_non_autoset_router_is_byte_identical() -> None:
80+
src = render_router(_ENTITIES["Note"], _INDEX)
81+
assert src is not None
82+
assert "import datetime as _dt" not in src
83+
assert "_asnow" not in src
84+
assert "_dt.datetime.now" not in src

0 commit comments

Comments
 (0)