pgdevkit 0.1.0__tar.gz → 0.2.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.
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/PKG-INFO +9 -1
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/README.md +8 -0
- pgdevkit-0.2.0/pgdevkit/connection.py +101 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/db/__init__.py +2 -0
- pgdevkit-0.1.0/skills/pgdevkit/references/complex_helper.py → pgdevkit-0.2.0/pgdevkit/db/complex_types.py +59 -78
- pgdevkit-0.2.0/pgdevkit/db/connection.py +133 -0
- pgdevkit-0.2.0/pgdevkit/db/crud.py +311 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/api.py +2 -2
- pgdevkit-0.2.0/pgdevkit/testdb/constants.py +28 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/container.py +2 -8
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/schema.py +74 -7
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pyproject.toml +1 -1
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/skills/pgdevkit/SKILL.md +8 -3
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/conftest.py +10 -4
- pgdevkit-0.2.0/tests/db/test_complex_types.py +97 -0
- pgdevkit-0.2.0/tests/db/test_connection.py +139 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/db/test_crud.py +80 -2
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_connection.py +16 -2
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_fetch_missing.py +2 -2
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_fetch_missing_cli.py +2 -2
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +2 -0
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/002_unsafe_drop_gadget_note.sql +3 -0
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/tables/gadget.sql +6 -0
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +1 -0
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/types/dimensions.sql +11 -0
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/types/mood.sql +8 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_api.py +1 -1
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_container.py +1 -4
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_query.py +2 -2
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_schema.py +72 -3
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/uv.lock +1 -1
- pgdevkit-0.1.0/pgdevkit/connection.py +0 -56
- pgdevkit-0.1.0/pgdevkit/db/connection.py +0 -74
- pgdevkit-0.1.0/pgdevkit/db/crud.py +0 -184
- pgdevkit-0.1.0/pgdevkit/testdb/constants.py +0 -11
- pgdevkit-0.1.0/tests/db/test_connection.py +0 -58
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.gitignore +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/.python-version +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/__init__.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.1.0 → pgdevkit-0.2.0}/tests/testdb/test_naming.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgdevkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: psycopg[binary]>=3.2.0
|
|
@@ -93,6 +93,14 @@ is already reachable at that address and skips container management if so.
|
|
|
93
93
|
Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
|
|
94
94
|
that check too.
|
|
95
95
|
|
|
96
|
+
To point at a local Postgres install instead of the podman container —
|
|
97
|
+
useful when podman isn't available, or you'd rather use peer authentication
|
|
98
|
+
as the current OS user — set `PGDEVKIT_TESTDB_HOST` to the unix socket
|
|
99
|
+
directory (e.g. `/var/run/postgresql`) and `PGDEVKIT_TESTDB_PASSWORD=""`.
|
|
100
|
+
The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
|
|
101
|
+
(`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
|
|
102
|
+
auth for local connections (Debian/Ubuntu Postgres ships this by default).
|
|
103
|
+
|
|
96
104
|
## `pgdevkit.db` — helpers for application code
|
|
97
105
|
|
|
98
106
|
Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
@@ -76,6 +76,14 @@ is already reachable at that address and skips container management if so.
|
|
|
76
76
|
Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
|
|
77
77
|
that check too.
|
|
78
78
|
|
|
79
|
+
To point at a local Postgres install instead of the podman container —
|
|
80
|
+
useful when podman isn't available, or you'd rather use peer authentication
|
|
81
|
+
as the current OS user — set `PGDEVKIT_TESTDB_HOST` to the unix socket
|
|
82
|
+
directory (e.g. `/var/run/postgresql`) and `PGDEVKIT_TESTDB_PASSWORD=""`.
|
|
83
|
+
The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
|
|
84
|
+
(`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
|
|
85
|
+
auth for local connections (Debian/Ubuntu Postgres ships this by default).
|
|
86
|
+
|
|
79
87
|
## `pgdevkit.db` — helpers for application code
|
|
80
88
|
|
|
81
89
|
Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from urllib.parse import quote, urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
_LAKEBASE_HOST_SUFFIXES = (
|
|
7
|
+
".database.azuredatabricks.net",
|
|
8
|
+
".database.cloud.databricks.com",
|
|
9
|
+
)
|
|
10
|
+
_AZURE_POSTGRES_HOST_SUFFIXES = (
|
|
11
|
+
".postgres.database.azure.com",
|
|
12
|
+
".postgres.cosmos.azure.com",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_default_credential = None
|
|
16
|
+
_managed_identity_credential = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_provider(host: str) -> Literal["azure_postgres", "databricks_lakebase"]:
|
|
20
|
+
"""Classify a Postgres hostname for Entra ID auth: Databricks Lakebase
|
|
21
|
+
(needs credential exchange) or the default Azure Postgres AAD token
|
|
22
|
+
flow — the latter is the fallback for any non-Lakebase host, since
|
|
23
|
+
`entra_user` is itself the caller's assertion that Entra auth applies.
|
|
24
|
+
For a strict "is this actually Azure Database for PostgreSQL" check
|
|
25
|
+
(e.g. to decide PgBouncer-aware pooling), use `is_azure_postgres_host`
|
|
26
|
+
instead."""
|
|
27
|
+
if any(host.endswith(suffix) for suffix in _LAKEBASE_HOST_SUFFIXES):
|
|
28
|
+
return "databricks_lakebase"
|
|
29
|
+
return "azure_postgres"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_azure_postgres_host(host: str) -> bool:
|
|
33
|
+
"""True only for actual Azure Database for PostgreSQL hostnames —
|
|
34
|
+
unlike `detect_provider`, this is not a fallback default."""
|
|
35
|
+
return any(host.endswith(suffix) for suffix in _AZURE_POSTGRES_HOST_SUFFIXES)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_azure_postgres_password(
|
|
39
|
+
*,
|
|
40
|
+
managed_identity: bool = False,
|
|
41
|
+
exclude_interactive_browser_credential: bool = True,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Fetch an Entra ID token to use as an Azure Postgres password.
|
|
44
|
+
|
|
45
|
+
`managed_identity=True` uses `ManagedIdentityCredential` (for workloads
|
|
46
|
+
running under an Azure-assigned identity); otherwise
|
|
47
|
+
`DefaultAzureCredential`, whose credential chain already falls back to
|
|
48
|
+
managed identity when no other credential is available. Both credential
|
|
49
|
+
objects are process-cached so repeated calls (one per new pooled
|
|
50
|
+
connection) don't re-probe the credential chain every time."""
|
|
51
|
+
try:
|
|
52
|
+
from azure.identity import DefaultAzureCredential, ManagedIdentityCredential
|
|
53
|
+
except ImportError:
|
|
54
|
+
raise ImportError("Install azure-identity extra: pip install pgdevkit[azure]")
|
|
55
|
+
global _default_credential, _managed_identity_credential
|
|
56
|
+
if managed_identity:
|
|
57
|
+
if _managed_identity_credential is None:
|
|
58
|
+
_managed_identity_credential = ManagedIdentityCredential()
|
|
59
|
+
credential = _managed_identity_credential
|
|
60
|
+
else:
|
|
61
|
+
if _default_credential is None:
|
|
62
|
+
_default_credential = DefaultAzureCredential(
|
|
63
|
+
exclude_interactive_browser_credential=exclude_interactive_browser_credential
|
|
64
|
+
)
|
|
65
|
+
credential = _default_credential
|
|
66
|
+
token = credential.get_token("https://ossrdbms-aad.database.windows.net/.default")
|
|
67
|
+
return token.token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_conninfo(
|
|
71
|
+
url: str,
|
|
72
|
+
entra_user: str | None = None,
|
|
73
|
+
*,
|
|
74
|
+
databricks_workspace_host: str | None = None,
|
|
75
|
+
databricks_instance: str | None = None,
|
|
76
|
+
managed_identity: bool = False,
|
|
77
|
+
exclude_interactive_browser_credential: bool = True,
|
|
78
|
+
) -> str:
|
|
79
|
+
if entra_user is None:
|
|
80
|
+
return url
|
|
81
|
+
|
|
82
|
+
parsed = urlparse(url)
|
|
83
|
+
host = parsed.hostname or ""
|
|
84
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
85
|
+
|
|
86
|
+
if detect_provider(host) == "databricks_lakebase":
|
|
87
|
+
if not databricks_workspace_host or not databricks_instance:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"Lakebase host detected — pass --databricks-workspace-host and --databricks-instance"
|
|
90
|
+
)
|
|
91
|
+
from .lakebase import get_lakebase_password
|
|
92
|
+
|
|
93
|
+
password = get_lakebase_password(databricks_workspace_host, databricks_instance)
|
|
94
|
+
else:
|
|
95
|
+
password = get_azure_postgres_password(
|
|
96
|
+
managed_identity=managed_identity,
|
|
97
|
+
exclude_interactive_browser_credential=exclude_interactive_browser_credential,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
netloc = f"{quote(entra_user, safe='')}:{quote(password, safe='')}@{host}{port}"
|
|
101
|
+
return urlunparse(parsed._replace(netloc=netloc))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from .complex_types import ComplexHelper
|
|
3
4
|
from .connection import PgPool
|
|
4
5
|
from .crud import (
|
|
5
6
|
pg_delete,
|
|
@@ -19,6 +20,7 @@ from .loader import SqlLoader
|
|
|
19
20
|
from .model import PostgresTableModel
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
23
|
+
"ComplexHelper",
|
|
22
24
|
"PgPool",
|
|
23
25
|
"PostgresTableModel",
|
|
24
26
|
"SqlLoader",
|
|
@@ -1,51 +1,53 @@
|
|
|
1
|
-
|
|
2
|
-
ComplexHelper — psycopg adapter for PostgreSQL composite types, enums, and JSONB.
|
|
1
|
+
from __future__ import annotations
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
that need to be registered with psycopg before inserting test data. Plain columns
|
|
6
|
-
and JSONB are handled automatically by pgdevkit.testdb.schema's _insert_test_data;
|
|
7
|
-
you only need this class for USER-DEFINED types, which that function does not
|
|
8
|
-
yet support natively — wrap the connection it's given before the INSERT:
|
|
9
|
-
|
|
10
|
-
from pgdevkit.testdb import schema # for reference, not a public extension point yet
|
|
11
|
-
from your_project.pg_complex_helper import ComplexHelper
|
|
12
|
-
|
|
13
|
-
async def insert_test_data_with_complex_types(json_file, table, force_reset, con):
|
|
14
|
-
helper = ComplexHelper(con)
|
|
15
|
-
schema_name, table_name = table.split(".")
|
|
16
|
-
complex_types = await helper.load_all_complex_types((schema_name, table_name))
|
|
17
|
-
rows = json.loads(json_file.read_text(encoding="utf-8"))
|
|
18
|
-
for row in rows:
|
|
19
|
-
for col, info in complex_types.items():
|
|
20
|
-
if col in row:
|
|
21
|
-
row[col] = await helper.recursive_convert(row[col], info, con)
|
|
22
|
-
# then insert `rows` the same way schema.py's _insert_test_data does
|
|
23
|
-
"""
|
|
3
|
+
from typing import Any, Callable
|
|
24
4
|
|
|
25
5
|
from psycopg import AsyncConnection
|
|
26
6
|
from psycopg.rows import dict_row
|
|
27
|
-
from typing import Any
|
|
28
7
|
from psycopg.sql import Identifier
|
|
29
8
|
from psycopg.types.composite import CompositeInfo, register_composite
|
|
30
|
-
from psycopg.types.json import Jsonb
|
|
31
9
|
from psycopg.types.enum import EnumInfo, register_enum
|
|
10
|
+
from psycopg.types.json import Jsonb
|
|
11
|
+
|
|
12
|
+
ComplexTypeInfo = CompositeInfo | EnumInfo | type[Jsonb] | None
|
|
32
13
|
|
|
33
14
|
|
|
34
15
|
class ComplexHelper:
|
|
35
|
-
|
|
16
|
+
"""psycopg adapter for PostgreSQL composite types, enums, and JSONB.
|
|
36
17
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
18
|
+
Detects a table's non-scalar columns (composite types, enums, JSONB) and
|
|
19
|
+
converts plain dict/list Python values into the psycopg-registered types
|
|
20
|
+
those columns need, recursing into nested composite fields.
|
|
40
21
|
|
|
22
|
+
`normalizers` lets a caller reshape a composite value before conversion,
|
|
23
|
+
keyed by composite type name (e.g. a project with a `locale_labels`
|
|
24
|
+
composite type that needs locale-key backfilling before it's built) —
|
|
25
|
+
this is intentionally the only project-specific extension point; nothing
|
|
26
|
+
else about a project's types is hardcoded here.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
con: AsyncConnection,
|
|
32
|
+
normalizers: dict[str, Callable[[dict], dict]] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.con = con
|
|
35
|
+
self.system_complex_type_dict: dict[Any, tuple[str, str]] | None = None
|
|
36
|
+
# Instance-scoped: a CompositeInfo/EnumInfo carries OIDs from a
|
|
37
|
+
# specific connection/database, so caching it on the class (shared
|
|
38
|
+
# across every connection) would leak stale OIDs across databases
|
|
39
|
+
# that happen to reuse the same type name — exactly the case for
|
|
40
|
+
# pgdevkit's per-worktree isolated test databases.
|
|
41
|
+
self.complex_types: dict[tuple[str, str], CompositeInfo | EnumInfo] = {}
|
|
41
42
|
self.registered: set[CompositeInfo | EnumInfo] = set()
|
|
43
|
+
self._normalizers = normalizers or {}
|
|
42
44
|
|
|
43
|
-
async def load_complex_type_dict(self):
|
|
45
|
+
async def load_complex_type_dict(self) -> None:
|
|
44
46
|
async with self.con.cursor(row_factory=dict_row) as cur:
|
|
45
47
|
await cur.execute("""
|
|
46
48
|
SELECT t.oid,
|
|
47
49
|
pg_catalog.format_type ( t.oid, NULL ) AS obj_name,
|
|
48
|
-
|
|
50
|
+
t.typtype
|
|
49
51
|
FROM pg_catalog.pg_type t
|
|
50
52
|
JOIN pg_catalog.pg_namespace n
|
|
51
53
|
ON n.oid = t.typnamespace
|
|
@@ -57,13 +59,9 @@ class ComplexHelper:
|
|
|
57
59
|
AND n.nspname <> 'information_schema'
|
|
58
60
|
AND n.nspname !~ '^pg_toast'""")
|
|
59
61
|
system_complex_types = await cur.fetchall()
|
|
60
|
-
self.system_complex_type_dict = {
|
|
61
|
-
r["oid"]: (r["obj_name"], r["typtype"]) for r in system_complex_types
|
|
62
|
-
}
|
|
62
|
+
self.system_complex_type_dict = {r["oid"]: (r["obj_name"], r["typtype"]) for r in system_complex_types}
|
|
63
63
|
|
|
64
|
-
async def _load_complex_type_from_colinfos(
|
|
65
|
-
self, res: dict[str, Any] | None
|
|
66
|
-
) -> CompositeInfo | EnumInfo | type[Jsonb] | None:
|
|
64
|
+
async def _load_complex_type_from_colinfos(self, res: dict[str, Any] | None) -> ComplexTypeInfo:
|
|
67
65
|
if not res:
|
|
68
66
|
return None
|
|
69
67
|
if res["data_type"].lower() == "jsonb":
|
|
@@ -78,15 +76,13 @@ class ComplexHelper:
|
|
|
78
76
|
return None
|
|
79
77
|
udt_schema: str = res["udt_schema"]
|
|
80
78
|
udt_name: str = res["udt_name"]
|
|
81
|
-
c = await self._get_complex_type(
|
|
82
|
-
f"{udt_schema}.{udt_name}", res["is_enum"], self.con
|
|
83
|
-
)
|
|
79
|
+
c = await self._get_complex_type(f"{udt_schema}.{udt_name}", res["is_enum"], self.con)
|
|
84
80
|
await self._recurse_register(c, self.con)
|
|
85
81
|
return c
|
|
86
82
|
|
|
87
83
|
async def load_all_complex_types(
|
|
88
84
|
self, table_name: tuple[str, str], include_generated: bool = False
|
|
89
|
-
) -> dict[str,
|
|
85
|
+
) -> dict[str, ComplexTypeInfo]:
|
|
90
86
|
if self.system_complex_type_dict is None:
|
|
91
87
|
await self.load_complex_type_dict()
|
|
92
88
|
colquery = """
|
|
@@ -100,7 +96,8 @@ class ComplexHelper:
|
|
|
100
96
|
udt_schema, udt_name,
|
|
101
97
|
e.enum_name is not null as is_enum
|
|
102
98
|
from information_schema.columns c
|
|
103
|
-
left join enum_types e on e.enum_schema=c.udt_schema
|
|
99
|
+
left join enum_types e on e.enum_schema=c.udt_schema
|
|
100
|
+
and (e.enum_name=c.udt_name or (c.data_type='ARRAY' and c.udt_name='_'||e.enum_name))
|
|
104
101
|
where table_schema=%(schema)s and table_name = %(tbl)s and (is_generated <> 'ALWAYS' or %(include_generated)s)"""
|
|
105
102
|
async with self.con.cursor(row_factory=dict_row) as cur:
|
|
106
103
|
await cur.execute(
|
|
@@ -112,14 +109,9 @@ class ComplexHelper:
|
|
|
112
109
|
},
|
|
113
110
|
)
|
|
114
111
|
res = await cur.fetchall()
|
|
115
|
-
return {
|
|
116
|
-
r["column_name"]: await self._load_complex_type_from_colinfos(r)
|
|
117
|
-
for r in res
|
|
118
|
-
}
|
|
112
|
+
return {r["column_name"]: await self._load_complex_type_from_colinfos(r) for r in res}
|
|
119
113
|
|
|
120
|
-
async def load_complex_type(
|
|
121
|
-
self, table_name: tuple[str, str], col_name: str
|
|
122
|
-
) -> CompositeInfo | type[Jsonb] | EnumInfo | None:
|
|
114
|
+
async def load_complex_type(self, table_name: tuple[str, str], col_name: str) -> ComplexTypeInfo:
|
|
123
115
|
if self.system_complex_type_dict is None:
|
|
124
116
|
await self.load_complex_type_dict()
|
|
125
117
|
colquery = """
|
|
@@ -133,7 +125,8 @@ class ComplexHelper:
|
|
|
133
125
|
udt_schema, udt_name,
|
|
134
126
|
e.enum_name is not null as is_enum
|
|
135
127
|
from information_schema.columns c
|
|
136
|
-
left join enum_types e on e.enum_schema=c.udt_schema
|
|
128
|
+
left join enum_types e on e.enum_schema=c.udt_schema
|
|
129
|
+
and (e.enum_name=c.udt_name or (c.data_type='ARRAY' and c.udt_name='_'||e.enum_name))
|
|
137
130
|
where table_schema=%(schema)s and table_name = %(tbl)s
|
|
138
131
|
and column_name = %(col)s"""
|
|
139
132
|
async with self.con.cursor(row_factory=dict_row) as cur:
|
|
@@ -142,18 +135,13 @@ class ComplexHelper:
|
|
|
142
135
|
{"schema": table_name[0], "tbl": table_name[1], "col": col_name},
|
|
143
136
|
)
|
|
144
137
|
res = await cur.fetchone()
|
|
145
|
-
|
|
146
138
|
return await self._load_complex_type_from_colinfos(res)
|
|
147
139
|
|
|
148
|
-
async def _get_complex_type(
|
|
149
|
-
self, name: str, is_enum: bool, con: AsyncConnection
|
|
150
|
-
) -> CompositeInfo | EnumInfo:
|
|
140
|
+
async def _get_complex_type(self, name: str, is_enum: bool, con: AsyncConnection) -> CompositeInfo | EnumInfo:
|
|
151
141
|
if name.endswith("[]"):
|
|
152
142
|
name = name[:-2]
|
|
153
|
-
schema, type_name = name.split(".")
|
|
154
|
-
if type_name.startswith(
|
|
155
|
-
"_"
|
|
156
|
-
): # the array type in PostgreSQL starts with an underscore
|
|
143
|
+
schema, type_name = name.split(".") if "." in name else ("public", name)
|
|
144
|
+
if type_name.startswith("_"): # the array type in PostgreSQL starts with an underscore
|
|
157
145
|
type_name = type_name[1:]
|
|
158
146
|
if is_enum:
|
|
159
147
|
ci = await EnumInfo.fetch(con, Identifier(schema, type_name))
|
|
@@ -165,12 +153,8 @@ class ComplexHelper:
|
|
|
165
153
|
self.complex_types[(schema, type_name)] = ci
|
|
166
154
|
return self.complex_types[(schema, type_name)]
|
|
167
155
|
|
|
168
|
-
async def _recurse_register(
|
|
169
|
-
self,
|
|
170
|
-
):
|
|
171
|
-
assert self.system_complex_type_dict is not None, (
|
|
172
|
-
"System complex type dictionary not loaded"
|
|
173
|
-
)
|
|
156
|
+
async def _recurse_register(self, info: CompositeInfo | EnumInfo, con: AsyncConnection) -> None:
|
|
157
|
+
assert self.system_complex_type_dict is not None, "System complex type dictionary not loaded"
|
|
174
158
|
if info not in self.registered:
|
|
175
159
|
if isinstance(info, EnumInfo):
|
|
176
160
|
register_enum(info, con)
|
|
@@ -188,7 +172,7 @@ class ComplexHelper:
|
|
|
188
172
|
async def recursive_convert(
|
|
189
173
|
self,
|
|
190
174
|
value: Any,
|
|
191
|
-
info:
|
|
175
|
+
info: ComplexTypeInfo,
|
|
192
176
|
con: AsyncConnection,
|
|
193
177
|
) -> Any:
|
|
194
178
|
if info is None:
|
|
@@ -197,20 +181,21 @@ class ComplexHelper:
|
|
|
197
181
|
return None
|
|
198
182
|
if self.system_complex_type_dict is None:
|
|
199
183
|
await self.load_complex_type_dict()
|
|
184
|
+
if info == Jsonb:
|
|
185
|
+
# A JSONB column's value is wrapped whole, even if it's a list —
|
|
186
|
+
# only an array-of-composite/enum column recurses per element.
|
|
187
|
+
return Jsonb(value)
|
|
200
188
|
if isinstance(value, list):
|
|
201
189
|
return [await self.recursive_convert(item, info, con) for item in value]
|
|
202
190
|
prms = {}
|
|
203
|
-
if info == Jsonb:
|
|
204
|
-
return Jsonb(value)
|
|
205
191
|
if isinstance(value, str):
|
|
206
192
|
assert isinstance(info, EnumInfo), f"Expected EnumInfo, got {type(info)}"
|
|
207
193
|
return getattr(info.enum, value) # Enum
|
|
208
|
-
assert isinstance(info, CompositeInfo), (
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
)
|
|
194
|
+
assert isinstance(info, CompositeInfo), f"Expected CompositeInfo, got {type(info)}"
|
|
195
|
+
assert self.system_complex_type_dict is not None, "System complex type dictionary not loaded"
|
|
196
|
+
normalizer = self._normalizers.get(info.name)
|
|
197
|
+
if normalizer is not None:
|
|
198
|
+
value = normalizer(value)
|
|
214
199
|
for k, v in value.items():
|
|
215
200
|
if v is None:
|
|
216
201
|
prms[k] = None
|
|
@@ -221,14 +206,10 @@ class ComplexHelper:
|
|
|
221
206
|
name, typtype = self.system_complex_type_dict[type_oid]
|
|
222
207
|
ci = await self._get_complex_type(name, typtype == "e", con)
|
|
223
208
|
if name.endswith("[]"):
|
|
224
|
-
prms[k] = [
|
|
225
|
-
await self.recursive_convert(item, ci, con) for item in v
|
|
226
|
-
]
|
|
209
|
+
prms[k] = [await self.recursive_convert(item, ci, con) for item in v]
|
|
227
210
|
else:
|
|
228
211
|
prms[k] = await self.recursive_convert(v, ci, con)
|
|
229
212
|
else:
|
|
230
213
|
prms[k] = v
|
|
231
|
-
assert info.python_type is not None,
|
|
232
|
-
f"Python type for {info.name} is null, maybe an array?"
|
|
233
|
-
)
|
|
214
|
+
assert info.python_type is not None, f"Python type for {info.name} is null, maybe an array?"
|
|
234
215
|
return info.python_type(**prms) if prms else None
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from psycopg_pool import AsyncConnectionPool, AsyncNullConnectionPool
|
|
8
|
+
|
|
9
|
+
from ..connection import detect_provider, get_azure_postgres_password, is_azure_postgres_host
|
|
10
|
+
from ..lakebase import get_lakebase_password
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PgPool:
|
|
14
|
+
"""A connection pool keyed off `{env_prefix}HOST/PORT/DB/USER/PASSWORD`
|
|
15
|
+
environment variables (e.g. env_prefix="MDM_POSTGRES_").
|
|
16
|
+
|
|
17
|
+
Pass `entra_user` to authenticate via Entra ID instead of a static
|
|
18
|
+
password. The Postgres host is inspected to pick the Azure Postgres AAD
|
|
19
|
+
token flow or Databricks Lakebase credential exchange; for the latter
|
|
20
|
+
also set `{env_prefix}DATABRICKS_WORKSPACE_HOST` and
|
|
21
|
+
`{env_prefix}DATABRICKS_INSTANCE`.
|
|
22
|
+
|
|
23
|
+
`use_null_pool="auto"` (the default) switches to a null pool — no local
|
|
24
|
+
pooling — when the host is Azure Postgres, since Azure's own PgBouncer
|
|
25
|
+
(transaction-mode) does the pooling; `connection_kwargs` then
|
|
26
|
+
auto-includes `prepare_threshold=None` too, since PgBouncer transaction
|
|
27
|
+
mode doesn't support server-side prepared statements. Pass an explicit
|
|
28
|
+
`use_null_pool`/`connection_kwargs` to override either.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
env_prefix: str = "POSTGRES_",
|
|
34
|
+
max_size: int = 40,
|
|
35
|
+
*,
|
|
36
|
+
entra_user: str | None = None,
|
|
37
|
+
credential_kind: Literal["default_azure", "managed_identity"] = "default_azure",
|
|
38
|
+
exclude_interactive_browser_credential: bool = True,
|
|
39
|
+
max_lifetime: float | None = None,
|
|
40
|
+
use_null_pool: bool | Literal["auto"] = "auto",
|
|
41
|
+
connection_kwargs: dict | None = None,
|
|
42
|
+
dsn_params: dict[str, str] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self._env_prefix = env_prefix
|
|
45
|
+
self._max_size = max_size
|
|
46
|
+
self._entra_user = entra_user
|
|
47
|
+
self._credential_kind = credential_kind
|
|
48
|
+
self._exclude_interactive_browser_credential = exclude_interactive_browser_credential
|
|
49
|
+
self._max_lifetime = max_lifetime
|
|
50
|
+
self._use_null_pool = use_null_pool
|
|
51
|
+
self._connection_kwargs = connection_kwargs
|
|
52
|
+
self._dsn_params = dsn_params or {}
|
|
53
|
+
self._pool: AsyncConnectionPool | AsyncNullConnectionPool | None = None
|
|
54
|
+
|
|
55
|
+
def _is_azure_postgres(self, host: str) -> bool:
|
|
56
|
+
return is_azure_postgres_host(host)
|
|
57
|
+
|
|
58
|
+
async def _dsn(self) -> str:
|
|
59
|
+
p = self._env_prefix
|
|
60
|
+
host = os.environ[p + "HOST"]
|
|
61
|
+
port = os.environ[p + "PORT"]
|
|
62
|
+
dbname = os.environ[p + "DB"]
|
|
63
|
+
|
|
64
|
+
if self._entra_user is None:
|
|
65
|
+
user = os.environ[p + "USER"]
|
|
66
|
+
password = os.environ[p + "PASSWORD"]
|
|
67
|
+
else:
|
|
68
|
+
user = self._entra_user
|
|
69
|
+
if detect_provider(host) == "databricks_lakebase":
|
|
70
|
+
workspace_host = os.environ[p + "DATABRICKS_WORKSPACE_HOST"]
|
|
71
|
+
instance_name = os.environ[p + "DATABRICKS_INSTANCE"]
|
|
72
|
+
password = await asyncio.to_thread(get_lakebase_password, workspace_host, instance_name)
|
|
73
|
+
else:
|
|
74
|
+
password = await asyncio.to_thread(
|
|
75
|
+
get_azure_postgres_password,
|
|
76
|
+
managed_identity=self._credential_kind == "managed_identity",
|
|
77
|
+
exclude_interactive_browser_credential=self._exclude_interactive_browser_credential,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
dsn = f"host={host} port={port} dbname={dbname} user={user} password={password}"
|
|
81
|
+
for key, value in self._dsn_params.items():
|
|
82
|
+
dsn += f" {key}={value}"
|
|
83
|
+
return dsn
|
|
84
|
+
|
|
85
|
+
async def open(self) -> None:
|
|
86
|
+
if self._pool is None:
|
|
87
|
+
host = os.environ[self._env_prefix + "HOST"]
|
|
88
|
+
is_azure_postgres = self._is_azure_postgres(host)
|
|
89
|
+
use_null_pool = is_azure_postgres if self._use_null_pool == "auto" else self._use_null_pool
|
|
90
|
+
connection_kwargs = self._connection_kwargs
|
|
91
|
+
if connection_kwargs is None:
|
|
92
|
+
# PgBouncer (transaction mode) doesn't support prepared statements
|
|
93
|
+
connection_kwargs = {"prepare_threshold": None} if is_azure_postgres else {}
|
|
94
|
+
max_lifetime = 3600.0 if self._max_lifetime is None else self._max_lifetime
|
|
95
|
+
if use_null_pool:
|
|
96
|
+
self._pool = AsyncNullConnectionPool(
|
|
97
|
+
conninfo=self._dsn,
|
|
98
|
+
open=False,
|
|
99
|
+
max_size=self._max_size,
|
|
100
|
+
max_lifetime=max_lifetime,
|
|
101
|
+
check=AsyncNullConnectionPool.check_connection,
|
|
102
|
+
kwargs=connection_kwargs,
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
self._pool = AsyncConnectionPool(
|
|
106
|
+
conninfo=self._dsn,
|
|
107
|
+
open=False,
|
|
108
|
+
max_size=self._max_size,
|
|
109
|
+
max_lifetime=max_lifetime,
|
|
110
|
+
check=AsyncConnectionPool.check_connection,
|
|
111
|
+
kwargs=connection_kwargs,
|
|
112
|
+
)
|
|
113
|
+
if not self._pool._opened:
|
|
114
|
+
await self._pool.open()
|
|
115
|
+
|
|
116
|
+
async def close(self) -> None:
|
|
117
|
+
if self._pool is not None:
|
|
118
|
+
await self._pool.close()
|
|
119
|
+
|
|
120
|
+
def connection(self):
|
|
121
|
+
"""Return an async connection context manager from the pool."""
|
|
122
|
+
if self._pool is None:
|
|
123
|
+
raise RuntimeError("Call open() first (e.g. in app startup).")
|
|
124
|
+
return self._pool.connection()
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def raw_pool(self) -> AsyncConnectionPool | AsyncNullConnectionPool:
|
|
128
|
+
"""The underlying psycopg_pool object — for integrating with other
|
|
129
|
+
pool-consuming libraries (e.g. Procrastinate's `open_async(pool=...)`)
|
|
130
|
+
that need it directly rather than going through `connection()`."""
|
|
131
|
+
if self._pool is None:
|
|
132
|
+
raise RuntimeError("Call open() first (e.g. in app startup).")
|
|
133
|
+
return self._pool
|