Skip to content

Commit 420ff3a

Browse files
dmealingclaude
andcommitted
feat(#203): Python ObjectManager @autoset CRUD stamping
The runtime ObjectManager now honors field.timestamp @autoset per the owner contract: create stamps EVERY onCreate AND onUpdate temporal column with one shared now() (the caller's value ignored — a fresh row's updated == created); update bumps every onUpdate column to now() and NEVER rewrites an onCreate column (stripping a stale caller value — the latent lost-update bug the issue calls out); a shared INSERT core backs both create and the new insert_preserving() escape hatch (writes the @autoset columns verbatim for import/restore). now() is keyed off the column's temporal type; all paths are a no-op for entities with no @autoset field. Verified: Python unit + conformance 756 passed (incl. the new test_object_manager_autoset_stamping.py: create-stamps-both, update-bumps-updated-only, insert_preserving-verbatim). Cross-port parity contract for #203; the shared api-contract gate lands with the other ports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 8a5f4f0 commit 420ff3a

2 files changed

Lines changed: 350 additions & 0 deletions

File tree

server/python/src/metaobjects/runtime/object_manager.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,45 @@ def create(self, entity_name: str, data: dict[str, Any]) -> dict[str, Any]:
218218
caller omits (e.g. a ``gen_random_uuid()`` PK) are left out of the INSERT
219219
and filled by Postgres; the full row — including the generated PK — is
220220
returned via ``RETURNING`` so a round-trip read can key off it.
221+
222+
#203 — ``@autoSet`` stamping: EVERY ``onCreate`` AND ``onUpdate`` temporal
223+
column is stamped with a single shared ``now()`` (keyed off the column's
224+
type), OVERRIDING any caller-supplied value — so a fresh row's updated
225+
timestamp equals its created one. Use :meth:`insert_preserving` for the
226+
import/restore path that must keep original timestamps.
227+
"""
228+
entity = self._require_entity(entity_name)
229+
# #203: stamp every onCreate AND onUpdate column with one shared now() (the
230+
# caller's value is ignored). No-op for entities that declare no @autoSet field.
231+
on_create, on_update = _auto_set_fields(entity)
232+
if on_create or on_update:
233+
base = _dt.datetime.now(_dt.timezone.utc)
234+
data = {**data}
235+
for f in (*on_create, *on_update):
236+
data[f.name] = _auto_set_stamp(f, base)
237+
return self._insert_row(entity, entity_name, data)
238+
239+
def insert_preserving(self, entity_name: str, data: dict[str, Any]) -> dict[str, Any]:
240+
"""#203 escape hatch — INSERT that writes the ``@autoSet`` columns VERBATIM
241+
from *data*, skipping the ``now()`` stamping :meth:`create` applies.
242+
243+
For import / restore / replication paths that must persist the ORIGINAL
244+
``createdAt`` / ``updatedAt`` timestamps a row carried, not fresh ones.
245+
Every other column behaves exactly as :meth:`create` (same write codec,
246+
same TPH discriminator injection, same ``RETURNING`` row). For an entity
247+
that declares no ``@autoSet`` field this is identical to :meth:`create`.
221248
"""
222249
entity = self._require_entity(entity_name)
250+
return self._insert_row(entity, entity_name, data)
251+
252+
def _insert_row(
253+
self, entity: MetaObject, entity_name: str, data: dict[str, Any]
254+
) -> dict[str, Any]:
255+
"""Shared INSERT core for :meth:`create` / :meth:`insert_preserving` — the
256+
two differ only in whether *data* was ``@autoSet``-stamped before arriving
257+
here. Builds the parameterized INSERT (coercing each value via the write
258+
codec), runs it ``RETURNING`` the full physical column set, and maps the
259+
row back to metadata field names."""
223260
table = self._table_name(entity)
224261

225262
# FR-017 TPH: a subtype create injects its discriminator value (the entity
@@ -284,6 +321,11 @@ def update(
284321
PATCH path cannot silently skip the type coercion INSERT applies — the
285322
per-port hazard the ``update-delete-all-types`` corpus gates.
286323
324+
#203 — ``@autoSet`` stamping: every ``onUpdate`` temporal column is bumped
325+
to ``now()`` (overriding any caller value), while ``onCreate`` columns are
326+
stripped and never rewritten — a full-row update carrying a stale
327+
``createdAt`` cannot clobber the write-once creation timestamp.
328+
287329
Returns the updated row (mapped to metadata field names) via ``RETURNING``.
288330
When no row matched the (TPH-scoped) PK, behavior follows *if_missing*
289331
(mirrors the TS ``WriteOpts.ifMissing``): ``"ignore"`` (default) → ``None``
@@ -297,6 +339,19 @@ def update(
297339
pk_field = self._primary_pk_field(entity)
298340
pk_col = _column_of(entity.find_field(pk_field))
299341

342+
# #203 — @autoSet: bump every onUpdate column to now(), and NEVER rewrite an
343+
# onCreate column — strip it even if the caller supplied a (stale) value.
344+
# Omitting this onCreate-skip is the latent lost-update bug the issue calls
345+
# out. No-op for entities that declare no @autoSet field.
346+
on_create, on_update = _auto_set_fields(entity)
347+
if on_create or on_update:
348+
base = _dt.datetime.now(_dt.timezone.utc)
349+
data = {**data}
350+
for f in on_create:
351+
data.pop(f.name, None) # created_at is write-once — never overwritten on update
352+
for f in on_update:
353+
data[f.name] = _auto_set_stamp(f, base)
354+
300355
# FR-017 TPH: the discriminator is immutable — strip it from the patch; the
301356
# by-id write is subtype-scoped (a row of a different subtype is invisible).
302357
tph = tph_subtype_of(entity)
@@ -717,6 +772,62 @@ def _coerce_write_value(field: MetaField, value: Any) -> Any:
717772
return value
718773

719774

775+
# ----------------------------------------------------------------------------
776+
# #203 — @autoSet CRUD stamping. A ``field.timestamp`` (or any temporal field)
777+
# marked ``@autoSet: onCreate|onUpdate`` declares "the runtime owns this
778+
# timestamp, the caller does not." create() stamps every onCreate AND onUpdate
779+
# column; update() bumps onUpdate and NEVER rewrites onCreate (the lost-update
780+
# guard); insert_preserving() writes them verbatim. The stamp is keyed off the
781+
# COLUMN's temporal type (generalizes past ``datetime`` to date/time), matching
782+
# the TS Zod-transform contract (server owns the value; a fresh row's updated
783+
# timestamp equals its created one).
784+
# ----------------------------------------------------------------------------
785+
786+
_AUTO_SET_TEMPORAL_SUBTYPES = (
787+
fc.FIELD_SUBTYPE_TIMESTAMP,
788+
fc.FIELD_SUBTYPE_DATE,
789+
fc.FIELD_SUBTYPE_TIME,
790+
)
791+
792+
793+
def _auto_set_fields(entity: MetaObject) -> tuple[list[MetaField], list[MetaField]]:
794+
"""The entity's ``@autoSet`` temporal fields split into ``(onCreate, onUpdate)``.
795+
796+
Reads the RESOLVING ``@autoSet`` (ADR-0039) so a field inherited from an
797+
abstract base — the common ``BaseEntity.createdAt/updatedAt`` pattern — is
798+
honored. A non-temporal field is skipped (``@autoSet`` is a temporal-only
799+
marker). Both lists are empty for an entity that declares no ``@autoSet``
800+
field, so the write path stays byte-identical for those entities."""
801+
on_create: list[MetaField] = []
802+
on_update: list[MetaField] = []
803+
for f in entity.fields():
804+
if f.sub_type not in _AUTO_SET_TEMPORAL_SUBTYPES:
805+
continue
806+
mode = f.get_meta_attr(fc.FIELD_ATTR_AUTO_SET) # ADR-0039 resolving: may be inherited via extends
807+
if mode == fc.AUTO_SET_ON_CREATE:
808+
on_create.append(f)
809+
elif mode == fc.AUTO_SET_ON_UPDATE:
810+
on_update.append(f)
811+
return on_create, on_update
812+
813+
814+
def _auto_set_stamp(field: MetaField, base: _dt.datetime) -> Any:
815+
"""The native ``now()`` value for an ``@autoSet`` *field*, derived from the
816+
single shared *base* instant so every column stamped in one operation is
817+
equal (a fresh row's created == updated). Keyed off the COLUMN's temporal
818+
type: ``field.date`` → ``date``; ``field.time`` → ``time``; ``field.timestamp``
819+
→ a tz-aware ``datetime`` (the instant default) unless ``@localTime`` opts into
820+
a naive wall-clock value (matching :func:`_coerce_write_value`)."""
821+
sub = field.sub_type
822+
if sub == fc.FIELD_SUBTYPE_DATE:
823+
return base.date()
824+
if sub == fc.FIELD_SUBTYPE_TIME:
825+
return base.time()
826+
# field.timestamp: instant / tz-aware by default; @localTime → naive wall clock.
827+
is_tz = field.get_meta_attr(dbc.FIELD_ATTR_LOCAL_TIME) is not True # ADR-0039 resolving
828+
return base if is_tz else base.replace(tzinfo=None)
829+
830+
720831
def _parse_datetime(text: str, *, tz_aware: bool) -> _dt.datetime:
721832
"""Parse an ISO-8601 timestamp authoring string to a native datetime.
722833
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Issue #203 — ObjectManager honors ``field.timestamp @autoSet: onCreate|onUpdate``.
2+
3+
The runtime write path (``create`` / ``update`` / ``insert_preserving``) owns
4+
the ``now()`` stamping so adopters stop hand-writing it in every repository. The
5+
contract (identical across ports):
6+
7+
* ``create`` — stamps EVERY ``onCreate`` AND ``onUpdate`` column with a single
8+
shared ``now()`` (the caller's value is ignored; a fresh row's updated ==
9+
created).
10+
* ``update`` — stamps ``onUpdate`` with ``now()`` and NEVER writes an
11+
``onCreate`` column (stripped even when the caller supplies one — the
12+
lost-update guard).
13+
* ``insert_preserving`` — the escape hatch: writes the ``@autoSet`` columns
14+
VERBATIM (import / restore / replication paths that must keep the original
15+
timestamps).
16+
* ``now()`` is keyed off the COLUMN's temporal type (``field.timestamp`` →
17+
datetime, tz-aware unless ``@localTime``; ``field.date`` → date).
18+
19+
White-box: a fake driver captures the emitted INSERT/UPDATE SQL + bound params
20+
so the stamping is asserted without a database.
21+
"""
22+
from __future__ import annotations
23+
24+
import json
25+
import re
26+
from datetime import date, datetime
27+
from typing import Any
28+
29+
from metaobjects import load_string
30+
from metaobjects.runtime import ObjectManager
31+
from metaobjects.runtime.object_manager import SelectResult
32+
33+
# BaseAudit carries the @autoSet timestamps; Doc INHERITS them via extends — the
34+
# issue's exact real-world shape (declared once on a base, inherited everywhere).
35+
# Plain has no @autoSet (proves byte-identical behavior for non-@autoSet entities).
36+
# DateDoc exercises a non-timestamp temporal (field.date) @autoSet column.
37+
_META = {
38+
"metadata.root": {
39+
"package": "t",
40+
"children": [
41+
{
42+
"object.entity": {
43+
"name": "BaseAudit",
44+
"abstract": True,
45+
"children": [
46+
{"field.timestamp": {"name": "createdAt", "@autoSet": "onCreate"}},
47+
{"field.timestamp": {"name": "updatedAt", "@autoSet": "onUpdate"}},
48+
],
49+
}
50+
},
51+
{
52+
"object.entity": {
53+
"name": "Doc",
54+
"extends": "BaseAudit",
55+
"children": [
56+
{"source.rdb": {"@table": "docs"}},
57+
{"field.long": {"name": "id"}},
58+
{"field.string": {"name": "title", "@required": True}},
59+
# @localTime opt-out → naive wall-clock stamp (no tzinfo).
60+
{"field.timestamp": {"name": "touchedAt", "@autoSet": "onUpdate", "@localTime": True}},
61+
{"identity.primary": {"name": "id", "@fields": ["id"]}},
62+
],
63+
}
64+
},
65+
{
66+
"object.entity": {
67+
"name": "Plain",
68+
"children": [
69+
{"source.rdb": {"@table": "plains"}},
70+
{"field.long": {"name": "id"}},
71+
{"field.string": {"name": "name"}},
72+
{"identity.primary": {"name": "id", "@fields": ["id"]}},
73+
],
74+
}
75+
},
76+
{
77+
"object.entity": {
78+
"name": "DateDoc",
79+
"children": [
80+
{"source.rdb": {"@table": "datedocs"}},
81+
{"field.long": {"name": "id"}},
82+
{"field.date": {"name": "createdOn", "@autoSet": "onCreate"}},
83+
{"identity.primary": {"name": "id", "@fields": ["id"]}},
84+
],
85+
}
86+
},
87+
],
88+
}
89+
}
90+
91+
92+
class FakeWriteDriver:
93+
"""Captures the write SQL + params; returns a canned single-row result so the
94+
ObjectManager's RETURNING-row mapping does not crash."""
95+
96+
def __init__(self) -> None:
97+
self.inserts: list[tuple[str, tuple[Any, ...]]] = []
98+
self.updates: list[tuple[str, tuple[Any, ...]]] = []
99+
100+
def insert_returning(self, sql: str, params: tuple[Any, ...] = ()) -> SelectResult:
101+
self.inserts.append((sql, params))
102+
return SelectResult([{"id": 1}], {})
103+
104+
def update_returning(self, sql: str, params: tuple[Any, ...] = ()) -> SelectResult:
105+
self.updates.append((sql, params))
106+
return SelectResult([{"id": 1}], {})
107+
108+
def select(self, sql: str, params: tuple[Any, ...] = ()) -> SelectResult: # pragma: no cover
109+
return SelectResult([{"id": 1}], {})
110+
111+
def scalar(self, sql: str, params: tuple[Any, ...] = ()) -> Any: # pragma: no cover
112+
return None
113+
114+
def execute_rowcount(self, sql: str, params: tuple[Any, ...] = ()) -> int: # pragma: no cover
115+
return 1
116+
117+
118+
def _om() -> tuple[ObjectManager, FakeWriteDriver]:
119+
root = load_string(json.dumps(_META)).root
120+
driver = FakeWriteDriver()
121+
return ObjectManager(root, driver), driver # type: ignore[arg-type]
122+
123+
124+
def _insert_cols(sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
125+
"""Map INSERT column → bound param."""
126+
m = re.search(r'INSERT INTO "[^"]+" \(([^)]*)\) VALUES', sql)
127+
assert m is not None, sql
128+
cols = [c.strip().strip('"') for c in m.group(1).split(",")]
129+
return dict(zip(cols, params))
130+
131+
132+
def _update_set(sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
133+
"""Map UPDATE SET column → bound param (WHERE/pk params excluded)."""
134+
m = re.search(r"\bSET (.*?) WHERE ", sql)
135+
assert m is not None, sql
136+
cols = re.findall(r'"([^"]+)" = %s', m.group(1))
137+
return dict(zip(cols, params[: len(cols)]))
138+
139+
140+
# --- create -----------------------------------------------------------------
141+
142+
143+
def test_create_stamps_both_oncreate_and_onupdate_equal() -> None:
144+
om, d = _om()
145+
om.create("Doc", {"title": "hello"})
146+
cols = _insert_cols(*d.inserts[0])
147+
assert isinstance(cols["createdAt"], datetime)
148+
assert isinstance(cols["updatedAt"], datetime)
149+
# A fresh row's updated timestamp equals its created one (single shared now()).
150+
assert cols["createdAt"] == cols["updatedAt"]
151+
152+
153+
def test_create_ignores_caller_supplied_autoset_values() -> None:
154+
om, d = _om()
155+
om.create("Doc", {"title": "x", "createdAt": "2000-01-01T00:00:00Z", "updatedAt": "2000-01-01T00:00:00Z"})
156+
cols = _insert_cols(*d.inserts[0])
157+
# The stale caller value is overridden with a fresh now(), not persisted.
158+
assert cols["createdAt"].year != 2000
159+
assert cols["createdAt"] == cols["updatedAt"]
160+
161+
162+
def test_create_localtime_is_naive_default_is_tzaware() -> None:
163+
om, d = _om()
164+
om.create("Doc", {"title": "x"})
165+
cols = _insert_cols(*d.inserts[0])
166+
# Default field.timestamp is instant / tz-aware; @localTime opts into naive.
167+
assert cols["createdAt"].tzinfo is not None
168+
assert cols["touchedAt"].tzinfo is None
169+
170+
171+
def test_create_date_autoset_stamped_as_date() -> None:
172+
om, d = _om()
173+
om.create("DateDoc", {})
174+
cols = _insert_cols(*d.inserts[0])
175+
# field.date now() is a date, not a datetime (keyed off the column's type).
176+
assert isinstance(cols["createdOn"], date)
177+
assert not isinstance(cols["createdOn"], datetime)
178+
179+
180+
# --- update -----------------------------------------------------------------
181+
182+
183+
def test_update_bumps_onupdate_and_skips_oncreate() -> None:
184+
om, d = _om()
185+
om.update("Doc", 1, {"title": "new"})
186+
setmap = _update_set(*d.updates[0])
187+
assert isinstance(setmap["updatedAt"], datetime)
188+
assert isinstance(setmap["touchedAt"], datetime)
189+
# created_at is write-once: never in the UPDATE SET clause.
190+
assert "createdAt" not in setmap
191+
192+
193+
def test_update_strips_caller_supplied_createdAt() -> None:
194+
om, d = _om()
195+
# A full-row update carrying a stale createdAt must NOT rewrite it (lost-update guard).
196+
om.update("Doc", 1, {"title": "new", "createdAt": "2000-01-01T00:00:00Z"})
197+
setmap = _update_set(*d.updates[0])
198+
assert "createdAt" not in setmap
199+
200+
201+
def test_update_overrides_caller_supplied_updatedAt() -> None:
202+
om, d = _om()
203+
om.update("Doc", 1, {"updatedAt": "2000-01-01T00:00:00Z"})
204+
setmap = _update_set(*d.updates[0])
205+
assert isinstance(setmap["updatedAt"], datetime)
206+
# Server owns updated_at — the caller's stale value is replaced with now().
207+
assert setmap["updatedAt"].year != 2000
208+
209+
210+
# --- insert_preserving (escape hatch) ---------------------------------------
211+
212+
213+
def test_insert_preserving_writes_autoset_verbatim() -> None:
214+
om, d = _om()
215+
om.insert_preserving(
216+
"Doc",
217+
{"title": "x", "createdAt": "2000-01-01T00:00:00Z", "updatedAt": "2001-02-03T04:05:06Z"},
218+
)
219+
cols = _insert_cols(*d.inserts[0])
220+
# Verbatim: the caller's original instants are preserved (no now() stamping).
221+
assert cols["createdAt"].year == 2000
222+
assert cols["updatedAt"].year == 2001
223+
224+
225+
# --- non-@autoSet entity: byte-identical (no behavior change) ---------------
226+
227+
228+
def test_entity_without_autoset_is_unchanged_on_create() -> None:
229+
om, d = _om()
230+
om.create("Plain", {"id": 5, "name": "n"})
231+
cols = _insert_cols(*d.inserts[0])
232+
assert set(cols.keys()) == {"id", "name"}
233+
234+
235+
def test_entity_without_autoset_is_unchanged_on_update() -> None:
236+
om, d = _om()
237+
om.update("Plain", 5, {"name": "n2"})
238+
setmap = _update_set(*d.updates[0])
239+
assert set(setmap.keys()) == {"name"}

0 commit comments

Comments
 (0)