omnibase_infra 0.2.9__py3-none-any.whl → 0.3.1__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.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/errors/__init__.py +18 -0
- omnibase_infra/errors/repository/__init__.py +78 -0
- omnibase_infra/errors/repository/errors_repository.py +424 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +12 -2
- omnibase_infra/runtime/db/__init__.py +73 -0
- omnibase_infra/runtime/db/models/__init__.py +41 -0
- omnibase_infra/runtime/db/models/model_repository_runtime_config.py +211 -0
- omnibase_infra/runtime/db/postgres_repository_runtime.py +573 -0
- omnibase_infra/validation/infra_validators.py +4 -1
- omnibase_infra/validation/validation_exemptions.yaml +18 -0
- {omnibase_infra-0.2.9.dist-info → omnibase_infra-0.3.1.dist-info}/METADATA +2 -2
- {omnibase_infra-0.2.9.dist-info → omnibase_infra-0.3.1.dist-info}/RECORD +16 -10
- {omnibase_infra-0.2.9.dist-info → omnibase_infra-0.3.1.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.9.dist-info → omnibase_infra-0.3.1.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.9.dist-info → omnibase_infra-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""PostgreSQL Repository Runtime.
|
|
4
|
+
|
|
5
|
+
This module provides a generic runtime for executing repository contracts
|
|
6
|
+
against PostgreSQL databases. The runtime enforces safety constraints,
|
|
7
|
+
deterministic query ordering, and configurable operation limits.
|
|
8
|
+
|
|
9
|
+
Key Features:
|
|
10
|
+
- Contract-driven: All operations defined in ModelDbRepositoryContract
|
|
11
|
+
- Positional parameters: Uses $1, $2, ... (no named param rewriting)
|
|
12
|
+
- Determinism enforcement: ORDER BY injection for multi-row queries
|
|
13
|
+
- Limit enforcement: LIMIT injection with configurable maximum
|
|
14
|
+
- Operation validation: Allowlist-based operation control
|
|
15
|
+
- Timeout enforcement: asyncio.wait_for() for query cancellation
|
|
16
|
+
|
|
17
|
+
Usage Example:
|
|
18
|
+
>>> import asyncpg
|
|
19
|
+
>>> from omnibase_infra.runtime.db import (
|
|
20
|
+
... ModelDbRepositoryContract,
|
|
21
|
+
... ModelDbOperation,
|
|
22
|
+
... ModelDbReturn,
|
|
23
|
+
... ModelRepositoryRuntimeConfig,
|
|
24
|
+
... )
|
|
25
|
+
>>> from omnibase_infra.runtime.db.postgres_repository_runtime import (
|
|
26
|
+
... PostgresRepositoryRuntime,
|
|
27
|
+
... )
|
|
28
|
+
>>>
|
|
29
|
+
>>> # Create contract
|
|
30
|
+
>>> contract = ModelDbRepositoryContract(
|
|
31
|
+
... name="users",
|
|
32
|
+
... database_ref="primary",
|
|
33
|
+
... ops={
|
|
34
|
+
... "find_by_id": ModelDbOperation(
|
|
35
|
+
... mode="select",
|
|
36
|
+
... sql="SELECT * FROM users WHERE id = $1",
|
|
37
|
+
... params=["user_id"],
|
|
38
|
+
... returns=ModelDbReturn(many=False),
|
|
39
|
+
... ),
|
|
40
|
+
... },
|
|
41
|
+
... )
|
|
42
|
+
>>>
|
|
43
|
+
>>> # Create runtime (with pool)
|
|
44
|
+
>>> pool = await asyncpg.create_pool(...)
|
|
45
|
+
>>> runtime = PostgresRepositoryRuntime(pool, contract)
|
|
46
|
+
>>>
|
|
47
|
+
>>> # Execute operation
|
|
48
|
+
>>> user = await runtime.call("find_by_id", 123)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import asyncio
|
|
54
|
+
import logging
|
|
55
|
+
import re
|
|
56
|
+
import time
|
|
57
|
+
from typing import TYPE_CHECKING
|
|
58
|
+
|
|
59
|
+
from omnibase_infra.enums import EnumInfraTransportType
|
|
60
|
+
from omnibase_infra.errors.repository import (
|
|
61
|
+
RepositoryContractError,
|
|
62
|
+
RepositoryExecutionError,
|
|
63
|
+
RepositoryTimeoutError,
|
|
64
|
+
RepositoryValidationError,
|
|
65
|
+
)
|
|
66
|
+
from omnibase_infra.models.errors import ModelInfraErrorContext
|
|
67
|
+
from omnibase_infra.runtime.db.models import (
|
|
68
|
+
ModelDbOperation,
|
|
69
|
+
ModelDbRepositoryContract,
|
|
70
|
+
ModelRepositoryRuntimeConfig,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
import asyncpg
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
# =============================================================================
|
|
79
|
+
# SQL Clause Detection Patterns
|
|
80
|
+
# =============================================================================
|
|
81
|
+
#
|
|
82
|
+
# These regex patterns provide simple ORDER BY and LIMIT clause detection
|
|
83
|
+
# for determinism enforcement. They use word boundaries (\b) and case-insensitive
|
|
84
|
+
# matching to identify SQL keywords.
|
|
85
|
+
#
|
|
86
|
+
# KNOWN LIMITATIONS:
|
|
87
|
+
# -----------------
|
|
88
|
+
# These patterns use simple regex matching, NOT a full SQL parser. As such,
|
|
89
|
+
# they can produce false positives in certain edge cases:
|
|
90
|
+
#
|
|
91
|
+
# 1. String Literals: Patterns inside quoted strings will match:
|
|
92
|
+
# - SELECT description FROM items WHERE note = 'sort ORDER BY priority'
|
|
93
|
+
# - SELECT * FROM logs WHERE message LIKE '%LIMIT 10 reached%'
|
|
94
|
+
#
|
|
95
|
+
# 2. Subqueries: Patterns in nested queries will match the outer detection:
|
|
96
|
+
# - SELECT * FROM (SELECT id FROM users ORDER BY created_at LIMIT 5) sub
|
|
97
|
+
# - The outer query appears to have ORDER BY/LIMIT, but doesn't
|
|
98
|
+
#
|
|
99
|
+
# 3. Comments: Patterns inside SQL comments will match:
|
|
100
|
+
# - SELECT * FROM users -- ORDER BY id for debugging
|
|
101
|
+
# - SELECT * FROM users /* LIMIT 100 was here */
|
|
102
|
+
#
|
|
103
|
+
# WHY THIS IS ACCEPTABLE:
|
|
104
|
+
# ----------------------
|
|
105
|
+
# 1. Contract SQL should be simple, predictable queries. Complex queries with
|
|
106
|
+
# subqueries, dynamic string construction, or embedded SQL in literals
|
|
107
|
+
# indicate contract design that should be reconsidered.
|
|
108
|
+
#
|
|
109
|
+
# 2. This detection is defense-in-depth, not primary validation. The contract
|
|
110
|
+
# author has explicit control over the SQL and can always add explicit
|
|
111
|
+
# ORDER BY and LIMIT clauses to avoid injection entirely.
|
|
112
|
+
#
|
|
113
|
+
# 3. False positives (detecting ORDER BY/LIMIT when not present at outer level)
|
|
114
|
+
# are safer than false negatives. A false positive skips injection, leaving
|
|
115
|
+
# the query unchanged. A false negative would inject duplicate clauses.
|
|
116
|
+
#
|
|
117
|
+
# RECOMMENDATION FOR COMPLEX QUERIES:
|
|
118
|
+
# ----------------------------------
|
|
119
|
+
# If your contract requires complex SQL with subqueries or string operations
|
|
120
|
+
# that contain SQL keywords, explicitly include ORDER BY and LIMIT in the
|
|
121
|
+
# outer query. This bypasses regex detection entirely:
|
|
122
|
+
#
|
|
123
|
+
# GOOD: "SELECT * FROM (SELECT id FROM users ORDER BY id) sub ORDER BY id LIMIT 100"
|
|
124
|
+
# AVOID: Relying on injection for queries with embedded SQL-like strings
|
|
125
|
+
#
|
|
126
|
+
# =============================================================================
|
|
127
|
+
_ORDER_BY_PATTERN = re.compile(r"\bORDER\s+BY\b", re.IGNORECASE)
|
|
128
|
+
# Pattern to detect numeric LIMIT for validation (e.g., LIMIT 100)
|
|
129
|
+
_LIMIT_NUMERIC_PATTERN = re.compile(r"\bLIMIT\s+(\d+)\b", re.IGNORECASE)
|
|
130
|
+
# Pattern to detect parameterized LIMIT (e.g., LIMIT $1) - cannot validate at build time
|
|
131
|
+
_LIMIT_PARAM_PATTERN = re.compile(r"\bLIMIT\s+\$\d+\b", re.IGNORECASE)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PostgresRepositoryRuntime:
|
|
135
|
+
"""Runtime for executing repository contracts against PostgreSQL.
|
|
136
|
+
|
|
137
|
+
Executes operations defined in a ModelDbRepositoryContract with
|
|
138
|
+
safety constraints, determinism guarantees, and configurable limits.
|
|
139
|
+
|
|
140
|
+
Thread Safety:
|
|
141
|
+
This class is NOT thread-safe for concurrent modifications.
|
|
142
|
+
The pool itself handles connection-level concurrency.
|
|
143
|
+
Multiple coroutines may call() concurrently on the same runtime.
|
|
144
|
+
|
|
145
|
+
Attributes:
|
|
146
|
+
pool: asyncpg connection pool for database access.
|
|
147
|
+
contract: Repository contract defining available operations.
|
|
148
|
+
config: Runtime configuration for safety and behavior.
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
>>> pool = await asyncpg.create_pool(dsn="postgresql://...")
|
|
152
|
+
>>> runtime = PostgresRepositoryRuntime(pool, contract)
|
|
153
|
+
>>> results = await runtime.call("find_all")
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
__slots__ = ("_config", "_contract", "_pool")
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
pool: asyncpg.Pool,
|
|
161
|
+
contract: ModelDbRepositoryContract,
|
|
162
|
+
config: ModelRepositoryRuntimeConfig | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Initialize the repository runtime.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
pool: asyncpg connection pool for database access.
|
|
168
|
+
contract: Repository contract defining available operations.
|
|
169
|
+
config: Optional runtime configuration. If None, uses defaults.
|
|
170
|
+
|
|
171
|
+
Example:
|
|
172
|
+
>>> runtime = PostgresRepositoryRuntime(
|
|
173
|
+
... pool=pool,
|
|
174
|
+
... contract=contract,
|
|
175
|
+
... config=ModelRepositoryRuntimeConfig(max_row_limit=100),
|
|
176
|
+
... )
|
|
177
|
+
"""
|
|
178
|
+
self._pool = pool
|
|
179
|
+
self._contract = contract
|
|
180
|
+
self._config = config or ModelRepositoryRuntimeConfig()
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def contract(self) -> ModelDbRepositoryContract:
|
|
184
|
+
"""Get the repository contract."""
|
|
185
|
+
return self._contract
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def config(self) -> ModelRepositoryRuntimeConfig:
|
|
189
|
+
"""Get the runtime configuration."""
|
|
190
|
+
return self._config
|
|
191
|
+
|
|
192
|
+
async def call(
|
|
193
|
+
self, op_name: str, *args: object
|
|
194
|
+
) -> list[dict[str, object]] | dict[str, object] | None:
|
|
195
|
+
"""Execute a named operation from the contract.
|
|
196
|
+
|
|
197
|
+
Validates the operation exists, checks allowed operations,
|
|
198
|
+
validates argument count, applies determinism and limit
|
|
199
|
+
constraints, and executes with timeout enforcement.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
op_name: Operation name as defined in contract.ops.
|
|
203
|
+
*args: Positional arguments matching contract params order.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
For many=True: list of dicts (possibly empty)
|
|
207
|
+
For many=False: single dict or None if no row found
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
RepositoryContractError: Operation not found, forbidden mode,
|
|
211
|
+
or determinism constraint violation (no PK for multi-row).
|
|
212
|
+
RepositoryValidationError: Argument count mismatch.
|
|
213
|
+
RepositoryExecutionError: Database execution error.
|
|
214
|
+
RepositoryTimeoutError: Query exceeded timeout.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
>>> # Single row lookup
|
|
218
|
+
>>> user = await runtime.call("find_by_id", 123)
|
|
219
|
+
>>> # Multi-row query
|
|
220
|
+
>>> users = await runtime.call("find_by_status", "active")
|
|
221
|
+
"""
|
|
222
|
+
start_time = time.monotonic()
|
|
223
|
+
context = self._create_error_context(op_name)
|
|
224
|
+
|
|
225
|
+
# Lookup operation in contract
|
|
226
|
+
operation = self._get_operation(op_name, context)
|
|
227
|
+
|
|
228
|
+
# Validate operation is allowed
|
|
229
|
+
self._validate_operation_allowed(operation, op_name, context)
|
|
230
|
+
|
|
231
|
+
# Validate argument count
|
|
232
|
+
self._validate_arg_count(operation, args, op_name, context)
|
|
233
|
+
|
|
234
|
+
# Build final SQL with determinism and limit constraints
|
|
235
|
+
sql = self._build_sql(operation, op_name, context)
|
|
236
|
+
|
|
237
|
+
# Execute with timeout
|
|
238
|
+
try:
|
|
239
|
+
result = await self._execute_with_timeout(
|
|
240
|
+
sql, args, operation, op_name, context
|
|
241
|
+
)
|
|
242
|
+
except TimeoutError as e:
|
|
243
|
+
timeout_seconds = self._config.timeout_ms / 1000.0
|
|
244
|
+
raise RepositoryTimeoutError(
|
|
245
|
+
f"Query '{op_name}' exceeded timeout of {timeout_seconds}s",
|
|
246
|
+
op_name=op_name,
|
|
247
|
+
table=self._get_primary_table(),
|
|
248
|
+
timeout_seconds=timeout_seconds,
|
|
249
|
+
sql_fingerprint=self._fingerprint_sql(sql),
|
|
250
|
+
context=context,
|
|
251
|
+
) from e
|
|
252
|
+
|
|
253
|
+
# Log metrics if enabled
|
|
254
|
+
if self._config.emit_metrics:
|
|
255
|
+
elapsed_ms = (time.monotonic() - start_time) * 1000
|
|
256
|
+
row_count = (
|
|
257
|
+
len(result) if isinstance(result, list) else (1 if result else 0)
|
|
258
|
+
)
|
|
259
|
+
logger.info(
|
|
260
|
+
"Repository operation completed",
|
|
261
|
+
extra={
|
|
262
|
+
"op_name": op_name,
|
|
263
|
+
"duration_ms": round(elapsed_ms, 2),
|
|
264
|
+
"rows_returned": row_count,
|
|
265
|
+
"repository": self._contract.name,
|
|
266
|
+
},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
def _create_error_context(self, op_name: str) -> ModelInfraErrorContext:
|
|
272
|
+
"""Create error context for infrastructure errors."""
|
|
273
|
+
return ModelInfraErrorContext.with_correlation(
|
|
274
|
+
transport_type=EnumInfraTransportType.DATABASE,
|
|
275
|
+
operation=f"repository.{op_name}",
|
|
276
|
+
target_name=self._contract.name,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _get_operation(
|
|
280
|
+
self, op_name: str, context: ModelInfraErrorContext
|
|
281
|
+
) -> ModelDbOperation:
|
|
282
|
+
"""Get operation from contract, raising error if not found."""
|
|
283
|
+
operation = self._contract.ops.get(op_name)
|
|
284
|
+
if operation is None:
|
|
285
|
+
available_ops = list(self._contract.ops.keys())
|
|
286
|
+
raise RepositoryContractError(
|
|
287
|
+
f"Unknown operation '{op_name}' not defined in contract '{self._contract.name}'. "
|
|
288
|
+
f"Available operations: {available_ops}",
|
|
289
|
+
op_name=op_name,
|
|
290
|
+
table=self._get_primary_table(),
|
|
291
|
+
context=context,
|
|
292
|
+
)
|
|
293
|
+
return operation
|
|
294
|
+
|
|
295
|
+
def _validate_operation_allowed(
|
|
296
|
+
self,
|
|
297
|
+
operation: ModelDbOperation,
|
|
298
|
+
op_name: str,
|
|
299
|
+
context: ModelInfraErrorContext,
|
|
300
|
+
) -> None:
|
|
301
|
+
"""Validate operation mode is allowed by config.
|
|
302
|
+
|
|
303
|
+
The contract uses 'read' or 'write' modes (validated by omnibase_core
|
|
304
|
+
validators at contract load time to ensure SQL verb matching).
|
|
305
|
+
"""
|
|
306
|
+
mode = operation.mode
|
|
307
|
+
|
|
308
|
+
# Check write operations against feature flag
|
|
309
|
+
if mode == "write" and not self._config.allow_write_operations:
|
|
310
|
+
raise RepositoryContractError(
|
|
311
|
+
f"Operation '{op_name}' uses 'write' mode which is disabled. "
|
|
312
|
+
"Set allow_write_operations=True in config to enable.",
|
|
313
|
+
op_name=op_name,
|
|
314
|
+
table=self._get_primary_table(),
|
|
315
|
+
context=context,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Check mode against allowlist
|
|
319
|
+
if mode not in self._config.allowed_modes:
|
|
320
|
+
raise RepositoryContractError(
|
|
321
|
+
f"Operation mode '{mode}' for '{op_name}' is not in allowed_modes. "
|
|
322
|
+
f"Allowed: {set(self._config.allowed_modes)}",
|
|
323
|
+
op_name=op_name,
|
|
324
|
+
table=self._get_primary_table(),
|
|
325
|
+
context=context,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _validate_arg_count(
|
|
329
|
+
self,
|
|
330
|
+
operation: ModelDbOperation,
|
|
331
|
+
args: tuple[object, ...],
|
|
332
|
+
op_name: str,
|
|
333
|
+
context: ModelInfraErrorContext,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Validate argument count matches contract params.
|
|
336
|
+
|
|
337
|
+
Contract params is a dict[str, ModelDbParam] where keys are param names.
|
|
338
|
+
"""
|
|
339
|
+
param_names = list(operation.params.keys())
|
|
340
|
+
expected = len(param_names)
|
|
341
|
+
actual = len(args)
|
|
342
|
+
if actual != expected:
|
|
343
|
+
raise RepositoryValidationError(
|
|
344
|
+
f"Operation '{op_name}' expects {expected} argument(s) ({param_names}), "
|
|
345
|
+
f"but received {actual}",
|
|
346
|
+
op_name=op_name,
|
|
347
|
+
table=self._get_primary_table(),
|
|
348
|
+
context=context,
|
|
349
|
+
expected_args=expected,
|
|
350
|
+
actual_args=actual,
|
|
351
|
+
param_names=param_names,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _build_sql(
|
|
355
|
+
self,
|
|
356
|
+
operation: ModelDbOperation,
|
|
357
|
+
op_name: str,
|
|
358
|
+
context: ModelInfraErrorContext,
|
|
359
|
+
) -> str:
|
|
360
|
+
"""Build final SQL with determinism and limit constraints.
|
|
361
|
+
|
|
362
|
+
Applies ORDER BY injection for multi-row queries without ORDER BY.
|
|
363
|
+
Applies LIMIT injection or validation based on config.
|
|
364
|
+
|
|
365
|
+
Only applies constraints to 'read' mode operations (SELECT).
|
|
366
|
+
"""
|
|
367
|
+
sql = operation.sql
|
|
368
|
+
is_read = operation.mode == "read"
|
|
369
|
+
is_multi_row = operation.returns.many
|
|
370
|
+
|
|
371
|
+
# Only apply constraints to read operations
|
|
372
|
+
if not is_read:
|
|
373
|
+
return sql
|
|
374
|
+
|
|
375
|
+
# Apply determinism constraints for multi-row reads
|
|
376
|
+
if is_multi_row:
|
|
377
|
+
sql = self._inject_order_by(sql, op_name, context)
|
|
378
|
+
|
|
379
|
+
# Apply limit constraints for multi-row reads
|
|
380
|
+
if is_multi_row:
|
|
381
|
+
sql = self._inject_limit(sql, op_name, context)
|
|
382
|
+
|
|
383
|
+
return sql
|
|
384
|
+
|
|
385
|
+
def _inject_order_by(
|
|
386
|
+
self,
|
|
387
|
+
sql: str,
|
|
388
|
+
op_name: str,
|
|
389
|
+
context: ModelInfraErrorContext,
|
|
390
|
+
) -> str:
|
|
391
|
+
"""Inject ORDER BY clause for deterministic multi-row results.
|
|
392
|
+
|
|
393
|
+
Rules:
|
|
394
|
+
- If ORDER BY exists: no injection needed
|
|
395
|
+
- If no ORDER BY and PK declared: inject ORDER BY {pk}
|
|
396
|
+
- If no ORDER BY and no PK: HARD ERROR
|
|
397
|
+
|
|
398
|
+
When injecting ORDER BY, the clause is inserted BEFORE any existing
|
|
399
|
+
LIMIT clause to produce valid SQL. For example:
|
|
400
|
+
- Input: "SELECT * FROM users LIMIT $1"
|
|
401
|
+
- Output: "SELECT * FROM users ORDER BY id LIMIT $1"
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
sql: The SQL query to potentially modify.
|
|
405
|
+
op_name: Operation name for error context.
|
|
406
|
+
context: Error context for exception raising.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
SQL with ORDER BY clause (injected or original).
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
RepositoryContractError: No ORDER BY and no primary_key_column.
|
|
413
|
+
"""
|
|
414
|
+
has_order_by = bool(_ORDER_BY_PATTERN.search(sql))
|
|
415
|
+
if has_order_by:
|
|
416
|
+
return sql
|
|
417
|
+
|
|
418
|
+
# No ORDER BY - check if we can inject
|
|
419
|
+
pk_column = self._config.primary_key_column
|
|
420
|
+
if pk_column is None:
|
|
421
|
+
raise RepositoryContractError(
|
|
422
|
+
f"Multi-row query '{op_name}' has no ORDER BY clause and "
|
|
423
|
+
"primary_key_column is not configured. Deterministic results "
|
|
424
|
+
"cannot be guaranteed. Either add ORDER BY to the SQL or "
|
|
425
|
+
"set primary_key_column in config.",
|
|
426
|
+
op_name=op_name,
|
|
427
|
+
table=self._get_primary_table(),
|
|
428
|
+
sql_fingerprint=self._fingerprint_sql(sql),
|
|
429
|
+
context=context,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Inject ORDER BY using configured order or just PK
|
|
433
|
+
order_by = self._config.default_order_by or pk_column
|
|
434
|
+
|
|
435
|
+
# Check if LIMIT exists - ORDER BY must be inserted BEFORE LIMIT
|
|
436
|
+
param_match = _LIMIT_PARAM_PATTERN.search(sql)
|
|
437
|
+
numeric_match = _LIMIT_NUMERIC_PATTERN.search(sql)
|
|
438
|
+
limit_match = param_match or numeric_match
|
|
439
|
+
|
|
440
|
+
if limit_match:
|
|
441
|
+
# Insert ORDER BY before LIMIT
|
|
442
|
+
limit_start = limit_match.start()
|
|
443
|
+
return (
|
|
444
|
+
f"{sql[:limit_start].rstrip()} ORDER BY {order_by} {sql[limit_start:]}"
|
|
445
|
+
)
|
|
446
|
+
else:
|
|
447
|
+
# No LIMIT, append at end
|
|
448
|
+
return f"{sql.rstrip().rstrip(';')} ORDER BY {order_by}"
|
|
449
|
+
|
|
450
|
+
def _inject_limit(
|
|
451
|
+
self,
|
|
452
|
+
sql: str,
|
|
453
|
+
op_name: str,
|
|
454
|
+
context: ModelInfraErrorContext,
|
|
455
|
+
) -> str:
|
|
456
|
+
"""Inject or validate LIMIT clause for multi-row results.
|
|
457
|
+
|
|
458
|
+
Rules:
|
|
459
|
+
- If parameterized LIMIT (e.g., $1): OK (no change, can't validate at build time)
|
|
460
|
+
- If numeric LIMIT > max_row_limit: HARD ERROR
|
|
461
|
+
- If numeric LIMIT <= max_row_limit: OK (no change)
|
|
462
|
+
- If no LIMIT: inject LIMIT {max_row_limit}
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
sql: The SQL query to potentially modify.
|
|
466
|
+
op_name: Operation name for error context.
|
|
467
|
+
context: Error context for exception raising.
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
SQL with LIMIT clause (injected or original).
|
|
471
|
+
|
|
472
|
+
Raises:
|
|
473
|
+
RepositoryContractError: Numeric LIMIT exceeds max_row_limit.
|
|
474
|
+
"""
|
|
475
|
+
max_limit = self._config.max_row_limit
|
|
476
|
+
|
|
477
|
+
# Check for parameterized LIMIT (e.g., LIMIT $1) - can't validate at build time
|
|
478
|
+
if _LIMIT_PARAM_PATTERN.search(sql):
|
|
479
|
+
return sql
|
|
480
|
+
|
|
481
|
+
# Check for numeric LIMIT (e.g., LIMIT 100) - can validate
|
|
482
|
+
limit_match = _LIMIT_NUMERIC_PATTERN.search(sql)
|
|
483
|
+
if limit_match:
|
|
484
|
+
# Existing numeric LIMIT - validate it
|
|
485
|
+
existing_limit = int(limit_match.group(1))
|
|
486
|
+
if existing_limit > max_limit:
|
|
487
|
+
raise RepositoryContractError(
|
|
488
|
+
f"Query '{op_name}' has LIMIT {existing_limit} which exceeds "
|
|
489
|
+
f"max_row_limit of {max_limit}. Reduce the LIMIT or increase "
|
|
490
|
+
"max_row_limit in config.",
|
|
491
|
+
op_name=op_name,
|
|
492
|
+
table=self._get_primary_table(),
|
|
493
|
+
sql_fingerprint=self._fingerprint_sql(sql),
|
|
494
|
+
context=context,
|
|
495
|
+
existing_limit=existing_limit,
|
|
496
|
+
max_row_limit=max_limit,
|
|
497
|
+
)
|
|
498
|
+
return sql
|
|
499
|
+
|
|
500
|
+
# No LIMIT - inject one
|
|
501
|
+
return f"{sql.rstrip().rstrip(';')} LIMIT {max_limit}"
|
|
502
|
+
|
|
503
|
+
async def _execute_with_timeout(
|
|
504
|
+
self,
|
|
505
|
+
sql: str,
|
|
506
|
+
args: tuple[object, ...],
|
|
507
|
+
operation: ModelDbOperation,
|
|
508
|
+
op_name: str,
|
|
509
|
+
context: ModelInfraErrorContext,
|
|
510
|
+
) -> list[dict[str, object]] | dict[str, object] | None:
|
|
511
|
+
"""Execute query with timeout enforcement.
|
|
512
|
+
|
|
513
|
+
Uses asyncio.wait_for() to enforce timeout.
|
|
514
|
+
Uses fetch() for many=True, fetchrow() for many=False.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
sql: Final SQL query to execute.
|
|
518
|
+
args: Positional arguments for the query.
|
|
519
|
+
operation: Operation specification.
|
|
520
|
+
op_name: Operation name for error context.
|
|
521
|
+
context: Error context for exception raising.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Query results as appropriate type.
|
|
525
|
+
|
|
526
|
+
Raises:
|
|
527
|
+
asyncio.TimeoutError: Query exceeded timeout (caught by caller).
|
|
528
|
+
RepositoryExecutionError: Database execution error.
|
|
529
|
+
"""
|
|
530
|
+
timeout_seconds = self._config.timeout_ms / 1000.0
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
async with self._pool.acquire() as conn:
|
|
534
|
+
if operation.returns.many:
|
|
535
|
+
# Multi-row: use fetch()
|
|
536
|
+
coro = conn.fetch(sql, *args)
|
|
537
|
+
records = await asyncio.wait_for(coro, timeout=timeout_seconds)
|
|
538
|
+
return [dict(record) for record in records]
|
|
539
|
+
else:
|
|
540
|
+
# Single-row: use fetchrow()
|
|
541
|
+
coro = conn.fetchrow(sql, *args)
|
|
542
|
+
record = await asyncio.wait_for(coro, timeout=timeout_seconds)
|
|
543
|
+
return dict(record) if record is not None else None
|
|
544
|
+
except TimeoutError:
|
|
545
|
+
# Re-raise for caller to handle
|
|
546
|
+
raise
|
|
547
|
+
except Exception as e:
|
|
548
|
+
# Wrap all other exceptions
|
|
549
|
+
raise RepositoryExecutionError(
|
|
550
|
+
f"Failed to execute operation '{op_name}': {e}",
|
|
551
|
+
op_name=op_name,
|
|
552
|
+
table=self._get_primary_table(),
|
|
553
|
+
sql_fingerprint=self._fingerprint_sql(sql),
|
|
554
|
+
context=context,
|
|
555
|
+
) from e
|
|
556
|
+
|
|
557
|
+
def _get_primary_table(self) -> str | None:
|
|
558
|
+
"""Get the primary table from contract for error context."""
|
|
559
|
+
return self._contract.tables[0] if self._contract.tables else None
|
|
560
|
+
|
|
561
|
+
def _fingerprint_sql(self, sql: str) -> str:
|
|
562
|
+
"""Create a safe fingerprint of SQL for logging/errors.
|
|
563
|
+
|
|
564
|
+
Truncates long SQL and removes potentially sensitive values.
|
|
565
|
+
"""
|
|
566
|
+
# Simple approach: truncate to reasonable length
|
|
567
|
+
max_len = 200
|
|
568
|
+
if len(sql) <= max_len:
|
|
569
|
+
return sql
|
|
570
|
+
return sql[:max_len] + "..."
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
__all__: list[str] = ["PostgresRepositoryRuntime"]
|
|
@@ -446,7 +446,10 @@ INFRA_NODES_PATH = "src/omnibase_infra/nodes/"
|
|
|
446
446
|
# - 115 (2026-01-29): OMN-1653 contract registry reducer (+2 unions)
|
|
447
447
|
# ContractRegistryEvent: 4-type union for event routing
|
|
448
448
|
# contract_yaml: dict | str for flexible YAML handling
|
|
449
|
-
|
|
449
|
+
# - 117 (2026-02-01): OMN-1783 PostgresRepositoryRuntime (+2 unions)
|
|
450
|
+
# call() return type: list[dict] | dict | None
|
|
451
|
+
# _execute_with_timeout() return type: list[dict] | dict | None
|
|
452
|
+
INFRA_MAX_UNIONS = 117
|
|
450
453
|
|
|
451
454
|
# Maximum allowed architecture violations in infrastructure code.
|
|
452
455
|
# Set to 0 (strict enforcement) to ensure one-model-per-file principle is always followed.
|
|
@@ -132,6 +132,24 @@ pattern_exemptions:
|
|
|
132
132
|
- CLAUDE.md (Accepted Pattern Exceptions - EventBusKafka Complexity)
|
|
133
133
|
ticket: OMN-1305
|
|
134
134
|
# ==========================================================================
|
|
135
|
+
# PostgresRepositoryRuntime Exemptions (OMN-1783)
|
|
136
|
+
# ==========================================================================
|
|
137
|
+
# Contract-driven database runtime with cohesive methods for query execution.
|
|
138
|
+
# All methods serve the same purpose: executing repository contract operations.
|
|
139
|
+
# Public: call(), contract (property), config (property)
|
|
140
|
+
# Private: _create_error_context, _get_operation, _validate_operation_allowed,
|
|
141
|
+
# _validate_arg_count, _build_sql, _inject_order_by, _inject_limit,
|
|
142
|
+
# _execute_with_timeout, _get_primary_table, _fingerprint_sql
|
|
143
|
+
- file_pattern: 'postgres_repository_runtime\.py'
|
|
144
|
+
class_pattern: "Class 'PostgresRepositoryRuntime'"
|
|
145
|
+
violation_pattern: 'has \d+ methods'
|
|
146
|
+
reason: >
|
|
147
|
+
Cohesive runtime class with single responsibility: executing repository contract operations. Methods are logically grouped: validation (3), SQL building (3), execution (1), and helpers (3). Breaking into smaller classes would reduce cohesion and complicate the simple contract-driven execution flow.
|
|
148
|
+
|
|
149
|
+
documentation:
|
|
150
|
+
- OMN-1783 PR description
|
|
151
|
+
ticket: OMN-1783
|
|
152
|
+
# ==========================================================================
|
|
135
153
|
# Protocol Plugin Architecture Exemptions
|
|
136
154
|
# ==========================================================================
|
|
137
155
|
# The 'execute' method name is a standard plugin architecture pattern.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: omnibase_infra
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: ONEX Infrastructure - Service integration and database infrastructure tools
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -28,7 +28,7 @@ Requires-Dist: jinja2 (>=3.1.6,<4.0.0)
|
|
|
28
28
|
Requires-Dist: jsonschema (>=4.20.0,<5.0.0)
|
|
29
29
|
Requires-Dist: mcp (>=1.25.0,<2.0.0)
|
|
30
30
|
Requires-Dist: neo4j (>=5.15.0,<6.0.0)
|
|
31
|
-
Requires-Dist: omnibase-core (>=0.
|
|
31
|
+
Requires-Dist: omnibase-core (>=0.12.0,<0.13.0)
|
|
32
32
|
Requires-Dist: omnibase-spi (>=0.6.4,<0.7.0)
|
|
33
33
|
Requires-Dist: opentelemetry-api (>=1.27.0,<2.0.0)
|
|
34
34
|
Requires-Dist: opentelemetry-exporter-otlp (>=1.27.0,<2.0.0)
|