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.
- metaobjects/__init__.py +75 -0
- metaobjects/agent_context/__init__.py +55 -0
- metaobjects/agent_context/_content/README.md +14 -0
- metaobjects/agent_context/_content/servers/csharp.meta.json +5 -0
- metaobjects/agent_context/_content/servers/java.meta.json +5 -0
- metaobjects/agent_context/_content/servers/kotlin.meta.json +5 -0
- metaobjects/agent_context/_content/servers/python.meta.json +5 -0
- metaobjects/agent_context/_content/servers/typescript.meta.json +5 -0
- metaobjects/agent_context/_content/skills/metaobjects-authoring/SKILL.md +301 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/SKILL.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/csharp.md +87 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/java.md +94 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/kotlin.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-codegen/references/typescript.md +135 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/SKILL.md +148 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/csharp.md +110 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/java.md +108 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/kotlin.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/python.md +116 -0
- metaobjects/agent_context/_content/skills/metaobjects-prompts/references/typescript.md +150 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/SKILL.md +130 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/java.md +96 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/kotlin.md +99 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/react.md +86 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/tanstack.md +119 -0
- metaobjects/agent_context/_content/skills/metaobjects-runtime-ui/references/typescript.md +92 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/SKILL.md +107 -0
- metaobjects/agent_context/_content/skills/metaobjects-verify/references/migration.md +72 -0
- metaobjects/agent_context/_content/templates/always-on.md.mustache +27 -0
- metaobjects/agent_context/assemble.py +133 -0
- metaobjects/agent_context/content_root.py +54 -0
- metaobjects/agent_context/scaffold.py +191 -0
- metaobjects/agent_context/types.py +44 -0
- metaobjects/attr_class_map.py +23 -0
- metaobjects/cli.py +696 -0
- metaobjects/codegen/__init__.py +0 -0
- metaobjects/codegen/config.py +11 -0
- metaobjects/codegen/constants.py +13 -0
- metaobjects/codegen/extract_delegate_emitter.py +384 -0
- metaobjects/codegen/extract_schema_emitter.py +139 -0
- metaobjects/codegen/format.py +31 -0
- metaobjects/codegen/fr010_field_mapping.py +220 -0
- metaobjects/codegen/generator.py +62 -0
- metaobjects/codegen/generator_registry.py +163 -0
- metaobjects/codegen/generators/__init__.py +0 -0
- metaobjects/codegen/generators/entity_model.py +263 -0
- metaobjects/codegen/generators/extractor_generator.py +317 -0
- metaobjects/codegen/generators/filter_allowlist_generator.py +309 -0
- metaobjects/codegen/generators/m2m_codegen.py +192 -0
- metaobjects/codegen/generators/output_parser_generator.py +272 -0
- metaobjects/codegen/generators/output_prompt_generator.py +192 -0
- metaobjects/codegen/generators/payload_vo_generator.py +672 -0
- metaobjects/codegen/generators/render_helper_generator.py +451 -0
- metaobjects/codegen/generators/router_generator.py +635 -0
- metaobjects/codegen/generators/template_generator.py +70 -0
- metaobjects/codegen/generators/tph_plan.py +120 -0
- metaobjects/codegen/generators/trace_helper_generator.py +336 -0
- metaobjects/codegen/instance_artifacts.py +15 -0
- metaobjects/codegen/output_format_spec_emitter.py +79 -0
- metaobjects/codegen/overwrite_policy.py +27 -0
- metaobjects/codegen/runner.py +110 -0
- metaobjects/codegen/runtime/__init__.py +6 -0
- metaobjects/codegen/runtime/filter_parser.py +193 -0
- metaobjects/codegen/type_map.py +84 -0
- metaobjects/core_types.py +809 -0
- metaobjects/datatype.py +19 -0
- metaobjects/documentation/__init__.py +28 -0
- metaobjects/documentation/doc_constants.py +20 -0
- metaobjects/documentation/doc_provider.py +20 -0
- metaobjects/documentation/doc_schema.py +24 -0
- metaobjects/errors.py +124 -0
- metaobjects/loader/__init__.py +0 -0
- metaobjects/loader/merge.py +287 -0
- metaobjects/loader/meta_data_loader.py +245 -0
- metaobjects/loader/sources/__init__.py +24 -0
- metaobjects/loader/sources/directory_source.py +50 -0
- metaobjects/loader/sources/file_source.py +41 -0
- metaobjects/loader/sources/meta_data_source.py +67 -0
- metaobjects/loader/sources/uri_source.py +56 -0
- metaobjects/loader/validate_discriminator.py +181 -0
- metaobjects/loader/validate_field_readonly.py +146 -0
- metaobjects/loader/validate_source_parameter_ref.py +159 -0
- metaobjects/loader/validate_source_physical_names.py +140 -0
- metaobjects/loader/validation_passes.py +1513 -0
- metaobjects/meta/__init__.py +1 -0
- metaobjects/meta/core/__init__.py +0 -0
- metaobjects/meta/core/attr/__init__.py +0 -0
- metaobjects/meta/core/attr/attr_constants.py +31 -0
- metaobjects/meta/core/attr/meta_attr.py +136 -0
- metaobjects/meta/core/field/__init__.py +0 -0
- metaobjects/meta/core/field/field_constants.py +105 -0
- metaobjects/meta/core/field/meta_field.py +76 -0
- metaobjects/meta/core/identity/__init__.py +0 -0
- metaobjects/meta/core/identity/identity_constants.py +19 -0
- metaobjects/meta/core/identity/meta_identity.py +8 -0
- metaobjects/meta/core/object/__init__.py +0 -0
- metaobjects/meta/core/object/meta_object.py +65 -0
- metaobjects/meta/core/object/meta_object_aware.py +43 -0
- metaobjects/meta/core/object/object_class_registry.py +56 -0
- metaobjects/meta/core/object/object_constants.py +13 -0
- metaobjects/meta/core/object/object_extract.py +400 -0
- metaobjects/meta/core/object/value_object.py +70 -0
- metaobjects/meta/core/relationship/__init__.py +0 -0
- metaobjects/meta/core/relationship/derive_m2m_fields.py +180 -0
- metaobjects/meta/core/relationship/meta_relationship.py +54 -0
- metaobjects/meta/core/relationship/relationship_constants.py +51 -0
- metaobjects/meta/core/validator/__init__.py +0 -0
- metaobjects/meta/core/validator/validator_constants.py +18 -0
- metaobjects/meta/meta_data.py +206 -0
- metaobjects/meta/meta_root.py +8 -0
- metaobjects/meta/persistence/__init__.py +0 -0
- metaobjects/meta/persistence/db/__init__.py +1 -0
- metaobjects/meta/persistence/db/db_constants.py +41 -0
- metaobjects/meta/persistence/db/db_provider.py +60 -0
- metaobjects/meta/persistence/origin/__init__.py +0 -0
- metaobjects/meta/persistence/origin/meta_origin.py +8 -0
- metaobjects/meta/persistence/origin/origin_constants.py +20 -0
- metaobjects/meta/persistence/source/__init__.py +0 -0
- metaobjects/meta/persistence/source/meta_source.py +137 -0
- metaobjects/meta/persistence/source/source_constants.py +115 -0
- metaobjects/meta/presentation/__init__.py +0 -0
- metaobjects/meta/presentation/layout/__init__.py +0 -0
- metaobjects/meta/presentation/layout/layout_constants.py +13 -0
- metaobjects/meta/presentation/layout/meta_layout.py +8 -0
- metaobjects/meta/presentation/view/__init__.py +0 -0
- metaobjects/meta/presentation/view/meta_view.py +8 -0
- metaobjects/meta/presentation/view/view_constants.py +22 -0
- metaobjects/meta/template/__init__.py +0 -0
- metaobjects/meta/template/meta_template.py +46 -0
- metaobjects/meta/template/template_constants.py +112 -0
- metaobjects/meta/template/template_provider.py +43 -0
- metaobjects/parser.py +380 -0
- metaobjects/parser_yaml.py +82 -0
- metaobjects/provider.py +111 -0
- metaobjects/py.typed +0 -0
- metaobjects/registry.py +210 -0
- metaobjects/registry_manifest.py +223 -0
- metaobjects/render/__init__.py +74 -0
- metaobjects/render/email_document.py +14 -0
- metaobjects/render/escapers.py +109 -0
- metaobjects/render/extract/__init__.py +59 -0
- metaobjects/render/extract/coerce.py +279 -0
- metaobjects/render/extract/extract.py +211 -0
- metaobjects/render/extract/extract_map.py +61 -0
- metaobjects/render/extract/json_forgiving_reader.py +203 -0
- metaobjects/render/extract/locate.py +65 -0
- metaobjects/render/extract/normalize.py +96 -0
- metaobjects/render/extract/strip.py +20 -0
- metaobjects/render/extract/types.py +332 -0
- metaobjects/render/extract/xml_forgiving_reader.py +162 -0
- metaobjects/render/filesystem_provider.py +51 -0
- metaobjects/render/prompt/__init__.py +32 -0
- metaobjects/render/prompt/output_format_renderer.py +340 -0
- metaobjects/render/prompt/output_format_spec.py +28 -0
- metaobjects/render/prompt/prompt_field.py +29 -0
- metaobjects/render/prompt/prompt_overrides.py +29 -0
- metaobjects/render/prompt/prompt_style.py +38 -0
- metaobjects/render/renderer.py +358 -0
- metaobjects/render/verify.py +266 -0
- metaobjects/runtime/__init__.py +39 -0
- metaobjects/runtime/llm_recorder.py +210 -0
- metaobjects/runtime/n2m_resolver.py +155 -0
- metaobjects/runtime/object_manager.py +715 -0
- metaobjects/runtime/tph.py +50 -0
- metaobjects/serializer_json.py +172 -0
- metaobjects/shared/__init__.py +0 -0
- metaobjects/shared/base_types.py +16 -0
- metaobjects/shared/separators.py +4 -0
- metaobjects/shared/structural.py +9 -0
- metaobjects/source/__init__.py +79 -0
- metaobjects/source/error_source.py +266 -0
- metaobjects/source/json_path.py +106 -0
- metaobjects/source/semantic_diff.py +98 -0
- metaobjects/source/yaml_positions.py +174 -0
- metaobjects/super_resolve.py +128 -0
- metaobjects/yaml_desugar.py +481 -0
- metaobjects-0.9.0.dist-info/METADATA +97 -0
- metaobjects-0.9.0.dist-info/RECORD +181 -0
- metaobjects-0.9.0.dist-info/WHEEL +4 -0
- metaobjects-0.9.0.dist-info/entry_points.txt +2 -0
- 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
|
+
|