nl2data-postgres 0.1.0__tar.gz

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 (25) hide show
  1. nl2data_postgres-0.1.0/PKG-INFO +146 -0
  2. nl2data_postgres-0.1.0/README.md +122 -0
  3. nl2data_postgres-0.1.0/pyproject.toml +56 -0
  4. nl2data_postgres-0.1.0/setup.cfg +4 -0
  5. nl2data_postgres-0.1.0/src/nl2data_postgres/__init__.py +26 -0
  6. nl2data_postgres-0.1.0/src/nl2data_postgres/adapter.py +215 -0
  7. nl2data_postgres-0.1.0/src/nl2data_postgres/client.py +121 -0
  8. nl2data_postgres-0.1.0/src/nl2data_postgres/config.py +109 -0
  9. nl2data_postgres-0.1.0/src/nl2data_postgres/discovery.py +351 -0
  10. nl2data_postgres-0.1.0/src/nl2data_postgres/errors.py +33 -0
  11. nl2data_postgres-0.1.0/src/nl2data_postgres/execution.py +148 -0
  12. nl2data_postgres-0.1.0/src/nl2data_postgres/py.typed +0 -0
  13. nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/PKG-INFO +146 -0
  14. nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/SOURCES.txt +23 -0
  15. nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/dependency_links.txt +1 -0
  16. nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/requires.txt +8 -0
  17. nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/top_level.txt +1 -0
  18. nl2data_postgres-0.1.0/tests/test_postgres_adapter.py +92 -0
  19. nl2data_postgres-0.1.0/tests/test_postgres_client.py +117 -0
  20. nl2data_postgres-0.1.0/tests/test_postgres_config.py +34 -0
  21. nl2data_postgres-0.1.0/tests/test_postgres_core_compat.py +12 -0
  22. nl2data_postgres-0.1.0/tests/test_postgres_discovery.py +32 -0
  23. nl2data_postgres-0.1.0/tests/test_postgres_executor.py +158 -0
  24. nl2data_postgres-0.1.0/tests/test_postgres_import_boundary.py +41 -0
  25. nl2data_postgres-0.1.0/tests/test_postgres_integration.py +133 -0
@@ -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,122 @@
1
+ # nl2data-postgres
2
+
3
+ An optional PostgreSQL backend integration for
4
+ [nl2data-core](https://github.com/emmansun/nl2data-core). It combines
5
+ metadata discovery and governed read-only SQL execution behind the core
6
+ provider-neutral contracts.
7
+
8
+ `psycopg` is an optional dependency and is loaded lazily at first use. The
9
+ core import boundary never loads the PostgreSQL driver.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install nl2data-postgres
15
+ ```
16
+
17
+ Requires Python 3.11+, `nl2data-core>=0.1.0`, and
18
+ `psycopg[binary,pool]>=3.1,<4`.
19
+
20
+ From a source checkout:
21
+
22
+ ```bash
23
+ pip install -e packages/nl2data-postgres
24
+ ```
25
+
26
+ ## Public surface
27
+
28
+ ```python
29
+ from nl2data_postgres import (
30
+ PostgresAdapterConfig,
31
+ PostgresMetadataDiscoverer,
32
+ PostgresQueryAdapter,
33
+ )
34
+ ```
35
+
36
+ - `PostgresAdapterConfig` — bounded discovery/execution configuration with
37
+ host-injected DSN references, object/field allowlists, and result bounds.
38
+ - `PostgresMetadataDiscoverer` — the core `MetadataDiscoverer` port
39
+ implementation for PostgreSQL.
40
+ - `PostgresQueryAdapter` — the core `QueryAdapter` port implementation
41
+ for read-only PostgreSQL SQL.
42
+
43
+ ## Configuration
44
+
45
+ Configuration is credential-free; hosts inject DSNs through a resolver
46
+ or environment at runtime. `PostgresAdapterConfig` carries only
47
+ bounded, fingerprintable settings.
48
+
49
+ ```python
50
+ from nl2data_postgres import PostgresAdapterConfig
51
+
52
+ config = PostgresAdapterConfig(
53
+ dsn_reference="env:NL2DATA_POSTGRES_DSN",
54
+ allowed_objects={"users", "orders"},
55
+ max_objects=256,
56
+ max_rows=10_000,
57
+ )
58
+ ```
59
+
60
+ ## Discovery
61
+
62
+ ```python
63
+ from nl2data_postgres import PostgresAdapterConfig, PostgresMetadataDiscoverer
64
+ from nl2data_core.metadata.protocol import MetadataDiscoveryConfig
65
+
66
+ discoverer = PostgresMetadataDiscoverer(
67
+ config=PostgresAdapterConfig(dsn_reference="env:NL2DATA_POSTGRES_DSN"),
68
+ allowed_objects={"users", "orders"},
69
+ )
70
+ snapshot = await discoverer.discover(MetadataDiscoveryConfig())
71
+ ```
72
+
73
+ Discovery returns a core `MetadataSnapshot` with normalized tables,
74
+ columns, types, primary/foreign keys, and bounded protected statistics.
75
+
76
+ ## Query execution
77
+
78
+ ```python
79
+ from nl2data_postgres import PostgresAdapterConfig, PostgresQueryAdapter
80
+ from nl2data_core.adapters.models import ValidationContext
81
+
82
+ adapter = PostgresQueryAdapter(
83
+ config=PostgresAdapterConfig(dsn_reference="env:NL2DATA_POSTGRES_DSN"),
84
+ allowed_objects={"users"},
85
+ )
86
+ artifact = adapter.parse("SELECT id, name FROM users LIMIT 10", ValidationContext())
87
+ validated = adapter.validate(artifact, ValidationContext())
88
+ result = await adapter.execute(validated, ValidationContext())
89
+ ```
90
+
91
+ Execution uses a read-only connection pool, enforces statement timeout,
92
+ limits rows/columns/bytes, and maps every result value to the protected
93
+ scalar set.
94
+
95
+ ## Failure classification
96
+
97
+ | Condition | Normalized result |
98
+ | --- | --- |
99
+ | Missing driver / unreachable service | Retryable `MetadataUnavailableError` |
100
+ | Authorization or permission denial | `MetadataUnauthorizedError` |
101
+ | Lifecycle misuse (unparsed/unvalidated artifacts) | `PostgresAdapterError` |
102
+ | Guard rejection (scope/obligation/limit) | `SQLGuardError` |
103
+ | Statement/result bounds exceeded | `PostgresExecutionError` |
104
+ | Malformed SQL | `SQLParseError` |
105
+
106
+ DSNs, raw SQL, native exceptions, and credentials never cross the
107
+ boundary.
108
+
109
+ ## Compatibility
110
+
111
+ The in-core `nl2data_core.adapters.sql.SqlMetadataDiscoverer` still
112
+ accepts `dialect="postgresql"` as a temporary compatibility path, but it
113
+ always uses the legacy in-core implementation and emits a deprecation
114
+ warning; it does not delegate to this package. New code should import
115
+ from `nl2data_postgres` directly.
116
+
117
+ ## More documentation
118
+
119
+ - [Documentation index](../../docs/README.md)
120
+ - [Adding an adapter or provider](../../docs/development/adding-adapter-or-provider.md)
121
+ - [Secrets and live testing](../../docs/operations/secrets.md)
122
+ - [Capabilities and support](../../docs/reference/capabilities.md)
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nl2data-postgres"
7
+ version = "0.1.0"
8
+ description = "PostgreSQL metadata discovery and governed SQL execution adapter for nl2data-core."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "NL2Data Contributors" }]
13
+ keywords = ["nl2data", "postgresql", "metadata", "sql", "adapter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Software Development :: Libraries",
22
+ ]
23
+ dependencies = [
24
+ "nl2data-core>=0.1.0",
25
+ "psycopg[binary,pool]>=3.1,<4",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "pytest-asyncio>=0.23",
32
+ "mypy>=1.10",
33
+ "ruff>=0.5",
34
+ ]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.mypy]
40
+ python_version = "3.11"
41
+ files = ["src"]
42
+ mypy_path = ["../../src"]
43
+ check_untyped_defs = true
44
+ disallow_untyped_defs = true
45
+ no_implicit_optional = true
46
+ warn_redundant_casts = true
47
+ warn_unused_ignores = true
48
+ warn_return_any = true
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py311"
53
+ src = ["src"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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