meridian-storage-postgresql 1.0.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 (24) hide show
  1. meridian_storage/adapters/postgresql/__init__.py +27 -0
  2. meridian_storage/adapters/postgresql/_errors.py +88 -0
  3. meridian_storage/adapters/postgresql/_operation.py +354 -0
  4. meridian_storage/adapters/postgresql/_runtime.py +273 -0
  5. meridian_storage/adapters/postgresql/_settings.py +472 -0
  6. meridian_storage/adapters/postgresql/_version.py +4 -0
  7. meridian_storage/adapters/postgresql/compatibility.json +22 -0
  8. meridian_storage/adapters/postgresql/descriptor/__init__.py +229 -0
  9. meridian_storage/adapters/postgresql/migration/__init__.py +281 -0
  10. meridian_storage/adapters/postgresql/probe/__init__.py +309 -0
  11. meridian_storage/adapters/postgresql/py.typed +1 -0
  12. meridian_storage/adapters/postgresql/query/__init__.py +824 -0
  13. meridian_storage/adapters/postgresql/query/_sql.py +85 -0
  14. meridian_storage/adapters/postgresql/query/_values.py +122 -0
  15. meridian_storage/adapters/postgresql/query/dml.py +440 -0
  16. meridian_storage/adapters/postgresql/schema/__init__.py +272 -0
  17. meridian_storage/adapters/postgresql/semantics/__init__.py +338 -0
  18. meridian_storage/adapters/postgresql/transactions/__init__.py +299 -0
  19. meridian_storage_postgresql-1.0.0.dist-info/METADATA +158 -0
  20. meridian_storage_postgresql-1.0.0.dist-info/RECORD +24 -0
  21. meridian_storage_postgresql-1.0.0.dist-info/WHEEL +4 -0
  22. meridian_storage_postgresql-1.0.0.dist-info/entry_points.txt +2 -0
  23. meridian_storage_postgresql-1.0.0.dist-info/licenses/LICENSE +201 -0
  24. meridian_storage_postgresql-1.0.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,27 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Meridian V1 PostgreSQL/PostGIS Adapter."""
3
+
4
+ from ._runtime import PostgreSQLAdapterFactory, PostgreSQLAdapterRuntime
5
+ from ._version import __version__
6
+ from .descriptor import DESCRIPTOR, QUERY_CAPABILITIES
7
+ from .migration import LogicalTransfer, MigrationExecutor, RecoveryHook
8
+ from .query import PostgreSQLQueryTranslator
9
+ from .query.dml import DMLCompiler
10
+ from .schema import MigrationPlan, SchemaCompiler
11
+ from .semantics import PostgreSQLSemanticsAdapter
12
+
13
+ __all__ = [
14
+ "DESCRIPTOR",
15
+ "QUERY_CAPABILITIES",
16
+ "DMLCompiler",
17
+ "LogicalTransfer",
18
+ "MigrationExecutor",
19
+ "MigrationPlan",
20
+ "PostgreSQLAdapterFactory",
21
+ "PostgreSQLAdapterRuntime",
22
+ "PostgreSQLQueryTranslator",
23
+ "PostgreSQLSemanticsAdapter",
24
+ "RecoveryHook",
25
+ "SchemaCompiler",
26
+ "__version__",
27
+ ]
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Credential-free PostgreSQL error mapping."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from meridian_storage.errors import (
7
+ AuthenticationError,
8
+ AuthorizationError,
9
+ ConflictError,
10
+ ConstraintError,
11
+ ErrorCode,
12
+ MeridianError,
13
+ MeridianTimeoutError,
14
+ SafeCause,
15
+ TransactionError,
16
+ TransientError,
17
+ UnavailableError,
18
+ ValidationError,
19
+ )
20
+
21
+
22
+ def map_postgresql_error(
23
+ exc: BaseException,
24
+ *,
25
+ operation_contract: str | None = None,
26
+ request_id: str | None = None,
27
+ execution_id: str | None = None,
28
+ ) -> MeridianError:
29
+ """Map SQLSTATE class without exposing SQL, values, hosts, or credentials."""
30
+
31
+ state = getattr(exc, "sqlstate", None)
32
+ details = {
33
+ "operation_contract": operation_contract,
34
+ "request_id": request_id,
35
+ "execution_id": execution_id,
36
+ "adapter_provenance": {
37
+ "adapter": "postgresql",
38
+ **({"sqlstate": state} if isinstance(state, str) else {}),
39
+ },
40
+ "cause": SafeCause(type=type(exc).__name__, code=state),
41
+ }
42
+ if state in {"28P01", "28000"}:
43
+ return AuthenticationError(
44
+ ErrorCode.ADAPTER_FAILURE, "PostgreSQL authentication failed", **details
45
+ )
46
+ if state == "42501":
47
+ return AuthorizationError(
48
+ ErrorCode.ADAPTER_FAILURE, "PostgreSQL authorization failed", **details
49
+ )
50
+ if state == "23505":
51
+ return ConflictError(
52
+ ErrorCode.IDEMPOTENCY_CONFLICT, "a unique value already exists", **details
53
+ )
54
+ if isinstance(state, str) and state.startswith("23"):
55
+ return ConstraintError(
56
+ ErrorCode.OPERATION_INVALID, "a PostgreSQL constraint rejected the operation", **details
57
+ )
58
+ if state in {"40001", "40P01", "55P03"}:
59
+ return TransientError(
60
+ ErrorCode.ADAPTER_FAILURE, "the PostgreSQL transaction must be retried", **details
61
+ )
62
+ if state == "57014":
63
+ return MeridianTimeoutError(
64
+ ErrorCode.DEADLINE_EXCEEDED, "the PostgreSQL statement deadline expired", **details
65
+ )
66
+ if isinstance(state, str) and state.startswith("08"):
67
+ return UnavailableError(
68
+ ErrorCode.ADAPTER_FAILURE,
69
+ "the PostgreSQL service is unavailable",
70
+ retryable=True,
71
+ **details,
72
+ )
73
+ if isinstance(state, str) and state.startswith("25"):
74
+ return TransactionError(
75
+ ErrorCode.TRANSACTION_STATE, "PostgreSQL transaction state is invalid", **details
76
+ )
77
+ if state in {"42P01", "42703", "42883"}:
78
+ return ValidationError(
79
+ ErrorCode.PHYSICAL_FINGERPRINT,
80
+ "the pinned PostgreSQL physical schema is absent or incompatible",
81
+ **details,
82
+ )
83
+ return UnavailableError(
84
+ ErrorCode.ADAPTER_FAILURE, "PostgreSQL rejected the adapter operation", **details
85
+ )
86
+
87
+
88
+ __all__ = ["map_postgresql_error"]
@@ -0,0 +1,354 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Core Operation dispatch without exposing engine concepts to consumers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Any, cast
9
+
10
+ from meridian_storage.query.adapter import CompiledQuery, TranslationContext
11
+ from meridian_storage.query.ast import (
12
+ Aggregate,
13
+ Field,
14
+ NamedAggregate,
15
+ Projection,
16
+ Sort,
17
+ full_text,
18
+ parse_filter,
19
+ )
20
+ from meridian_storage.query.wire import (
21
+ PageSpec,
22
+ QueryOperation,
23
+ QueryTarget,
24
+ ResultSpec,
25
+ SafetyBudget,
26
+ TraversalSpec,
27
+ )
28
+ from meridian_storage.semantics import RecordReference, sha256_fingerprint
29
+ from meridian_storage.spi.adapters import ExecutionRequest
30
+
31
+ from ._settings import PostgreSQLSettings
32
+ from .query import PostgreSQLQueryTranslator
33
+ from .query.dml import DMLCommand, DMLCompiler
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class QueryCommand:
38
+ compiled: CompiledQuery
39
+ translator: PostgreSQLQueryTranslator
40
+
41
+
42
+ type AdapterCommand = DMLCommand | QueryCommand
43
+
44
+
45
+ class OperationCompiler:
46
+ def __init__(
47
+ self,
48
+ settings: PostgreSQLSettings,
49
+ translator: PostgreSQLQueryTranslator,
50
+ ) -> None:
51
+ self.settings = settings
52
+ self.translator = translator
53
+ self.dml = DMLCompiler(settings)
54
+
55
+ def compile(self, request: ExecutionRequest) -> AdapterCommand:
56
+ operation = request.operation
57
+ if operation.catalog not in {"structured", "evidence"}:
58
+ raise ValueError("PostgreSQL V1 implements structured and evidence operations")
59
+ if operation.operation_version != "1.0.0":
60
+ raise ValueError("PostgreSQL V1 accepts only Operation version 1.0.0")
61
+ prefix = f"meridian.{operation.catalog}."
62
+ if not operation.operation_contract.startswith(prefix):
63
+ raise ValueError("Operation contract does not match its Catalog")
64
+ method = operation.operation_contract.removeprefix(prefix)
65
+ allowed = {
66
+ "structured": {
67
+ "aggregate",
68
+ "create_resource",
69
+ "delete",
70
+ "get",
71
+ "patch",
72
+ "publish_schema",
73
+ "put",
74
+ "query",
75
+ "search",
76
+ "traverse",
77
+ },
78
+ "evidence": {"append", "query"},
79
+ }
80
+ if method not in allowed[operation.catalog]:
81
+ raise ValueError(f"unsupported {operation.catalog} Operation contract: {method!r}")
82
+ if method in {"create_resource", "publish_schema"}:
83
+ raise ValueError("physical DDL is only available through the Platform migration hook")
84
+ if len(operation.resources) < 1:
85
+ raise ValueError("PostgreSQL Operation requires a Resource")
86
+ if any(resource.catalog != operation.catalog for resource in operation.resources):
87
+ raise ValueError("Operation Resources must belong to its Catalog")
88
+ if (
89
+ method != "traverse"
90
+ and "queryPlan" not in operation.input
91
+ and len(operation.resources) != 1
92
+ ):
93
+ raise ValueError("non-traversal Operations require exactly one Resource")
94
+ if (
95
+ method in {"put", "get", "patch", "delete", "append"}
96
+ and "queryPlan" not in operation.input
97
+ ):
98
+ return self.dml.compile(
99
+ method,
100
+ operation.resources[0],
101
+ cast(Mapping[str, object], operation.input),
102
+ request.context,
103
+ )
104
+ query_operation = self._query_operation(request, method)
105
+ if set(query_operation.resources) != set(operation.resources):
106
+ raise ValueError("query plan Resources differ from the enclosing Operation")
107
+ context = TranslationContext(
108
+ binding_id=request.binding_id,
109
+ plan_fingerprint=query_operation.fingerprint,
110
+ registry_fingerprint=request.registry_fingerprint,
111
+ schema_fingerprints={
112
+ ref.canonical: self.settings.layout(ref).schema_fingerprint
113
+ for ref in query_operation.resources
114
+ },
115
+ scope_fingerprint=self._scope_fingerprint(request),
116
+ deadline_ms=self._deadline_ms(request, query_operation.budget.deadline_ms),
117
+ )
118
+ return QueryCommand(self.translator.compile(query_operation, context), self.translator)
119
+
120
+ def _query_operation(self, request: ExecutionRequest, method: str) -> QueryOperation:
121
+ raw_plan = request.operation.input.get("queryPlan")
122
+ if raw_plan is not None:
123
+ if not isinstance(raw_plan, Mapping):
124
+ raise TypeError("queryPlan must be a released QueryOperation mapping")
125
+ return QueryOperation.from_mapping(cast(Mapping[str, object], raw_plan))
126
+ values = cast(Mapping[str, object], request.operation.input)
127
+ resource = request.operation.resources[0]
128
+ layout = self.settings.layout(resource)
129
+ target = QueryTarget(resource)
130
+ where = values.get("where", {})
131
+ if not isinstance(where, Mapping):
132
+ raise TypeError("structured query where must be an object")
133
+ predicate = parse_filter(where)
134
+ configured_result_limit = request.operation.input.get("resultByteLimit", 16 * 1024 * 1024)
135
+ result_limit = (
136
+ configured_result_limit
137
+ if isinstance(configured_result_limit, int)
138
+ and not isinstance(configured_result_limit, bool)
139
+ else 16 * 1024 * 1024
140
+ )
141
+ budget = SafetyBudget(
142
+ deadline_ms=self._deadline_ms(request, 30_000),
143
+ max_result_values=10_000,
144
+ max_normalized_bytes=min(result_limit, 16 * 1024 * 1024),
145
+ )
146
+ if method == "query":
147
+ return self._structured_query(values, target, predicate, budget, layout)
148
+ if method == "search":
149
+ return self._structured_search(values, target, predicate, budget, layout)
150
+ if method == "aggregate":
151
+ return self._structured_aggregate(values, target, predicate, budget)
152
+ if method == "traverse":
153
+ return self._structured_traverse(request, values, target, budget)
154
+ raise ValueError(f"unsupported {request.operation.catalog} Operation contract: {method!r}")
155
+
156
+ @staticmethod
157
+ def _structured_query(
158
+ values: Mapping[str, object],
159
+ target: QueryTarget,
160
+ predicate: Any,
161
+ budget: SafetyBudget,
162
+ layout: Any,
163
+ ) -> QueryOperation:
164
+ select = values.get("select", ())
165
+ order_by = values.get("orderBy", ())
166
+ limit = values.get("limit", 50)
167
+ if (
168
+ not isinstance(select, Sequence)
169
+ or isinstance(select, (str, bytes))
170
+ or not isinstance(order_by, Sequence)
171
+ or isinstance(order_by, (str, bytes))
172
+ or isinstance(limit, bool)
173
+ or not isinstance(limit, int)
174
+ ):
175
+ raise TypeError("structured query select, orderBy, or limit has an invalid type")
176
+ projection = tuple(Projection(Field(cast(str, name)), cast(str, name)) for name in select)
177
+ sorts: list[Sort] = []
178
+ for item in order_by:
179
+ if not isinstance(item, Mapping) or set(item) - {"field", "direction", "nulls"}:
180
+ raise ValueError("orderBy entries require field/direction/nulls")
181
+ field_name = item.get("field")
182
+ if not isinstance(field_name, str) or field_name not in layout.field_map:
183
+ raise ValueError("orderBy references an unknown field")
184
+ sorts.append(
185
+ Sort(
186
+ Field(field_name),
187
+ cast(str, item.get("direction", "asc")),
188
+ cast(str, item.get("nulls", "last")),
189
+ )
190
+ )
191
+ return QueryOperation(
192
+ catalog=target.resource.catalog,
193
+ targets=(target,),
194
+ operation="scan",
195
+ result=ResultSpec("records", projection),
196
+ filter=predicate,
197
+ order=tuple(sorts),
198
+ page=PageSpec(limit, cast(str | None, values.get("cursor"))),
199
+ budget=budget,
200
+ )
201
+
202
+ @staticmethod
203
+ def _structured_search(
204
+ values: Mapping[str, object],
205
+ target: QueryTarget,
206
+ predicate: Any,
207
+ budget: SafetyBudget,
208
+ layout: Any,
209
+ ) -> QueryOperation:
210
+ if values.get("facets") or values.get("highlights"):
211
+ raise ValueError("this V1 profile does not advertise facets or highlights")
212
+ full_text_fields = tuple(
213
+ field for index in layout.indexes if index.kind == "full-text" for field in index.fields
214
+ )
215
+ if not full_text_fields:
216
+ raise ValueError("search requires a pinned full-text index")
217
+ query = values.get("query")
218
+ if isinstance(query, Mapping):
219
+ text = query.get("text")
220
+ selected = query.get("fields", full_text_fields)
221
+ if not isinstance(selected, Sequence) or isinstance(selected, (str, bytes)):
222
+ raise TypeError("search fields must be an array")
223
+ fields = tuple(cast(str, item) for item in selected)
224
+ else:
225
+ text = query
226
+ fields = full_text_fields
227
+ if not isinstance(text, str):
228
+ raise TypeError("search query text must be a string")
229
+ if (
230
+ not fields
231
+ or len(set(fields)) != len(fields)
232
+ or any(field not in full_text_fields for field in fields)
233
+ ):
234
+ raise ValueError("search fields must be unique and backed by pinned full-text indexes")
235
+ match = full_text(text, fields=fields)
236
+ combined = match if predicate is None else predicate.and_(match)
237
+ limit = values.get("limit", 50)
238
+ if isinstance(limit, bool) or not isinstance(limit, int):
239
+ raise TypeError("search limit must be an integer")
240
+ return QueryOperation(
241
+ catalog=target.resource.catalog,
242
+ targets=(target,),
243
+ operation="search",
244
+ result=ResultSpec("search"),
245
+ filter=combined,
246
+ page=PageSpec(limit, cast(str | None, values.get("cursor"))),
247
+ budget=budget,
248
+ )
249
+
250
+ @staticmethod
251
+ def _structured_aggregate(
252
+ values: Mapping[str, object],
253
+ target: QueryTarget,
254
+ predicate: Any,
255
+ budget: SafetyBudget,
256
+ ) -> QueryOperation:
257
+ raw_grouping = values.get("groupBy", ())
258
+ raw_metrics = values.get("metrics", ())
259
+ if (
260
+ not isinstance(raw_grouping, Sequence)
261
+ or isinstance(raw_grouping, (str, bytes))
262
+ or not isinstance(raw_metrics, Sequence)
263
+ or isinstance(raw_metrics, (str, bytes))
264
+ ):
265
+ raise TypeError("aggregate groupBy and metrics must be arrays")
266
+ grouping = tuple(Field(cast(str, name)) for name in raw_grouping)
267
+ aggregates: list[NamedAggregate] = []
268
+ for metric in raw_metrics:
269
+ if not isinstance(metric, Mapping) or set(metric) - {
270
+ "name",
271
+ "function",
272
+ "field",
273
+ "distinct",
274
+ }:
275
+ raise ValueError("aggregate metric is not a closed V1 metric")
276
+ name = metric.get("name")
277
+ function = metric.get("function")
278
+ field_name = metric.get("field")
279
+ if not isinstance(name, str) or not isinstance(function, str):
280
+ raise TypeError("aggregate name and function must be strings")
281
+ distinct = metric.get("distinct", False)
282
+ if not isinstance(distinct, bool):
283
+ raise TypeError("aggregate distinct must be a boolean")
284
+ operand = None if field_name is None else Field(cast(str, field_name))
285
+ aggregates.append(
286
+ NamedAggregate(
287
+ name,
288
+ Aggregate(function, operand, distinct),
289
+ )
290
+ )
291
+ return QueryOperation(
292
+ catalog="structured",
293
+ targets=(target,),
294
+ operation="aggregate",
295
+ result=ResultSpec("aggregate"),
296
+ filter=predicate,
297
+ grouping=grouping,
298
+ aggregates=tuple(aggregates),
299
+ budget=budget,
300
+ )
301
+
302
+ @staticmethod
303
+ def _structured_traverse(
304
+ request: ExecutionRequest,
305
+ values: Mapping[str, object],
306
+ target: QueryTarget,
307
+ budget: SafetyBudget,
308
+ ) -> QueryOperation:
309
+ start = values.get("start")
310
+ if not isinstance(start, Mapping):
311
+ raise TypeError("traversal start must be a RecordReference")
312
+ relations = tuple(request.operation.resources[1:])
313
+ all_neighbors = values.get("allNeighbors") is True
314
+ registry_fingerprint = (
315
+ cast(str, values.get("registryFingerprint")) if all_neighbors else None
316
+ )
317
+ max_depth = values.get("maxDepth", 1)
318
+ if isinstance(max_depth, bool) or not isinstance(max_depth, int):
319
+ raise TypeError("traversal maxDepth must be an integer")
320
+ traversal = TraversalSpec(
321
+ start=RecordReference.from_mapping(cast(Mapping[str, object], start)),
322
+ relation_collections=relations,
323
+ all_neighbors=all_neighbors,
324
+ max_depth=max_depth,
325
+ result_shape="records",
326
+ registry_fingerprint=registry_fingerprint,
327
+ )
328
+ return QueryOperation(
329
+ catalog="structured",
330
+ targets=(target,),
331
+ operation="traverse",
332
+ result=ResultSpec("records"),
333
+ traversal=traversal,
334
+ page=PageSpec(min(budget.max_returned_paths, 500)),
335
+ budget=budget,
336
+ )
337
+
338
+ def _scope_fingerprint(self, request: ExecutionRequest) -> str:
339
+ return sha256_fingerprint(
340
+ {
341
+ "tenant": request.context.tenant,
342
+ "scope": dict(sorted(request.context.scope.items())),
343
+ }
344
+ )
345
+
346
+ @staticmethod
347
+ def _deadline_ms(request: ExecutionRequest, default: int) -> int:
348
+ remaining = request.context.remaining_seconds()
349
+ if remaining is None:
350
+ return default
351
+ return max(1, min(default, int(remaining * 1000)))
352
+
353
+
354
+ __all__ = ["AdapterCommand", "OperationCompiler", "QueryCommand"]