pgdevkit 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.
- pgdevkit/__init__.py +0 -0
- pgdevkit/cli.py +217 -0
- pgdevkit/connection.py +56 -0
- pgdevkit/db/__init__.py +37 -0
- pgdevkit/db/connection.py +74 -0
- pgdevkit/db/crud.py +184 -0
- pgdevkit/db/loader.py +19 -0
- pgdevkit/db/model.py +23 -0
- pgdevkit/diff.py +253 -0
- pgdevkit/docs/database-layout.md +145 -0
- pgdevkit/fetch_missing.py +229 -0
- pgdevkit/introspect.py +190 -0
- pgdevkit/lakebase.py +90 -0
- pgdevkit/models.py +102 -0
- pgdevkit/parser.py +336 -0
- pgdevkit/skills/pgdevkit/SKILL.md +283 -0
- pgdevkit/skills/pgdevkit/references/complex_helper.py +234 -0
- pgdevkit/skills/pgdevkit/references/dynamic-sql.md +92 -0
- pgdevkit/skills/pgdevkit/references/temporal-tables.md +33 -0
- pgdevkit/testdb/__init__.py +3 -0
- pgdevkit/testdb/api.py +137 -0
- pgdevkit/testdb/config.py +51 -0
- pgdevkit/testdb/constants.py +11 -0
- pgdevkit/testdb/container.py +95 -0
- pgdevkit/testdb/naming.py +41 -0
- pgdevkit/testdb/query.py +65 -0
- pgdevkit/testdb/schema.py +188 -0
- pgdevkit-0.1.0.dist-info/METADATA +140 -0
- pgdevkit-0.1.0.dist-info/RECORD +31 -0
- pgdevkit-0.1.0.dist-info/WHEEL +4 -0
- pgdevkit-0.1.0.dist-info/entry_points.txt +2 -0
pgdevkit/introspect.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
from psycopg.rows import dict_row
|
|
7
|
+
|
|
8
|
+
from .models import (
|
|
9
|
+
ColumnDef, ConstraintDef, CompositeTypeDef, DatabaseSchema,
|
|
10
|
+
EnumDef, FunctionDef, IndexDef, TableDef, ViewDef,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _q(conn: Any, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
15
|
+
with conn.cursor(row_factory=dict_row) as cur:
|
|
16
|
+
cur.execute(sql, params)
|
|
17
|
+
return cur.fetchall() # type: ignore[return-value]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def introspect_db(conninfo: str) -> DatabaseSchema:
|
|
21
|
+
with psycopg.connect(conninfo) as conn:
|
|
22
|
+
db = DatabaseSchema()
|
|
23
|
+
_load_schemas(conn, db)
|
|
24
|
+
_load_tables(conn, db)
|
|
25
|
+
_load_views(conn, db)
|
|
26
|
+
_load_functions(conn, db)
|
|
27
|
+
_load_enums(conn, db)
|
|
28
|
+
_load_composites(conn, db)
|
|
29
|
+
_load_indexes(conn, db)
|
|
30
|
+
return db
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_schemas(conn: Any, db: DatabaseSchema) -> None:
|
|
34
|
+
rows = _q(conn, (
|
|
35
|
+
"SELECT schema_name FROM information_schema.schemata "
|
|
36
|
+
"WHERE schema_name NOT LIKE 'pg_%' AND schema_name != 'information_schema'"
|
|
37
|
+
))
|
|
38
|
+
db.schemas = {r["schema_name"] for r in rows}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_tables(conn: Any, db: DatabaseSchema) -> None:
|
|
42
|
+
tables = _q(conn, """
|
|
43
|
+
SELECT n.nspname AS schema, c.relname AS name, c.relispartition AS is_partition
|
|
44
|
+
FROM pg_catalog.pg_class c
|
|
45
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
46
|
+
WHERE c.relkind IN ('r', 'p')
|
|
47
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
48
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
for row in tables:
|
|
52
|
+
tschema, tname = row["schema"], row["name"]
|
|
53
|
+
table = TableDef(schema=tschema, name=tname, is_partition=bool(row["is_partition"]))
|
|
54
|
+
|
|
55
|
+
for c in _q(conn, """
|
|
56
|
+
SELECT a.attname AS name,
|
|
57
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type,
|
|
58
|
+
NOT a.attnotnull AS is_nullable,
|
|
59
|
+
pg_get_expr(d.adbin, d.adrelid) AS col_default,
|
|
60
|
+
a.attgenerated != '' AS is_generated
|
|
61
|
+
FROM pg_catalog.pg_attribute a
|
|
62
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
63
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
64
|
+
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
65
|
+
WHERE n.nspname = %(s)s AND c.relname = %(t)s
|
|
66
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
67
|
+
ORDER BY a.attnum
|
|
68
|
+
""", {"s": tschema, "t": tname}):
|
|
69
|
+
table.columns.append(ColumnDef(
|
|
70
|
+
name=c["name"],
|
|
71
|
+
data_type=c["data_type"],
|
|
72
|
+
is_nullable=bool(c["is_nullable"]),
|
|
73
|
+
default=c["col_default"],
|
|
74
|
+
is_generated=bool(c["is_generated"]),
|
|
75
|
+
))
|
|
76
|
+
|
|
77
|
+
kind_map = {"p": "PRIMARY KEY", "u": "UNIQUE", "f": "FOREIGN KEY", "c": "CHECK"}
|
|
78
|
+
for c in _q(conn, """
|
|
79
|
+
SELECT conname AS name, contype AS kind,
|
|
80
|
+
pg_get_constraintdef(oid) AS definition
|
|
81
|
+
FROM pg_catalog.pg_constraint
|
|
82
|
+
WHERE conrelid = (
|
|
83
|
+
SELECT c.oid FROM pg_catalog.pg_class c
|
|
84
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
85
|
+
WHERE n.nspname = %(s)s AND c.relname = %(t)s
|
|
86
|
+
)
|
|
87
|
+
""", {"s": tschema, "t": tname}):
|
|
88
|
+
table.constraints.append(ConstraintDef(
|
|
89
|
+
name=c["name"],
|
|
90
|
+
kind=kind_map.get(c["kind"], c["kind"]),
|
|
91
|
+
definition=(c["definition"] or "").lower(),
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
db.tables[table.qualified_name] = table
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_views(conn: Any, db: DatabaseSchema) -> None:
|
|
98
|
+
for r in _q(conn, """
|
|
99
|
+
SELECT n.nspname AS schema, c.relname AS name,
|
|
100
|
+
pg_get_viewdef(c.oid, true) AS definition
|
|
101
|
+
FROM pg_catalog.pg_class c
|
|
102
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
103
|
+
WHERE c.relkind = 'v'
|
|
104
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
105
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
106
|
+
"""):
|
|
107
|
+
v = ViewDef(schema=r["schema"], name=r["name"], definition=(r["definition"] or "").lower())
|
|
108
|
+
db.views[v.qualified_name] = v
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _load_functions(conn: Any, db: DatabaseSchema) -> None:
|
|
112
|
+
for r in _q(conn, """
|
|
113
|
+
SELECT n.nspname AS schema, p.proname AS name,
|
|
114
|
+
pg_get_function_arguments(p.oid) AS args,
|
|
115
|
+
pg_get_function_result(p.oid) AS return_type,
|
|
116
|
+
l.lanname AS language, p.prosrc AS body,
|
|
117
|
+
CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END AS kind
|
|
118
|
+
FROM pg_catalog.pg_proc p
|
|
119
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
120
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
121
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
122
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
123
|
+
"""):
|
|
124
|
+
raw_body = r["body"] or ""
|
|
125
|
+
lines = [line.strip() for line in raw_body.splitlines()]
|
|
126
|
+
body = "\n".join(line.lower() for line in lines if line)
|
|
127
|
+
func = FunctionDef(
|
|
128
|
+
schema=r["schema"], name=r["name"],
|
|
129
|
+
args=(r["args"] or "").lower(),
|
|
130
|
+
return_type=(r["return_type"] or "").lower(),
|
|
131
|
+
language=r["language"], body=body, kind=r["kind"],
|
|
132
|
+
)
|
|
133
|
+
db.functions[func.qualified_name] = func
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _load_enums(conn: Any, db: DatabaseSchema) -> None:
|
|
137
|
+
for r in _q(conn, """
|
|
138
|
+
SELECT n.nspname AS schema, t.typname AS name,
|
|
139
|
+
array_agg(e.enumlabel ORDER BY e.enumsortorder) AS values
|
|
140
|
+
FROM pg_catalog.pg_type t
|
|
141
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
142
|
+
JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid
|
|
143
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
144
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
145
|
+
GROUP BY n.nspname, t.typname
|
|
146
|
+
"""):
|
|
147
|
+
enum = EnumDef(schema=r["schema"], name=r["name"], values=list(r["values"]))
|
|
148
|
+
db.enums[enum.qualified_name] = enum
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _load_composites(conn: Any, db: DatabaseSchema) -> None:
|
|
152
|
+
composites: dict[str, CompositeTypeDef] = {}
|
|
153
|
+
for r in _q(conn, """
|
|
154
|
+
SELECT n.nspname AS schema, t.typname AS name,
|
|
155
|
+
a.attname AS field_name,
|
|
156
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod) AS field_type
|
|
157
|
+
FROM pg_catalog.pg_type t
|
|
158
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
159
|
+
JOIN pg_catalog.pg_class c ON c.oid = t.typrelid AND c.relkind = 'c'
|
|
160
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
161
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
162
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
163
|
+
ORDER BY n.nspname, t.typname, a.attnum
|
|
164
|
+
"""):
|
|
165
|
+
key = f"{r['schema']}.{r['name']}"
|
|
166
|
+
if key not in composites:
|
|
167
|
+
composites[key] = CompositeTypeDef(schema=r["schema"], name=r["name"], fields=[])
|
|
168
|
+
composites[key].fields.append((r["field_name"], r["field_type"]))
|
|
169
|
+
db.composites = composites
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_indexes(conn: Any, db: DatabaseSchema) -> None:
|
|
173
|
+
for r in _q(conn, """
|
|
174
|
+
SELECT n.nspname AS schema, t.relname AS table_name,
|
|
175
|
+
i.relname AS index_name,
|
|
176
|
+
pg_get_indexdef(ix.indexrelid) AS definition
|
|
177
|
+
FROM pg_catalog.pg_index ix
|
|
178
|
+
JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid
|
|
179
|
+
JOIN pg_catalog.pg_class t ON t.oid = ix.indrelid
|
|
180
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
|
|
181
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
182
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
183
|
+
AND NOT ix.indisprimary
|
|
184
|
+
"""):
|
|
185
|
+
idx = IndexDef(
|
|
186
|
+
schema=r["schema"], table=r["table_name"],
|
|
187
|
+
name=r["index_name"],
|
|
188
|
+
definition=(r["definition"] or "").lower(),
|
|
189
|
+
)
|
|
190
|
+
db.indexes[idx.qualified_name] = idx
|
pgdevkit/lakebase.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
from urllib import request as urllib_request
|
|
9
|
+
from urllib.error import HTTPError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from azure.identity import DefaultAzureCredential
|
|
13
|
+
|
|
14
|
+
_DATABRICKS_RESOURCE_SCOPE = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d/.default"
|
|
15
|
+
_REFRESH_MARGIN_SECONDS = 300
|
|
16
|
+
|
|
17
|
+
_credential: DefaultAzureCredential | None = None
|
|
18
|
+
_credential_cache: dict[tuple[str, str], tuple[str, float]] = {}
|
|
19
|
+
_dns_cache: dict[tuple[str, str], str] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _databricks_resource_token() -> str:
|
|
23
|
+
try:
|
|
24
|
+
from azure.identity import DefaultAzureCredential
|
|
25
|
+
except ImportError:
|
|
26
|
+
raise ImportError("Install azure-identity extra: pip install pgdevkit[azure]")
|
|
27
|
+
global _credential
|
|
28
|
+
if _credential is None:
|
|
29
|
+
_credential = DefaultAzureCredential()
|
|
30
|
+
return _credential.get_token(_DATABRICKS_RESOURCE_SCOPE).token
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _request_json(url: str, token: str, *, body: dict | None = None) -> dict:
|
|
34
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
35
|
+
req = urllib_request.Request(
|
|
36
|
+
url,
|
|
37
|
+
data=data,
|
|
38
|
+
method="POST" if body is not None else "GET",
|
|
39
|
+
headers={
|
|
40
|
+
"Authorization": f"Bearer {token}",
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
},
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
with urllib_request.urlopen(req, timeout=10) as resp:
|
|
46
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
47
|
+
except HTTPError as e:
|
|
48
|
+
detail = e.read().decode("utf-8", errors="replace")
|
|
49
|
+
raise RuntimeError(f"Databricks API request failed [{e.code}]: {detail}") from e
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_expiration(expiration_time: str) -> float:
|
|
53
|
+
return datetime.datetime.fromisoformat(expiration_time).timestamp()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_lakebase_password(workspace_host: str, instance_name: str) -> str:
|
|
57
|
+
"""Return a valid short-lived Postgres password for the given Lakebase
|
|
58
|
+
instance, fetching/refreshing the credential as needed."""
|
|
59
|
+
key = (workspace_host, instance_name)
|
|
60
|
+
cached = _credential_cache.get(key)
|
|
61
|
+
if cached is not None:
|
|
62
|
+
token, expires_at = cached
|
|
63
|
+
if time.time() < expires_at - _REFRESH_MARGIN_SECONDS:
|
|
64
|
+
return token
|
|
65
|
+
|
|
66
|
+
entra_token = _databricks_resource_token()
|
|
67
|
+
response = _request_json(
|
|
68
|
+
f"{workspace_host.rstrip('/')}/api/2.0/database/credentials",
|
|
69
|
+
entra_token,
|
|
70
|
+
body={"instance_names": [instance_name], "request_id": str(uuid.uuid4())},
|
|
71
|
+
)
|
|
72
|
+
token = response["token"]
|
|
73
|
+
expires_at = _parse_expiration(response["expiration_time"])
|
|
74
|
+
_credential_cache[key] = (token, expires_at)
|
|
75
|
+
return token
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def resolve_read_write_dns(workspace_host: str, instance_name: str) -> str:
|
|
79
|
+
"""Resolve and cache the Postgres read/write DNS endpoint for a Lakebase instance."""
|
|
80
|
+
key = (workspace_host, instance_name)
|
|
81
|
+
if key in _dns_cache:
|
|
82
|
+
return _dns_cache[key]
|
|
83
|
+
entra_token = _databricks_resource_token()
|
|
84
|
+
response = _request_json(
|
|
85
|
+
f"{workspace_host.rstrip('/')}/api/2.0/database/instances/{instance_name}",
|
|
86
|
+
entra_token,
|
|
87
|
+
)
|
|
88
|
+
dns = response["read_write_dns"]
|
|
89
|
+
_dns_cache[key] = dns
|
|
90
|
+
return dns
|
pgdevkit/models.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ColumnDef:
|
|
7
|
+
name: str
|
|
8
|
+
data_type: str
|
|
9
|
+
is_nullable: bool
|
|
10
|
+
default: str | None
|
|
11
|
+
is_generated: bool = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConstraintDef:
|
|
16
|
+
name: str | None
|
|
17
|
+
kind: str # PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK
|
|
18
|
+
definition: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TableDef:
|
|
23
|
+
schema: str
|
|
24
|
+
name: str
|
|
25
|
+
columns: list[ColumnDef] = field(default_factory=list)
|
|
26
|
+
constraints: list[ConstraintDef] = field(default_factory=list)
|
|
27
|
+
is_partition: bool = False
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def qualified_name(self) -> str:
|
|
31
|
+
return f"{self.schema}.{self.name}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ViewDef:
|
|
36
|
+
schema: str
|
|
37
|
+
name: str
|
|
38
|
+
definition: str
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def qualified_name(self) -> str:
|
|
42
|
+
return f"{self.schema}.{self.name}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class FunctionDef:
|
|
47
|
+
schema: str
|
|
48
|
+
name: str
|
|
49
|
+
args: str
|
|
50
|
+
return_type: str
|
|
51
|
+
language: str
|
|
52
|
+
body: str
|
|
53
|
+
kind: str = "function"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def qualified_name(self) -> str:
|
|
57
|
+
return f"{self.schema}.{self.name}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class EnumDef:
|
|
62
|
+
schema: str
|
|
63
|
+
name: str
|
|
64
|
+
values: list[str]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def qualified_name(self) -> str:
|
|
68
|
+
return f"{self.schema}.{self.name}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class CompositeTypeDef:
|
|
73
|
+
schema: str
|
|
74
|
+
name: str
|
|
75
|
+
fields: list[tuple[str, str]]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def qualified_name(self) -> str:
|
|
79
|
+
return f"{self.schema}.{self.name}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class IndexDef:
|
|
84
|
+
schema: str
|
|
85
|
+
table: str
|
|
86
|
+
name: str
|
|
87
|
+
definition: str
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def qualified_name(self) -> str:
|
|
91
|
+
return self.name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class DatabaseSchema:
|
|
96
|
+
schemas: set[str] = field(default_factory=set)
|
|
97
|
+
tables: dict[str, TableDef] = field(default_factory=dict)
|
|
98
|
+
views: dict[str, ViewDef] = field(default_factory=dict)
|
|
99
|
+
functions: dict[str, FunctionDef] = field(default_factory=dict)
|
|
100
|
+
enums: dict[str, EnumDef] = field(default_factory=dict)
|
|
101
|
+
composites: dict[str, CompositeTypeDef] = field(default_factory=dict)
|
|
102
|
+
indexes: dict[str, IndexDef] = field(default_factory=dict)
|