pgdevkit 0.13.0__py3-none-any.whl → 0.14.2__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/db/fetch.py +52 -6
- pgdevkit/fastapi/__init__.py +3 -0
- pgdevkit/fastapi/json_response.py +259 -0
- pgdevkit/skills/pgdevkit/SKILL.md +29 -4
- {pgdevkit-0.13.0.dist-info → pgdevkit-0.14.2.dist-info}/METADATA +102 -4
- {pgdevkit-0.13.0.dist-info → pgdevkit-0.14.2.dist-info}/RECORD +8 -6
- {pgdevkit-0.13.0.dist-info → pgdevkit-0.14.2.dist-info}/WHEEL +0 -0
- {pgdevkit-0.13.0.dist-info → pgdevkit-0.14.2.dist-info}/entry_points.txt +0 -0
pgdevkit/db/fetch.py
CHANGED
|
@@ -2,9 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import math
|
|
5
6
|
import re
|
|
6
7
|
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
7
|
-
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
|
8
|
+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, suppress
|
|
8
9
|
from string.templatelib import Template
|
|
9
10
|
from typing import TYPE_CHECKING, Any, LiteralString, Protocol, overload
|
|
10
11
|
|
|
@@ -84,8 +85,37 @@ def _render(query: SqlQuery) -> Any:
|
|
|
84
85
|
return query.sql(dialect="postgres")
|
|
85
86
|
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
def _check_statement_timeout(statement_timeout: float | None) -> None:
|
|
89
|
+
if statement_timeout is not None and statement_timeout <= 0:
|
|
90
|
+
raise ValueError(f"statement_timeout must be a positive number of seconds, got {statement_timeout}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@asynccontextmanager
|
|
94
|
+
async def _statement_timeout(c: AsyncConnection, seconds: float | None) -> AsyncIterator[None]:
|
|
95
|
+
"""Run the block under Postgres' `statement_timeout`, set with `set_config(..., is_local => true)`: it is
|
|
96
|
+
scoped to the current transaction (so it can't leak into a pool, and it is safe behind PgBouncer's
|
|
97
|
+
transaction pooling) and put back afterwards for a borrowed connection that stays in its transaction.
|
|
98
|
+
`None` leaves the connection's own setting alone."""
|
|
99
|
+
if seconds is None:
|
|
100
|
+
yield
|
|
101
|
+
return
|
|
102
|
+
async with AsyncExitStack() as stack:
|
|
103
|
+
if c.autocommit:
|
|
104
|
+
await stack.enter_async_context(c.transaction()) # a local setting needs a transaction to live in
|
|
105
|
+
cur = await c.execute(
|
|
106
|
+
"SELECT current_setting('statement_timeout'), set_config('statement_timeout', %(ms)s, true)",
|
|
107
|
+
{"ms": str(max(1, math.ceil(seconds * 1000)))},
|
|
108
|
+
)
|
|
109
|
+
row = await cur.fetchone()
|
|
110
|
+
assert row is not None
|
|
111
|
+
yield
|
|
112
|
+
await c.execute("SELECT set_config('statement_timeout', %(previous)s, true)", {"previous": row[0]})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _run_query(
|
|
116
|
+
c: AsyncConnection, query: Any, params: QueryParams, statement_timeout: float | None
|
|
117
|
+
) -> list[dict[str, Any]]:
|
|
118
|
+
async with _statement_timeout(c, statement_timeout), c.cursor(row_factory=dict_row) as cur:
|
|
89
119
|
await cur.execute(query, params)
|
|
90
120
|
return await cur.fetchall()
|
|
91
121
|
|
|
@@ -129,11 +159,12 @@ async def _fetch_dicts(
|
|
|
129
159
|
con: AsyncConnection | None,
|
|
130
160
|
pool: ConnectionSource | None,
|
|
131
161
|
cancel: asyncio.Event | None,
|
|
162
|
+
statement_timeout: float | None,
|
|
132
163
|
) -> list[dict[str, Any]]:
|
|
133
164
|
if cancel is not None and cancel.is_set():
|
|
134
165
|
raise QueryCanceled("fetch_all: cancelled before the query started")
|
|
135
166
|
async with _acquire(con, pool) as c:
|
|
136
|
-
work = asyncio.ensure_future(_run_query(c, query, params))
|
|
167
|
+
work = asyncio.ensure_future(_run_query(c, query, params, statement_timeout))
|
|
137
168
|
stop = asyncio.ensure_future(cancel.wait()) if cancel is not None else None
|
|
138
169
|
try:
|
|
139
170
|
await asyncio.wait([work, *([stop] if stop else [])], return_when=asyncio.FIRST_COMPLETED)
|
|
@@ -162,6 +193,7 @@ async def fetch_all[M: BaseModel](
|
|
|
162
193
|
con: AsyncConnection | None = None,
|
|
163
194
|
pool: ConnectionSource | None = None,
|
|
164
195
|
cancel: asyncio.Event | None = None,
|
|
196
|
+
statement_timeout: float | None = None,
|
|
165
197
|
) -> list[M]: ...
|
|
166
198
|
|
|
167
199
|
|
|
@@ -174,6 +206,7 @@ async def fetch_all[T](
|
|
|
174
206
|
con: AsyncConnection | None = None,
|
|
175
207
|
pool: ConnectionSource | None = None,
|
|
176
208
|
cancel: asyncio.Event | None = None,
|
|
209
|
+
statement_timeout: float | None = None,
|
|
177
210
|
) -> list[T]: ...
|
|
178
211
|
|
|
179
212
|
|
|
@@ -187,6 +220,7 @@ async def fetch_all(
|
|
|
187
220
|
con: AsyncConnection | None = None,
|
|
188
221
|
pool: ConnectionSource | None = None,
|
|
189
222
|
cancel: asyncio.Event | None = None,
|
|
223
|
+
statement_timeout: float | None = None,
|
|
190
224
|
) -> list[dict[str, Any]]: ...
|
|
191
225
|
|
|
192
226
|
|
|
@@ -198,6 +232,7 @@ async def fetch_all[M: BaseModel](
|
|
|
198
232
|
con: AsyncConnection | None = None,
|
|
199
233
|
pool: ConnectionSource | None = None,
|
|
200
234
|
cancel: asyncio.Event | None = None,
|
|
235
|
+
statement_timeout: float | None = None,
|
|
201
236
|
) -> list[M]: ...
|
|
202
237
|
|
|
203
238
|
|
|
@@ -209,6 +244,7 @@ async def fetch_all[T](
|
|
|
209
244
|
con: AsyncConnection | None = None,
|
|
210
245
|
pool: ConnectionSource | None = None,
|
|
211
246
|
cancel: asyncio.Event | None = None,
|
|
247
|
+
statement_timeout: float | None = None,
|
|
212
248
|
) -> list[T]: ...
|
|
213
249
|
|
|
214
250
|
|
|
@@ -221,6 +257,7 @@ async def fetch_all(
|
|
|
221
257
|
con: AsyncConnection | None = None,
|
|
222
258
|
pool: ConnectionSource | None = None,
|
|
223
259
|
cancel: asyncio.Event | None = None,
|
|
260
|
+
statement_timeout: float | None = None,
|
|
224
261
|
) -> list[dict[str, Any]]: ...
|
|
225
262
|
|
|
226
263
|
|
|
@@ -233,17 +270,25 @@ async def fetch_all(
|
|
|
233
270
|
con: AsyncConnection | None = None,
|
|
234
271
|
pool: ConnectionSource | None = None,
|
|
235
272
|
cancel: asyncio.Event | None = None,
|
|
273
|
+
statement_timeout: float | None = None,
|
|
236
274
|
) -> list[Any]:
|
|
237
275
|
"""Run one query and return every row.
|
|
238
276
|
|
|
239
277
|
Rows come back as plain dicts, or, with `model=` (a Pydantic model class), validated into
|
|
240
278
|
that model; `row_mapper=` converts each dict row into any other shape. The two are exclusive.
|
|
241
279
|
|
|
280
|
+
Pass `statement_timeout` (seconds) to let Postgres abort the query after that long: `fetch_all` then
|
|
281
|
+
raises `psycopg.errors.QueryCanceled` ("canceling statement due to statement timeout"). It is applied
|
|
282
|
+
with `SET LOCAL` semantics, i.e. for this query's transaction only; a borrowed `con` in autocommit mode
|
|
283
|
+
gets a short transaction around the query, and any other borrowed `con` gets its previous setting back.
|
|
284
|
+
Without it, the connection's own `statement_timeout` (default: none) applies.
|
|
285
|
+
|
|
242
286
|
Pass `con` to run on a connection you already hold (e.g. inside a transaction): it is
|
|
243
287
|
borrowed, never committed, closed or returned to a pool. Without it, a connection is taken
|
|
244
288
|
from `pool` or the pool registered via `set_default_pool()` and released afterwards.
|
|
245
289
|
|
|
246
|
-
Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects
|
|
290
|
+
Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects; to stream a large result
|
|
291
|
+
from FastAPI see `pgdevkit.fastapi.PostgresJsonResponse`, which does that wiring itself) to abort a running
|
|
247
292
|
query on the server: `fetch_all` then raises `psycopg.errors.QueryCanceled`. If the awaiting
|
|
248
293
|
task is cancelled instead, the query is cancelled server-side too and `CancelledError`
|
|
249
294
|
propagates, so the query never keeps running unattended. `cancel` aborts whatever statement is
|
|
@@ -261,7 +306,8 @@ async def fetch_all(
|
|
|
261
306
|
raise TypeError("Pass either `model` or `row_mapper`, not both.")
|
|
262
307
|
if isinstance(query, Template) and params is not None:
|
|
263
308
|
raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
|
|
264
|
-
|
|
309
|
+
_check_statement_timeout(statement_timeout)
|
|
310
|
+
rows = await _fetch_dicts(_render(query), params, con, pool, cancel, statement_timeout)
|
|
265
311
|
if model is not None:
|
|
266
312
|
return [model.model_validate(row) for row in rows]
|
|
267
313
|
if row_mapper is not None:
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Stream a Postgres query to the client as a JSON array, cancelling the query if the client goes away.
|
|
2
|
+
|
|
3
|
+
Requires the ``fastapi`` extra: ``pip install pgdevkit[fastapi]``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from contextlib import aclosing
|
|
10
|
+
from collections.abc import AsyncGenerator, Mapping
|
|
11
|
+
from string.templatelib import Template
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
from fastapi.responses import StreamingResponse
|
|
16
|
+
from psycopg import AsyncConnection, pq
|
|
17
|
+
from psycopg.errors import QueryCanceled
|
|
18
|
+
from psycopg.pq import TransactionStatus
|
|
19
|
+
from psycopg.rows import tuple_row
|
|
20
|
+
from psycopg.sql import SQL, Composable, Composed
|
|
21
|
+
from starlette.exceptions import HTTPException
|
|
22
|
+
from starlette.requests import ClientDisconnect
|
|
23
|
+
from starlette.types import Message, Receive, Scope, Send
|
|
24
|
+
|
|
25
|
+
from ..db.fetch import (
|
|
26
|
+
ConnectionSource,
|
|
27
|
+
QueryParams,
|
|
28
|
+
SqlQuery,
|
|
29
|
+
_acquire,
|
|
30
|
+
_check_statement_timeout,
|
|
31
|
+
_render,
|
|
32
|
+
_statement_timeout,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_CANCEL_TIMEOUT_SECONDS = 5.0
|
|
38
|
+
# libpq < 17 (a system libpq; psycopg's binary wheels bundle a newer one) has no chunked rows mode.
|
|
39
|
+
_CHUNKED_ROWS = pq.version() >= 170000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PostgresJsonResponse(StreamingResponse):
|
|
43
|
+
"""Run ``query`` and stream its rows as ``[{...},\\n{...}]`` (``row_to_json`` per row, computed by Postgres).
|
|
44
|
+
|
|
45
|
+
Connections work as in ``pgdevkit.db.fetch_all``: pass ``con`` (one you opened; it must stay open until the
|
|
46
|
+
response has been sent, e.g. a ``Depends`` with ``yield``), or ``pool`` (anything with ``.connection()``), or
|
|
47
|
+
register the app's pool once with ``set_default_pool()``. A pooled connection is acquired when streaming starts
|
|
48
|
+
and released when it ends. ``query`` and ``params`` are those of ``fetch_all`` too (a literal string, a
|
|
49
|
+
``psycopg.sql`` composable, a sqlglot expression or a t-string) with values bound via ``params``; a t-string carries
|
|
50
|
+
its own values and takes no ``params``.
|
|
51
|
+
|
|
52
|
+
The query is wrapped as a subquery, so it cannot be a data-modifying CTE; for that (or for custom JSON) pass
|
|
53
|
+
``query_produces_json=True`` and make it return one *text* column holding the JSON of each row.
|
|
54
|
+
|
|
55
|
+
If the client disconnects (or the request is cancelled) while the query is still running, the query is
|
|
56
|
+
cancelled in Postgres. A connection that was mid-query at that point is closed instead of being reused.
|
|
57
|
+
|
|
58
|
+
``statement_timeout`` (seconds) lets Postgres abort the query after that long, as in ``fetch_all``: before the
|
|
59
|
+
first byte that is a 504 ``{"error": "Query timed out"}``, later the response is cut short like any other
|
|
60
|
+
error. It limits query execution only: a client that stops reading is *not* freed by it (Postgres defers the
|
|
61
|
+
cancel while blocked writing), so keep a write timeout in the reverse proxy. Needs a connection that is not in
|
|
62
|
+
autocommit mode.
|
|
63
|
+
|
|
64
|
+
Errors before the first byte become a JSON ``{"error": ...}`` response (status 500, or the status of a raised
|
|
65
|
+
``HTTPException``). After that the status line is gone: the error is logged and re-raised, and the response
|
|
66
|
+
ends without its closing ``]``, so clients can't mistake it for a complete array. Subclass and set
|
|
67
|
+
``expose_errors = True`` (e.g. in dev/test) to include ``str(error)`` in the 500 response.
|
|
68
|
+
|
|
69
|
+
Postgres sends rows in 8 kB buffers, so a slow query with small rows delivers its first bytes late.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
expose_errors: bool = False
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
query: SqlQuery,
|
|
77
|
+
params: QueryParams = None,
|
|
78
|
+
*,
|
|
79
|
+
con: AsyncConnection | None = None,
|
|
80
|
+
pool: ConnectionSource | None = None,
|
|
81
|
+
query_produces_json: bool = False,
|
|
82
|
+
batch_size: int = 1000,
|
|
83
|
+
statement_timeout: float | None = None,
|
|
84
|
+
status_code: int = 200,
|
|
85
|
+
headers: Mapping[str, str] | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
if con is not None and pool is not None:
|
|
88
|
+
raise TypeError("Pass either `con` or `pool`, not both.")
|
|
89
|
+
if batch_size < 1:
|
|
90
|
+
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
|
91
|
+
_check_statement_timeout(statement_timeout)
|
|
92
|
+
if isinstance(query, Template) and params is not None:
|
|
93
|
+
raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
|
|
94
|
+
rendered = _render(query)
|
|
95
|
+
self.query = rendered if query_produces_json else _as_json_rows(rendered)
|
|
96
|
+
self.params = params
|
|
97
|
+
self.con = con
|
|
98
|
+
self.pool = pool
|
|
99
|
+
self.batch_size = batch_size
|
|
100
|
+
self.statement_timeout = statement_timeout
|
|
101
|
+
# The body is produced by stream_response() itself, so the base class' iterator is never used.
|
|
102
|
+
super().__init__((), status_code=status_code, headers=headers, media_type="application/json")
|
|
103
|
+
|
|
104
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
105
|
+
# Starlette only watches for http.disconnect on ASGI spec < 2.4, so do it ourselves. An asyncio timeout is
|
|
106
|
+
# the cancel scope: unlike an anyio task group, it doesn't leave Granian hanging when the body fails.
|
|
107
|
+
loop = asyncio.get_running_loop()
|
|
108
|
+
|
|
109
|
+
async def watch_for_disconnect(cancel_scope: asyncio.Timeout) -> None:
|
|
110
|
+
while (await receive())["type"] != "http.disconnect":
|
|
111
|
+
pass
|
|
112
|
+
cancel_scope.reschedule(loop.time()) # i.e. cancel the stream now
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
async with asyncio.timeout(None) as cancel_scope:
|
|
116
|
+
watcher = asyncio.create_task(watch_for_disconnect(cancel_scope))
|
|
117
|
+
try:
|
|
118
|
+
await self.stream_response(send)
|
|
119
|
+
finally:
|
|
120
|
+
watcher.cancel()
|
|
121
|
+
except TimeoutError:
|
|
122
|
+
if not cancel_scope.expired():
|
|
123
|
+
raise
|
|
124
|
+
# the client is gone and the query has been cancelled: nobody is left to answer
|
|
125
|
+
if self.background is not None:
|
|
126
|
+
await self.background()
|
|
127
|
+
|
|
128
|
+
async def stream_response(self, send: Send) -> None:
|
|
129
|
+
started = False
|
|
130
|
+
try:
|
|
131
|
+
async with _acquire(self.con, self.pool) as conn:
|
|
132
|
+
try:
|
|
133
|
+
# aclosing: an `async for` leaves its generator suspended when the body fails (e.g. send()
|
|
134
|
+
# raising); the event loop would finalize it later, after the connection is closed.
|
|
135
|
+
async with aclosing(self._chunks(conn)) as chunks:
|
|
136
|
+
async for chunk in chunks:
|
|
137
|
+
if not started:
|
|
138
|
+
await _send(send, self._start_message(self.status_code, self.raw_headers))
|
|
139
|
+
started = True
|
|
140
|
+
await _send(send, {"type": "http.response.body", "body": chunk, "more_body": True})
|
|
141
|
+
finally:
|
|
142
|
+
# Runs for completion too, but only acts if the query is still in flight.
|
|
143
|
+
await _cancel_if_running(conn)
|
|
144
|
+
except ClientDisconnect: # nobody to report to
|
|
145
|
+
raise
|
|
146
|
+
except Exception as err:
|
|
147
|
+
if started:
|
|
148
|
+
logger.exception("Postgres query failed after the response had started; it is cut short")
|
|
149
|
+
raise
|
|
150
|
+
if self.statement_timeout is not None and isinstance(err, QueryCanceled):
|
|
151
|
+
logger.warning("Postgres query exceeded the statement_timeout of %ss", self.statement_timeout)
|
|
152
|
+
err = HTTPException(status_code=504, detail="Query timed out")
|
|
153
|
+
if isinstance(err, HTTPException):
|
|
154
|
+
logger.info("Request refused: %s", err.detail)
|
|
155
|
+
else:
|
|
156
|
+
logger.exception("Error executing Postgres query")
|
|
157
|
+
await self._send_error(send, err)
|
|
158
|
+
return
|
|
159
|
+
if not started:
|
|
160
|
+
await _send(send, self._start_message(self.status_code, self.raw_headers))
|
|
161
|
+
await _send(send, {"type": "http.response.body", "body": b"", "more_body": False})
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _start_message(status: int, headers: list[tuple[bytes, bytes]]) -> Message:
|
|
165
|
+
return {"type": "http.response.start", "status": status, "headers": headers}
|
|
166
|
+
|
|
167
|
+
async def _send_error(self, send: Send, err: Exception) -> None:
|
|
168
|
+
if isinstance(err, HTTPException):
|
|
169
|
+
status, message = err.status_code, err.detail
|
|
170
|
+
else:
|
|
171
|
+
status, message = 500, str(err) if self.expose_errors else "Internal Server Error"
|
|
172
|
+
body = json.dumps({"error": message}).encode()
|
|
173
|
+
headers = [(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode())]
|
|
174
|
+
if isinstance(err, HTTPException) and err.headers:
|
|
175
|
+
headers += [(k.lower().encode(), v.encode()) for k, v in err.headers.items()]
|
|
176
|
+
await _send(send, self._start_message(status, headers))
|
|
177
|
+
await _send(send, {"type": "http.response.body", "body": body, "more_body": False})
|
|
178
|
+
|
|
179
|
+
async def _chunks(self, conn: AsyncConnection) -> AsyncGenerator[bytes]:
|
|
180
|
+
"""Yield the JSON array in pieces of ``batch_size`` rows. Nothing is yielded before the first batch is
|
|
181
|
+
complete, so a failing query surfaces before any byte (and the status line) is sent."""
|
|
182
|
+
if self.statement_timeout is not None and conn.autocommit:
|
|
183
|
+
# (a transaction block would try to roll back a connection that a disconnect left mid-query)
|
|
184
|
+
raise TypeError("statement_timeout needs a connection that is not in autocommit mode")
|
|
185
|
+
batch: list[str] = []
|
|
186
|
+
prefix = "["
|
|
187
|
+
async with (
|
|
188
|
+
_statement_timeout(conn, self.statement_timeout),
|
|
189
|
+
conn.cursor(row_factory=tuple_row) as cur, # whatever the connection was configured with
|
|
190
|
+
):
|
|
191
|
+
# (typed as an iterator, but it is an async generator, which is what aclosing() needs)
|
|
192
|
+
size = self.batch_size if _CHUNKED_ROWS else 1
|
|
193
|
+
rows = cast("AsyncGenerator[tuple[Any, ...]]", cur.stream(self.query, self.params, size=size))
|
|
194
|
+
async with aclosing(rows): # same reason as in stream_response(): finish psycopg's cleanup in order
|
|
195
|
+
async for (row_json,) in rows:
|
|
196
|
+
batch.append(row_json)
|
|
197
|
+
if len(batch) >= self.batch_size:
|
|
198
|
+
yield (prefix + ",\n".join(batch)).encode()
|
|
199
|
+
batch, prefix = [], ","
|
|
200
|
+
if batch or prefix == "[": # remaining rows, or an empty result
|
|
201
|
+
yield (prefix + ",\n".join(batch) + "]").encode()
|
|
202
|
+
else: # the last batch was full and has been sent already
|
|
203
|
+
yield b"]"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _as_json_rows(query: Any) -> Composed | Template:
|
|
207
|
+
if isinstance(query, Template):
|
|
208
|
+
# psycopg expands a Template nested with `:q` in place, its own values staying bound parameters
|
|
209
|
+
return t"select row_to_json(s)::text from (\n{query:q}\n) s"
|
|
210
|
+
if isinstance(query, Composable):
|
|
211
|
+
inner = query
|
|
212
|
+
else:
|
|
213
|
+
# bdt-lint: ignore sql-unverified-call -- callers pass a trusted literal (type-checked only); stacked statements
|
|
214
|
+
# are rejected by the extended protocol that cursor.stream() uses
|
|
215
|
+
inner = SQL(query.strip().rstrip(";"))
|
|
216
|
+
# newline before the closing paren: the query may end in a line comment
|
|
217
|
+
return SQL("select row_to_json(s)::text from (\n{}\n) s").format(inner)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def _send(send: Send, message: Message) -> None:
|
|
221
|
+
try:
|
|
222
|
+
await send(message)
|
|
223
|
+
except OSError: # what servers speaking ASGI spec >= 2.4 do when the client is gone
|
|
224
|
+
raise ClientDisconnect from None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _cancel_if_running(conn: AsyncConnection) -> None:
|
|
228
|
+
"""Cancel the in-flight query on ``conn`` and close it, however often the request is cancelled meanwhile.
|
|
229
|
+
|
|
230
|
+
The connection can't be reused afterwards (the cancelled query's results are still pending), and a pool must
|
|
231
|
+
not get it back mid-query, so it is closed; a pool simply opens a fresh one.
|
|
232
|
+
|
|
233
|
+
Only a query that is still running (status ACTIVE) is cancelled: a cancel request that arrives after the
|
|
234
|
+
query finished would hit whatever statement the connection runs next once it is back in the pool.
|
|
235
|
+
"""
|
|
236
|
+
if conn.closed or conn.info.transaction_status != TransactionStatus.ACTIVE:
|
|
237
|
+
return
|
|
238
|
+
# Its own task, awaited through asyncio.shield: a native task.cancel() (e.g. the disconnect watcher firing
|
|
239
|
+
# while we are already cleaning up) would otherwise interrupt it. The anyio shield covers scope cancellation.
|
|
240
|
+
cleanup = asyncio.ensure_future(_cancel_and_close(conn))
|
|
241
|
+
interrupted = False
|
|
242
|
+
with anyio.CancelScope(shield=True):
|
|
243
|
+
while not cleanup.done():
|
|
244
|
+
try:
|
|
245
|
+
await asyncio.shield(cleanup)
|
|
246
|
+
except asyncio.CancelledError:
|
|
247
|
+
interrupted = True
|
|
248
|
+
cleanup.result()
|
|
249
|
+
if interrupted:
|
|
250
|
+
raise asyncio.CancelledError
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _cancel_and_close(conn: AsyncConnection) -> None:
|
|
254
|
+
try:
|
|
255
|
+
async with asyncio.timeout(_CANCEL_TIMEOUT_SECONDS):
|
|
256
|
+
await conn.cancel_safe(timeout=_CANCEL_TIMEOUT_SECONDS)
|
|
257
|
+
except Exception:
|
|
258
|
+
logger.warning("Could not cancel Postgres query", exc_info=True)
|
|
259
|
+
await conn.close()
|
|
@@ -4,8 +4,9 @@ plugin: coding
|
|
|
4
4
|
description: >
|
|
5
5
|
Use pgdevkit for any PostgreSQL work in a Python project: a local
|
|
6
6
|
Docker/Podman test database (`pgdb testdb`), importable ORM-free CRUD and
|
|
7
|
-
query helpers (`pgdevkit.db`: `pg_*`, `fetch_all`, `PgPool`, `SqlLoader`),
|
|
8
|
-
|
|
7
|
+
query helpers (`pgdevkit.db`: `pg_*`, `fetch_all`, `PgPool`, `SqlLoader`), streaming a query as JSON from a
|
|
8
|
+
FastAPI endpoint with query cancellation on client disconnect (`pgdevkit.fastapi.PostgresJsonResponse`: large grid
|
|
9
|
+
endpoints), and the `database/`-folder schema-as-code convention. Supersedes the old postgres-test-setup, postgres-best-practices,
|
|
9
10
|
and database-in-source skills — pgdevkit is a real dependency now, not
|
|
10
11
|
copy-pasted reference files. Use whenever the user wants to set up a local
|
|
11
12
|
test Postgres, write or review psycopg code, add a table/view/function to
|
|
@@ -43,7 +44,7 @@ Core rules for every piece of database code in a project using pgdevkit:
|
|
|
43
44
|
uv add pgdevkit[cli,db]
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
`cli` pulls in `typer`/`rich` for the `pgdb` command; `db` pulls in `pydantic`/`psycopg-pool` for the importable CRUD helpers. Skip
|
|
47
|
+
`cli` pulls in `typer`/`rich` for the `pgdb` command; `db` pulls in `pydantic`/`psycopg-pool` for the importable CRUD helpers; `fastapi` adds `pgdevkit.fastapi` (streaming JSON responses; FastAPI apps use `pgdevkit[cli,db,fastapi]`). Skip any extra the project doesn't need (e.g. a project using only `pgdb testdb` doesn't need `db`).
|
|
47
48
|
|
|
48
49
|
---
|
|
49
50
|
|
|
@@ -220,7 +221,8 @@ async with pool.connection() as conn: # inside a transaction you already hold
|
|
|
220
221
|
|
|
221
222
|
- `model=` validates each row into a Pydantic model; `row_mapper=` maps each dict row to anything else (exclusive with `model`).
|
|
222
223
|
- Passing `con=` runs on *your* connection: `fetch_all` never commits, closes or releases it. Without it, a connection is borrowed from `pool=` (or the `set_default_pool()` pool) and released afterwards.
|
|
223
|
-
- `
|
|
224
|
+
- `statement_timeout=<seconds>` (optional) lets Postgres abort the query after that long, raising `QueryCanceled`; it is applied with `SET LOCAL` semantics for this call only (a borrowed `con` gets its previous value back).
|
|
225
|
+
- `cancel=` takes an `asyncio.Event`: set it (e.g. when the HTTP client disconnects) and the running query is cancelled on the server, raising `psycopg.errors.QueryCanceled`. If the awaiting task is cancelled instead, the server-side query is cancelled too and `CancelledError` propagates as usual (not `QueryCanceled`), so no query keeps running unattended. Don't use `cancel=` on a `con` that concurrent tasks share (it aborts whatever statement is running on it); if the cancel request itself can't be delivered, the connection is closed. After `QueryCanceled` a borrowed `con` is usable again once you `await con.rollback()` (autocommit connections need nothing). To stream a whole result as JSON from FastAPI (it wires the disconnect itself) see `PostgresJsonResponse` below.
|
|
224
226
|
- `query` is typed `SqlQuery`: a literal string (`SqlLoader.load_sql()`), a sqlglot expression, a `psycopg.sql` composable or a t-string. A plain `str` fails type checking on purpose, so don't build SQL with f-strings/concatenation. A sqlglot expression is only as safe as the strings it was built from (its builders parse plain strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text, and write a literal `%` as `%%` when you also pass `params`. A t-string carries its own values, so don't also pass `params`.
|
|
225
227
|
|
|
226
228
|
### Dynamic SQL
|
|
@@ -255,6 +257,28 @@ test-data seeding and `pgdevkit.db.crud`'s CRUD helpers. Pass a `normalizers`
|
|
|
255
257
|
dict (keyed by composite type name) if a project needs to reshape a value
|
|
256
258
|
before conversion (e.g. backfilling missing locale keys).
|
|
257
259
|
|
|
260
|
+
### Streaming JSON from FastAPI — `pgdevkit.fastapi`
|
|
261
|
+
|
|
262
|
+
For large/grid-style read endpoints, return a `PostgresJsonResponse` instead of `fetch_all()` + a list of
|
|
263
|
+
dicts: Postgres builds the JSON, rows are streamed, and the query is **cancelled in Postgres when the client
|
|
264
|
+
disconnects**. Needs the `fastapi` extra (FastAPI >= 0.118). The pool registered with `set_default_pool()` (see
|
|
265
|
+
*Connection pool*) is used, as in `fetch_all`.
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
from pgdevkit.fastapi import PostgresJsonResponse
|
|
269
|
+
|
|
270
|
+
@router.get("/articles", responses={200: {"model": list[ArticleOut]}})
|
|
271
|
+
async def articles(lng: str) -> PostgresJsonResponse:
|
|
272
|
+
return PostgresJsonResponse(sql.load_sql("articles", "list_articles"), {"lng": lng})
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- Query: from a `.sql` file via `SqlLoader` like any non-trivial query (or `psycopg.sql`, sqlglot or a t-string for dynamic SQL), values via `params` (`%(name)s`; none with a t-string, it carries its own). That it is a literal is enforced by the type checker only: never build it from user input. Data-modifying CTE or custom JSON: `query_produces_json=True` (one text column per row).
|
|
276
|
+
- `responses={200: {"model": ...}}` keeps the OpenAPI schema typed for the generated frontend client; `response_model` is ignored and rows are **not validated**.
|
|
277
|
+
- Connections as in `fetch_all`: default pool, `pool=`, or `con=`. Prefer the pool: a `con` you opened must outlive the response (`Depends` with `yield`, never `async with ... as conn: return PostgresJsonResponse(q, con=conn)`), and a disconnect aborts its transaction.
|
|
278
|
+
- Errors show as `{"error": "Internal Server Error"}` (500); `expose_errors = IS_DEV` in a subclass shows the text (leaks SQL/values: never in prod). An error after the first byte leaves the array unterminated, so clients fail to parse it.
|
|
279
|
+
- `statement_timeout=<seconds>` (optional, as in `fetch_all`): Postgres aborts a slow query; before the first byte the client gets a 504. It does not free a client that stopped reading (use a proxy write timeout), and needs a non-autocommit connection.
|
|
280
|
+
- It does no authorization; scope the query yourself. Full reference: the `pgdevkit.fastapi` section of the README.
|
|
281
|
+
|
|
258
282
|
### SQL formatting
|
|
259
283
|
|
|
260
284
|
```bash
|
|
@@ -331,6 +355,7 @@ Reports drift between the `database/` `.sql` files and the actual schema — tab
|
|
|
331
355
|
- [ ] Inline SQL only for trivial queries ≤ 4 lines; anything with JOINs/CTEs/aggregations/subqueries uses a `.sql` file
|
|
332
356
|
- [ ] All parameters use `%(name)s` style with a dict argument
|
|
333
357
|
- [ ] Custom read queries use `fetch_all(...)` (`model=` where the shape is stable; pass `con=` inside a transaction); results mapped to a Pydantic model; table-mapped models extend `PostgresTableModel`
|
|
358
|
+
- [ ] Large/grid reads from FastAPI may use `pgdevkit.fastapi.PostgresJsonResponse` (streams, cancels on disconnect, no model validation)
|
|
334
359
|
- [ ] No `LATERAL JOIN` — use a CTE that groups/aggregates first, then joins it
|
|
335
360
|
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
336
361
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgdevkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.2
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
@@ -15,6 +15,10 @@ Requires-Dist: typer>=0.26.7; extra == 'cli'
|
|
|
15
15
|
Provides-Extra: db
|
|
16
16
|
Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
|
|
17
17
|
Requires-Dist: pydantic>=2.0; extra == 'db'
|
|
18
|
+
Provides-Extra: fastapi
|
|
19
|
+
Requires-Dist: fastapi>=0.118.0; extra == 'fastapi'
|
|
20
|
+
Requires-Dist: psycopg-pool>=3.3.0; extra == 'fastapi'
|
|
21
|
+
Requires-Dist: pydantic>=2.0; extra == 'fastapi'
|
|
18
22
|
Provides-Extra: mssql
|
|
19
23
|
Requires-Dist: mssql-python>=1.0.0; extra == 'mssql'
|
|
20
24
|
Description-Content-Type: text/markdown
|
|
@@ -427,14 +431,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
427
431
|
`--entra-user`. For Lakebase hosts, also set the
|
|
428
432
|
`{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
|
|
429
433
|
env vars.
|
|
430
|
-
- **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
|
|
434
|
+
- **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None, statement_timeout=None)`**
|
|
431
435
|
— run one custom query and get every row back as dicts, as validated
|
|
432
436
|
Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
|
|
433
437
|
connection you already hold (it is borrowed: never committed, closed or
|
|
434
438
|
released); otherwise a connection is taken from `pool` or the pool registered
|
|
435
439
|
with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
|
|
436
|
-
(`QueryCanceled
|
|
437
|
-
|
|
440
|
+
(`QueryCanceled`; to stream a result as JSON from FastAPI see `PostgresJsonResponse`, which watches for the
|
|
441
|
+
disconnect itself); cancelling the awaiting task cancels it there too, and
|
|
442
|
+
`CancelledError` propagates as usual. `statement_timeout=<seconds>` lets Postgres abort the query after that
|
|
443
|
+
long (`QueryCanceled`, "canceling statement due to statement timeout"); it is applied per call with `SET LOCAL`
|
|
444
|
+
semantics, so it never outlives the call (a borrowed `con` gets its previous setting back). `query` must be a literal string, a sqlglot
|
|
438
445
|
expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
|
|
439
446
|
expression is only as safe as the strings it was built from: pass user values as
|
|
440
447
|
`exp.Placeholder` + `params`).
|
|
@@ -482,6 +489,97 @@ async with pool.connection() as con:
|
|
|
482
489
|
await pg_upsert(con, Widget(id=1, name="thing"), Widget)
|
|
483
490
|
```
|
|
484
491
|
|
|
492
|
+
## `pgdevkit.fastapi` — streaming JSON responses
|
|
493
|
+
|
|
494
|
+
Requires the `fastapi` extra (`pip install pgdevkit[fastapi]`, FastAPI >= 0.118).
|
|
495
|
+
|
|
496
|
+
`PostgresJsonResponse` runs a query and streams its rows to the client as a JSON array. Postgres builds the JSON
|
|
497
|
+
(`row_to_json`), so there is no row-by-row Python serialization and no pydantic round trip, and memory use stays flat
|
|
498
|
+
for big results. **If the client goes away, the query is cancelled in Postgres** instead of running to completion.
|
|
499
|
+
(`fetch_all(..., cancel=event)` cancels a buffered query too, but you wire the event to the disconnect yourself.)
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
from pgdevkit.db import PgPool, set_default_pool
|
|
503
|
+
from pgdevkit.fastapi import PostgresJsonResponse
|
|
504
|
+
|
|
505
|
+
pool = PgPool(env_prefix="APP_POSTGRES_")
|
|
506
|
+
set_default_pool(pool) # once at startup, shared with fetch_all
|
|
507
|
+
|
|
508
|
+
@router.get("/articles", responses={200: {"model": list[ArticleOut]}})
|
|
509
|
+
async def articles(lng: str) -> PostgresJsonResponse:
|
|
510
|
+
return PostgresJsonResponse(
|
|
511
|
+
"select id, name from articles where lng = %(lng)s order by name",
|
|
512
|
+
{"lng": lng},
|
|
513
|
+
)
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
The `responses=` entry keeps the OpenAPI schema (and a generated frontend client) typed; `response_model` is ignored
|
|
517
|
+
for a `Response`, and the rows are *not* validated against the model.
|
|
518
|
+
|
|
519
|
+
- **Connections** work as in `fetch_all`: the pool registered with `set_default_pool()`, an explicit `pool=` (anything
|
|
520
|
+
with `.connection()`, e.g. `PgPool`), or a `con=` you opened yourself. A pooled connection is acquired when streaming
|
|
521
|
+
starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
|
|
522
|
+
with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
|
|
523
|
+
closes it too early. Prefer the pool.
|
|
524
|
+
- **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
|
|
525
|
+
through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
|
|
526
|
+
is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
|
|
527
|
+
build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
|
|
528
|
+
*text* column per row (a `json` column or NULL fails). A trailing `;` is fine in a string, a `;` followed by a
|
|
529
|
+
comment is not.
|
|
530
|
+
- **Options** — `batch_size` (rows per chunk, >= 1, default 1000; don't take it from user input),
|
|
531
|
+
`statement_timeout` (seconds, see below), `status_code`, `headers` (e.g. `{"Cache-Control": "max-age=3600"}`).
|
|
532
|
+
|
|
533
|
+
### Cancellation
|
|
534
|
+
|
|
535
|
+
The response watches for `http.disconnect` itself (Starlette only does that for ASGI spec < 2.4), also while the
|
|
536
|
+
query is still running and nothing has been sent yet. It then cancels the query with `cancel_safe()`, shielded from
|
|
537
|
+
the cancellation that is tearing the request down, and closes the connection if it was left mid-query: a pool opens a
|
|
538
|
+
fresh one instead of reusing it. Only a query that is still running is cancelled, so a late cancel can never
|
|
539
|
+
hit the next statement on a reused connection. A frontend `AbortController` / closed tab therefore frees the database.
|
|
540
|
+
|
|
541
|
+
With `con=`, a disconnect aborts the query *and your transaction*, unlike `fetch_all`: the connection is afterwards
|
|
542
|
+
either left in an aborted transaction (idle, with autocommit) or closed, depending on whether the stream was waiting for
|
|
543
|
+
Postgres or for the client.
|
|
544
|
+
Don't use it afterwards, and don't count on earlier writes in that transaction.
|
|
545
|
+
|
|
546
|
+
Connections are held while the client reads, so a client that reads very slowly pins one (`statement_timeout` does not
|
|
547
|
+
help, see below). Enforce a write timeout in the reverse proxy and size the pool accordingly. Disconnect behaviour can only be tested over a real socket
|
|
548
|
+
(`httpx.ASGITransport` never sends `http.disconnect`); see `tests/test_pg_json_granian.py`.
|
|
549
|
+
|
|
550
|
+
### Statement timeout
|
|
551
|
+
|
|
552
|
+
`statement_timeout=<seconds>` (optional, also on `fetch_all`) lets **Postgres** abort the query after that long. It
|
|
553
|
+
is set with `SET LOCAL` semantics for this one query: transaction-scoped, so it can't leak into the pool and works
|
|
554
|
+
behind PgBouncer's transaction pooling, and a `con` you passed gets its previous setting back. Before the first byte
|
|
555
|
+
the client gets a `504 {"error": "Query timed out"}`; after that the response is cut short like any other error. It
|
|
556
|
+
needs a connection that is not in autocommit mode. Without it, the connection's own `statement_timeout` applies.
|
|
557
|
+
|
|
558
|
+
It limits *query execution*, nothing else: Postgres defers a cancel while it is blocked writing to a client that
|
|
559
|
+
has stopped reading, so a stalled reader keeps its connection regardless (a backend was observed staying `active`
|
|
560
|
+
in `ClientWrite` well past a 1 s timeout). For that, use a write timeout in the reverse proxy.
|
|
561
|
+
|
|
562
|
+
### Errors
|
|
563
|
+
|
|
564
|
+
An error before the first byte becomes a JSON `{"error": ...}` response (500, or the status and headers of an
|
|
565
|
+
`HTTPException` raised by a custom pool); the text is hidden unless you subclass and set `expose_errors = True`
|
|
566
|
+
(it contains SQL fragments and parameter values: never in production):
|
|
567
|
+
|
|
568
|
+
```python
|
|
569
|
+
class AppJsonResponse(PostgresJsonResponse):
|
|
570
|
+
expose_errors = IS_DEV
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
After the first byte the status line is gone: the error is logged and re-raised, and the response ends **without
|
|
574
|
+
the closing `]`**, so clients fail to parse it rather than accepting a silently shortened array. (Granian, which our
|
|
575
|
+
apps run on, ends such a response cleanly with the truncated body.)
|
|
576
|
+
|
|
577
|
+
### Notes
|
|
578
|
+
|
|
579
|
+
It does no authorization: scope the query yourself. The first byte is sent once `batch_size` rows (or the whole
|
|
580
|
+
result) are available, so lower `batch_size` for earlier output; Postgres itself also sends rows in 8 kB buffers,
|
|
581
|
+
so a slow query with tiny rows delivers late either way.
|
|
582
|
+
|
|
485
583
|
## Releasing
|
|
486
584
|
|
|
487
585
|
Bump `version` in `pyproject.toml` as part of your PR, same as any other
|
|
@@ -24,11 +24,13 @@ pgdevkit/db/__init__.py,sha256=_NdK4qAy0_0bSl3__dM0Mfi5n38NPIU-I6OMlAzR1sI,949
|
|
|
24
24
|
pgdevkit/db/complex_types.py,sha256=bv3oMztSnrhz4-OJROBXfiouL2jJ8TxpSR_83HMLmTI,11197
|
|
25
25
|
pgdevkit/db/connection.py,sha256=ylc_KVYsOvPeuW5GL1ebDDH0O3w93oe-EHwkUH4KoVs,5793
|
|
26
26
|
pgdevkit/db/crud.py,sha256=t49aZmEJCIetn0du9OdWse3VSYAQe39S-jDaEt1SjmI,12537
|
|
27
|
-
pgdevkit/db/fetch.py,sha256=
|
|
27
|
+
pgdevkit/db/fetch.py,sha256=i_BXBnJjVh7cOiKFmHFH9HBWg6owXThvgRGrMC0v204,13595
|
|
28
28
|
pgdevkit/db/loader.py,sha256=0Swmp0h0s7I4acG0Dv3cem2YD4lFvq8aFEbFQPPQrxg,616
|
|
29
29
|
pgdevkit/db/model.py,sha256=ntuXonq-SCGkbNw-lvdMS5VuzLjxYRT8O6xaVqiw56I,980
|
|
30
30
|
pgdevkit/db/mssql_crud.py,sha256=yN4izZ0fsFwAs14008JECHH4dqjNbdHUHGEG8kidyNs,11374
|
|
31
31
|
pgdevkit/db/mssql_sql.py,sha256=DO3UMsTFN9pg0tNdaPGb0wf6lWlHGcqwqzkrGVhJF2g,2292
|
|
32
|
+
pgdevkit/fastapi/__init__.py,sha256=Oku0U7wNblWJj_wbyiw_ik299vAsdl1fX6FCth0KliI,84
|
|
33
|
+
pgdevkit/fastapi/json_response.py,sha256=9NbsHeGARrhKoORIFEJtMivprXyHe_QvGgu6CEK0DKk,13058
|
|
32
34
|
pgdevkit/testdb/__init__.py,sha256=tphuORo4t0QHQp_gFi6ZHXXgd32w6qUoKlaQCab6izA,366
|
|
33
35
|
pgdevkit/testdb/_docker.py,sha256=-W59YBbcSIl9H_YmySRtVbOV4ImZPHOq9hxmlO-p56s,2807
|
|
34
36
|
pgdevkit/testdb/api.py,sha256=13YgTz6hPcYWfsUlQJhJ7k20-W_4hzepedVaT8gWhxI,9709
|
|
@@ -43,10 +45,10 @@ pgdevkit/testdb/mssql/api.py,sha256=Ah95QTsOLvtubyHxRWDywjX9NCvTVgurmANLP5RY2Ck,
|
|
|
43
45
|
pgdevkit/testdb/mssql/constants.py,sha256=qyA1FCv-2LAQ_C8OydePEXhyG_ZBPPB7vg1686xVpVU,3453
|
|
44
46
|
pgdevkit/testdb/mssql/container.py,sha256=sqJPAuhN1by3kZZ96gLet4pRt3uJmUMO6Kkmvjydg1w,3791
|
|
45
47
|
pgdevkit/docs/database-layout.md,sha256=j9N8GuT63D2oVf9rIOEv8GTWs4yTtjZTGH-zM5Wrg5o,8163
|
|
46
|
-
pgdevkit/skills/pgdevkit/SKILL.md,sha256=
|
|
48
|
+
pgdevkit/skills/pgdevkit/SKILL.md,sha256=pAJF-Xd6wLeiGJ2VPyOfVaSIw1yWUNczjA-x4HICz_A,19258
|
|
47
49
|
pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=jA-sO8Z10CaFFjYhGHlzZFrvNeXxj1QiAxoCn8RPPVc,2974
|
|
48
50
|
pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
|
|
49
|
-
pgdevkit-0.
|
|
50
|
-
pgdevkit-0.
|
|
51
|
-
pgdevkit-0.
|
|
52
|
-
pgdevkit-0.
|
|
51
|
+
pgdevkit-0.14.2.dist-info/METADATA,sha256=QD23CSUwKMdpV_AesUYjoM15XeHW1ftPNHt_WbL5YcU,31283
|
|
52
|
+
pgdevkit-0.14.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
53
|
+
pgdevkit-0.14.2.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
|
|
54
|
+
pgdevkit-0.14.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|