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.
- nl2data_postgres-0.1.0/PKG-INFO +146 -0
- nl2data_postgres-0.1.0/README.md +122 -0
- nl2data_postgres-0.1.0/pyproject.toml +56 -0
- nl2data_postgres-0.1.0/setup.cfg +4 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/__init__.py +26 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/adapter.py +215 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/client.py +121 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/config.py +109 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/discovery.py +351 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/errors.py +33 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/execution.py +148 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres/py.typed +0 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/PKG-INFO +146 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/SOURCES.txt +23 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/dependency_links.txt +1 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/requires.txt +8 -0
- nl2data_postgres-0.1.0/src/nl2data_postgres.egg-info/top_level.txt +1 -0
- nl2data_postgres-0.1.0/tests/test_postgres_adapter.py +92 -0
- nl2data_postgres-0.1.0/tests/test_postgres_client.py +117 -0
- nl2data_postgres-0.1.0/tests/test_postgres_config.py +34 -0
- nl2data_postgres-0.1.0/tests/test_postgres_core_compat.py +12 -0
- nl2data_postgres-0.1.0/tests/test_postgres_discovery.py +32 -0
- nl2data_postgres-0.1.0/tests/test_postgres_executor.py +158 -0
- nl2data_postgres-0.1.0/tests/test_postgres_import_boundary.py +41 -0
- 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,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
|