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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_INVALID_CHARS = re.compile(r"[^a-z0-9_]+")
|
|
9
|
+
_MAX_SLUG_LEN = 30
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def slugify(value: str) -> str:
|
|
13
|
+
"""Lowercase, replace invalid chars with '_', truncate+hash if too long."""
|
|
14
|
+
slug = _INVALID_CHARS.sub("_", value.lower()).strip("_")
|
|
15
|
+
if not slug:
|
|
16
|
+
slug = "x"
|
|
17
|
+
if len(slug) <= _MAX_SLUG_LEN:
|
|
18
|
+
return slug
|
|
19
|
+
digest = hashlib.sha256(slug.encode()).hexdigest()[:8]
|
|
20
|
+
return f"{slug[:_MAX_SLUG_LEN]}_{digest}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def current_branch(cwd: Path | None = None) -> str:
|
|
24
|
+
"""Return the branch checked out in the git worktree rooted at cwd."""
|
|
25
|
+
result = subprocess.run(
|
|
26
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
27
|
+
cwd=cwd,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
text=True,
|
|
30
|
+
check=True,
|
|
31
|
+
)
|
|
32
|
+
return result.stdout.strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def workspace_db_name(project_name: str, branch: str) -> str:
|
|
36
|
+
"""Compute a Postgres-safe, collision-resistant database name for this
|
|
37
|
+
project+branch. A second slugify pass over the joined string guarantees
|
|
38
|
+
the result stays under Postgres's 63-byte identifier limit even when
|
|
39
|
+
both inputs are already at the per-component truncation limit."""
|
|
40
|
+
joined = f"{slugify(project_name)}_{slugify(branch)}"
|
|
41
|
+
return slugify(joined)
|
pgdevkit/testdb/query.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import LiteralString, cast
|
|
5
|
+
|
|
6
|
+
import psycopg
|
|
7
|
+
from psycopg.rows import dict_row
|
|
8
|
+
|
|
9
|
+
_DOLLAR_TAG = re.compile(r"\$[A-Za-z_]*\$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _split_statements(sql: str) -> list[str]:
|
|
13
|
+
"""Split on ';', but treat single/double-quoted strings and dollar-quoted
|
|
14
|
+
bodies (e.g. a plpgsql function's $$ ... $$) as opaque so an embedded ';'
|
|
15
|
+
inside them doesn't split the statement in two."""
|
|
16
|
+
statements: list[str] = []
|
|
17
|
+
buf: list[str] = []
|
|
18
|
+
i, n = 0, len(sql)
|
|
19
|
+
while i < n:
|
|
20
|
+
ch = sql[i]
|
|
21
|
+
if ch == "$" and (m := _DOLLAR_TAG.match(sql, i)):
|
|
22
|
+
tag = m.group()
|
|
23
|
+
end = sql.find(tag, m.end())
|
|
24
|
+
end = n if end == -1 else end + len(tag)
|
|
25
|
+
buf.append(sql[i:end])
|
|
26
|
+
i = end
|
|
27
|
+
continue
|
|
28
|
+
if ch in ("'", '"'):
|
|
29
|
+
end = i + 1
|
|
30
|
+
while end < n:
|
|
31
|
+
if sql[end] == ch:
|
|
32
|
+
end += 1
|
|
33
|
+
if sql[end : end + 1] == ch: # doubled quote = escaped literal quote
|
|
34
|
+
end += 1
|
|
35
|
+
continue
|
|
36
|
+
break
|
|
37
|
+
end += 1
|
|
38
|
+
buf.append(sql[i:end])
|
|
39
|
+
i = end
|
|
40
|
+
continue
|
|
41
|
+
if ch == ";":
|
|
42
|
+
statements.append("".join(buf))
|
|
43
|
+
buf = []
|
|
44
|
+
i += 1
|
|
45
|
+
continue
|
|
46
|
+
buf.append(ch)
|
|
47
|
+
i += 1
|
|
48
|
+
statements.append("".join(buf))
|
|
49
|
+
return [s.strip() for s in statements if s.strip()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def execute(dsn: str, sql: str) -> list[dict] | None:
|
|
53
|
+
"""Run one or more statements against dsn. Returns the rows of the final
|
|
54
|
+
statement if it produced any, else None."""
|
|
55
|
+
statements = _split_statements(sql)
|
|
56
|
+
last_rows: list[dict] | None = None
|
|
57
|
+
async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as con:
|
|
58
|
+
for stmt in statements:
|
|
59
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
60
|
+
# stmt is arbitrary, caller-provided SQL text (a .sql file or
|
|
61
|
+
# --sql argument) — not a compile-time literal, but this
|
|
62
|
+
# function's entire purpose is to run it as-is.
|
|
63
|
+
await cur.execute(cast(LiteralString, stmt))
|
|
64
|
+
last_rows = await cur.fetchall() if cur.description else None
|
|
65
|
+
return last_rows
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
import sqlglot
|
|
12
|
+
import sqlglot.expressions as exp
|
|
13
|
+
from psycopg.rows import dict_row
|
|
14
|
+
from psycopg.sql import SQL, Identifier, Placeholder
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
logging.getLogger("sqlglot").setLevel(logging.ERROR)
|
|
18
|
+
|
|
19
|
+
# The `database/` folder convention (layer dirs, object-type subfolders and
|
|
20
|
+
# their apply order, file-naming rules) is documented in
|
|
21
|
+
# docs/database-layout.md — keep that table in sync with this dict.
|
|
22
|
+
_TYPE_ORDER = {
|
|
23
|
+
"schema": 1,
|
|
24
|
+
"types": 2,
|
|
25
|
+
"tables": 3,
|
|
26
|
+
"scalar_functions": 4,
|
|
27
|
+
"functions": 5,
|
|
28
|
+
"views": 6,
|
|
29
|
+
"table_functions": 7,
|
|
30
|
+
"procedures": 8,
|
|
31
|
+
"permissions": 100,
|
|
32
|
+
"indexes": 101,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_type_order(path: Path) -> int:
|
|
37
|
+
filename = re.sub(r"^\d+(\.\d+)?", "", path.name).removeprefix("_").removesuffix(".sql")
|
|
38
|
+
if filename in _TYPE_ORDER:
|
|
39
|
+
return _TYPE_ORDER[filename]
|
|
40
|
+
if path.parent.name in _TYPE_ORDER:
|
|
41
|
+
return _TYPE_ORDER[path.parent.name]
|
|
42
|
+
raise ValueError(f"Unknown SQL type for {path.name} in {path.parent.name}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _strip_layer_prefix(schema_name: str) -> str:
|
|
46
|
+
"""Strip a leading numeric layer prefix (e.g. "1_dim" -> "dim") so it
|
|
47
|
+
matches the unprefixed schema name used in SQL identifiers."""
|
|
48
|
+
if re.match(r"^\d+_", schema_name):
|
|
49
|
+
return schema_name.split("_", 1)[1]
|
|
50
|
+
return schema_name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SCHEMA_QUALIFIED_TYPES = {
|
|
54
|
+
"types",
|
|
55
|
+
"tables",
|
|
56
|
+
"scalar_functions",
|
|
57
|
+
"functions",
|
|
58
|
+
"views",
|
|
59
|
+
"table_functions",
|
|
60
|
+
"procedures",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_sql_deps(sql: str) -> set[str]:
|
|
65
|
+
exprs = sqlglot.parse(sql, dialect="postgres")
|
|
66
|
+
deps: set[str] = set()
|
|
67
|
+
for e in exprs:
|
|
68
|
+
if e is None:
|
|
69
|
+
continue
|
|
70
|
+
for t in e.find_all(exp.Table):
|
|
71
|
+
if t.args.get("this") is not None and t.args.get("db") is not None:
|
|
72
|
+
deps.add(str(t))
|
|
73
|
+
return deps
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _iter_sql_files(database_dir: Path):
|
|
77
|
+
"""Yield (Path, sql_content) pairs in dependency-safe execution order."""
|
|
78
|
+
files: list[Path] = []
|
|
79
|
+
for root, _, dbfiles in os.walk(database_dir):
|
|
80
|
+
if "_migration_scripts" in root or "migrations" in root:
|
|
81
|
+
continue
|
|
82
|
+
for file in dbfiles:
|
|
83
|
+
if file in ("all.sql", "100_permissions.sql"):
|
|
84
|
+
continue
|
|
85
|
+
if file.endswith(".sql") and ".prod" not in file:
|
|
86
|
+
files.append(Path(root) / file)
|
|
87
|
+
|
|
88
|
+
delivered: set[str] = set()
|
|
89
|
+
delayed: list[tuple[str | None, Path, str]] = []
|
|
90
|
+
all_declared: set[str] = set()
|
|
91
|
+
|
|
92
|
+
for file in sorted(files, key=lambda p: (_get_type_order(p), p.name)):
|
|
93
|
+
content = file.read_text(encoding="utf-8")
|
|
94
|
+
deps = _get_sql_deps(content)
|
|
95
|
+
if file.parent.name in _SCHEMA_QUALIFIED_TYPES:
|
|
96
|
+
schema = _strip_layer_prefix(file.parent.parent.name)
|
|
97
|
+
full_name = f"{schema}.{file.stem}"
|
|
98
|
+
deps.discard(full_name) # the file's own CREATE target is not a real dependency
|
|
99
|
+
all_declared.add(full_name)
|
|
100
|
+
if not deps or all(d in delivered for d in deps):
|
|
101
|
+
delivered.add(full_name)
|
|
102
|
+
yield file, content
|
|
103
|
+
else:
|
|
104
|
+
delayed.append((full_name, file, content))
|
|
105
|
+
continue
|
|
106
|
+
if not deps or all(d in delivered for d in deps):
|
|
107
|
+
yield file, content
|
|
108
|
+
else:
|
|
109
|
+
delayed.append((None, file, content))
|
|
110
|
+
|
|
111
|
+
while delayed:
|
|
112
|
+
progressed = False
|
|
113
|
+
for i in range(len(delayed) - 1, -1, -1):
|
|
114
|
+
declared_name, file, content = delayed[i]
|
|
115
|
+
deps = _get_sql_deps(content)
|
|
116
|
+
if declared_name:
|
|
117
|
+
deps.discard(declared_name)
|
|
118
|
+
if all(d in delivered or d not in all_declared for d in deps):
|
|
119
|
+
if declared_name:
|
|
120
|
+
delivered.add(declared_name)
|
|
121
|
+
yield file, content
|
|
122
|
+
delayed.pop(i)
|
|
123
|
+
progressed = True
|
|
124
|
+
if not progressed:
|
|
125
|
+
raise ValueError(f"Circular or missing SQL dependencies: {[f[1] for f in delayed]}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _insert_test_data(
|
|
129
|
+
json_file: Path, table: str, force_reset: bool, con: psycopg.AsyncConnection
|
|
130
|
+
) -> None:
|
|
131
|
+
if not json_file.exists():
|
|
132
|
+
return
|
|
133
|
+
rows: list[dict[str, Any]] = json.loads(json_file.read_text(encoding="utf-8"))
|
|
134
|
+
if not rows:
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
schema, table_name = table.split(".")
|
|
138
|
+
async with con.cursor(row_factory=dict_row) as cur:
|
|
139
|
+
if not force_reset:
|
|
140
|
+
await cur.execute(SQL("SELECT count(*) AS cnt FROM {t}").format(t=Identifier(schema, table_name)))
|
|
141
|
+
row = await cur.fetchone()
|
|
142
|
+
if row and row["cnt"] == len(rows):
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
col_names = list(rows[0].keys())
|
|
146
|
+
for row in rows:
|
|
147
|
+
for col in col_names:
|
|
148
|
+
if isinstance(row[col], (dict, list)):
|
|
149
|
+
row[col] = json.dumps(row[col])
|
|
150
|
+
|
|
151
|
+
await cur.execute(SQL("DELETE FROM {t}").format(t=Identifier(schema, table_name)))
|
|
152
|
+
insert_sql = SQL("INSERT INTO {t} ({cols}) VALUES ({vals})").format(
|
|
153
|
+
t=Identifier(schema, table_name),
|
|
154
|
+
cols=SQL(", ").join(Identifier(c) for c in col_names),
|
|
155
|
+
vals=SQL(", ").join(Placeholder(c) for c in col_names),
|
|
156
|
+
)
|
|
157
|
+
await cur.executemany(insert_sql, rows)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def apply_schema(
|
|
161
|
+
con: psycopg.AsyncConnection,
|
|
162
|
+
database_dir: Path,
|
|
163
|
+
extensions: tuple[str, ...] = (),
|
|
164
|
+
force_reset: bool = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Apply every .sql file under database_dir (in dependency-safe order)
|
|
167
|
+
and seed any matching .test_data.json files. Safe to call repeatedly."""
|
|
168
|
+
await con.set_autocommit(True)
|
|
169
|
+
for extension in extensions:
|
|
170
|
+
await con.execute(SQL("CREATE EXTENSION IF NOT EXISTS {e}").format(e=Identifier(extension)))
|
|
171
|
+
|
|
172
|
+
async def _apply(file: Path, sql: str) -> None:
|
|
173
|
+
await con.execute(cast(Any, sql))
|
|
174
|
+
json_file = file.with_suffix(".test_data.json")
|
|
175
|
+
if json_file.exists():
|
|
176
|
+
schema_name = _strip_layer_prefix(file.parent.parent.name)
|
|
177
|
+
await _insert_test_data(json_file, f"{schema_name}.{file.stem}", force_reset, con)
|
|
178
|
+
|
|
179
|
+
failures: list[tuple[Path, str]] = []
|
|
180
|
+
for file, sql in _iter_sql_files(database_dir):
|
|
181
|
+
try:
|
|
182
|
+
await _apply(file, sql)
|
|
183
|
+
except Exception as e: # noqa: BLE001
|
|
184
|
+
logger.warning("Error executing %s (will retry): %s", file, e)
|
|
185
|
+
failures.append((file, sql))
|
|
186
|
+
|
|
187
|
+
for file, sql in failures:
|
|
188
|
+
await _apply(file, sql)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgdevkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A helper for developing with Postgres
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: psycopg[binary]>=3.2.0
|
|
7
|
+
Requires-Dist: sqlglot[c]>=30.11.0
|
|
8
|
+
Provides-Extra: azure
|
|
9
|
+
Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
|
|
10
|
+
Provides-Extra: cli
|
|
11
|
+
Requires-Dist: rich>=13.0.0; extra == 'cli'
|
|
12
|
+
Requires-Dist: typer>=0.26.7; extra == 'cli'
|
|
13
|
+
Provides-Extra: db
|
|
14
|
+
Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
|
|
15
|
+
Requires-Dist: pydantic>=2.0; extra == 'db'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# pgdevkit
|
|
19
|
+
|
|
20
|
+
A helper for developing with Postgres.
|
|
21
|
+
|
|
22
|
+
## `pgdb compare`
|
|
23
|
+
|
|
24
|
+
Compare a directory of SQL scripts (see the `database-in-source` layout
|
|
25
|
+
convention) against a live database and report differences:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pgdb compare --url postgresql://user:pass@host:port/db path/to/database/
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Entra ID auth (Azure Postgres / Databricks Lakebase)
|
|
32
|
+
|
|
33
|
+
Pass `--entra-user <identity>` to `pgdb compare` to authenticate with an
|
|
34
|
+
Entra ID token instead of a static password. Which token flow is used is
|
|
35
|
+
auto-detected from the database hostname:
|
|
36
|
+
|
|
37
|
+
- **Azure Database for PostgreSQL** (`*.postgres.database.azure.com`,
|
|
38
|
+
`*.postgres.cosmos.azure.com`) — the default: fetches a token via
|
|
39
|
+
`DefaultAzureCredential` and uses it directly as the password. Requires
|
|
40
|
+
the `azure` extra: `pip install pgdevkit[azure]`.
|
|
41
|
+
- **Databricks Lakebase** (`*.database.azuredatabricks.net`,
|
|
42
|
+
`*.database.cloud.databricks.com`) — fetches a Databricks-scoped Entra
|
|
43
|
+
token, then exchanges it for a short-lived Postgres credential via the
|
|
44
|
+
Databricks workspace API. Also requires `--databricks-workspace-host`
|
|
45
|
+
and `--databricks-instance`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pgdb compare --url postgresql://instance-abc.database.azuredatabricks.net:5432/databricks_postgres \
|
|
49
|
+
--entra-user alice@example.com \
|
|
50
|
+
--databricks-workspace-host https://adb-123456789.azuredatabricks.net \
|
|
51
|
+
--databricks-instance myinstance \
|
|
52
|
+
path/to/database/
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
(`--url`'s own user/password, if any, are discarded and replaced — `--entra-user`
|
|
56
|
+
plus the fetched token become the connection's actual credentials.)
|
|
57
|
+
|
|
58
|
+
## `pgdb testdb`
|
|
59
|
+
|
|
60
|
+
Manages a single shared, Podman-backed Postgres container for local tests
|
|
61
|
+
across all your projects — no more one-container-per-project-per-worktree.
|
|
62
|
+
Isolation between projects and worktrees is per-database, inside one
|
|
63
|
+
container.
|
|
64
|
+
|
|
65
|
+
Add to `pyproject.toml`:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[tool.pgdevkit]
|
|
69
|
+
name = "myproject" # optional; defaults to the repo directory name
|
|
70
|
+
database_dir = "database" # optional; defaults to "database"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Add to `conftest.py`:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import os
|
|
77
|
+
import pytest
|
|
78
|
+
from pgdevkit.testdb import ensure_testdb
|
|
79
|
+
|
|
80
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
81
|
+
def ensure_test_postgres():
|
|
82
|
+
for k, v in ensure_testdb().items():
|
|
83
|
+
os.environ[k] = v
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
|
|
87
|
+
|
|
88
|
+
Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
|
|
89
|
+
be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
|
|
90
|
+
`PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching
|
|
91
|
+
podman/docker, pgdevkit first checks (with a short timeout) whether Postgres
|
|
92
|
+
is already reachable at that address and skips container management if so.
|
|
93
|
+
Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
|
|
94
|
+
that check too.
|
|
95
|
+
|
|
96
|
+
## `pgdevkit.db` — helpers for application code
|
|
97
|
+
|
|
98
|
+
Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
99
|
+
|
|
100
|
+
- **`PostgresTableModel`** — a `pydantic.BaseModel` base class for models
|
|
101
|
+
that map 1:1 to a table row. Implement `get_table_name()` (returns
|
|
102
|
+
`(schema, table)`) and `get_primary_key()` on each model.
|
|
103
|
+
- **`PgPool`** — an async connection pool keyed off
|
|
104
|
+
`{env_prefix}HOST/PORT/DB/USER/PASSWORD` env vars. Call `await pool.open()`
|
|
105
|
+
once at startup, then use `async with pool.connection() as con:`.
|
|
106
|
+
Pass `entra_user` to authenticate via Entra ID instead of a static
|
|
107
|
+
password — same host-based auto-detection as `pgdb compare`'s
|
|
108
|
+
`--entra-user`. For Lakebase hosts, also set the
|
|
109
|
+
`{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
|
|
110
|
+
env vars.
|
|
111
|
+
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
|
|
112
|
+
`pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
|
|
113
|
+
`pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
|
|
114
|
+
`pg_delete_dict` — typed (`PostgresTableModel`-based) or dict-based CRUD
|
|
115
|
+
against a table, built on `psycopg` for safe identifier/value handling.
|
|
116
|
+
- **`SqlLoader`** — loads and caches `.sql` files from
|
|
117
|
+
`{root}/<topic>/<name>.sql`, for keeping hand-written queries out of
|
|
118
|
+
Python source.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from pgdevkit.db import PgPool, PostgresTableModel, pg_retrieve, pg_upsert
|
|
122
|
+
|
|
123
|
+
class Widget(PostgresTableModel):
|
|
124
|
+
id: int
|
|
125
|
+
name: str
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def get_table_name() -> tuple[str, str]:
|
|
129
|
+
return ("public", "widget")
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def get_primary_key() -> list[str]:
|
|
133
|
+
return ["id"]
|
|
134
|
+
|
|
135
|
+
pool = PgPool(env_prefix="POSTGRES_")
|
|
136
|
+
await pool.open()
|
|
137
|
+
async with pool.connection() as con:
|
|
138
|
+
widget = await pg_retrieve(con, Widget, {"id": 1})
|
|
139
|
+
await pg_upsert(con, Widget(id=1, name="thing"), Widget)
|
|
140
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
pgdevkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pgdevkit/cli.py,sha256=71z8wBVhj51N_ZC34S4NxVCyE58esXdVyarDphI_d4o,8309
|
|
3
|
+
pgdevkit/connection.py,sha256=UaHzF4QaYTf7v5-LWscHJcTV11byfVGGlSdb5S4CvfM,1901
|
|
4
|
+
pgdevkit/diff.py,sha256=oZhkMWWtsI1UAo9eJ7LJ-PbBGab01Rh1iTIA7iaTLXw,9550
|
|
5
|
+
pgdevkit/fetch_missing.py,sha256=1-2NBDCQNtirywO9Ucm775UKSACfAzcnQlp-4Jh2weE,8136
|
|
6
|
+
pgdevkit/introspect.py,sha256=QYIlRuZk4lBHGxaW7zb_kFRIuNfi3AxH0Q2F_GKzlVI,7870
|
|
7
|
+
pgdevkit/lakebase.py,sha256=0PABOk4p-2klLTY8QAUibQf9QeyNHKywUWEWGdkUY8k,3165
|
|
8
|
+
pgdevkit/models.py,sha256=dAZ8f7pgDsxoLVrDWsM4h0qwJ0pOzXDdWMGsnjrW80E,2103
|
|
9
|
+
pgdevkit/parser.py,sha256=B3NRtcSBuEUcQNp5lxEMi6ApVedkbNUNdklCea-Ts40,12021
|
|
10
|
+
pgdevkit/db/__init__.py,sha256=H8muiCuNr_ogJo4tg3iLxxfR2jTckSrxxrVQyTd_3I4,701
|
|
11
|
+
pgdevkit/db/connection.py,sha256=yIj1pBS_vX4CFNZvidGf1AIIKlXblfQKejNYEO5KfVA,2606
|
|
12
|
+
pgdevkit/db/crud.py,sha256=uWVTgHI-JJezXufP-WDHs6Q8LPtSw8YEk7IErVmyDe4,7223
|
|
13
|
+
pgdevkit/db/loader.py,sha256=0Swmp0h0s7I4acG0Dv3cem2YD4lFvq8aFEbFQPPQrxg,616
|
|
14
|
+
pgdevkit/db/model.py,sha256=sAE4KMrDApxCiDvIRz2spa4YNJYq-fxyb5R1Cxt19ro,663
|
|
15
|
+
pgdevkit/testdb/__init__.py,sha256=-APwuluorU3iRCUsiJPK7Bc1AS5_IXxCdKB4zuaQ868,178
|
|
16
|
+
pgdevkit/testdb/api.py,sha256=MEaH9zH542jWSm9w1SseTk8vNIqgI5fwRKd8iVMH3vM,4930
|
|
17
|
+
pgdevkit/testdb/config.py,sha256=v0KFSkWhhZ8vy9167nqHqPsuCjilVrIQ6iqo8m_9-Hk,1545
|
|
18
|
+
pgdevkit/testdb/constants.py,sha256=-pa05wdqXkSszl9xlO2wMLGpkGzGz9vrWFxLgs8Ep3Y,465
|
|
19
|
+
pgdevkit/testdb/container.py,sha256=s3G8v6z_BMQF6SPtixUcbBC0xjMak9TzKt_EJWsUEg8,3162
|
|
20
|
+
pgdevkit/testdb/naming.py,sha256=Kyk5qEfxUkbggUG5bD7U7CJ_kAET332VPQNB-O5kvTY,1318
|
|
21
|
+
pgdevkit/testdb/query.py,sha256=e4UbgdCKKIot3a8Qixg-OX-Af0rlQ4a8ODhJBD8mtVM,2318
|
|
22
|
+
pgdevkit/testdb/schema.py,sha256=WsA3-czg5DhyEbNj-TBTpU9w4MdgwFHb02TowuvYzJo,6572
|
|
23
|
+
pgdevkit/docs/database-layout.md,sha256=dNvDH956xSXuAnLqkOopyOfaIm_A-RtUd57VTaZ1GQU,6035
|
|
24
|
+
pgdevkit/skills/pgdevkit/SKILL.md,sha256=P6CojgqlF3oOEfcETLwN22YvyZCPK4ofPn2DMx70fFE,11324
|
|
25
|
+
pgdevkit/skills/pgdevkit/references/complex_helper.py,sha256=0qQ-35ae2gUGdUXM8vL5mHRkVph-NNOBaJbQQvU8Stk,10062
|
|
26
|
+
pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=aTR1sx81moiTwaMaiOecaqk-9rT8y5Zxz7W1BoC3vK0,2774
|
|
27
|
+
pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
|
|
28
|
+
pgdevkit-0.1.0.dist-info/METADATA,sha256=MhTnLQk9TdbMax7XXtS4njJjq1w4Yps95oIQ-HqcSNk,5128
|
|
29
|
+
pgdevkit-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
30
|
+
pgdevkit-0.1.0.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
|
|
31
|
+
pgdevkit-0.1.0.dist-info/RECORD,,
|