metaobjects 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. metaobjects/__init__.py +75 -0
  2. metaobjects/agent_context/__init__.py +55 -0
  3. metaobjects/agent_context/_content/README.md +14 -0
  4. metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
  5. metaobjects/agent_context/_content/servers/java.meta.json +5 -0
  6. metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
  7. metaobjects/agent_context/_content/servers/python.meta.json +5 -0
  8. metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
  9. metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
  10. metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
  11. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
  12. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
  13. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
  14. metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
  15. metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
  16. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
  17. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
  18. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
  19. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
  20. metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
  21. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
  22. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
  23. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
  24. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
  25. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
  26. metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
  27. metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
  28. metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
  29. metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
  30. metaobjects/agent_context/assemble.py +133 -0
  31. metaobjects/agent_context/content_root.py +54 -0
  32. metaobjects/agent_context/scaffold.py +191 -0
  33. metaobjects/agent_context/types.py +44 -0
  34. metaobjects/attr_class_map.py +23 -0
  35. metaobjects/cli.py +696 -0
  36. metaobjects/codegen/__init__.py +0 -0
  37. metaobjects/codegen/config.py +11 -0
  38. metaobjects/codegen/constants.py +13 -0
  39. metaobjects/codegen/extract_delegate_emitter.py +384 -0
  40. metaobjects/codegen/extract_schema_emitter.py +139 -0
  41. metaobjects/codegen/format.py +31 -0
  42. metaobjects/codegen/fr010_field_mapping.py +220 -0
  43. metaobjects/codegen/generator.py +62 -0
  44. metaobjects/codegen/generator_registry.py +163 -0
  45. metaobjects/codegen/generators/__init__.py +0 -0
  46. metaobjects/codegen/generators/entity_model.py +263 -0
  47. metaobjects/codegen/generators/extractor_generator.py +317 -0
  48. metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
  49. metaobjects/codegen/generators/m2m_codegen.py +192 -0
  50. metaobjects/codegen/generators/output_parser_generator.py +272 -0
  51. metaobjects/codegen/generators/output_prompt_generator.py +192 -0
  52. metaobjects/codegen/generators/payload_vo_generator.py +672 -0
  53. metaobjects/codegen/generators/render_helper_generator.py +451 -0
  54. metaobjects/codegen/generators/router_generator.py +635 -0
  55. metaobjects/codegen/generators/template_generator.py +70 -0
  56. metaobjects/codegen/generators/tph_plan.py +120 -0
  57. metaobjects/codegen/generators/trace_helper_generator.py +336 -0
  58. metaobjects/codegen/instance_artifacts.py +15 -0
  59. metaobjects/codegen/output_format_spec_emitter.py +79 -0
  60. metaobjects/codegen/overwrite_policy.py +27 -0
  61. metaobjects/codegen/runner.py +110 -0
  62. metaobjects/codegen/runtime/__init__.py +6 -0
  63. metaobjects/codegen/runtime/filter_parser.py +193 -0
  64. metaobjects/codegen/type_map.py +84 -0
  65. metaobjects/core_types.py +809 -0
  66. metaobjects/datatype.py +19 -0
  67. metaobjects/documentation/__init__.py +28 -0
  68. metaobjects/documentation/doc_constants.py +20 -0
  69. metaobjects/documentation/doc_provider.py +20 -0
  70. metaobjects/documentation/doc_schema.py +24 -0
  71. metaobjects/errors.py +124 -0
  72. metaobjects/loader/__init__.py +0 -0
  73. metaobjects/loader/merge.py +287 -0
  74. metaobjects/loader/meta_data_loader.py +245 -0
  75. metaobjects/loader/sources/__init__.py +24 -0
  76. metaobjects/loader/sources/directory_source.py +50 -0
  77. metaobjects/loader/sources/file_source.py +41 -0
  78. metaobjects/loader/sources/meta_data_source.py +67 -0
  79. metaobjects/loader/sources/uri_source.py +56 -0
  80. metaobjects/loader/validate_discriminator.py +181 -0
  81. metaobjects/loader/validate_field_readonly.py +146 -0
  82. metaobjects/loader/validate_source_parameter_ref.py +159 -0
  83. metaobjects/loader/validate_source_physical_names.py +140 -0
  84. metaobjects/loader/validation_passes.py +1513 -0
  85. metaobjects/meta/__init__.py +1 -0
  86. metaobjects/meta/core/__init__.py +0 -0
  87. metaobjects/meta/core/attr/__init__.py +0 -0
  88. metaobjects/meta/core/attr/attr_constants.py +31 -0
  89. metaobjects/meta/core/attr/meta_attr.py +136 -0
  90. metaobjects/meta/core/field/__init__.py +0 -0
  91. metaobjects/meta/core/field/field_constants.py +105 -0
  92. metaobjects/meta/core/field/meta_field.py +76 -0
  93. metaobjects/meta/core/identity/__init__.py +0 -0
  94. metaobjects/meta/core/identity/identity_constants.py +19 -0
  95. metaobjects/meta/core/identity/meta_identity.py +8 -0
  96. metaobjects/meta/core/object/__init__.py +0 -0
  97. metaobjects/meta/core/object/meta_object.py +65 -0
  98. metaobjects/meta/core/object/meta_object_aware.py +43 -0
  99. metaobjects/meta/core/object/object_class_registry.py +56 -0
  100. metaobjects/meta/core/object/object_constants.py +13 -0
  101. metaobjects/meta/core/object/object_extract.py +400 -0
  102. metaobjects/meta/core/object/value_object.py +70 -0
  103. metaobjects/meta/core/relationship/__init__.py +0 -0
  104. metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
  105. metaobjects/meta/core/relationship/meta_relationship.py +54 -0
  106. metaobjects/meta/core/relationship/relationship_constants.py +51 -0
  107. metaobjects/meta/core/validator/__init__.py +0 -0
  108. metaobjects/meta/core/validator/validator_constants.py +18 -0
  109. metaobjects/meta/meta_data.py +206 -0
  110. metaobjects/meta/meta_root.py +8 -0
  111. metaobjects/meta/persistence/__init__.py +0 -0
  112. metaobjects/meta/persistence/db/__init__.py +1 -0
  113. metaobjects/meta/persistence/db/db_constants.py +41 -0
  114. metaobjects/meta/persistence/db/db_provider.py +60 -0
  115. metaobjects/meta/persistence/origin/__init__.py +0 -0
  116. metaobjects/meta/persistence/origin/meta_origin.py +8 -0
  117. metaobjects/meta/persistence/origin/origin_constants.py +20 -0
  118. metaobjects/meta/persistence/source/__init__.py +0 -0
  119. metaobjects/meta/persistence/source/meta_source.py +137 -0
  120. metaobjects/meta/persistence/source/source_constants.py +115 -0
  121. metaobjects/meta/presentation/__init__.py +0 -0
  122. metaobjects/meta/presentation/layout/__init__.py +0 -0
  123. metaobjects/meta/presentation/layout/layout_constants.py +13 -0
  124. metaobjects/meta/presentation/layout/meta_layout.py +8 -0
  125. metaobjects/meta/presentation/view/__init__.py +0 -0
  126. metaobjects/meta/presentation/view/meta_view.py +8 -0
  127. metaobjects/meta/presentation/view/view_constants.py +22 -0
  128. metaobjects/meta/template/__init__.py +0 -0
  129. metaobjects/meta/template/meta_template.py +46 -0
  130. metaobjects/meta/template/template_constants.py +112 -0
  131. metaobjects/meta/template/template_provider.py +43 -0
  132. metaobjects/parser.py +380 -0
  133. metaobjects/parser_yaml.py +82 -0
  134. metaobjects/provider.py +111 -0
  135. metaobjects/py.typed +0 -0
  136. metaobjects/registry.py +210 -0
  137. metaobjects/registry_manifest.py +223 -0
  138. metaobjects/render/__init__.py +74 -0
  139. metaobjects/render/email_document.py +14 -0
  140. metaobjects/render/escapers.py +109 -0
  141. metaobjects/render/extract/__init__.py +59 -0
  142. metaobjects/render/extract/coerce.py +279 -0
  143. metaobjects/render/extract/extract.py +211 -0
  144. metaobjects/render/extract/extract_map.py +61 -0
  145. metaobjects/render/extract/json_forgiving_reader.py +203 -0
  146. metaobjects/render/extract/locate.py +65 -0
  147. metaobjects/render/extract/normalize.py +96 -0
  148. metaobjects/render/extract/strip.py +20 -0
  149. metaobjects/render/extract/types.py +332 -0
  150. metaobjects/render/extract/xml_forgiving_reader.py +162 -0
  151. metaobjects/render/filesystem_provider.py +51 -0
  152. metaobjects/render/prompt/__init__.py +32 -0
  153. metaobjects/render/prompt/output_format_renderer.py +340 -0
  154. metaobjects/render/prompt/output_format_spec.py +28 -0
  155. metaobjects/render/prompt/prompt_field.py +29 -0
  156. metaobjects/render/prompt/prompt_overrides.py +29 -0
  157. metaobjects/render/prompt/prompt_style.py +38 -0
  158. metaobjects/render/renderer.py +358 -0
  159. metaobjects/render/verify.py +266 -0
  160. metaobjects/runtime/__init__.py +39 -0
  161. metaobjects/runtime/llm_recorder.py +210 -0
  162. metaobjects/runtime/n2m_resolver.py +155 -0
  163. metaobjects/runtime/object_manager.py +715 -0
  164. metaobjects/runtime/tph.py +50 -0
  165. metaobjects/serializer_json.py +172 -0
  166. metaobjects/shared/__init__.py +0 -0
  167. metaobjects/shared/base_types.py +16 -0
  168. metaobjects/shared/separators.py +4 -0
  169. metaobjects/shared/structural.py +9 -0
  170. metaobjects/source/__init__.py +79 -0
  171. metaobjects/source/error_source.py +266 -0
  172. metaobjects/source/json_path.py +106 -0
  173. metaobjects/source/semantic_diff.py +98 -0
  174. metaobjects/source/yaml_positions.py +174 -0
  175. metaobjects/super_resolve.py +128 -0
  176. metaobjects/yaml_desugar.py +481 -0
  177. metaobjects-0.9.0.dist-info/METADATA +97 -0
  178. metaobjects-0.9.0.dist-info/RECORD +181 -0
  179. metaobjects-0.9.0.dist-info/WHEEL +4 -0
  180. metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
  181. metaobjects-0.9.0.dist-info/licenses/LICENSE +189 -0
@@ -0,0 +1,715 @@
1
+ """ObjectManager + PostgresDriver — minimal query runtime.
2
+
3
+ Compiles a Filter dict to parameterized SQL and runs it via a pg-style
4
+ driver. Identifiers are double-quoted throughout (mixed-case columns like
5
+ `programId` round-trip through PG); placeholders are pg8000 / psycopg-style
6
+ ``%s``.
7
+
8
+ Per ADR-0019 (runtime return-type contract) the query path returns **native,
9
+ in-process Python types** — pg8000's own `int` / `Decimal` / `datetime` /
10
+ `date` / `time` / `uuid.UUID` / `dict` / `list`. Canonicalization to the
11
+ cross-port wire form is a *serialization/boundary* concern, applied by the
12
+ persistence runner (see ``tests/integration/normalization.py``), never baked
13
+ into this runtime query path.
14
+
15
+ The one piece of SQL-type information the boundary cannot recover from a native
16
+ Python value is the int4-vs-int8 distinction (pg8000 returns plain ``int`` for
17
+ both INTEGER and BIGINT, and a BIGINT aggregate over an INTEGER column — e.g.
18
+ ``count``/``sum`` → BIGINT vs ``min``/``max`` → INTEGER on the projection views —
19
+ is genuinely indistinguishable by value). Other ports get this for free from
20
+ their driver's native typing (Java JDBC ``Long`` vs ``Integer``; node-postgres
21
+ BIGINT-as-string vs INTEGER-as-number). To keep the same discriminator available
22
+ at the Python boundary we expose the per-column OID alongside each query — as
23
+ out-of-band type metadata, not by mutating the native row values — so the runner
24
+ can apply the BIGINT→string wire rule by SQL type, exactly like the other ports.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import datetime as _dt
29
+ import decimal as _decimal
30
+ import uuid as _uuid
31
+ from collections.abc import Iterable
32
+ from typing import Any, Protocol
33
+
34
+ from ..meta.meta_root import MetaRoot
35
+ from ..meta.core.object.meta_object import MetaObject
36
+ from ..meta.core.field.meta_field import MetaField
37
+ from ..meta.core.field import field_constants as fc
38
+ from ..meta.core.identity import identity_constants as ic
39
+ from ..meta.persistence.db import db_constants as dbc
40
+ from ..meta.persistence.source.meta_source import MetaSource
41
+ from ..meta.persistence.source import source_constants as sc
42
+ from .n2m_resolver import (
43
+ N2mDescriptor,
44
+ collect_column_ids,
45
+ collect_symmetric_target_ids,
46
+ resolve_n2m_descriptor,
47
+ )
48
+ from .tph import TphSubtype, tph_subtype_of
49
+
50
+
51
+ # Filter shape:
52
+ # {"field": "value"} → equality shortcut
53
+ # {"field": {"eq": v, "gt": v, ...}} → typed ops on a field
54
+ # {"and": [filter, filter, ...]} → top-level combinator
55
+ Filter = dict[str, Any]
56
+
57
+
58
+ class Cursor(Protocol):
59
+ def execute(self, sql: str, params: tuple[Any, ...] = ...) -> Any: ...
60
+ def fetchall(self) -> list[Any]: ...
61
+ @property
62
+ def description(self) -> Any: ...
63
+ def close(self) -> None: ...
64
+
65
+
66
+ class Connection(Protocol):
67
+ def cursor(self) -> Cursor: ...
68
+ def commit(self) -> None: ...
69
+ def close(self) -> None: ...
70
+
71
+
72
+ class SelectResult:
73
+ """A native-typed result set plus the per-column OID type metadata.
74
+
75
+ ``rows`` carry pg8000's native Python values verbatim (ADR-0019 — the runtime
76
+ returns native types, never wire-strings). ``column_oids`` maps each selected
77
+ column name to its Postgres type OID so the serialization boundary can apply
78
+ the int4-vs-int8 (and any other SQL-type-driven) wire rule without inspecting
79
+ the value — type metadata travels beside the data, not inside it.
80
+ """
81
+
82
+ __slots__ = ("rows", "column_oids")
83
+
84
+ def __init__(self, rows: list[dict[str, Any]], column_oids: dict[str, int]) -> None:
85
+ self.rows = rows
86
+ self.column_oids = column_oids
87
+
88
+
89
+ class PostgresDriver:
90
+ """Wrap a DB-API 2 connection (pg8000 / psycopg). Owns no state itself."""
91
+
92
+ def __init__(self, conn: Connection) -> None:
93
+ self._conn = conn
94
+
95
+ def select(self, sql: str, params: tuple[Any, ...] = ()) -> SelectResult:
96
+ cur = self._conn.cursor()
97
+ try:
98
+ cur.execute(sql, params)
99
+ cols = [d[0] for d in cur.description]
100
+ oids = [d[1] for d in cur.description]
101
+ column_oids = {c: oids[i] for i, c in enumerate(cols)}
102
+ rows = [
103
+ {c: v for c, v in zip(cols, row)}
104
+ for row in cur.fetchall()
105
+ ]
106
+ return SelectResult(rows, column_oids)
107
+ finally:
108
+ cur.close()
109
+
110
+ def scalar(self, sql: str, params: tuple[Any, ...] = ()) -> Any:
111
+ result = self.select(sql, params)
112
+ if not result.rows:
113
+ return None
114
+ return next(iter(result.rows[0].values()))
115
+
116
+ def insert_returning(self, sql: str, params: tuple[Any, ...] = ()) -> SelectResult:
117
+ """Run an INSERT ... RETURNING and return a single-row :class:`SelectResult`
118
+ (row + per-column OID type metadata).
119
+
120
+ The write path commits on success (each conformance scenario owns a
121
+ fresh container, so autocommit-per-write semantics are fine and keep the
122
+ round-trip read in the same connection visible without a separate
123
+ transaction handshake). The OIDs carry the int4-vs-int8 (BIGINT→string)
124
+ wire discriminator into the serialization boundary, exactly like the read
125
+ path — so an op:create asserting the RETURNING row sees BIGINT as a string.
126
+ """
127
+ cur = self._conn.cursor()
128
+ try:
129
+ cur.execute(sql, params)
130
+ cols = [d[0] for d in cur.description]
131
+ oids = [d[1] for d in cur.description]
132
+ row = cur.fetchone()
133
+ self._conn.commit()
134
+ column_oids = {c: oids[i] for i, c in enumerate(cols)}
135
+ return SelectResult([{c: row[i] for i, c in enumerate(cols)}], column_oids)
136
+ finally:
137
+ cur.close()
138
+
139
+ def update_returning(
140
+ self, sql: str, params: tuple[Any, ...] = ()
141
+ ) -> SelectResult | None:
142
+ """Run an UPDATE ... RETURNING; return a single-row :class:`SelectResult`
143
+ (row + per-column OID type metadata), or ``None`` when no row matched.
144
+ Commits on success (same per-write autocommit semantics as
145
+ ``insert_returning``). The OIDs carry the int4-vs-int8 (BIGINT→string)
146
+ wire discriminator into the serialization boundary, exactly like the read
147
+ path — so a BIGINT/currency column updates back as a numeric string."""
148
+ cur = self._conn.cursor()
149
+ try:
150
+ cur.execute(sql, params)
151
+ cols = [d[0] for d in cur.description]
152
+ oids = [d[1] for d in cur.description]
153
+ row = cur.fetchone()
154
+ self._conn.commit()
155
+ if row is None:
156
+ return None
157
+ column_oids = {c: oids[i] for i, c in enumerate(cols)}
158
+ return SelectResult([{c: row[i] for i, c in enumerate(cols)}], column_oids)
159
+ finally:
160
+ cur.close()
161
+
162
+ def execute_rowcount(self, sql: str, params: tuple[Any, ...] = ()) -> int:
163
+ """Run a DML statement (DELETE / UPDATE) and return the affected row
164
+ count. Commits on success."""
165
+ cur = self._conn.cursor()
166
+ try:
167
+ cur.execute(sql, params)
168
+ count = cur.rowcount
169
+ self._conn.commit()
170
+ return int(count) if count is not None else 0
171
+ finally:
172
+ cur.close()
173
+
174
+
175
+ class ObjectManager:
176
+ """Method-based read API. Translates Filter dicts → parameterized SQL."""
177
+
178
+ def __init__(self, root: MetaRoot, driver: PostgresDriver) -> None:
179
+ self._root = root
180
+ self._driver = driver
181
+ self._entity_by_name: dict[str, MetaObject] = {}
182
+ for c in root.own_children():
183
+ if isinstance(c, MetaObject):
184
+ self._entity_by_name.setdefault(c.name, c)
185
+ #: Per-field Postgres type OID from the most recent ``find_*`` query,
186
+ #: keyed by metadata field name. Out-of-band SQL-type metadata for the
187
+ #: serialization boundary (the int4-vs-int8 wire discriminator); the
188
+ #: returned row values themselves stay native (ADR-0019).
189
+ #:
190
+ #: Single-query-scoped: overwritten by each ``find_*`` call, so read it
191
+ #: immediately after the query whose result you are serializing. Not
192
+ #: safe to interleave concurrent queries on one ObjectManager instance
193
+ #: (the row values are unaffected — only this discriminator is racy).
194
+ self.last_column_oids: dict[str, int] = {}
195
+
196
+ # --- Public API ----------------------------------------------------------
197
+
198
+ def find_by_id(self, entity_name: str, id_value: Any) -> dict[str, Any] | None:
199
+ entity = self._require_entity(entity_name)
200
+ pk_field = self._primary_pk_field(entity)
201
+ rows = self.find_many(entity_name, {pk_field: id_value}, sort=None, limit=1, offset=None)
202
+ return rows[0] if rows else None
203
+
204
+ def create(self, entity_name: str, data: dict[str, Any]) -> dict[str, Any]:
205
+ """INSERT a row through the runtime write path, returning the inserted row.
206
+
207
+ ``data`` carries field-keyed values in their *native authoring forms*
208
+ (the persistence-conformance ``op: roundtrip`` contract): a decimal /
209
+ uuid / temporal as a string, a ``field.object`` as a dict, currency as an
210
+ integer (minor units). Each value is coerced to the native Python type
211
+ the driver binds to the column's physical type (``Decimal`` / ``uuid.UUID``
212
+ / ``date`` / ``time`` / naive-or-aware ``datetime`` / ``dict``→jsonb), so
213
+ the WRITE codec is exercised end-to-end. Server-defaulted columns the
214
+ caller omits (e.g. a ``gen_random_uuid()`` PK) are left out of the INSERT
215
+ and filled by Postgres; the full row — including the generated PK — is
216
+ returned via ``RETURNING`` so a round-trip read can key off it.
217
+ """
218
+ entity = self._require_entity(entity_name)
219
+ table = self._table_name(entity)
220
+
221
+ # FR-017 TPH: a subtype create injects its discriminator value (the entity
222
+ # names the subtype; the caller never sets it).
223
+ tph = tph_subtype_of(entity)
224
+ if tph is not None:
225
+ data = {**data, tph.field: tph.value}
226
+
227
+ insert_cols: list[str] = []
228
+ params: list[Any] = []
229
+ for field_name, raw in data.items():
230
+ f = entity.find_field(field_name)
231
+ if f is None:
232
+ raise ValueError(
233
+ f"create('{entity_name}'): no field '{field_name}' in metadata"
234
+ )
235
+ insert_cols.append(_column_of(f))
236
+ params.append(_coerce_write_value(f, raw))
237
+
238
+ # Always RETURNING the full physical column set so the inserted row —
239
+ # including any server-generated PK / default — comes back, then map
240
+ # columns → metadata field names for cross-port row-shape parity.
241
+ all_cols = [_column_of(f) for f in entity.fields()]
242
+ col_to_field = {_column_of(f): f.name for f in entity.fields()}
243
+
244
+ if insert_cols:
245
+ col_list = ", ".join(_q(c) for c in insert_cols)
246
+ placeholders = ", ".join("%s" for _ in insert_cols)
247
+ sql = (
248
+ f"INSERT INTO {_q(table)} ({col_list}) VALUES ({placeholders}) "
249
+ f"RETURNING {', '.join(_q(c) for c in all_cols)}"
250
+ )
251
+ else:
252
+ sql = (
253
+ f"INSERT INTO {_q(table)} DEFAULT VALUES "
254
+ f"RETURNING {', '.join(_q(c) for c in all_cols)}"
255
+ )
256
+ result = self._driver.insert_returning(sql, tuple(params))
257
+ # Surface the RETURNING column OIDs (mapped to metadata field names) so the
258
+ # serialization boundary applies the same SQL-type wire rules as a read — a
259
+ # BIGINT / currency column comes back as a numeric string. Mirrors update().
260
+ self.last_column_oids = {
261
+ col_to_field.get(c, c): oid for c, oid in result.column_oids.items()
262
+ }
263
+ row = result.rows[0]
264
+ return {col_to_field.get(k, k): v for k, v in row.items()}
265
+
266
+ def update(
267
+ self,
268
+ entity_name: str,
269
+ id_value: Any,
270
+ data: dict[str, Any],
271
+ *,
272
+ if_missing: str = "ignore",
273
+ ) -> dict[str, Any] | None:
274
+ """UPDATE a row by primary key through the runtime write path.
275
+
276
+ ``data`` carries field-keyed values in the same *native authoring forms*
277
+ as :meth:`create` (a decimal/uuid/temporal as a string, ``field.object``
278
+ as a dict, currency as integer minor units). Each value is run through the
279
+ SAME write codec the INSERT path uses (``_coerce_write_value``) so the
280
+ PATCH path cannot silently skip the type coercion INSERT applies — the
281
+ per-port hazard the ``update-delete-all-types`` corpus gates.
282
+
283
+ Returns the updated row (mapped to metadata field names) via ``RETURNING``.
284
+ When no row matched the (TPH-scoped) PK, behavior follows *if_missing*
285
+ (mirrors the TS ``WriteOpts.ifMissing``): ``"ignore"`` (default) → ``None``
286
+ so a REST route renders 404; ``"throw"`` → raise so the persistence DSL's
287
+ ``expect-error`` op is satisfied. The discriminator is immutable, so a TPH
288
+ subtype's patch strips it and the by-id write is scoped to the subtype (a
289
+ cross-subtype id matches no row → the same not-found path).
290
+ """
291
+ entity = self._require_entity(entity_name)
292
+ table = self._table_name(entity)
293
+ pk_field = self._primary_pk_field(entity)
294
+ pk_col = _column_of(entity.find_field(pk_field))
295
+
296
+ # FR-017 TPH: the discriminator is immutable — strip it from the patch; the
297
+ # by-id write is subtype-scoped (a row of a different subtype is invisible).
298
+ tph = tph_subtype_of(entity)
299
+ if tph is not None and tph.field in data:
300
+ data = {k: v for k, v in data.items() if k != tph.field}
301
+
302
+ set_cols: list[str] = []
303
+ params: list[Any] = []
304
+ for field_name, raw in data.items():
305
+ f = entity.find_field(field_name)
306
+ if f is None:
307
+ raise ValueError(
308
+ f"update('{entity_name}'): no field '{field_name}' in metadata"
309
+ )
310
+ set_cols.append(_column_of(f))
311
+ params.append(_coerce_write_value(f, raw))
312
+ if not set_cols:
313
+ # No columns to set → just read the (scoped) row back by PK (no-op update).
314
+ row = self.find_by_id(entity_name, id_value)
315
+ return self._on_missing_update(entity_name, pk_field, id_value, if_missing) if row is None else row
316
+
317
+ all_cols = [_column_of(f) for f in entity.fields()]
318
+ col_to_field = {_column_of(f): f.name for f in entity.fields()}
319
+ assignments = ", ".join(f"{_q(c)} = %s" for c in set_cols)
320
+ params.append(_coerce_write_value(entity.find_field(pk_field), id_value))
321
+ where = f"{_q(pk_col)} = %s"
322
+ if tph is not None:
323
+ where += f" AND {_q(_column_of(entity.find_field(tph.field)))} = %s"
324
+ params.append(tph.value)
325
+ sql = (
326
+ f"UPDATE {_q(table)} SET {assignments} WHERE {where} "
327
+ f"RETURNING {', '.join(_q(c) for c in all_cols)}"
328
+ )
329
+ result = self._driver.update_returning(sql, tuple(params))
330
+ if result is None:
331
+ self.last_column_oids = {}
332
+ return self._on_missing_update(entity_name, pk_field, id_value, if_missing)
333
+ # Surface the RETURNING column OIDs (mapped to metadata field names) so the
334
+ # serialization boundary applies the same SQL-type wire rules as a read —
335
+ # a BIGINT / currency column comes back as a numeric string. Mirrors the
336
+ # find_many bookkeeping.
337
+ self.last_column_oids = {
338
+ col_to_field.get(c, c): oid for c, oid in result.column_oids.items()
339
+ }
340
+ row = result.rows[0]
341
+ return {col_to_field.get(k, k): v for k, v in row.items()}
342
+
343
+ @staticmethod
344
+ def _on_missing_update(
345
+ entity_name: str, pk_field: str, id_value: Any, if_missing: str
346
+ ) -> None:
347
+ """No (TPH-scoped) row matched an update: ``"throw"`` raises (the persistence
348
+ ``expect-error`` contract), ``"ignore"`` returns ``None`` (REST route → 404)."""
349
+ if if_missing == "throw":
350
+ raise KeyError(
351
+ f"update('{entity_name}'): no row with {pk_field}={id_value!r} in scope"
352
+ )
353
+ return None
354
+
355
+ def delete(self, entity_name: str, id_value: Any) -> bool:
356
+ """DELETE a row by primary key through the runtime write path.
357
+
358
+ Returns ``True`` when a row was deleted, ``False`` when the PK matched
359
+ nothing. Mirrors the TS ``om.delete`` boolean outcome contract.
360
+ """
361
+ entity = self._require_entity(entity_name)
362
+ table = self._table_name(entity)
363
+ pk_field = self._primary_pk_field(entity)
364
+ pk_col = _column_of(entity.find_field(pk_field))
365
+ params: list[Any] = [_coerce_write_value(entity.find_field(pk_field), id_value)]
366
+ where = f"{_q(pk_col)} = %s"
367
+ # FR-017 TPH: a subtype delete is scoped to its discriminator (cross-subtype
368
+ # delete matches no row → False, mirroring the per-subtype route's 404).
369
+ tph = tph_subtype_of(entity)
370
+ if tph is not None:
371
+ where += f" AND {_q(_column_of(entity.find_field(tph.field)))} = %s"
372
+ params.append(tph.value)
373
+ sql = f"DELETE FROM {_q(table)} WHERE {where}"
374
+ return self._driver.execute_rowcount(sql, tuple(params)) > 0
375
+
376
+ def find_many(
377
+ self,
378
+ entity_name: str,
379
+ filter: Filter | None = None,
380
+ *,
381
+ sort: Iterable[tuple[str, str]] | None = None,
382
+ limit: int | None = None,
383
+ offset: int | None = None,
384
+ ) -> list[dict[str, Any]]:
385
+ entity = self._require_entity(entity_name)
386
+ table = self._table_name(entity)
387
+ cols = [_column_of(f) for f in entity.fields()]
388
+ sql = f'SELECT {", ".join(_q(c) for c in cols)} FROM {_q(table)}'
389
+ params: list[Any] = []
390
+ # FR-017 TPH: a subtype read is scoped to its discriminator value (a row of
391
+ # a different subtype is invisible); the base entity (no @discriminatorValue)
392
+ # reads polymorphically across the single table.
393
+ filter = self._scope_filter(filter, tph_subtype_of(entity))
394
+ where = _compile_filter(filter, entity) if filter else None
395
+ if where is not None:
396
+ sql += " WHERE " + where[0]
397
+ params.extend(where[1])
398
+ if sort:
399
+ order_parts = []
400
+ for field_name, direction in sort:
401
+ f = entity.find_field(field_name)
402
+ col = _column_of(f) if f is not None else field_name
403
+ d = "DESC" if direction.lower() == "desc" else "ASC"
404
+ order_parts.append(f"{_q(col)} {d}")
405
+ if order_parts:
406
+ sql += " ORDER BY " + ", ".join(order_parts)
407
+ if limit is not None:
408
+ sql += f" LIMIT {int(limit)}"
409
+ if offset is not None:
410
+ sql += f" OFFSET {int(offset)}"
411
+ result = self._driver.select(sql, tuple(params))
412
+ # Map raw column → metadata field name for cross-port row-shape parity.
413
+ # Values stay native (ADR-0019); the boundary canonicalizes them.
414
+ col_to_field = {_column_of(f): f.name for f in entity.fields()}
415
+ self.last_column_oids = {
416
+ col_to_field.get(c, c): oid for c, oid in result.column_oids.items()
417
+ }
418
+ return [
419
+ {col_to_field.get(k, k): v for k, v in row.items()} for row in result.rows
420
+ ]
421
+
422
+ def count(self, entity_name: str, filter: Filter | None = None) -> int:
423
+ entity = self._require_entity(entity_name)
424
+ table = self._table_name(entity)
425
+ sql = f"SELECT COUNT(*) FROM {_q(table)}"
426
+ params: list[Any] = []
427
+ filter = self._scope_filter(filter, tph_subtype_of(entity)) # FR-017 TPH subtype scope
428
+ where = _compile_filter(filter, entity) if filter else None
429
+ if where is not None:
430
+ sql += " WHERE " + where[0]
431
+ params.extend(where[1])
432
+ n = self._driver.scalar(sql, tuple(params))
433
+ return int(n) if n is not None else 0
434
+
435
+ def relate(
436
+ self, entity_name: str, record: dict[str, Any], relation_name: str
437
+ ) -> list[dict[str, Any]] | dict[str, Any] | None:
438
+ """Traverse a relationship from a source *record* to its related rows.
439
+
440
+ M:N (FR-017) is resolved generically from metadata: derive the junction
441
+ FK fields from the junction's two ``identity.reference`` children, query
442
+ the junction, then load the target rows. Three modes — hetero, directed
443
+ self-join (``@sourceRefField``), symmetric self-join (``@symmetric``) —
444
+ mirror the TS reference resolver. ``record`` is a source-key dict (e.g.
445
+ ``{"id": 1}``); only the source PK is read from it.
446
+ """
447
+ entity = self._require_entity(entity_name)
448
+ desc = resolve_n2m_descriptor(entity, relation_name, self._entity_by_name)
449
+ if desc is None:
450
+ raise ValueError(
451
+ f"Relationship '{relation_name}' on '{entity_name}' is not a "
452
+ f"resolvable M:N relationship (only M:N traversal is supported "
453
+ f"by relate() today)"
454
+ )
455
+ return self._relate_n2m(entity, desc, record)
456
+
457
+ def _relate_n2m(
458
+ self, entity: MetaObject, desc: N2mDescriptor, record: dict[str, Any]
459
+ ) -> list[dict[str, Any]]:
460
+ junction = self._require_entity(desc.junction_entity_name)
461
+ target = self._require_entity(desc.target_entity_name)
462
+
463
+ # Source PK value from the in-process record (the relate `by` key).
464
+ source_pk_field = self._primary_pk_field(entity)
465
+ source_id = record.get(source_pk_field)
466
+ if source_id is None:
467
+ return []
468
+
469
+ # Physical junction columns for the derived FK fields.
470
+ source_col = self._junction_column(junction, desc.source_field)
471
+ target_col = self._junction_column(junction, desc.target_field)
472
+
473
+ # Query the junction. Symmetric unions both FK columns; directed/hetero
474
+ # filter only the source side.
475
+ join_table = self._table_name(junction)
476
+ select_cols = f"{_q(source_col)}, {_q(target_col)}"
477
+ if desc.symmetric:
478
+ sql = (
479
+ f"SELECT {select_cols} FROM {_q(join_table)} "
480
+ f"WHERE {_q(source_col)} = %s OR {_q(target_col)} = %s"
481
+ )
482
+ params: tuple[Any, ...] = (source_id, source_id)
483
+ else:
484
+ sql = (
485
+ f"SELECT {select_cols} FROM {_q(join_table)} "
486
+ f"WHERE {_q(source_col)} = %s"
487
+ )
488
+ params = (source_id,)
489
+ join_rows = self._driver.select(sql, params).rows
490
+
491
+ # Collect the related (target) ids.
492
+ if desc.symmetric:
493
+ target_ids = collect_symmetric_target_ids(
494
+ join_rows, source_col, target_col, {source_id}
495
+ )
496
+ else:
497
+ target_ids = collect_column_ids(join_rows, target_col)
498
+ if not target_ids:
499
+ return []
500
+
501
+ # Load the target rows by PK. Reuse find_many so the row shape (metadata
502
+ # field names) + last_column_oids match every other read path.
503
+ target_pk_field = self._primary_pk_field(target)
504
+ return self.find_many(
505
+ desc.target_entity_name, {target_pk_field: {"in": target_ids}}
506
+ )
507
+
508
+ # --- Helpers -------------------------------------------------------------
509
+
510
+ def _require_entity(self, name: str) -> MetaObject:
511
+ e = self._entity_by_name.get(name)
512
+ if e is None:
513
+ raise KeyError(f"No entity named '{name}' in loaded metadata")
514
+ return e
515
+
516
+ def _table_name(self, entity: MetaObject) -> str:
517
+ # FR-016 / ADR-0018 — physical_name implements the four-step rule
518
+ # (kind-matching alias → legacy @table → source.name → entity-name
519
+ # fallback), so this just delegates to the primary source.
520
+ for c in entity.own_children():
521
+ if isinstance(c, MetaSource) and c.role() == sc.SOURCE_ROLE_PRIMARY:
522
+ pn = c.physical_name()
523
+ if pn:
524
+ return pn
525
+ # FR-017 TPH: a subtype declares no source of its own — it shares the
526
+ # discriminator base's single table, inherited via the super chain. Fall
527
+ # back to the effective (inherited) primary source before the name default.
528
+ for c in entity.children():
529
+ if isinstance(c, MetaSource) and c.role() == sc.SOURCE_ROLE_PRIMARY:
530
+ pn = c.physical_name()
531
+ if pn:
532
+ return pn
533
+ return entity.name
534
+
535
+ @staticmethod
536
+ def _scope_filter(filter: Filter | None, tph: TphSubtype | None) -> Filter | None:
537
+ """AND the TPH discriminator predicate into a filter (subtype-scoped reads).
538
+ Non-TPH (``tph is None``) returns the filter unchanged."""
539
+ if tph is None:
540
+ return filter
541
+ disc: Filter = {tph.field: tph.value} # equality shortcut
542
+ if not filter:
543
+ return disc
544
+ return {"and": [filter, disc]}
545
+
546
+ def _junction_column(self, junction: MetaObject, field_name: str) -> str:
547
+ """Physical column for a junction FK field (metadata field name → column)."""
548
+ f = junction.find_field(field_name)
549
+ if f is None:
550
+ raise ValueError(
551
+ f"Junction '{junction.name}' has no field '{field_name}'"
552
+ )
553
+ return _column_of(f)
554
+
555
+ def primary_key_field(self, entity_name: str) -> str:
556
+ """The single-field primary-key NAME for an entity, from its
557
+ ``identity.primary`` ``@fields``. ``op: roundtrip`` reads the inserted
558
+ row back by this key (composite PKs are not supported by roundtrip)."""
559
+ return self._primary_pk_field(self._require_entity(entity_name))
560
+
561
+ def _primary_pk_field(self, entity: MetaObject) -> str:
562
+ pi = entity.primary_identity()
563
+ if pi is None:
564
+ raise ValueError(f"Entity '{entity.name}' has no primary identity")
565
+ raw = pi.attr(ic.IDENTITY_ATTR_FIELDS)
566
+ if isinstance(raw, str):
567
+ return raw
568
+ if isinstance(raw, (list, tuple)) and raw:
569
+ return str(raw[0])
570
+ raise ValueError(f"Entity '{entity.name}' primary identity has no fields")
571
+
572
+
573
+ # ----------------------------------------------------------------------------
574
+ # Filter compiler — Filter dict → (WHERE clause SQL, params tuple)
575
+ # ----------------------------------------------------------------------------
576
+
577
+
578
+ def _compile_filter(f: Filter | None, entity: MetaObject) -> tuple[str, list[Any]] | None:
579
+ if not f:
580
+ return None
581
+ # Top-level `and: [filter, filter, ...]` combinator.
582
+ if "and" in f and isinstance(f["and"], list):
583
+ parts: list[str] = []
584
+ params: list[Any] = []
585
+ for child in f["and"]:
586
+ compiled = _compile_filter(child, entity)
587
+ if compiled is None:
588
+ continue
589
+ parts.append("(" + compiled[0] + ")")
590
+ params.extend(compiled[1])
591
+ if not parts:
592
+ return None
593
+ return " AND ".join(parts), params
594
+
595
+ parts = []
596
+ params = []
597
+ for field_name, ops in f.items():
598
+ mf = entity.find_field(field_name)
599
+ col = _column_of(mf) if mf is not None else field_name
600
+ if not isinstance(ops, dict):
601
+ # Shortcut: {field: value} → equality
602
+ parts.append(f"{_q(col)} = %s")
603
+ params.append(ops)
604
+ continue
605
+ for op, value in ops.items():
606
+ sql, p = _op_clause(col, op, value)
607
+ parts.append(sql)
608
+ params.extend(p)
609
+ if not parts:
610
+ return None
611
+ return " AND ".join(parts), params
612
+
613
+
614
+ def _op_clause(col: str, op: str, value: Any) -> tuple[str, list[Any]]:
615
+ """Translate one operator → SQL + params. Mirrors TS/C#/Java semantics."""
616
+ qc = _q(col)
617
+ if op == "eq": return f"{qc} = %s", [value]
618
+ if op == "ne": return f"{qc} <> %s", [value]
619
+ if op == "gt": return f"{qc} > %s", [value]
620
+ if op == "gte": return f"{qc} >= %s", [value]
621
+ if op == "lt": return f"{qc} < %s", [value]
622
+ if op == "lte": return f"{qc} <= %s", [value]
623
+ if op == "like": return f"{qc} LIKE %s", [value]
624
+ if op == "isNull":
625
+ wants_null = bool(value) if not isinstance(value, str) else value.lower() == "true"
626
+ return (f"{qc} IS NULL" if wants_null else f"{qc} IS NOT NULL"), []
627
+ if op == "in":
628
+ if not value:
629
+ # Empty IN list — match nothing.
630
+ return "FALSE", []
631
+ placeholders = ", ".join("%s" for _ in value)
632
+ return f"{qc} IN ({placeholders})", list(value)
633
+ raise ValueError(f"Unsupported filter op '{op}' on column '{col}'")
634
+
635
+
636
+ # ----------------------------------------------------------------------------
637
+ # Write codec — authoring form → native Python type the driver binds (ADR-0019
638
+ # write side). pg8000 maps Decimal→numeric, uuid.UUID→uuid, naive datetime→
639
+ # timestamp, aware datetime→timestamptz, date→date, time→time, dict→jsonb. We
640
+ # only need to turn the corpus' authoring *strings* into those native types; the
641
+ # driver does the rest. Mirrors the TS runtime write coercer (type-coercer.ts):
642
+ # jsonb objects + native binding per subtype, everything else passes through.
643
+ # ----------------------------------------------------------------------------
644
+
645
+
646
+ def _coerce_write_value(field: MetaField, value: Any) -> Any:
647
+ if value is None:
648
+ return None
649
+ sub = field.sub_type
650
+ col_type = field.attr(dbc.FIELD_ATTR_DB_COLUMN_TYPE)
651
+
652
+ # decimal / currency: a decimal authored as a string → Decimal (exact; never
653
+ # via float). currency is integer minor units — already an int, pass through.
654
+ if sub == fc.FIELD_SUBTYPE_DECIMAL:
655
+ return value if isinstance(value, _decimal.Decimal) else _decimal.Decimal(str(value))
656
+
657
+ # uuid (logical field.uuid, or a string field pinned to a uuid column):
658
+ # string → uuid.UUID so the driver binds the native uuid type. PG stores it
659
+ # lowercase-canonically regardless of input case.
660
+ if sub == fc.FIELD_SUBTYPE_UUID or col_type == dbc.DB_COLUMN_TYPE_UUID:
661
+ return value if isinstance(value, _uuid.UUID) else _uuid.UUID(str(value))
662
+
663
+ # temporal: parse the authoring string to the native type, with tz-awareness
664
+ # driven by the field (TIMESTAMP → naive, TIMESTAMPTZ → aware) so the driver
665
+ # binds the correct physical type.
666
+ if sub == fc.FIELD_SUBTYPE_DATE:
667
+ return value if isinstance(value, _dt.date) else _dt.date.fromisoformat(str(value))
668
+ if sub == fc.FIELD_SUBTYPE_TIME:
669
+ return value if isinstance(value, _dt.time) else _dt.time.fromisoformat(str(value))
670
+ if sub == fc.FIELD_SUBTYPE_TIMESTAMP:
671
+ if isinstance(value, _dt.datetime):
672
+ return value
673
+ is_tz = col_type == dbc.DB_COLUMN_TYPE_TIMESTAMP_TZ
674
+ return _parse_datetime(str(value), tz_aware=is_tz)
675
+
676
+ # field.object (jsonb storage): a dict/list passes through — pg8000 binds it
677
+ # to the jsonb column natively (no manual JSON.stringify, unlike node-pg).
678
+ # A field.string pinned to a jsonb column behaves the same way.
679
+ # Everything else (string / int / long / double / float / boolean / enum)
680
+ # is already the native type pg8000 binds directly.
681
+ return value
682
+
683
+
684
+ def _parse_datetime(text: str, *, tz_aware: bool) -> _dt.datetime:
685
+ """Parse an ISO-8601 timestamp authoring string to a native datetime.
686
+
687
+ A trailing ``Z`` (Zulu/UTC) is normalized to ``+00:00`` for ``fromisoformat``.
688
+ For a TIMESTAMPTZ column we keep the resulting datetime tz-aware (defaulting
689
+ an offset-less string to UTC); for a plain TIMESTAMP column we strip any
690
+ offset to a naive wall-clock value so the driver binds ``timestamp`` (not
691
+ ``timestamptz``).
692
+ """
693
+ iso = text[:-1] + "+00:00" if text.endswith("Z") else text
694
+ dt = _dt.datetime.fromisoformat(iso)
695
+ if tz_aware:
696
+ if dt.tzinfo is None:
697
+ dt = dt.replace(tzinfo=_dt.timezone.utc)
698
+ return dt
699
+ # Naive wall clock: drop any offset without shifting (the wire form already
700
+ # carried the intended wall-clock components).
701
+ return dt.replace(tzinfo=None)
702
+
703
+
704
+ def _column_of(field: MetaField | None) -> str:
705
+ if field is None:
706
+ return ""
707
+ col = field.attr(fc.FIELD_ATTR_COLUMN)
708
+ return col if isinstance(col, str) and col else field.name
709
+
710
+
711
+ def _q(ident: str) -> str:
712
+ if '"' in ident:
713
+ raise ValueError(f"unsafe identifier: {ident}")
714
+ return f'"{ident}"'
715
+