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/__init__.py
ADDED
|
File without changes
|
pgdevkit/cli.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich import box
|
|
11
|
+
|
|
12
|
+
from . import testdb
|
|
13
|
+
from .connection import build_conninfo
|
|
14
|
+
from .diff import DiffKind, compute_diff
|
|
15
|
+
from .fetch_missing import SUBFOLDER, find_missing_objects, layer_folder_for, reconstruct_ddl
|
|
16
|
+
from .introspect import introspect_db
|
|
17
|
+
from .parser import parse_directory
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(name="pgdb", help="PostgreSQL database schema tools")
|
|
20
|
+
console = Console()
|
|
21
|
+
err_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
|
|
24
|
+
app.add_typer(testdb_app, name="testdb")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def compare(
|
|
29
|
+
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
|
|
30
|
+
entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
|
|
31
|
+
databricks_workspace_host: str | None = typer.Option(
|
|
32
|
+
None,
|
|
33
|
+
"--databricks-workspace-host",
|
|
34
|
+
help="Databricks workspace URL, e.g. https://adb-....azuredatabricks.net (required for Lakebase hosts)",
|
|
35
|
+
),
|
|
36
|
+
databricks_instance: str | None = typer.Option(
|
|
37
|
+
None, "--databricks-instance", help="Lakebase instance name (required for Lakebase hosts)"
|
|
38
|
+
),
|
|
39
|
+
report_extra_db: bool = typer.Option(False, "--report-extra-db", help="Report objects in DB but not in scripts"),
|
|
40
|
+
scripts_dir: Path = typer.Argument(..., help="Directory containing SQL scripts"),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Compare SQL scripts to a live PostgreSQL database and report differences."""
|
|
43
|
+
if not scripts_dir.is_dir():
|
|
44
|
+
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
45
|
+
raise typer.Exit(2)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
conninfo = build_conninfo(
|
|
49
|
+
url,
|
|
50
|
+
entra_user,
|
|
51
|
+
databricks_workspace_host=databricks_workspace_host,
|
|
52
|
+
databricks_instance=databricks_instance,
|
|
53
|
+
)
|
|
54
|
+
except ValueError as e:
|
|
55
|
+
err_console.print(f"[red]Error:[/red] {e}")
|
|
56
|
+
raise typer.Exit(2)
|
|
57
|
+
|
|
58
|
+
with console.status("Parsing SQL scripts..."):
|
|
59
|
+
scripts_schema = parse_directory(scripts_dir)
|
|
60
|
+
|
|
61
|
+
with console.status("Introspecting database..."):
|
|
62
|
+
db_schema = introspect_db(conninfo)
|
|
63
|
+
|
|
64
|
+
diffs = compute_diff(scripts_schema, db_schema, report_extra_db=report_extra_db)
|
|
65
|
+
|
|
66
|
+
if not diffs:
|
|
67
|
+
console.print("[green]No differences found.[/green]")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
71
|
+
table.add_column("Kind", style="cyan", min_width=20)
|
|
72
|
+
table.add_column("Type", style="magenta", min_width=15)
|
|
73
|
+
table.add_column("Object")
|
|
74
|
+
table.add_column("Detail", style="dim")
|
|
75
|
+
|
|
76
|
+
kind_style = {
|
|
77
|
+
DiffKind.MISSING_IN_DB: "[yellow]missing in DB[/yellow]",
|
|
78
|
+
DiffKind.MISSING_IN_SCRIPTS: "[blue]missing in scripts[/blue]",
|
|
79
|
+
DiffKind.MISMATCH: "[red]mismatch[/red]",
|
|
80
|
+
}
|
|
81
|
+
for d in diffs:
|
|
82
|
+
table.add_row(kind_style[d.kind], d.object_type, d.object_name, d.detail)
|
|
83
|
+
|
|
84
|
+
console.print(table)
|
|
85
|
+
console.print(f"\n[bold red]{len(diffs)} difference(s) found.[/bold red]")
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("fetch-missing")
|
|
90
|
+
def fetch_missing(
|
|
91
|
+
scripts_dir: Path = typer.Argument(..., help="The database/ folder to compare against and write into"),
|
|
92
|
+
url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
|
|
93
|
+
entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
|
|
94
|
+
write: bool = typer.Option(False, "--write", help="Write the reconstructed .sql files (default: dry run)"),
|
|
95
|
+
only: list[str] = typer.Option([], "--only", help="Only fetch schema.name (repeatable); default is everything"),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Find tables/views/functions that exist in the database but aren't
|
|
98
|
+
tracked under scripts_dir, and reverse-engineer their DDL into new files."""
|
|
99
|
+
if not scripts_dir.is_dir():
|
|
100
|
+
err_console.print(f"[red]Error:[/red] {scripts_dir} is not a directory")
|
|
101
|
+
raise typer.Exit(2)
|
|
102
|
+
|
|
103
|
+
conninfo = build_conninfo(url, entra_user)
|
|
104
|
+
|
|
105
|
+
with console.status("Comparing database/ against the live schema..."):
|
|
106
|
+
missing = find_missing_objects(scripts_dir, conninfo)
|
|
107
|
+
|
|
108
|
+
if only:
|
|
109
|
+
wanted = set(only)
|
|
110
|
+
missing = [m for m in missing if m.qualified_name in wanted]
|
|
111
|
+
|
|
112
|
+
if not missing:
|
|
113
|
+
console.print("[green]No missing objects.[/green]")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
117
|
+
table.add_column("Type", style="magenta")
|
|
118
|
+
table.add_column("Object")
|
|
119
|
+
table.add_column("Destination", style="dim")
|
|
120
|
+
for m in missing:
|
|
121
|
+
dest = layer_folder_for(scripts_dir, m.schema) / SUBFOLDER[m.object_type] / f"{m.name}.sql"
|
|
122
|
+
table.add_row(m.object_type, m.qualified_name, str(dest))
|
|
123
|
+
console.print(table)
|
|
124
|
+
|
|
125
|
+
if not write:
|
|
126
|
+
console.print("\n[yellow]Dry run[/yellow] — pass --write to create these files.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
written = 0
|
|
130
|
+
with psycopg.connect(conninfo) as conn:
|
|
131
|
+
for m in missing:
|
|
132
|
+
dest_dir = layer_folder_for(scripts_dir, m.schema) / SUBFOLDER[m.object_type]
|
|
133
|
+
dest = dest_dir / f"{m.name}.sql"
|
|
134
|
+
if dest.exists():
|
|
135
|
+
console.print(f" [yellow]SKIP[/yellow] {dest} (already exists)")
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
ddl = reconstruct_ddl(conn, m)
|
|
139
|
+
except Exception as e: # noqa: BLE001
|
|
140
|
+
err_console.print(f"[red]Error[/red] reconstructing {m.qualified_name}: {e}")
|
|
141
|
+
continue
|
|
142
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
dest.write_text(ddl, encoding="utf-8")
|
|
144
|
+
console.print(f" [green]WROTE[/green] {dest}")
|
|
145
|
+
written += 1
|
|
146
|
+
|
|
147
|
+
console.print(f"\nWrote {written} file(s).")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@testdb_app.command("up")
|
|
151
|
+
def testdb_up() -> None:
|
|
152
|
+
"""Ensure the container is running, the workspace DB exists, and schema is applied."""
|
|
153
|
+
testdb.ensure_testdb()
|
|
154
|
+
info = testdb.status()
|
|
155
|
+
console.print(f"[green]Test DB ready:[/green] {info['database']} ({info['dsn']})")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@testdb_app.command("reset")
|
|
159
|
+
def testdb_reset() -> None:
|
|
160
|
+
"""Drop and recreate only this workspace's database, then reapply schema + seed data."""
|
|
161
|
+
testdb.reset_testdb()
|
|
162
|
+
info = testdb.status()
|
|
163
|
+
console.print(f"[green]Test DB reset:[/green] {info['database']}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@testdb_app.command("run-sql")
|
|
167
|
+
def testdb_run_sql(
|
|
168
|
+
file: Path | None = typer.Argument(None, help="Path to a .sql file"),
|
|
169
|
+
sql: str | None = typer.Option(None, "--sql", help="Inline SQL string"),
|
|
170
|
+
results: bool = typer.Option(False, "--results", help="Print query results as a table"),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Run SQL against this workspace's database."""
|
|
173
|
+
if (file is None) == (sql is None):
|
|
174
|
+
err_console.print("[red]Error:[/red] pass exactly one of FILE or --sql")
|
|
175
|
+
raise typer.Exit(2)
|
|
176
|
+
statement = file.read_text(encoding="utf-8") if file else sql
|
|
177
|
+
assert statement is not None
|
|
178
|
+
rows = testdb.run_sql(statement)
|
|
179
|
+
|
|
180
|
+
if rows is None:
|
|
181
|
+
console.print("OK")
|
|
182
|
+
return
|
|
183
|
+
if not results:
|
|
184
|
+
console.print(f"OK — {len(rows)} row(s)")
|
|
185
|
+
return
|
|
186
|
+
if not rows:
|
|
187
|
+
console.print("(0 row(s))")
|
|
188
|
+
return
|
|
189
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
|
|
190
|
+
for col in rows[0]:
|
|
191
|
+
table.add_column(col)
|
|
192
|
+
for row in rows:
|
|
193
|
+
table.add_row(*(str(v) for v in row.values()))
|
|
194
|
+
console.print(table)
|
|
195
|
+
console.print(f"({len(rows)} row(s))")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@testdb_app.command("status")
|
|
199
|
+
def testdb_status() -> None:
|
|
200
|
+
"""Show container state, this workspace's database name, and DSN."""
|
|
201
|
+
for key, value in testdb.status().items():
|
|
202
|
+
console.print(f"{key}: {value}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@testdb_app.command("shell")
|
|
206
|
+
def testdb_shell() -> None:
|
|
207
|
+
"""Drop into psql against this workspace's database."""
|
|
208
|
+
os.execvp("psql", ["psql", testdb.dsn_for()])
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@testdb_app.command("clean")
|
|
212
|
+
def testdb_clean(
|
|
213
|
+
all: bool = typer.Option(False, "--all", help="Drop every database belonging to this project"),
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Drop this workspace's database (or every database of this project with --all)."""
|
|
216
|
+
testdb.clean_testdb(all=all)
|
|
217
|
+
console.print("[green]Cleaned.[/green]")
|
pgdevkit/connection.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
|
|
11
|
+
|
|
12
|
+
def detect_provider(host: str) -> Literal["azure_postgres", "databricks_lakebase"]:
|
|
13
|
+
"""Classify a Postgres hostname: Databricks Lakebase (needs credential
|
|
14
|
+
exchange) or the default Azure Postgres AAD token flow."""
|
|
15
|
+
if any(host.endswith(suffix) for suffix in _LAKEBASE_HOST_SUFFIXES):
|
|
16
|
+
return "databricks_lakebase"
|
|
17
|
+
return "azure_postgres"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_azure_postgres_password() -> str:
|
|
21
|
+
try:
|
|
22
|
+
from azure.identity import DefaultAzureCredential
|
|
23
|
+
except ImportError:
|
|
24
|
+
raise ImportError("Install azure-identity extra: pip install pgdevkit[azure]")
|
|
25
|
+
cred = DefaultAzureCredential()
|
|
26
|
+
token = cred.get_token("https://ossrdbms-aad.database.windows.net/.default")
|
|
27
|
+
return token.token
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_conninfo(
|
|
31
|
+
url: str,
|
|
32
|
+
entra_user: str | None = None,
|
|
33
|
+
*,
|
|
34
|
+
databricks_workspace_host: str | None = None,
|
|
35
|
+
databricks_instance: str | None = None,
|
|
36
|
+
) -> str:
|
|
37
|
+
if entra_user is None:
|
|
38
|
+
return url
|
|
39
|
+
|
|
40
|
+
parsed = urlparse(url)
|
|
41
|
+
host = parsed.hostname or ""
|
|
42
|
+
port = f":{parsed.port}" if parsed.port else ""
|
|
43
|
+
|
|
44
|
+
if detect_provider(host) == "databricks_lakebase":
|
|
45
|
+
if not databricks_workspace_host or not databricks_instance:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
"Lakebase host detected — pass --databricks-workspace-host and --databricks-instance"
|
|
48
|
+
)
|
|
49
|
+
from .lakebase import get_lakebase_password
|
|
50
|
+
|
|
51
|
+
password = get_lakebase_password(databricks_workspace_host, databricks_instance)
|
|
52
|
+
else:
|
|
53
|
+
password = get_azure_postgres_password()
|
|
54
|
+
|
|
55
|
+
netloc = f"{quote(entra_user, safe='')}:{quote(password, safe='')}@{host}{port}"
|
|
56
|
+
return urlunparse(parsed._replace(netloc=netloc))
|
pgdevkit/db/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .connection import PgPool
|
|
4
|
+
from .crud import (
|
|
5
|
+
pg_delete,
|
|
6
|
+
pg_delete_dict,
|
|
7
|
+
pg_insert,
|
|
8
|
+
pg_insert_many,
|
|
9
|
+
pg_retrieve,
|
|
10
|
+
pg_retrieve_many,
|
|
11
|
+
pg_update,
|
|
12
|
+
pg_update_dict,
|
|
13
|
+
pg_upsert,
|
|
14
|
+
pg_upsert_dict,
|
|
15
|
+
pg_upsert_many,
|
|
16
|
+
pg_upsert_many_dict,
|
|
17
|
+
)
|
|
18
|
+
from .loader import SqlLoader
|
|
19
|
+
from .model import PostgresTableModel
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"PgPool",
|
|
23
|
+
"PostgresTableModel",
|
|
24
|
+
"SqlLoader",
|
|
25
|
+
"pg_delete",
|
|
26
|
+
"pg_delete_dict",
|
|
27
|
+
"pg_insert",
|
|
28
|
+
"pg_insert_many",
|
|
29
|
+
"pg_retrieve",
|
|
30
|
+
"pg_retrieve_many",
|
|
31
|
+
"pg_update",
|
|
32
|
+
"pg_update_dict",
|
|
33
|
+
"pg_upsert",
|
|
34
|
+
"pg_upsert_dict",
|
|
35
|
+
"pg_upsert_many",
|
|
36
|
+
"pg_upsert_many_dict",
|
|
37
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from psycopg_pool import AsyncConnectionPool
|
|
7
|
+
|
|
8
|
+
from ..connection import detect_provider, get_azure_postgres_password
|
|
9
|
+
from ..lakebase import get_lakebase_password
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PgPool:
|
|
13
|
+
"""A connection pool keyed off `{env_prefix}HOST/PORT/DB/USER/PASSWORD`
|
|
14
|
+
environment variables (e.g. env_prefix="MDM_POSTGRES_").
|
|
15
|
+
|
|
16
|
+
Pass `entra_user` to authenticate via Entra ID instead of a static
|
|
17
|
+
password. The Postgres host is inspected to pick the Azure Postgres AAD
|
|
18
|
+
token flow or Databricks Lakebase credential exchange; for the latter
|
|
19
|
+
also set `{env_prefix}DATABRICKS_WORKSPACE_HOST` and
|
|
20
|
+
`{env_prefix}DATABRICKS_INSTANCE`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
env_prefix: str = "POSTGRES_",
|
|
26
|
+
max_size: int = 40,
|
|
27
|
+
*,
|
|
28
|
+
entra_user: str | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._env_prefix = env_prefix
|
|
31
|
+
self._max_size = max_size
|
|
32
|
+
self._entra_user = entra_user
|
|
33
|
+
self._pool: AsyncConnectionPool | None = None
|
|
34
|
+
|
|
35
|
+
async def _dsn(self) -> str:
|
|
36
|
+
p = self._env_prefix
|
|
37
|
+
host = os.environ[p + "HOST"]
|
|
38
|
+
port = os.environ[p + "PORT"]
|
|
39
|
+
dbname = os.environ[p + "DB"]
|
|
40
|
+
|
|
41
|
+
if self._entra_user is None:
|
|
42
|
+
user = os.environ[p + "USER"]
|
|
43
|
+
password = os.environ[p + "PASSWORD"]
|
|
44
|
+
else:
|
|
45
|
+
user = self._entra_user
|
|
46
|
+
if detect_provider(host) == "databricks_lakebase":
|
|
47
|
+
workspace_host = os.environ[p + "DATABRICKS_WORKSPACE_HOST"]
|
|
48
|
+
instance_name = os.environ[p + "DATABRICKS_INSTANCE"]
|
|
49
|
+
password = await asyncio.to_thread(get_lakebase_password, workspace_host, instance_name)
|
|
50
|
+
else:
|
|
51
|
+
password = await asyncio.to_thread(get_azure_postgres_password)
|
|
52
|
+
|
|
53
|
+
return f"host={host} port={port} dbname={dbname} user={user} password={password}"
|
|
54
|
+
|
|
55
|
+
async def open(self) -> None:
|
|
56
|
+
if self._pool is None:
|
|
57
|
+
self._pool = AsyncConnectionPool(
|
|
58
|
+
conninfo=self._dsn,
|
|
59
|
+
open=False,
|
|
60
|
+
max_size=self._max_size,
|
|
61
|
+
check=AsyncConnectionPool.check_connection,
|
|
62
|
+
)
|
|
63
|
+
if not self._pool._opened:
|
|
64
|
+
await self._pool.open()
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
if self._pool is not None:
|
|
68
|
+
await self._pool.close()
|
|
69
|
+
|
|
70
|
+
def connection(self):
|
|
71
|
+
"""Return an async connection context manager from the pool."""
|
|
72
|
+
if self._pool is None:
|
|
73
|
+
raise RuntimeError("Call open() first (e.g. in app startup).")
|
|
74
|
+
return self._pool.connection()
|
pgdevkit/db/crud.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable, Mapping, Optional, Sequence, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
from psycopg.connection_async import AsyncConnection
|
|
6
|
+
from psycopg.rows import dict_row
|
|
7
|
+
from psycopg.sql import SQL, Identifier, Placeholder
|
|
8
|
+
|
|
9
|
+
from .model import PostgresTableModel
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T", bound=PostgresTableModel)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def pg_retrieve(con: AsyncConnection, data_type: Type[T], pks: dict) -> T | None:
|
|
15
|
+
"""Fetch a single row by primary key(s)."""
|
|
16
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
17
|
+
schema, table = data_type.get_table_name()
|
|
18
|
+
query = SQL("SELECT * FROM {tbl} WHERE {where}").format(
|
|
19
|
+
tbl=Identifier(schema, table),
|
|
20
|
+
where=SQL(" AND ").join(SQL("{col} = {val}").format(col=Identifier(pk), val=Placeholder(pk)) for pk in pks),
|
|
21
|
+
)
|
|
22
|
+
await cur.execute(query, pks)
|
|
23
|
+
row = await cur.fetchone()
|
|
24
|
+
return data_type(**row) if row else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def pg_retrieve_many(
|
|
28
|
+
con: AsyncConnection,
|
|
29
|
+
data_type: Type[T],
|
|
30
|
+
filters: dict,
|
|
31
|
+
*,
|
|
32
|
+
from_dict: Optional[Callable[[Mapping], T]] = None,
|
|
33
|
+
) -> Sequence[T]:
|
|
34
|
+
"""Fetch multiple rows matching all filter key=value pairs."""
|
|
35
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
36
|
+
schema, table = data_type.get_table_name()
|
|
37
|
+
if filters:
|
|
38
|
+
query = SQL("SELECT * FROM {tbl} WHERE {where}").format(
|
|
39
|
+
tbl=Identifier(schema, table),
|
|
40
|
+
where=SQL(" AND ").join(
|
|
41
|
+
SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in filters
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
query = SQL("SELECT * FROM {tbl}").format(tbl=Identifier(schema, table))
|
|
46
|
+
await cur.execute(query, filters)
|
|
47
|
+
rows = await cur.fetchall()
|
|
48
|
+
fn = from_dict or (lambda d: data_type(**d))
|
|
49
|
+
return [fn(r) for r in rows]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def pg_insert(con: AsyncConnection, table_name: tuple[str, str], data: dict) -> dict[str, Any]:
|
|
53
|
+
"""Insert one row and return the full row (RETURNING *)."""
|
|
54
|
+
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals}) RETURNING *").format(
|
|
55
|
+
tbl=Identifier(*table_name),
|
|
56
|
+
cols=SQL(", ").join(Identifier(k) for k in data),
|
|
57
|
+
vals=SQL(", ").join(Placeholder(k) for k in data),
|
|
58
|
+
)
|
|
59
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
60
|
+
await cur.execute(query, data)
|
|
61
|
+
row = await cur.fetchone()
|
|
62
|
+
assert row is not None
|
|
63
|
+
return row
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def pg_update_dict(
|
|
67
|
+
con: AsyncConnection,
|
|
68
|
+
table_name: tuple[str, str],
|
|
69
|
+
data: dict,
|
|
70
|
+
primary_keys: Sequence[str],
|
|
71
|
+
) -> Any | None:
|
|
72
|
+
"""Update a row identified by primary_keys. Returns the raw row tuple."""
|
|
73
|
+
set_parts = [
|
|
74
|
+
SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in data if k not in primary_keys
|
|
75
|
+
]
|
|
76
|
+
where_parts = [SQL("{col} = {val}").format(col=Identifier(pk), val=Placeholder(pk)) for pk in primary_keys]
|
|
77
|
+
query = SQL("UPDATE {tbl} SET {sets} WHERE {where} RETURNING *").format(
|
|
78
|
+
tbl=Identifier(*table_name),
|
|
79
|
+
sets=SQL(", ").join(set_parts),
|
|
80
|
+
where=SQL(" AND ").join(where_parts),
|
|
81
|
+
)
|
|
82
|
+
async with con.cursor() as cur:
|
|
83
|
+
await cur.execute(query, data)
|
|
84
|
+
return await cur.fetchone()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def pg_update(con: AsyncConnection, data: T, data_type: type[T]) -> Any | None:
|
|
88
|
+
"""Update a typed model instance."""
|
|
89
|
+
return await pg_update_dict(con, data_type.get_table_name(), data.model_dump(), data_type.get_primary_key())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def pg_upsert_dict(
|
|
93
|
+
con: AsyncConnection,
|
|
94
|
+
table_name: tuple[str, str],
|
|
95
|
+
data: dict,
|
|
96
|
+
primary_keys: Sequence[str],
|
|
97
|
+
) -> dict:
|
|
98
|
+
"""INSERT ... ON CONFLICT ... DO UPDATE, returns the row as a dict."""
|
|
99
|
+
fields = list(data)
|
|
100
|
+
updates = [SQL("{col} = EXCLUDED.{col}").format(col=Identifier(k)) for k in fields]
|
|
101
|
+
query = SQL(
|
|
102
|
+
"INSERT INTO {tbl} ({cols}) VALUES ({vals}) ON CONFLICT ({pks}) DO UPDATE SET {updates} RETURNING *"
|
|
103
|
+
).format(
|
|
104
|
+
tbl=Identifier(*table_name),
|
|
105
|
+
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
106
|
+
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
107
|
+
pks=SQL(", ").join(Identifier(pk) for pk in primary_keys),
|
|
108
|
+
updates=SQL(", ").join(updates),
|
|
109
|
+
)
|
|
110
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
111
|
+
await cur.execute(query, data)
|
|
112
|
+
row = await cur.fetchone()
|
|
113
|
+
assert row is not None
|
|
114
|
+
return row
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def pg_upsert(con: AsyncConnection, data: T, data_type: type[T]) -> dict:
|
|
118
|
+
"""Upsert a typed model instance."""
|
|
119
|
+
return await pg_upsert_dict(con, data_type.get_table_name(), data.model_dump(), data_type.get_primary_key())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def pg_upsert_many_dict(
|
|
123
|
+
con: AsyncConnection,
|
|
124
|
+
table_name: tuple[str, str],
|
|
125
|
+
data: Sequence[dict],
|
|
126
|
+
primary_keys: Sequence[str],
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Batch upsert — one round-trip via executemany."""
|
|
129
|
+
if not data:
|
|
130
|
+
return
|
|
131
|
+
fields = list(data[0])
|
|
132
|
+
updates = [SQL("{col} = EXCLUDED.{col}").format(col=Identifier(k)) for k in fields if k not in primary_keys]
|
|
133
|
+
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals}) ON CONFLICT ({pks}) DO UPDATE SET {updates}").format(
|
|
134
|
+
tbl=Identifier(*table_name),
|
|
135
|
+
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
136
|
+
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
137
|
+
pks=SQL(", ").join(Identifier(pk) for pk in primary_keys),
|
|
138
|
+
updates=SQL(", ").join(updates),
|
|
139
|
+
)
|
|
140
|
+
async with con.cursor() as cur:
|
|
141
|
+
await cur.executemany(query, data)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def pg_upsert_many(con: AsyncConnection, data: Sequence[T], data_type: type[T]) -> None:
|
|
145
|
+
await pg_upsert_many_dict(
|
|
146
|
+
con, data_type.get_table_name(), [d.model_dump() for d in data], data_type.get_primary_key()
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def pg_insert_many(
|
|
151
|
+
con: AsyncConnection,
|
|
152
|
+
table_name: tuple[str, str],
|
|
153
|
+
data: Sequence[dict],
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Batch insert — no RETURNING, one round-trip via executemany."""
|
|
156
|
+
if not data:
|
|
157
|
+
return
|
|
158
|
+
fields = list(data[0])
|
|
159
|
+
query = SQL("INSERT INTO {tbl} ({cols}) VALUES ({vals})").format(
|
|
160
|
+
tbl=Identifier(*table_name),
|
|
161
|
+
cols=SQL(", ").join(Identifier(k) for k in fields),
|
|
162
|
+
vals=SQL(", ").join(Placeholder(k) for k in fields),
|
|
163
|
+
)
|
|
164
|
+
async with con.cursor() as cur:
|
|
165
|
+
await cur.executemany(query, data)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def pg_delete_dict(con: AsyncConnection, table_name: tuple[str, str], data: dict) -> dict | None:
|
|
169
|
+
"""Delete by arbitrary key dict, returns the deleted row."""
|
|
170
|
+
where_parts = [SQL("{col} = {val}").format(col=Identifier(k), val=Placeholder(k)) for k in data]
|
|
171
|
+
query = SQL("DELETE FROM {tbl} WHERE {where} RETURNING *").format(
|
|
172
|
+
tbl=Identifier(*table_name),
|
|
173
|
+
where=SQL(" AND ").join(where_parts),
|
|
174
|
+
)
|
|
175
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
176
|
+
await cur.execute(query, data)
|
|
177
|
+
return await cur.fetchone()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def pg_delete(con: AsyncConnection, data: T, data_type: type[T]) -> T | None:
|
|
181
|
+
"""Delete a typed model instance by its primary key(s)."""
|
|
182
|
+
pk_dict = {pk: getattr(data, pk) for pk in data_type.get_primary_key()}
|
|
183
|
+
row = await pg_delete_dict(con, data_type.get_table_name(), pk_dict)
|
|
184
|
+
return data_type.model_validate(row) if row else None
|
pgdevkit/db/loader.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import LiteralString, cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SqlLoader:
|
|
9
|
+
"""Loads and caches SQL text from `{root}/<topic>/<name>.sql`."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, root: Path) -> None:
|
|
12
|
+
self._root = root
|
|
13
|
+
self._load = lru_cache(maxsize=None)(self._read)
|
|
14
|
+
|
|
15
|
+
def _read(self, topic: str, name: str) -> LiteralString:
|
|
16
|
+
return cast(LiteralString, (self._root / topic / f"{name}.sql").read_text(encoding="utf-8"))
|
|
17
|
+
|
|
18
|
+
def load_sql(self, topic: str, name: str) -> LiteralString:
|
|
19
|
+
return self._load(topic, name)
|
pgdevkit/db/model.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PostgresTableModel(BaseModel, ABC):
|
|
10
|
+
"""Base class for models that map 1:1 to a database table/row.
|
|
11
|
+
|
|
12
|
+
Models representing partial results (joins, aggregations, projections)
|
|
13
|
+
should extend `pydantic.BaseModel` directly instead."""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def get_table_name() -> tuple[str, str]:
|
|
18
|
+
"""Return (schema, table), e.g. ('public', 'users')."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def get_primary_key() -> Sequence[str]:
|
|
23
|
+
"""Return the primary key column name(s)."""
|