nl2data-postgres 0.1.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.
- nl2data_postgres/__init__.py +26 -0
- nl2data_postgres/adapter.py +215 -0
- nl2data_postgres/client.py +121 -0
- nl2data_postgres/config.py +109 -0
- nl2data_postgres/discovery.py +351 -0
- nl2data_postgres/errors.py +33 -0
- nl2data_postgres/execution.py +148 -0
- nl2data_postgres/py.typed +0 -0
- nl2data_postgres-0.1.0.dist-info/METADATA +146 -0
- nl2data_postgres-0.1.0.dist-info/RECORD +12 -0
- nl2data_postgres-0.1.0.dist-info/WHEEL +5 -0
- nl2data_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""PostgreSQL metadata discovery and governed SQL execution for nl2data-core.
|
|
2
|
+
|
|
3
|
+
The package implements the core ``MetadataDiscoverer`` and ``QueryAdapter``
|
|
4
|
+
ports using ``psycopg``. The driver is never imported at package import
|
|
5
|
+
time; it is loaded lazily when a discoverer or adapter is first used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import PostgresAdapterConfig
|
|
13
|
+
|
|
14
|
+
__all__ = ["PostgresAdapterConfig"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def __getattr__(name: str) -> Any:
|
|
18
|
+
if name == "PostgresMetadataDiscoverer":
|
|
19
|
+
from .discovery import PostgresMetadataDiscoverer
|
|
20
|
+
|
|
21
|
+
return PostgresMetadataDiscoverer
|
|
22
|
+
if name == "PostgresQueryAdapter":
|
|
23
|
+
from .adapter import PostgresQueryAdapter
|
|
24
|
+
|
|
25
|
+
return PostgresQueryAdapter
|
|
26
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""PostgreSQL ``QueryAdapter`` port implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from nl2data_core.adapters.models import (
|
|
10
|
+
AdapterCapabilities,
|
|
11
|
+
AdapterLimits,
|
|
12
|
+
AsyncMode,
|
|
13
|
+
CostEstimate,
|
|
14
|
+
ExecutionResult,
|
|
15
|
+
GeneratedArtifact,
|
|
16
|
+
ParsedArtifact,
|
|
17
|
+
ValidatedArtifact,
|
|
18
|
+
ValidationContext,
|
|
19
|
+
)
|
|
20
|
+
from nl2data_core.adapters.sql.guard import SQLGuardPolicy, assert_guarded
|
|
21
|
+
from nl2data_core.adapters.sql.parsing import parse_sql
|
|
22
|
+
from nl2data_core.metadata.protocol import MetadataDiscoveryCapability
|
|
23
|
+
from sqlglot import exp
|
|
24
|
+
|
|
25
|
+
from .config import PostgresAdapterConfig
|
|
26
|
+
from .errors import PostgresAdapterError
|
|
27
|
+
from .execution import PostgresExecutor
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PostgresQueryAdapter:
|
|
31
|
+
"""Read-only, single-statement PostgreSQL adapter over the core contract."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config: PostgresAdapterConfig,
|
|
36
|
+
*,
|
|
37
|
+
allowed_objects: frozenset[str] = frozenset(),
|
|
38
|
+
allowed_columns: frozenset[str] | None = None,
|
|
39
|
+
require_limit: bool = True,
|
|
40
|
+
snapshot_fingerprint: str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._config = config
|
|
43
|
+
self._policy = SQLGuardPolicy(
|
|
44
|
+
allowed_objects=allowed_objects,
|
|
45
|
+
allowed_columns=allowed_columns,
|
|
46
|
+
max_rows=config.max_rows,
|
|
47
|
+
require_limit=require_limit,
|
|
48
|
+
)
|
|
49
|
+
self._snapshot_fingerprint = snapshot_fingerprint
|
|
50
|
+
self._executor = PostgresExecutor(config)
|
|
51
|
+
self._parsed_by_id: dict[str, Any] = {}
|
|
52
|
+
self._sql_by_id: dict[str, str] = {}
|
|
53
|
+
|
|
54
|
+
def capabilities(self) -> AdapterCapabilities:
|
|
55
|
+
return AdapterCapabilities(
|
|
56
|
+
adapter_type="sql",
|
|
57
|
+
query_language="sql",
|
|
58
|
+
async_mode=AsyncMode.THREAD_OFFLOAD,
|
|
59
|
+
features=frozenset(
|
|
60
|
+
{
|
|
61
|
+
"read_only",
|
|
62
|
+
"single_statement",
|
|
63
|
+
"bounded_results",
|
|
64
|
+
"ast_validation",
|
|
65
|
+
"aggregation",
|
|
66
|
+
"ordering",
|
|
67
|
+
"list_ops",
|
|
68
|
+
"contains",
|
|
69
|
+
"metadata_discovery",
|
|
70
|
+
"join",
|
|
71
|
+
"multi_entity",
|
|
72
|
+
"cte",
|
|
73
|
+
"grouping",
|
|
74
|
+
"union",
|
|
75
|
+
"computed_expressions",
|
|
76
|
+
"prior_period",
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
limits=AdapterLimits(
|
|
80
|
+
max_query_length=self._config.max_query_length,
|
|
81
|
+
max_result_rows=self._policy.max_rows,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def metadata_discovery_capability(self) -> MetadataDiscoveryCapability:
|
|
86
|
+
return MetadataDiscoveryCapability(
|
|
87
|
+
backend="sql:postgresql",
|
|
88
|
+
supported=True,
|
|
89
|
+
max_objects=1_024,
|
|
90
|
+
max_fields_per_object=16_384,
|
|
91
|
+
supports_statistics=True,
|
|
92
|
+
supports_sampling=False,
|
|
93
|
+
description="bounded postgresql catalog introspection",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def parse(self, query: str, context: ValidationContext) -> ParsedArtifact:
|
|
97
|
+
parsed = parse_sql(
|
|
98
|
+
query,
|
|
99
|
+
dialect="postgres",
|
|
100
|
+
artifact_id=f"pg-{len(self._parsed_by_id) + 1}",
|
|
101
|
+
max_query_length=self._config.max_query_length,
|
|
102
|
+
)
|
|
103
|
+
self._parsed_by_id[parsed.artifact_id] = parsed
|
|
104
|
+
return ParsedArtifact(
|
|
105
|
+
artifact_id=parsed.artifact_id,
|
|
106
|
+
fingerprint=parsed.fingerprint,
|
|
107
|
+
parse_metadata={
|
|
108
|
+
"statement_type": parsed.statement_type,
|
|
109
|
+
"tables": ",".join(parsed.tables),
|
|
110
|
+
"dialect": parsed.dialect,
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def _obligation_policy(self, context: ValidationContext) -> SQLGuardPolicy:
|
|
115
|
+
if not context.required_obligation_fingerprints:
|
|
116
|
+
return self._policy
|
|
117
|
+
return replace(
|
|
118
|
+
self._policy,
|
|
119
|
+
required_obligation_fingerprints=(
|
|
120
|
+
self._policy.required_obligation_fingerprints
|
|
121
|
+
| context.required_obligation_fingerprints
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def validate(self, artifact: ParsedArtifact, context: ValidationContext) -> ValidatedArtifact:
|
|
126
|
+
parsed = self._parsed_by_id.get(artifact.artifact_id)
|
|
127
|
+
if parsed is None:
|
|
128
|
+
raise PostgresAdapterError(
|
|
129
|
+
"cannot validate an artifact that was not parsed by this adapter",
|
|
130
|
+
details={"artifact_id": artifact.artifact_id},
|
|
131
|
+
)
|
|
132
|
+
if (
|
|
133
|
+
self._snapshot_fingerprint is not None
|
|
134
|
+
and context.snapshot_fingerprint is not None
|
|
135
|
+
and context.snapshot_fingerprint != self._snapshot_fingerprint
|
|
136
|
+
):
|
|
137
|
+
raise PostgresAdapterError(
|
|
138
|
+
"the metadata snapshot does not match the adapter's bound snapshot",
|
|
139
|
+
details={"artifact_id": artifact.artifact_id},
|
|
140
|
+
)
|
|
141
|
+
guard = assert_guarded(
|
|
142
|
+
parsed,
|
|
143
|
+
self._obligation_policy(context),
|
|
144
|
+
statement=exp.maybe_parse(parsed.sql_text, dialect="postgres"),
|
|
145
|
+
field_bindings=context.field_bindings,
|
|
146
|
+
)
|
|
147
|
+
self._sql_by_id[artifact.artifact_id] = parsed.sql_text
|
|
148
|
+
return ValidatedArtifact(
|
|
149
|
+
artifact_id=artifact.artifact_id,
|
|
150
|
+
fingerprint=guard.fingerprint,
|
|
151
|
+
snapshot_fingerprint=context.snapshot_fingerprint,
|
|
152
|
+
validation_metadata={
|
|
153
|
+
"tables": ",".join(parsed.tables),
|
|
154
|
+
"limit": str(parsed.limit_value or 0),
|
|
155
|
+
"columns": ",".join(parsed.columns),
|
|
156
|
+
},
|
|
157
|
+
obligations_verified=guard.obligations_verified,
|
|
158
|
+
bounded_rows=parsed.limit_value,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def generate(self, query: str, context: ValidationContext) -> GeneratedArtifact:
|
|
162
|
+
parsed = parse_sql(
|
|
163
|
+
query,
|
|
164
|
+
dialect="postgres",
|
|
165
|
+
artifact_id=f"pg-{len(self._parsed_by_id) + 1}",
|
|
166
|
+
max_query_length=self._config.max_query_length,
|
|
167
|
+
)
|
|
168
|
+
guard = assert_guarded(
|
|
169
|
+
parsed,
|
|
170
|
+
self._obligation_policy(context),
|
|
171
|
+
statement=exp.maybe_parse(parsed.sql_text, dialect="postgres"),
|
|
172
|
+
field_bindings=context.field_bindings,
|
|
173
|
+
)
|
|
174
|
+
self._parsed_by_id[parsed.artifact_id] = parsed
|
|
175
|
+
self._sql_by_id[parsed.artifact_id] = parsed.sql_text
|
|
176
|
+
return GeneratedArtifact(
|
|
177
|
+
artifact_id=parsed.artifact_id,
|
|
178
|
+
fingerprint=guard.fingerprint,
|
|
179
|
+
content_type="text/sql",
|
|
180
|
+
size_bytes=len(parsed.sql_text.encode("utf-8")),
|
|
181
|
+
metadata={"dialect": parsed.dialect},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def estimate_cost(
|
|
185
|
+
self, artifact: ValidatedArtifact, context: ValidationContext
|
|
186
|
+
) -> CostEstimate:
|
|
187
|
+
parsed = self._parsed_by_id.get(artifact.artifact_id)
|
|
188
|
+
if parsed is None:
|
|
189
|
+
raise PostgresAdapterError(
|
|
190
|
+
"cannot estimate an artifact that was not parsed by this adapter",
|
|
191
|
+
details={"artifact_id": artifact.artifact_id},
|
|
192
|
+
)
|
|
193
|
+
return CostEstimate(estimated_units=parsed.limit_value or self._policy.max_rows)
|
|
194
|
+
|
|
195
|
+
async def execute(
|
|
196
|
+
self, artifact: ValidatedArtifact, context: ValidationContext
|
|
197
|
+
) -> ExecutionResult:
|
|
198
|
+
sql_text = self._sql_by_id.get(artifact.artifact_id)
|
|
199
|
+
if sql_text is None:
|
|
200
|
+
raise PostgresAdapterError(
|
|
201
|
+
"cannot execute an artifact that was not validated by this adapter",
|
|
202
|
+
details={"artifact_id": artifact.artifact_id},
|
|
203
|
+
)
|
|
204
|
+
return await asyncio.to_thread(
|
|
205
|
+
self._executor.execute,
|
|
206
|
+
sql_text,
|
|
207
|
+
max_rows=context.limits.max_result_rows if context.limits else self._policy.max_rows,
|
|
208
|
+
max_result_bytes=context.max_result_bytes,
|
|
209
|
+
timeout_seconds=context.execution_timeout_seconds,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def close(self) -> None:
|
|
213
|
+
self._parsed_by_id.clear()
|
|
214
|
+
self._sql_by_id.clear()
|
|
215
|
+
self._executor.close()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Lazy psycopg client loading and bounded read-only connection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
from importlib.util import find_spec
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from nl2data.errors import NL2DataError
|
|
13
|
+
from nl2data_core.metadata.protocol import (
|
|
14
|
+
MetadataDiscoveryError,
|
|
15
|
+
MetadataUnauthorizedError,
|
|
16
|
+
MetadataUnavailableError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .config import PostgresAdapterConfig
|
|
20
|
+
|
|
21
|
+
_AUTH_SQLSTATES = frozenset({"28P01", "42501", "42502"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pool_module() -> Any:
|
|
25
|
+
"""Return the psycopg_pool module, raising a safe error if absent."""
|
|
26
|
+
if find_spec("psycopg_pool") is None:
|
|
27
|
+
raise MetadataUnavailableError(
|
|
28
|
+
"the psycopg pool driver is not installed; install the 'postgres' extra",
|
|
29
|
+
details={"cause_type": "ImportError"},
|
|
30
|
+
)
|
|
31
|
+
return import_module("psycopg_pool")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _raise_normalized_discovery_error(error: BaseException, message: str) -> None:
|
|
35
|
+
"""Normalize a psycopg failure into the safe discovery error family."""
|
|
36
|
+
cause_type = error.__class__.__name__
|
|
37
|
+
sqlstate = getattr(error, "sqlstate", None)
|
|
38
|
+
if sqlstate in _AUTH_SQLSTATES or cause_type in {
|
|
39
|
+
"InsufficientPrivilege",
|
|
40
|
+
"InvalidPassword",
|
|
41
|
+
"PasswordMismatch",
|
|
42
|
+
}:
|
|
43
|
+
raise MetadataUnauthorizedError(message, details={"cause_type": cause_type}) from error
|
|
44
|
+
if cause_type in {
|
|
45
|
+
"OperationalError",
|
|
46
|
+
"InterfaceError",
|
|
47
|
+
"ConnectionError",
|
|
48
|
+
"PoolTimeout",
|
|
49
|
+
}:
|
|
50
|
+
raise MetadataUnavailableError(message, details={"cause_type": cause_type}) from error
|
|
51
|
+
raise MetadataDiscoveryError(message, details={"cause_type": cause_type}) from error
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PostgresPool:
|
|
55
|
+
"""A lazily-created bounded psycopg connection pool.
|
|
56
|
+
|
|
57
|
+
The pool is created only on first use and is closed idempotently.
|
|
58
|
+
DSN resolution and driver loading happen lazily, so importing this
|
|
59
|
+
module does not require ``psycopg``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, config: PostgresAdapterConfig) -> None:
|
|
63
|
+
self._config = config
|
|
64
|
+
self._pool: Any | None = None
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
def _connect_timeout(self) -> float:
|
|
68
|
+
return min(self._config.timeout_seconds, 5.0)
|
|
69
|
+
|
|
70
|
+
def _ensure_pool(self) -> Any:
|
|
71
|
+
if self._pool is not None:
|
|
72
|
+
return self._pool
|
|
73
|
+
with self._lock:
|
|
74
|
+
if self._pool is not None:
|
|
75
|
+
return self._pool
|
|
76
|
+
pool_module = _pool_module()
|
|
77
|
+
try:
|
|
78
|
+
dsn = self._config.resolve_dsn()
|
|
79
|
+
except ValueError as error:
|
|
80
|
+
raise MetadataUnavailableError(
|
|
81
|
+
"postgresql dsn reference could not be resolved",
|
|
82
|
+
details={"cause_type": type(error).__name__},
|
|
83
|
+
) from error
|
|
84
|
+
try:
|
|
85
|
+
self._pool = pool_module.ConnectionPool(
|
|
86
|
+
dsn,
|
|
87
|
+
min_size=self._config.pool_min_size,
|
|
88
|
+
max_size=self._config.pool_max_size,
|
|
89
|
+
open=True,
|
|
90
|
+
kwargs={"connect_timeout": self._connect_timeout()},
|
|
91
|
+
)
|
|
92
|
+
self._pool.wait(timeout=self._connect_timeout())
|
|
93
|
+
except Exception as error:
|
|
94
|
+
self._pool = None
|
|
95
|
+
_raise_normalized_discovery_error(error, "could not connect to postgresql")
|
|
96
|
+
return self._pool
|
|
97
|
+
|
|
98
|
+
@contextlib.contextmanager
|
|
99
|
+
def connection(self) -> Iterator[Any]:
|
|
100
|
+
"""Yield a read-only or standard connection from the pool."""
|
|
101
|
+
pool = self._ensure_pool()
|
|
102
|
+
connection: Any | None = None
|
|
103
|
+
try:
|
|
104
|
+
with pool.connection() as connection:
|
|
105
|
+
if self._config.read_only:
|
|
106
|
+
connection.execute("SET TRANSACTION READ ONLY")
|
|
107
|
+
yield connection
|
|
108
|
+
except NL2DataError:
|
|
109
|
+
raise
|
|
110
|
+
except Exception as error:
|
|
111
|
+
if connection is None:
|
|
112
|
+
_raise_normalized_discovery_error(error, "could not obtain a postgresql connection")
|
|
113
|
+
_raise_normalized_discovery_error(error, "postgresql operation failed")
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
"""Release the pool idempotently."""
|
|
117
|
+
with self._lock:
|
|
118
|
+
if self._pool is not None:
|
|
119
|
+
with contextlib.suppress(Exception):
|
|
120
|
+
self._pool.close()
|
|
121
|
+
self._pool = None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Bounded, credential-free PostgreSQL adapter configuration.
|
|
2
|
+
|
|
3
|
+
DSNs are host-injected references (for example ``env:VAR_NAME``) rather
|
|
4
|
+
than raw connection strings, so configuration stays fingerprintable and
|
|
5
|
+
safe to serialize. The package resolves the reference at runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
15
|
+
|
|
16
|
+
_MAX_QUERY_LENGTH = 10_000
|
|
17
|
+
_MAX_RESULT_ROWS = 100_000
|
|
18
|
+
_MAX_RESULT_BYTES = 10_485_760
|
|
19
|
+
_MAX_TIMEOUT_SECONDS = 3600.0
|
|
20
|
+
_MAX_OBJECTS = 1_024
|
|
21
|
+
_MAX_FIELDS_PER_OBJECT = 16_384
|
|
22
|
+
_MAX_STATISTICS = 8_192
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PostgresAdapterConfig(BaseModel):
|
|
26
|
+
"""Bounded PostgreSQL discovery and execution configuration.
|
|
27
|
+
|
|
28
|
+
``dsn_reference`` is a host-injected DSN reference such as
|
|
29
|
+
``env:NL2DATA_POSTGRES_DSN``. The reference is resolved lazily at
|
|
30
|
+
runtime; the configuration object never carries a raw password or
|
|
31
|
+
connection string.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
35
|
+
|
|
36
|
+
dsn_reference: str = Field(min_length=1, max_length=512)
|
|
37
|
+
schema_name: str | None = Field(default=None, min_length=1, max_length=128)
|
|
38
|
+
allowed_objects: frozenset[str] = Field(default_factory=frozenset, max_length=_MAX_OBJECTS)
|
|
39
|
+
allowed_fields: frozenset[str] = Field(
|
|
40
|
+
default_factory=frozenset, max_length=_MAX_FIELDS_PER_OBJECT
|
|
41
|
+
)
|
|
42
|
+
max_objects: int = Field(default=256, ge=1, le=_MAX_OBJECTS)
|
|
43
|
+
max_fields_per_object: int = Field(default=1_024, ge=1, le=_MAX_FIELDS_PER_OBJECT)
|
|
44
|
+
max_statistics: int = Field(default=1_024, ge=0, le=_MAX_STATISTICS)
|
|
45
|
+
include_statistics: bool = True
|
|
46
|
+
timeout_seconds: float = Field(default=30.0, gt=0.0, le=_MAX_TIMEOUT_SECONDS)
|
|
47
|
+
max_query_length: int = Field(default=_MAX_QUERY_LENGTH, ge=1, le=1_000_000)
|
|
48
|
+
max_rows: int = Field(default=_MAX_RESULT_ROWS, ge=1, le=1_000_000_000)
|
|
49
|
+
max_result_bytes: int = Field(default=_MAX_RESULT_BYTES, ge=1, le=1_073_741_824)
|
|
50
|
+
pool_min_size: int = Field(default=1, ge=0, le=64)
|
|
51
|
+
pool_max_size: int = Field(default=4, ge=1, le=128)
|
|
52
|
+
read_only: bool = True
|
|
53
|
+
source_id: str | None = Field(default=None, min_length=1, max_length=128)
|
|
54
|
+
|
|
55
|
+
@field_validator("allowed_objects", "allowed_fields")
|
|
56
|
+
@classmethod
|
|
57
|
+
def _bounded_identifiers(cls, value: frozenset[str]) -> frozenset[str]:
|
|
58
|
+
import re
|
|
59
|
+
|
|
60
|
+
pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.]{0,127}$")
|
|
61
|
+
for identifier in value:
|
|
62
|
+
if pattern.fullmatch(identifier) is None:
|
|
63
|
+
raise ValueError("allowlist entries must be bounded identifiers")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
@field_validator("pool_max_size")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _pool_size_consistent(cls, value: int, info: Any) -> int:
|
|
69
|
+
if (info.data.get("pool_min_size") or 0) > value:
|
|
70
|
+
raise ValueError("pool_min_size cannot exceed pool_max_size")
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
def resolve_dsn(self) -> str:
|
|
74
|
+
"""Resolve the host-injected DSN reference to a connection string."""
|
|
75
|
+
reference = self.dsn_reference.strip()
|
|
76
|
+
if reference.startswith("env:"):
|
|
77
|
+
env_name = reference[4:]
|
|
78
|
+
env_value = os.environ.get(env_name)
|
|
79
|
+
if env_value is None:
|
|
80
|
+
raise ValueError(f"DSN environment variable '{env_name}' is not set")
|
|
81
|
+
return env_value
|
|
82
|
+
if reference.startswith("dsn:"):
|
|
83
|
+
return reference[4:]
|
|
84
|
+
return reference
|
|
85
|
+
|
|
86
|
+
def safe_payload(self) -> dict[str, Any]:
|
|
87
|
+
"""Safe, credential-free serialization of bounded settings."""
|
|
88
|
+
return {
|
|
89
|
+
"dsn_reference": self.dsn_reference,
|
|
90
|
+
"schema_name": self.schema_name,
|
|
91
|
+
"allowed_objects": sorted(self.allowed_objects),
|
|
92
|
+
"allowed_fields": sorted(self.allowed_fields),
|
|
93
|
+
"max_objects": self.max_objects,
|
|
94
|
+
"max_fields_per_object": self.max_fields_per_object,
|
|
95
|
+
"max_statistics": self.max_statistics,
|
|
96
|
+
"include_statistics": self.include_statistics,
|
|
97
|
+
"timeout_seconds": self.timeout_seconds,
|
|
98
|
+
"max_query_length": self.max_query_length,
|
|
99
|
+
"max_rows": self.max_rows,
|
|
100
|
+
"max_result_bytes": self.max_result_bytes,
|
|
101
|
+
"pool_min_size": self.pool_min_size,
|
|
102
|
+
"pool_max_size": self.pool_max_size,
|
|
103
|
+
"read_only": self.read_only,
|
|
104
|
+
"source_id": self.source_id,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
def fingerprint(self) -> str:
|
|
108
|
+
"""Stable fingerprint of this configuration; no secrets included."""
|
|
109
|
+
return strict_sha256_fingerprint(self.safe_payload())
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""PostgreSQL metadata discovery behind the provider-neutral core contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
11
|
+
from nl2data_core.metadata.models import (
|
|
12
|
+
MetadataConstraint,
|
|
13
|
+
MetadataConstraintKind,
|
|
14
|
+
MetadataEvidence,
|
|
15
|
+
MetadataField,
|
|
16
|
+
MetadataFreshness,
|
|
17
|
+
MetadataObject,
|
|
18
|
+
MetadataObjectKind,
|
|
19
|
+
MetadataProvenance,
|
|
20
|
+
MetadataRelationship,
|
|
21
|
+
MetadataRelationshipKind,
|
|
22
|
+
MetadataSnapshot,
|
|
23
|
+
MetadataSourceReference,
|
|
24
|
+
MetadataStatistic,
|
|
25
|
+
MetadataStatisticKind,
|
|
26
|
+
MetadataTrustLevel,
|
|
27
|
+
)
|
|
28
|
+
from nl2data_core.metadata.protocol import (
|
|
29
|
+
MetadataDiscoveryCapability,
|
|
30
|
+
MetadataDiscoveryConfig,
|
|
31
|
+
MetadataDiscoveryError,
|
|
32
|
+
MetadataUnauthorizedError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from .client import PostgresPool
|
|
36
|
+
from .config import PostgresAdapterConfig
|
|
37
|
+
|
|
38
|
+
_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.]{0,127}$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _quote_ident(name: str) -> str:
|
|
42
|
+
return '"' + name.replace('"', '""') + '"'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sql_type(declared: str) -> str:
|
|
46
|
+
"""Normalize a declared SQL type into a bounded canonical name."""
|
|
47
|
+
base = declared.split("(", 1)[0].strip().upper()
|
|
48
|
+
aliases = {
|
|
49
|
+
"CHARACTER VARYING": "VARCHAR",
|
|
50
|
+
"CHAR VARYING": "VARCHAR",
|
|
51
|
+
"CHARACTER": "CHAR",
|
|
52
|
+
"DOUBLE PRECISION": "DOUBLE",
|
|
53
|
+
"TIMESTAMP WITHOUT TIME ZONE": "TIMESTAMP",
|
|
54
|
+
"TIMESTAMP WITH TIME ZONE": "TIMESTAMPTZ",
|
|
55
|
+
}
|
|
56
|
+
base = aliases.get(base, base)
|
|
57
|
+
return base[:64] or "UNKNOWN"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PostgresMetadataDiscoverer:
|
|
61
|
+
"""Read-only PostgreSQL metadata discovery over the core contract."""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
config: PostgresAdapterConfig,
|
|
66
|
+
*,
|
|
67
|
+
allowed_objects: frozenset[str] = frozenset(),
|
|
68
|
+
allowed_fields: frozenset[str] = frozenset(),
|
|
69
|
+
) -> None:
|
|
70
|
+
self._config = config
|
|
71
|
+
self._allowed_objects = allowed_objects
|
|
72
|
+
self._allowed_fields = allowed_fields
|
|
73
|
+
self._pool = PostgresPool(config)
|
|
74
|
+
|
|
75
|
+
def capability(self) -> MetadataDiscoveryCapability:
|
|
76
|
+
"""Declare the discovery bounds this backend supports."""
|
|
77
|
+
return MetadataDiscoveryCapability(
|
|
78
|
+
backend="sql:postgresql",
|
|
79
|
+
supported=True,
|
|
80
|
+
max_objects=1_024,
|
|
81
|
+
max_fields_per_object=16_384,
|
|
82
|
+
supports_statistics=True,
|
|
83
|
+
supports_sampling=False,
|
|
84
|
+
description="bounded postgresql catalog introspection",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def discover(self, config: MetadataDiscoveryConfig) -> MetadataSnapshot:
|
|
88
|
+
"""Discover a bounded canonical snapshot of the configured source."""
|
|
89
|
+
effective_objects = (
|
|
90
|
+
self._allowed_objects & config.allowed_objects
|
|
91
|
+
if config.allowed_objects
|
|
92
|
+
else self._allowed_objects
|
|
93
|
+
)
|
|
94
|
+
if not effective_objects:
|
|
95
|
+
raise MetadataUnauthorizedError(
|
|
96
|
+
"no objects are authorized for metadata discovery",
|
|
97
|
+
details={"authorized_objects": str(len(self._allowed_objects))},
|
|
98
|
+
)
|
|
99
|
+
return await asyncio.to_thread(
|
|
100
|
+
self._discover_sync, config, effective_objects, self._allowed_fields
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def close(self) -> None:
|
|
104
|
+
"""Release the underlying connection pool."""
|
|
105
|
+
self._pool.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _discover_sync(
|
|
109
|
+
self,
|
|
110
|
+
config: MetadataDiscoveryConfig,
|
|
111
|
+
allowed_objects: frozenset[str],
|
|
112
|
+
allowed_fields: frozenset[str],
|
|
113
|
+
) -> MetadataSnapshot:
|
|
114
|
+
"""Synchronous PostgreSQL discovery using the discoverer's bounded pool."""
|
|
115
|
+
with self._pool.connection() as connection:
|
|
116
|
+
return _introspect(connection, self._config, config, allowed_objects, allowed_fields)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _introspect(
|
|
120
|
+
connection: Any,
|
|
121
|
+
pg_config: PostgresAdapterConfig,
|
|
122
|
+
config: MetadataDiscoveryConfig,
|
|
123
|
+
allowed_objects: frozenset[str],
|
|
124
|
+
allowed_fields: frozenset[str],
|
|
125
|
+
) -> MetadataSnapshot:
|
|
126
|
+
schema = pg_config.schema_name or "public"
|
|
127
|
+
objects: list[MetadataObject] = []
|
|
128
|
+
relationships: list[MetadataRelationship] = []
|
|
129
|
+
evidence: list[MetadataEvidence] = []
|
|
130
|
+
bounded_objects = False
|
|
131
|
+
bounded_fields = False
|
|
132
|
+
bounded_samples = False
|
|
133
|
+
statistic_count = 0
|
|
134
|
+
deadline = time.monotonic() + config.timeout_seconds
|
|
135
|
+
|
|
136
|
+
timeout_ms = max(1, int(config.timeout_seconds * 1000))
|
|
137
|
+
connection.execute(f"SET statement_timeout = {timeout_ms}")
|
|
138
|
+
|
|
139
|
+
rows = connection.execute(
|
|
140
|
+
"SELECT table_name, table_type FROM information_schema.tables "
|
|
141
|
+
"WHERE table_schema = %s AND table_type IN ('BASE TABLE', 'VIEW') "
|
|
142
|
+
"ORDER BY table_name",
|
|
143
|
+
(schema,),
|
|
144
|
+
).fetchall()
|
|
145
|
+
catalog_objects = [
|
|
146
|
+
(str(name), str(kind))
|
|
147
|
+
for name, kind in rows
|
|
148
|
+
if _IDENTIFIER_PATTERN.fullmatch(str(name)) is not None
|
|
149
|
+
]
|
|
150
|
+
selected = [(name, kind) for name, kind in catalog_objects if name in allowed_objects]
|
|
151
|
+
if len(selected) > config.max_objects:
|
|
152
|
+
selected = selected[: config.max_objects]
|
|
153
|
+
bounded_objects = True
|
|
154
|
+
|
|
155
|
+
for name, kind in selected:
|
|
156
|
+
if time.monotonic() >= deadline:
|
|
157
|
+
raise MetadataDiscoveryError(
|
|
158
|
+
"metadata discovery exceeded the authorized timeout",
|
|
159
|
+
details={"timeout_seconds": str(config.timeout_seconds)},
|
|
160
|
+
)
|
|
161
|
+
object_kind = MetadataObjectKind.VIEW if kind == "VIEW" else MetadataObjectKind.TABLE
|
|
162
|
+
columns = connection.execute(
|
|
163
|
+
"SELECT column_name, data_type, is_nullable "
|
|
164
|
+
"FROM information_schema.columns "
|
|
165
|
+
"WHERE table_schema = %s AND table_name = %s "
|
|
166
|
+
"ORDER BY ordinal_position",
|
|
167
|
+
(schema, name),
|
|
168
|
+
).fetchall()
|
|
169
|
+
if len(columns) > config.max_fields_per_object:
|
|
170
|
+
columns = columns[: config.max_fields_per_object]
|
|
171
|
+
bounded_fields = True
|
|
172
|
+
|
|
173
|
+
fields: list[MetadataField] = []
|
|
174
|
+
for column in columns:
|
|
175
|
+
column_name = str(column[0])
|
|
176
|
+
if _IDENTIFIER_PATTERN.fullmatch(column_name) is None:
|
|
177
|
+
bounded_fields = True
|
|
178
|
+
continue
|
|
179
|
+
if config.allowed_fields and column_name not in config.allowed_fields:
|
|
180
|
+
continue
|
|
181
|
+
if allowed_fields and column_name not in allowed_fields:
|
|
182
|
+
continue
|
|
183
|
+
fields.append(
|
|
184
|
+
MetadataField(
|
|
185
|
+
field_id=column_name,
|
|
186
|
+
object_id=name,
|
|
187
|
+
path=column_name,
|
|
188
|
+
data_type=_sql_type(str(column[1] or "UNKNOWN")),
|
|
189
|
+
nullable=str(column[2]).lower() != "no",
|
|
190
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
field_evidence = MetadataEvidence(
|
|
194
|
+
evidence_id=f"pg-obj-{name}",
|
|
195
|
+
kind="object",
|
|
196
|
+
reference=strict_sha256_fingerprint(
|
|
197
|
+
{"object": name, "fields": sorted(field.field_id for field in fields)}
|
|
198
|
+
),
|
|
199
|
+
description="postgresql catalog object observation",
|
|
200
|
+
)
|
|
201
|
+
evidence.append(field_evidence)
|
|
202
|
+
|
|
203
|
+
constraints: list[MetadataConstraint] = []
|
|
204
|
+
pk_rows = connection.execute(
|
|
205
|
+
"SELECT kcu.column_name "
|
|
206
|
+
"FROM information_schema.table_constraints tc "
|
|
207
|
+
"JOIN information_schema.key_column_usage kcu "
|
|
208
|
+
" ON kcu.constraint_name = tc.constraint_name "
|
|
209
|
+
" AND kcu.table_schema = tc.table_schema "
|
|
210
|
+
"WHERE tc.table_schema = %s AND tc.table_name = %s "
|
|
211
|
+
" AND tc.constraint_type = 'PRIMARY KEY' "
|
|
212
|
+
"ORDER BY kcu.ordinal_position",
|
|
213
|
+
(schema, name),
|
|
214
|
+
).fetchall()
|
|
215
|
+
primary_fields = [str(row[0]) for row in pk_rows if row[0] is not None]
|
|
216
|
+
if primary_fields:
|
|
217
|
+
constraints.append(
|
|
218
|
+
MetadataConstraint(
|
|
219
|
+
constraint_id=f"{name}_pk",
|
|
220
|
+
kind=MetadataConstraintKind.PRIMARY_KEY,
|
|
221
|
+
fields=frozenset(primary_fields),
|
|
222
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
unique_by_name: dict[str, list[str]] = {}
|
|
226
|
+
for constraint_name, column_name in connection.execute(
|
|
227
|
+
"SELECT tc.constraint_name, kcu.column_name "
|
|
228
|
+
"FROM information_schema.table_constraints tc "
|
|
229
|
+
"JOIN information_schema.key_column_usage kcu "
|
|
230
|
+
" ON kcu.constraint_name = tc.constraint_name "
|
|
231
|
+
" AND kcu.table_schema = tc.table_schema "
|
|
232
|
+
"WHERE tc.table_schema = %s AND tc.table_name = %s "
|
|
233
|
+
" AND tc.constraint_type = 'UNIQUE' "
|
|
234
|
+
"ORDER BY tc.constraint_name, kcu.ordinal_position",
|
|
235
|
+
(schema, name),
|
|
236
|
+
).fetchall():
|
|
237
|
+
unique_by_name.setdefault(str(constraint_name), []).append(str(column_name))
|
|
238
|
+
field_ids = {field.field_id for field in fields}
|
|
239
|
+
for constraint_name, unique_columns in unique_by_name.items():
|
|
240
|
+
bounded_columns = [column for column in unique_columns if column in field_ids]
|
|
241
|
+
if bounded_columns:
|
|
242
|
+
constraints.append(
|
|
243
|
+
MetadataConstraint(
|
|
244
|
+
constraint_id=f"{name}_{constraint_name}",
|
|
245
|
+
kind=MetadataConstraintKind.UNIQUE,
|
|
246
|
+
fields=frozenset(bounded_columns),
|
|
247
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
statistics: list[MetadataStatistic] = []
|
|
252
|
+
if config.include_statistics and statistic_count < config.max_statistics:
|
|
253
|
+
count_row = connection.execute(
|
|
254
|
+
f"SELECT COUNT(*) FROM {_quote_ident(schema)}.{_quote_ident(name)}"
|
|
255
|
+
).fetchone()
|
|
256
|
+
if count_row is not None:
|
|
257
|
+
statistics.append(
|
|
258
|
+
MetadataStatistic(
|
|
259
|
+
statistic_id=f"{name}_row_count",
|
|
260
|
+
kind=MetadataStatisticKind.ROW_COUNT,
|
|
261
|
+
scope_object_id=name,
|
|
262
|
+
value=float(count_row[0]),
|
|
263
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
statistic_count += 1
|
|
267
|
+
|
|
268
|
+
objects.append(
|
|
269
|
+
MetadataObject(
|
|
270
|
+
object_id=name,
|
|
271
|
+
kind=object_kind,
|
|
272
|
+
name=name,
|
|
273
|
+
fields=tuple(fields),
|
|
274
|
+
constraints=tuple(constraints),
|
|
275
|
+
statistics=tuple(statistics),
|
|
276
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
277
|
+
observed_incomplete=False,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
for _constraint_name, from_column, to_column, target_table in (
|
|
282
|
+
connection.execute(
|
|
283
|
+
"SELECT c.conname, a.attname AS from_column, "
|
|
284
|
+
" fa.attname AS to_column, f.relname AS target_table "
|
|
285
|
+
"FROM pg_constraint c "
|
|
286
|
+
"JOIN pg_class t ON t.oid = c.conrelid "
|
|
287
|
+
"JOIN pg_namespace n ON n.oid = t.relnamespace "
|
|
288
|
+
"CROSS JOIN LATERAL unnest(c.conkey, c.confkey) "
|
|
289
|
+
" WITH ORDINALITY AS u(fk_attnum, ref_attnum, ord) "
|
|
290
|
+
"JOIN pg_attribute a "
|
|
291
|
+
" ON a.attrelid = c.conrelid AND a.attnum = u.fk_attnum "
|
|
292
|
+
"JOIN pg_attribute fa "
|
|
293
|
+
" ON fa.attrelid = c.confrelid AND fa.attnum = u.ref_attnum "
|
|
294
|
+
"JOIN pg_class f ON f.oid = c.confrelid "
|
|
295
|
+
"WHERE c.contype = 'f' AND n.nspname = %s AND t.relname = %s "
|
|
296
|
+
"ORDER BY c.conname, u.ord",
|
|
297
|
+
(schema, name),
|
|
298
|
+
).fetchall()
|
|
299
|
+
):
|
|
300
|
+
if str(target_table) not in allowed_objects or str(from_column) not in field_ids:
|
|
301
|
+
continue
|
|
302
|
+
relationship_id = f"{name}_{str(target_table)}_via_{str(from_column)}"
|
|
303
|
+
relationships.append(
|
|
304
|
+
MetadataRelationship(
|
|
305
|
+
relationship_id=relationship_id,
|
|
306
|
+
kind=MetadataRelationshipKind.FOREIGN_KEY,
|
|
307
|
+
source_object_id=name,
|
|
308
|
+
target_object_id=str(target_table),
|
|
309
|
+
source_fields=frozenset({str(from_column)}),
|
|
310
|
+
target_fields=frozenset({str(to_column)}),
|
|
311
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
constraints.append(
|
|
315
|
+
MetadataConstraint(
|
|
316
|
+
constraint_id=f"{relationship_id}_fk",
|
|
317
|
+
kind=MetadataConstraintKind.FOREIGN_KEY,
|
|
318
|
+
fields=frozenset({str(from_column)}),
|
|
319
|
+
trust_level=MetadataTrustLevel.DECLARED,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
source_digest = strict_sha256_fingerprint(
|
|
324
|
+
{
|
|
325
|
+
"objects": sorted(obj.object_id for obj in objects),
|
|
326
|
+
"fingerprints": sorted(evidence_item.reference for evidence_item in evidence),
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
return MetadataSnapshot(
|
|
330
|
+
snapshot_id=f"pg-{source_digest[-16:]}",
|
|
331
|
+
source=MetadataSourceReference(
|
|
332
|
+
source_id=pg_config.source_id or "postgresql",
|
|
333
|
+
catalog_fingerprint=source_digest,
|
|
334
|
+
description="read-only postgresql catalog",
|
|
335
|
+
),
|
|
336
|
+
objects=tuple(objects),
|
|
337
|
+
relationships=tuple(relationships),
|
|
338
|
+
freshness=MetadataFreshness(
|
|
339
|
+
bounded_objects=bounded_objects,
|
|
340
|
+
bounded_fields=bounded_fields,
|
|
341
|
+
bounded_samples=bounded_samples,
|
|
342
|
+
sample_limit=config.max_samples,
|
|
343
|
+
),
|
|
344
|
+
provenance=MetadataProvenance(
|
|
345
|
+
discovered_by_fingerprint=strict_sha256_fingerprint(
|
|
346
|
+
{"backend": "sql:postgresql", "schema": schema}
|
|
347
|
+
),
|
|
348
|
+
method="postgresql_introspection",
|
|
349
|
+
evidence=tuple(evidence),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Safe normalized errors for the PostgreSQL adapter boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from nl2data.errors import ErrorCategory, ErrorCode, NL2DataError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PostgresAdapterError(NL2DataError):
|
|
11
|
+
"""Raised for PostgreSQL adapter misuse outside the guarded lifecycle."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
14
|
+
super().__init__(
|
|
15
|
+
ErrorCategory.ADAPTER,
|
|
16
|
+
ErrorCode.SQL_REJECTED,
|
|
17
|
+
message,
|
|
18
|
+
retryable=False,
|
|
19
|
+
details=details,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PostgresExecutionError(NL2DataError):
|
|
24
|
+
"""Raised when PostgreSQL execution fails or produces unsupported values."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
27
|
+
super().__init__(
|
|
28
|
+
ErrorCategory.ADAPTER,
|
|
29
|
+
ErrorCode.SQL_EXECUTION_FAILED,
|
|
30
|
+
message,
|
|
31
|
+
retryable=False,
|
|
32
|
+
details=details,
|
|
33
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Read-only PostgreSQL SQL execution with bounded protected results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from nl2data_core.adapters.models import ExecutionResult
|
|
11
|
+
from nl2data_core.canonical import strict_sha256_fingerprint
|
|
12
|
+
from nl2data_core.compilation.expansion import ZeroDivisionPolicyError
|
|
13
|
+
|
|
14
|
+
from .client import PostgresPool
|
|
15
|
+
from .config import PostgresAdapterConfig
|
|
16
|
+
from .errors import PostgresExecutionError
|
|
17
|
+
|
|
18
|
+
#: The protected public scalar set; everything else is unsupported.
|
|
19
|
+
_SCALAR_TYPES: tuple[type, ...] = (str, int, float, bool, type(None))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _normalize_cell(value: Any, *, row_index: int, column_index: int) -> Any:
|
|
23
|
+
if isinstance(value, _SCALAR_TYPES):
|
|
24
|
+
return value
|
|
25
|
+
if isinstance(value, Decimal):
|
|
26
|
+
#: NUMERIC columns arrive as Decimal; normalize into the protected
|
|
27
|
+
#: float scalar so the same computed math (e.g. ROUND(100.0 * a / b))
|
|
28
|
+
#: yields SQLite-equivalent float64 values. Non-finite numerics
|
|
29
|
+
#: (NaN/Infinity) stay fail-closed: a poisoned float would break the
|
|
30
|
+
#: canonical JSON payload downstream.
|
|
31
|
+
converted = float(value)
|
|
32
|
+
if math.isfinite(converted):
|
|
33
|
+
return converted
|
|
34
|
+
raise PostgresExecutionError(
|
|
35
|
+
"database returned a value outside the protected scalar set",
|
|
36
|
+
details={
|
|
37
|
+
"row_index": str(row_index),
|
|
38
|
+
"column_index": str(column_index),
|
|
39
|
+
"value_type": type(value).__name__,
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PostgresExecutor:
|
|
45
|
+
"""Bounded read-only executor for a single PostgreSQL adapter."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: PostgresAdapterConfig) -> None:
|
|
48
|
+
self._config = config
|
|
49
|
+
self._pool = PostgresPool(config)
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
self._pool.close()
|
|
53
|
+
|
|
54
|
+
def execute(
|
|
55
|
+
self,
|
|
56
|
+
sql: str,
|
|
57
|
+
*,
|
|
58
|
+
max_rows: int | None = None,
|
|
59
|
+
max_columns: int | None = None,
|
|
60
|
+
max_result_bytes: int | None = None,
|
|
61
|
+
timeout_seconds: float | None = None,
|
|
62
|
+
) -> ExecutionResult:
|
|
63
|
+
"""Execute one validated read-only statement and map to protected scalars."""
|
|
64
|
+
effective_max_rows = max_rows if max_rows is not None else self._config.max_rows
|
|
65
|
+
effective_max_columns = max_columns if max_columns is not None else 1_000
|
|
66
|
+
effective_max_bytes = (
|
|
67
|
+
max_result_bytes if max_result_bytes is not None else self._config.max_result_bytes
|
|
68
|
+
)
|
|
69
|
+
effective_timeout = timeout_seconds or self._config.timeout_seconds
|
|
70
|
+
started = time.monotonic()
|
|
71
|
+
deadline = started + effective_timeout
|
|
72
|
+
|
|
73
|
+
with self._pool.connection() as connection:
|
|
74
|
+
timeout_ms = max(1, int(effective_timeout * 1000))
|
|
75
|
+
connection.execute(f"SET statement_timeout = {timeout_ms}")
|
|
76
|
+
try:
|
|
77
|
+
cursor = connection.execute(sql)
|
|
78
|
+
except Exception as error:
|
|
79
|
+
if getattr(error, "sqlstate", None) == "22012":
|
|
80
|
+
raise ZeroDivisionPolicyError(
|
|
81
|
+
"calculated-field division by zero",
|
|
82
|
+
details={"sqlstate": "22012"},
|
|
83
|
+
) from error
|
|
84
|
+
raise PostgresExecutionError(
|
|
85
|
+
"postgresql execution failed",
|
|
86
|
+
details={"cause_type": type(error).__name__},
|
|
87
|
+
) from error
|
|
88
|
+
|
|
89
|
+
columns = tuple(description[0] for description in cursor.description or ())
|
|
90
|
+
if len(columns) > effective_max_columns:
|
|
91
|
+
raise PostgresExecutionError(
|
|
92
|
+
"result column count exceeds the bounded maximum",
|
|
93
|
+
details={"max_columns": str(effective_max_columns)},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
rows: list[tuple[Any, ...]] = []
|
|
97
|
+
for row_index, row in enumerate(cursor.fetchmany(effective_max_rows + 1)):
|
|
98
|
+
if row_index >= effective_max_rows:
|
|
99
|
+
raise PostgresExecutionError(
|
|
100
|
+
"result row count exceeds the bounded maximum",
|
|
101
|
+
details={"max_rows": str(effective_max_rows)},
|
|
102
|
+
)
|
|
103
|
+
rows.append(
|
|
104
|
+
tuple(
|
|
105
|
+
_normalize_cell(cell, row_index=row_index, column_index=col_index)
|
|
106
|
+
for col_index, cell in enumerate(row)
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
if effective_max_bytes is not None:
|
|
110
|
+
text_bytes = sum(
|
|
111
|
+
len(str(cell).encode("utf-8"))
|
|
112
|
+
for current_row in rows
|
|
113
|
+
for cell in current_row
|
|
114
|
+
)
|
|
115
|
+
result_bytes = sum(len(str(column).encode("utf-8")) for column in columns)
|
|
116
|
+
result_bytes += text_bytes
|
|
117
|
+
if result_bytes > effective_max_bytes:
|
|
118
|
+
raise PostgresExecutionError(
|
|
119
|
+
"result bytes exceed the authorized maximum",
|
|
120
|
+
details={
|
|
121
|
+
"max_result_bytes": str(effective_max_bytes),
|
|
122
|
+
"actual": str(result_bytes),
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
if time.monotonic() >= deadline:
|
|
126
|
+
raise PostgresExecutionError(
|
|
127
|
+
"SQL execution exceeded the authorized timeout",
|
|
128
|
+
details={"timeout_seconds": str(effective_timeout)},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Rows are model-native tuples; the fingerprint payload carries them
|
|
132
|
+
# as JSON arrays so strict canonicalization stays fail-closed.
|
|
133
|
+
fingerprint = strict_sha256_fingerprint(
|
|
134
|
+
{
|
|
135
|
+
"columns": list(columns),
|
|
136
|
+
"rows": [list(row) for row in rows],
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
duration_ms = int((time.monotonic() - started) * 1000)
|
|
140
|
+
return ExecutionResult(
|
|
141
|
+
result_id=f"result-{fingerprint[-16:]}",
|
|
142
|
+
fingerprint=fingerprint,
|
|
143
|
+
row_count=len(rows),
|
|
144
|
+
columns=columns,
|
|
145
|
+
rows=tuple(rows),
|
|
146
|
+
duration_ms=duration_ms,
|
|
147
|
+
metadata={"dialect": "postgres"},
|
|
148
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nl2data-postgres
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PostgreSQL metadata discovery and governed SQL execution adapter for nl2data-core.
|
|
5
|
+
Author: NL2Data Contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: nl2data,postgresql,metadata,sql,adapter
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: nl2data-core>=0.1.0
|
|
18
|
+
Requires-Dist: psycopg[binary,pool]<4,>=3.1
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# nl2data-postgres
|
|
26
|
+
|
|
27
|
+
An optional PostgreSQL backend integration for
|
|
28
|
+
[nl2data-core](https://github.com/emmansun/nl2data-core). It combines
|
|
29
|
+
metadata discovery and governed read-only SQL execution behind the core
|
|
30
|
+
provider-neutral contracts.
|
|
31
|
+
|
|
32
|
+
`psycopg` is an optional dependency and is loaded lazily at first use. The
|
|
33
|
+
core import boundary never loads the PostgreSQL driver.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install nl2data-postgres
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.11+, `nl2data-core>=0.1.0`, and
|
|
42
|
+
`psycopg[binary,pool]>=3.1,<4`.
|
|
43
|
+
|
|
44
|
+
From a source checkout:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e packages/nl2data-postgres
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Public surface
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from nl2data_postgres import (
|
|
54
|
+
PostgresAdapterConfig,
|
|
55
|
+
PostgresMetadataDiscoverer,
|
|
56
|
+
PostgresQueryAdapter,
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- `PostgresAdapterConfig` — bounded discovery/execution configuration with
|
|
61
|
+
host-injected DSN references, object/field allowlists, and result bounds.
|
|
62
|
+
- `PostgresMetadataDiscoverer` — the core `MetadataDiscoverer` port
|
|
63
|
+
implementation for PostgreSQL.
|
|
64
|
+
- `PostgresQueryAdapter` — the core `QueryAdapter` port implementation
|
|
65
|
+
for read-only PostgreSQL SQL.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Configuration is credential-free; hosts inject DSNs through a resolver
|
|
70
|
+
or environment at runtime. `PostgresAdapterConfig` carries only
|
|
71
|
+
bounded, fingerprintable settings.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from nl2data_postgres import PostgresAdapterConfig
|
|
75
|
+
|
|
76
|
+
config = PostgresAdapterConfig(
|
|
77
|
+
dsn_reference="env:NL2DATA_POSTGRES_DSN",
|
|
78
|
+
allowed_objects={"users", "orders"},
|
|
79
|
+
max_objects=256,
|
|
80
|
+
max_rows=10_000,
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Discovery
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from nl2data_postgres import PostgresAdapterConfig, PostgresMetadataDiscoverer
|
|
88
|
+
from nl2data_core.metadata.protocol import MetadataDiscoveryConfig
|
|
89
|
+
|
|
90
|
+
discoverer = PostgresMetadataDiscoverer(
|
|
91
|
+
config=PostgresAdapterConfig(dsn_reference="env:NL2DATA_POSTGRES_DSN"),
|
|
92
|
+
allowed_objects={"users", "orders"},
|
|
93
|
+
)
|
|
94
|
+
snapshot = await discoverer.discover(MetadataDiscoveryConfig())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Discovery returns a core `MetadataSnapshot` with normalized tables,
|
|
98
|
+
columns, types, primary/foreign keys, and bounded protected statistics.
|
|
99
|
+
|
|
100
|
+
## Query execution
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from nl2data_postgres import PostgresAdapterConfig, PostgresQueryAdapter
|
|
104
|
+
from nl2data_core.adapters.models import ValidationContext
|
|
105
|
+
|
|
106
|
+
adapter = PostgresQueryAdapter(
|
|
107
|
+
config=PostgresAdapterConfig(dsn_reference="env:NL2DATA_POSTGRES_DSN"),
|
|
108
|
+
allowed_objects={"users"},
|
|
109
|
+
)
|
|
110
|
+
artifact = adapter.parse("SELECT id, name FROM users LIMIT 10", ValidationContext())
|
|
111
|
+
validated = adapter.validate(artifact, ValidationContext())
|
|
112
|
+
result = await adapter.execute(validated, ValidationContext())
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Execution uses a read-only connection pool, enforces statement timeout,
|
|
116
|
+
limits rows/columns/bytes, and maps every result value to the protected
|
|
117
|
+
scalar set.
|
|
118
|
+
|
|
119
|
+
## Failure classification
|
|
120
|
+
|
|
121
|
+
| Condition | Normalized result |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| Missing driver / unreachable service | Retryable `MetadataUnavailableError` |
|
|
124
|
+
| Authorization or permission denial | `MetadataUnauthorizedError` |
|
|
125
|
+
| Lifecycle misuse (unparsed/unvalidated artifacts) | `PostgresAdapterError` |
|
|
126
|
+
| Guard rejection (scope/obligation/limit) | `SQLGuardError` |
|
|
127
|
+
| Statement/result bounds exceeded | `PostgresExecutionError` |
|
|
128
|
+
| Malformed SQL | `SQLParseError` |
|
|
129
|
+
|
|
130
|
+
DSNs, raw SQL, native exceptions, and credentials never cross the
|
|
131
|
+
boundary.
|
|
132
|
+
|
|
133
|
+
## Compatibility
|
|
134
|
+
|
|
135
|
+
The in-core `nl2data_core.adapters.sql.SqlMetadataDiscoverer` still
|
|
136
|
+
accepts `dialect="postgresql"` as a temporary compatibility path, but it
|
|
137
|
+
always uses the legacy in-core implementation and emits a deprecation
|
|
138
|
+
warning; it does not delegate to this package. New code should import
|
|
139
|
+
from `nl2data_postgres` directly.
|
|
140
|
+
|
|
141
|
+
## More documentation
|
|
142
|
+
|
|
143
|
+
- [Documentation index](../../docs/README.md)
|
|
144
|
+
- [Adding an adapter or provider](../../docs/development/adding-adapter-or-provider.md)
|
|
145
|
+
- [Secrets and live testing](../../docs/operations/secrets.md)
|
|
146
|
+
- [Capabilities and support](../../docs/reference/capabilities.md)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
nl2data_postgres/__init__.py,sha256=aRHG-IuP1pFfbYGCNA4-HWdRFa-CLF7Rp4x3KfcuqOk,852
|
|
2
|
+
nl2data_postgres/adapter.py,sha256=_X6IN-cNz7OiizES3xbcP-P2HRx0VccTOExozxxx7bE,8240
|
|
3
|
+
nl2data_postgres/client.py,sha256=VfRPRLzmJLuPc5RRHYflwVZANhZYGIJTXPlmrpTHGoc,4549
|
|
4
|
+
nl2data_postgres/config.py,sha256=dS5Np_GldMWBunkkatPkeN32RRUrXjplQM1FDzRRAfA,4607
|
|
5
|
+
nl2data_postgres/discovery.py,sha256=Jpz0LiF4gQrD3URahVRhHuCv8ysyvqycb5JetEjXtik,13768
|
|
6
|
+
nl2data_postgres/errors.py,sha256=PDqmv5GA4_jfpGQBjA7vYn44RFLZeSGcD1Fj3QkA8Dg,1033
|
|
7
|
+
nl2data_postgres/execution.py,sha256=OGoGWRfAn0Pt8PTXf2DOVwgEBZRmg8Mzeh1Zg_2x5-U,6156
|
|
8
|
+
nl2data_postgres/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
nl2data_postgres-0.1.0.dist-info/METADATA,sha256=zAj3mH9hh1zFLAF5oxpPclewD611qzMrNilGkKl3tEg,4836
|
|
10
|
+
nl2data_postgres-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
nl2data_postgres-0.1.0.dist-info/top_level.txt,sha256=OIQ8dphDbQpNfaAaJ_HPlVuT2IyHdTH0kJH9ZGVXv8Q,17
|
|
12
|
+
nl2data_postgres-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nl2data_postgres
|