Skip to content

Commit df72be5

Browse files
dmealingclaude
andcommitted
fix(python): a view's currency reached the wire as a string; the TPH seam states the base-list obligation
Both found by running an adopter estate's Python port on the published rc6. Currency. A table's field.currency column is BIGINT, which pg8000 hands back as an int. A view's need not be: SUM over BIGINT is Postgres numeric, read back as a Decimal, and a FastAPI route serializes a Decimal as a JSON string - so a projection answered "totalRate": "100000" where the contract says 100000, while the entity routes beside it were right. ObjectManager's read codec (_decode_read_value) now decodes an integral currency value to int whatever the column type; a fractional one (an avg over minor units) has no honest int and is returned as read. field.decimal keeps its Decimal. TPH seam. The generated router returns whatever the consumer's repository returns, and the natural repository - ObjectManager - reads a TPH base with the base's columns only, which is the persistence contract in every port. Wired together, GET /api/<base> lost every subtype field and nothing errored. Not changing the runtime contract; the generated Protocol's docstring now states that for subtype=None each row carries its own subtype's fields, and how to get them. Generated output changes by that docstring only. Verified: new runtime tests (5) + the seam test; Python codegen 585/0, runtime 41/0, unit + query scenarios + return types 557/0. The estate's Python port runs 43/43 on this build (42/43 on published rc6, the currency scenario). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EaGv4zNk4oT7FooD4eK5MD
1 parent 8c5942b commit df72be5

5 files changed

Lines changed: 114 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,26 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
302302
reference server and the Python in-memory seam ignored the filter and returned every row,
303303
and the TS reference server's allowlist gave the discriminator no operators.
304304

305+
- **Python runtime: a `field.currency` read from a view answered as a JSON STRING, not
306+
integer minor units.** A table's currency column is BIGINT, which the driver already hands
307+
back as an `int`. A view's need not be: `SUM` over BIGINT is Postgres `numeric`, read back
308+
as a `Decimal`, and a FastAPI route serializes a `Decimal` as a string — so
309+
`GET /api/shipment_summaries` answered `"totalRate": "100000"` where the contract says
310+
`100000`, while the entity routes beside it were right. The value's wire form depended on
311+
the physical column type instead of the declared field. `ObjectManager`'s read codec now
312+
decodes an integral currency value to `int` whatever the column type; a fractional one (an
313+
`avg` over minor units) has no honest integer and is returned as read. Found by an adopter
314+
estate's Python port once its read-only projection router existed (1.0.5's F22 fix).
315+
316+
- **Python codegen: the generated TPH repository Protocol now states what the base
317+
collection must return.** The router returns whatever the consumer's repository returns,
318+
and the natural repository — `ObjectManager` — reads a TPH base with the base's columns
319+
only (the persistence contract in every port). An adopter wiring one to the other served
320+
`GET /api/<base>` with every subtype field missing and nothing erroring. The Protocol's
321+
docstring now says that for `subtype=None` each row carries its OWN subtype's fields, and
322+
how to get them. **Generated output changes (a docstring only)** — run `metaobjects gen`
323+
to pick it up.
324+
305325
- **Maven plugin: a relative `<sourceDir>` resolved against the shell's working directory,
306326
not the module.** The documented `<sourceDir>src/main/metaobjects</sourceDir>` therefore
307327
failed `SourceDir [src/main/metaobjects] does not exist` in any multi-module reactor build

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -937,7 +937,12 @@ def _render_tph_router(
937937
# Subtype-keyed repository Protocol (None == the polymorphic base).
938938
parts.append(f"class {repo_class}(Protocol):")
939939
parts.append(' """GENERATED — TPH seam. `subtype` is the @discriminatorValue, or None for')
940-
parts.append(' the polymorphic base; the consumer scopes the single table accordingly."""')
940+
parts.append(' the polymorphic base; the consumer scopes the single table accordingly.')
941+
parts.append("")
942+
parts.append(" For `subtype=None` the base collection is the discriminated union: each row")
943+
parts.append(" carries its OWN subtype's fields, and `find_by_id(None, id)` likewise. The")
944+
parts.append(" route returns what this returns. ObjectManager's base read projects only the")
945+
parts.append(" base's columns, so read a base row through its own subtype entity.\"\"\"")
941946
parts.append(" def list(")
942947
parts.append(" self,")
943948
parts.append(" subtype: str | None,")

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -903,10 +903,15 @@ def _int_value_map(field: MetaField) -> dict[Any, Any] | None:
903903
def _decode_read_value(field: MetaField, value: Any) -> Any:
904904
"""Decode a stored value back to its authoring form on read.
905905
906-
The inverse of :func:`_coerce_write_value`'s int-backed-enum branch, and
907-
today its only case: the column holds the member's integer, callers expect
908-
the member SYMBOL. Every other field subtype is returned verbatim, keeping
909-
ADR-0019's "runtime returns native in-process types" contract intact.
906+
Two cases. The inverse of :func:`_coerce_write_value`'s int-backed-enum branch:
907+
the column holds the member's integer, callers expect the member SYMBOL. And
908+
``field.currency``, whose native type is ``int`` minor units: a table column is
909+
BIGINT and already reads back as an ``int``, but a VIEW column need not be —
910+
``SUM`` over BIGINT is Postgres ``numeric``, read back as a ``Decimal``, which a
911+
FastAPI route then serializes as a JSON STRING. An integral ``Decimal`` is
912+
decoded to ``int``; a fractional one (an ``avg`` over minor units) has no honest
913+
``int`` and is returned as read. Every other field subtype is returned verbatim,
914+
keeping ADR-0019's "runtime returns native in-process types" contract intact.
910915
911916
An int with no member RAISES. The database then holds a value the model says
912917
is impossible — a hand-written INSERT, or a member removed without a
@@ -918,6 +923,10 @@ def _decode_read_value(field: MetaField, value: Any) -> Any:
918923
"""
919924
if value is None:
920925
return None
926+
if field.sub_type == fc.FIELD_SUBTYPE_CURRENCY:
927+
if isinstance(value, _decimal.Decimal) and value == value.to_integral_value():
928+
return int(value)
929+
return value
921930
if field.sub_type != fc.FIELD_SUBTYPE_ENUM:
922931
return value
923932
int_map = _int_value_map(field)

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ def test_base_router_polymorphic_plus_per_subtype(entities, index) -> None:
130130
# ---- filter allowlist: base folds in subtype-only filterable columns -------
131131

132132

133+
def test_base_seam_states_the_discriminated_union_obligation(entities, index) -> None:
134+
# The router returns whatever the consumer's repository returns, and the natural
135+
# repository — ObjectManager — reads a TPH BASE with the base's columns only (the
136+
# persistence contract in every port). An adopter wiring one to the other served
137+
# `GET /<base>` without any subtype field and nothing errored. The seam says so.
138+
src = render_router(entities["Auth"], index)
139+
assert src is not None
140+
prose = " ".join(src.split()) # the docstring wraps; assert on its words
141+
assert "each row carries its OWN subtype's fields" in prose
142+
assert "ObjectManager's base read projects only the base's columns" in prose
143+
144+
133145
def test_base_allowlist_unions_subtype_fields(entities, index) -> None:
134146
src = render_filter_allowlist(entities["Auth"], index)
135147
assert src is not None
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""field.currency read decoding — the Python ObjectManager.
2+
3+
Currency is integer minor units, natively and on the wire. A table column stores it as
4+
BIGINT, which pg8000 already hands back as an ``int``. A VIEW column need not: ``SUM`` over
5+
BIGINT is Postgres ``numeric``, which pg8000 hands back as a ``Decimal`` — and a ``Decimal``
6+
leaves a FastAPI route as the JSON STRING ``"100000"`` (Pydantic v2 serializes it that way),
7+
where the contract says the number ``100000``. So the value's wire form depended on the
8+
physical column type rather than on the declared field. The read codec now decodes an
9+
integral currency value to ``int`` whatever the column's type.
10+
"""
11+
from __future__ import annotations
12+
13+
from decimal import Decimal
14+
15+
from metaobjects.loader.meta_data_loader import MetaDataLoader
16+
from metaobjects.loader.sources import InMemoryStringSource
17+
from metaobjects.runtime.object_manager import _decode_read_value
18+
19+
MODEL = """{"metadata.root": {"package": "acme", "children": [
20+
{"object.entity": {"name": "Leg", "children": [
21+
{"source.rdb": {"@table": "legs"}},
22+
{"field.long": {"name": "id"}},
23+
{"field.currency": {"name": "rate", "@currency": "USD"}},
24+
{"field.decimal": {"name": "weight", "@precision": 10, "@scale": 2}},
25+
{"identity.primary": {"name": "pk", "@fields": "id", "@generation": "increment"}}
26+
]}}
27+
]}}"""
28+
29+
30+
def _field(name: str):
31+
result = MetaDataLoader().load([InMemoryStringSource(MODEL, "test.json")])
32+
assert result.errors == []
33+
leg = next(c for c in result.root.children() if c.name == "Leg")
34+
return next(f for f in leg.fields() if f.name == name)
35+
36+
37+
def test_integral_decimal_currency_decodes_to_int_minor_units() -> None:
38+
value = _decode_read_value(_field("rate"), Decimal("100000"))
39+
assert value == 100000
40+
assert type(value) is int
41+
42+
43+
def test_int_currency_is_unchanged() -> None:
44+
value = _decode_read_value(_field("rate"), 1099)
45+
assert value == 1099
46+
assert type(value) is int
47+
48+
49+
def test_null_currency_stays_null() -> None:
50+
assert _decode_read_value(_field("rate"), None) is None
51+
52+
53+
def test_fractional_decimal_currency_is_left_alone() -> None:
54+
# An avg over minor units can be fractional; no int is honest, so the value is
55+
# returned as read rather than silently rounded.
56+
value = _decode_read_value(_field("rate"), Decimal("12.5"))
57+
assert value == Decimal("12.5")
58+
59+
60+
def test_field_decimal_keeps_its_decimal() -> None:
61+
value = _decode_read_value(_field("weight"), Decimal("12.50"))
62+
assert value == Decimal("12.50")
63+
assert type(value) is Decimal

0 commit comments

Comments
 (0)