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 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))
@@ -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)."""