Skip to content

Commit 09b312c

Browse files
dmealingclaude
andcommitted
fix(python-codegen): @column is the column name, not the model's field name
The generated `<Entity>` read model named its Pydantic field `@column or field.name`. Everything else in the port keys by `field.name`: the FR-036 `<Entity>Create` / `<Entity>Patch` models, the generated router (which stamps `dto["createdAt"]`), and the runtime — `ObjectManager` builds `{_column_of(f): f.name}` on every read and every RETURNING clause, commented "for cross-port row-shape parity". So one generated module disagreed with itself about what a field is called. The read model's stated reason — "the Python model field IS the column, so it binds straight to the row" — was never true of that runtime, which hands back field-keyed rows by design. The rationale it shipped under (#79) was different and narrower: idiomatic snake_case for a Python consumer, which held only because the example's `@column` happened to be the snake_case of its field name. It also leaked a free-form name. `@column` is an arbitrary override, so `callPurpose` + `@column: purpose_code` emitted a Pydantic field `purpose_code` — neither the wire name nor anything derived from the field name, just the DB column surfacing in the API shape. Idiomatic snake_case is a real goal and this is the wrong lever for it: `columnNamingStrategy` applied to `field.name` is the right one, and this port could not select it either. That is the next commit. The replaced test is rewritten rather than deleted — it pinned the behaviour, so it has to state the opposite and say why — and its `@column` is deliberately no longer the snake_case of the field name, since that coincidence is what made the old behaviour look correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NynrRND6ZUwGvq3ZUTCfxG
1 parent b8fba15 commit 09b312c

2 files changed

Lines changed: 41 additions & 21 deletions

File tree

‎server/python/src/metaobjects/codegen/generators/entity_model.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,20 @@ def _field_line(field: MetaField, imports: set[str], config: GenConfig) -> tuple
339339
else:
340340
assignment = " = None"
341341

342-
# Pydantic field name = @column when present, else field.name. A cross-port
343-
# entity carries the camelCase field.name (the TS property) AND the snake_case
344-
# @column (the physical DB column); the Python model field IS the column, so it
345-
# binds straight to the row. Backward-compatible: snake-authored models with no
346-
# @column emit field.name unchanged.
347-
py_name = field.attrs().get(fc.FIELD_ATTR_COLUMN) or field.name
348-
return f" {py_name}: {annotation}{assignment}", uses_field
342+
# Pydantic field name = field.name, ALWAYS. `@column` is the physical column name,
343+
# a different axis: it is what the persistence layer binds to, never what the model
344+
# calls the field. Every other surface in this port already keyed by field.name —
345+
# the FR-036 create/patch models, the generated router, and the runtime, whose
346+
# ObjectManager maps {_column_of(f): f.name} on every read and RETURNING clause
347+
# "for cross-port row-shape parity" — so keying the read model by @column made one
348+
# generated module disagree with itself, and its stated reason (that the model field
349+
# binds straight to the row) was never true.
350+
#
351+
# Idiomatic snake_case for a Python consumer is a real goal and this is not the
352+
# lever for it: `columnNamingStrategy` applied to field.name is, because @column is
353+
# free-form — `callPurpose` + `@column: purpose_code` used to emit a field
354+
# `purpose_code`, which is neither the wire name nor derived from the field name.
355+
return f" {field.name}: {annotation}{assignment}", uses_field
349356

350357

351358
def _primary_identity_field_names(entity: MetaObject) -> list[str]:

‎server/python/tests/codegen/test_entity_model.py‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -128,22 +128,34 @@ def test_nested_object_array_imports_ref() -> None:
128128
assert "posts: list[PostBrief] | None = None" in out
129129

130130

131-
def test_column_attr_overrides_python_field_name() -> None:
132-
"""A field's ``@column`` (the physical DB column) is the READ-model Pydantic field
133-
name when present, falling back to ``field.name``. This lets one entity carry the
134-
camelCase ``field.name`` the TS port emits as a property AND the snake_case
135-
``@column`` the Python read model emits as the model field (= DB column), so a single
136-
cross-port entity feeds both languages idiomatically. The FR-036 create/patch
137-
VALIDATION models, by contrast, key by the WIRE name (field.name) — the router
138-
validates the raw wire body (see test_create_model_validates_against_wire_name)."""
131+
def test_column_attr_does_not_rename_the_read_model_field() -> None:
132+
"""``@column`` is the PHYSICAL column name. It must not become the Pydantic field
133+
name — that is a different axis, and conflating them made one generated module
134+
disagree with itself.
135+
136+
The read model used to key by ``@column``. Everything else in this port keys by
137+
``field.name``: the FR-036 create/patch models, the generated router (it stamps
138+
``dto["createdAt"]``), and the runtime — ``ObjectManager`` maps
139+
``{_column_of(f): f.name}`` on every read and RETURNING clause, commented "for
140+
cross-port row-shape parity". So the read model's stated reason ("the Python model
141+
field IS the column, so it binds straight to the row") was false: the runtime never
142+
hands out column-keyed rows.
143+
144+
It also leaked. ``@column`` is a free-form override, so a field ``callPurpose`` with
145+
``@column: purpose_code`` produced a Pydantic field ``purpose_code`` — neither the
146+
wire name nor anything derived from the field name, just the DB column surfacing in
147+
the API shape. Idiomatic snake_case for Python is a real goal; the lever for it is
148+
``columnNamingStrategy`` applied to ``field.name``, not the column override.
149+
"""
139150
f = _f("callPurpose", fc.FIELD_SUBTYPE_STRING, required=True)
140-
f.set_attr(fc.FIELD_ATTR_COLUMN, "call_purpose")
151+
# Deliberately NOT the snake_case of the field name — that coincidence is what made
152+
# the old behaviour look right.
153+
f.set_attr(fc.FIELD_ATTR_COLUMN, "purpose_code")
141154
e = _entity("LlmCall", [f], package="app::telemetry")
142155
out = render_entity_model(e)
143-
# Scope to the read model (before the first validation model): it binds @column.
144156
read_model = out.split("class LlmCallCreate")[0]
145-
assert "call_purpose: str" in read_model
146-
assert "callPurpose" not in read_model
157+
assert "callPurpose: str" in read_model
158+
assert "purpose_code" not in read_model
147159

148160

149161
def test_field_name_used_when_no_column_attr() -> None:
@@ -301,8 +313,9 @@ def test_create_model_validates_against_wire_name_not_column() -> None:
301313
f.set_attr(fc.FIELD_ATTR_COLUMN, "full_name")
302314
mod = _load_models(_entity("Person", [f], package="app::p"))
303315

304-
# Read model keeps the @column name; the create model keys by the wire name.
305-
assert "full_name" in mod.Person.model_fields
316+
# BOTH key by the wire name now — the read model no longer renames itself to
317+
# @column, so one module no longer disagrees with itself about what a field is called.
318+
assert "fullName" in mod.Person.model_fields
306319
assert "fullName" in mod.PersonCreate.model_fields
307320
# An over-length WIRE value is now actually rejected (was silently dropped before).
308321
with pytest.raises(ValidationError):

0 commit comments

Comments
 (0)