pgdevkit 0.12.0__tar.gz → 0.13.0__tar.gz
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-0.12.0 → pgdevkit-0.13.0}/PKG-INFO +13 -2
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/README.md +11 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/__init__.py +5 -0
- pgdevkit-0.13.0/pgdevkit/db/fetch.py +269 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pyproject.toml +3 -3
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/skills/pgdevkit/SKILL.md +27 -13
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/skills/pgdevkit/references/dynamic-sql.md +3 -0
- pgdevkit-0.13.0/tests/db/test_fetch.py +253 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/uv.lock +3 -3
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.github/workflows/auto-release.yml +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.github/workflows/python-test.yml +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.gitignore +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/.python-version +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/docs/database-layout.md +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/areas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/base.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/backends/postgres.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/mssql_crud.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/db/mssql_sql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/dialect.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/envtag.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/migrate.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/migrate_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/models.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/mssql_introspect.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/schemas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/sql_text.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/stats.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/_docker.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/container.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/api.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/constants.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/mssql/container.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/pgdevkit/testdb/schema.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/conftest.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_mssql_crud_live.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/db/test_mssql_crud_sql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_areas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_compare.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_compare_mssql_live.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_connection.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_dialect.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_diff_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_envtag.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_areas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_env.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_grants.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_mssql_live.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_migrate_schemas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_mssql_introspect.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_parser_areas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_parser_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_parser_schemas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_schemas.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_stats_cli.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/test_stats_mssql_live.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/conftest.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_api_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_api_mssql_live.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_config_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_container.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_container_mssql.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_docker.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_mssql_constants.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_naming.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_query.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_schema.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_schema_filtering.py +0 -0
- {pgdevkit-0.12.0 → pgdevkit-0.13.0}/tests/testdb/test_schema_mssql.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pgdevkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.0
|
|
4
4
|
Summary: A helper for developing with Postgres
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: docker>=7.1.0
|
|
7
|
-
Requires-Dist: psycopg[binary]>=3.
|
|
7
|
+
Requires-Dist: psycopg[binary]>=3.3.0
|
|
8
8
|
Requires-Dist: sqlglot>=30.11.0
|
|
9
9
|
Provides-Extra: azure
|
|
10
10
|
Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
|
|
@@ -427,6 +427,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
427
427
|
`--entra-user`. For Lakebase hosts, also set the
|
|
428
428
|
`{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
|
|
429
429
|
env vars.
|
|
430
|
+
- **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
|
|
431
|
+
— run one custom query and get every row back as dicts, as validated
|
|
432
|
+
Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
|
|
433
|
+
connection you already hold (it is borrowed: never committed, closed or
|
|
434
|
+
released); otherwise a connection is taken from `pool` or the pool registered
|
|
435
|
+
with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
|
|
436
|
+
(`QueryCanceled`); cancelling the awaiting task cancels it there too, and
|
|
437
|
+
`CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
|
|
438
|
+
expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
|
|
439
|
+
expression is only as safe as the strings it was built from: pass user values as
|
|
440
|
+
`exp.Placeholder` + `params`).
|
|
430
441
|
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
|
|
431
442
|
`pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
|
|
432
443
|
`pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
|
|
@@ -406,6 +406,17 @@ Install with the `db` extra: `pip install pgdevkit[db]`.
|
|
|
406
406
|
`--entra-user`. For Lakebase hosts, also set the
|
|
407
407
|
`{env_prefix}DATABRICKS_WORKSPACE_HOST` and `{env_prefix}DATABRICKS_INSTANCE`
|
|
408
408
|
env vars.
|
|
409
|
+
- **`fetch_all(query, params=None, *, model=None, row_mapper=None, con=None, pool=None, cancel=None)`**
|
|
410
|
+
— run one custom query and get every row back as dicts, as validated
|
|
411
|
+
Pydantic `model` instances, or through a `row_mapper`. Pass `con` to run on a
|
|
412
|
+
connection you already hold (it is borrowed: never committed, closed or
|
|
413
|
+
released); otherwise a connection is taken from `pool` or the pool registered
|
|
414
|
+
with `set_default_pool()`. Set the `cancel` `asyncio.Event` to abort the query on the server
|
|
415
|
+
(`QueryCanceled`); cancelling the awaiting task cancels it there too, and
|
|
416
|
+
`CancelledError` propagates as usual. `query` must be a literal string, a sqlglot
|
|
417
|
+
expression, a `psycopg.sql` composable or a t-string — never an f-string (a sqlglot
|
|
418
|
+
expression is only as safe as the strings it was built from: pass user values as
|
|
419
|
+
`exp.Placeholder` + `params`).
|
|
409
420
|
- **CRUD functions** — `pg_retrieve`, `pg_retrieve_many`, `pg_insert`,
|
|
410
421
|
`pg_insert_many`, `pg_update`, `pg_update_dict`, `pg_upsert`,
|
|
411
422
|
`pg_upsert_dict`, `pg_upsert_many`, `pg_upsert_many_dict`, `pg_delete`,
|
|
@@ -16,15 +16,19 @@ from .crud import (
|
|
|
16
16
|
pg_upsert_many,
|
|
17
17
|
pg_upsert_many_dict,
|
|
18
18
|
)
|
|
19
|
+
from .fetch import ConnectionSource, SqlQuery, fetch_all, set_default_pool
|
|
19
20
|
from .loader import SqlLoader
|
|
20
21
|
from .model import PostgresTableModel, TableModel
|
|
21
22
|
|
|
22
23
|
__all__ = [
|
|
23
24
|
"ComplexHelper",
|
|
25
|
+
"ConnectionSource",
|
|
24
26
|
"PgPool",
|
|
25
27
|
"PostgresTableModel",
|
|
26
28
|
"SqlLoader",
|
|
29
|
+
"SqlQuery",
|
|
27
30
|
"TableModel",
|
|
31
|
+
"fetch_all",
|
|
28
32
|
"pg_delete",
|
|
29
33
|
"pg_delete_dict",
|
|
30
34
|
"pg_insert",
|
|
@@ -37,4 +41,5 @@ __all__ = [
|
|
|
37
41
|
"pg_upsert_dict",
|
|
38
42
|
"pg_upsert_many",
|
|
39
43
|
"pg_upsert_many_dict",
|
|
44
|
+
"set_default_pool",
|
|
40
45
|
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
|
8
|
+
from string.templatelib import Template
|
|
9
|
+
from typing import TYPE_CHECKING, Any, LiteralString, Protocol, overload
|
|
10
|
+
|
|
11
|
+
from psycopg.connection_async import AsyncConnection
|
|
12
|
+
from psycopg.errors import QueryCanceled
|
|
13
|
+
from psycopg.rows import dict_row
|
|
14
|
+
from psycopg.sql import Composable
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from sqlglot import exp # imported lazily: only needed to type sqlglot queries (rendering is duck-typed)
|
|
21
|
+
|
|
22
|
+
# Same params shape psycopg's Cursor.execute() accepts: a %(name)s-style mapping,
|
|
23
|
+
# or a positional %s-style sequence.
|
|
24
|
+
QueryParams = Mapping[str, Any] | Sequence[Any] | None
|
|
25
|
+
|
|
26
|
+
# The only things a query may be: a literal string (e.g. from `SqlLoader.load_sql()`), a sqlglot
|
|
27
|
+
# expression, or psycopg's own safe-composition types (`psycopg.sql` / a t-string). A plain `str`
|
|
28
|
+
# is rejected by the type checker on purpose, so user-controlled text can't be concatenated in.
|
|
29
|
+
type SqlQueryNoTemplate = LiteralString | exp.Expression | Composable
|
|
30
|
+
type SqlQuery = SqlQueryNoTemplate | Template
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConnectionSource(Protocol):
|
|
34
|
+
"""Anything that hands out an async connection context manager -- `PgPool` fits."""
|
|
35
|
+
|
|
36
|
+
def connection(self) -> AbstractAsyncContextManager[AsyncConnection]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_default_source: ConnectionSource | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def set_default_pool(pool: ConnectionSource | None) -> None:
|
|
43
|
+
"""Register where `fetch_all()` gets its connection when called without `con`/`pool`
|
|
44
|
+
(typically the app's `PgPool`, once at startup). Pass `None` to clear it. This is process-wide
|
|
45
|
+
state meant for single-database apps: with several databases/tenants pass `pool=` explicitly."""
|
|
46
|
+
global _default_source
|
|
47
|
+
_default_source = pool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@asynccontextmanager
|
|
51
|
+
async def _acquire(con: AsyncConnection | None, pool: ConnectionSource | None) -> AsyncIterator[AsyncConnection]:
|
|
52
|
+
"""Yield `con` untouched (the caller owns it: no commit/close/return-to-pool here), or a
|
|
53
|
+
connection borrowed from `pool` / the default pool for the duration of the block."""
|
|
54
|
+
if con is not None:
|
|
55
|
+
if pool is not None:
|
|
56
|
+
raise TypeError("Pass either `con` or `pool`, not both.")
|
|
57
|
+
yield con
|
|
58
|
+
return
|
|
59
|
+
source = pool if pool is not None else _default_source
|
|
60
|
+
if source is None:
|
|
61
|
+
raise RuntimeError("No connection: pass `con=`/`pool=`, or register a pool with `set_default_pool()`.")
|
|
62
|
+
async with source.connection() as borrowed:
|
|
63
|
+
yield borrowed
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_PLACEHOLDER_NAME = re.compile(r"\w+")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render(query: SqlQuery) -> Any:
|
|
70
|
+
if isinstance(query, (str, Composable, Template)):
|
|
71
|
+
return query
|
|
72
|
+
if not hasattr(query, "sql"):
|
|
73
|
+
raise TypeError(f"fetch_all: unsupported query type {type(query).__name__}")
|
|
74
|
+
# Anything else is a sqlglot expression (no sqlglot import needed to know that; it is loaded
|
|
75
|
+
# already). Its postgres dialect renders `exp.Placeholder("id")` as `%(id)s`.
|
|
76
|
+
from sqlglot import exp
|
|
77
|
+
|
|
78
|
+
for node in query.walk():
|
|
79
|
+
# sqlglot renders these verbatim, so they could smuggle arbitrary SQL into the statement.
|
|
80
|
+
if isinstance(node, exp.Command):
|
|
81
|
+
raise ValueError("fetch_all: raw sqlglot Command nodes are not allowed")
|
|
82
|
+
if isinstance(node, exp.Placeholder) and node.name and not _PLACEHOLDER_NAME.fullmatch(node.name):
|
|
83
|
+
raise ValueError(f"fetch_all: invalid placeholder name {node.name!r}")
|
|
84
|
+
return query.sql(dialect="postgres")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _run_query(c: AsyncConnection, query: Any, params: QueryParams) -> list[dict[str, Any]]:
|
|
88
|
+
async with c.cursor(row_factory=dict_row) as cur:
|
|
89
|
+
await cur.execute(query, params)
|
|
90
|
+
return await cur.fetchall()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _abort(c: AsyncConnection, work: asyncio.Future[Any]) -> None:
|
|
94
|
+
"""Cancel `work`'s query on the server and wait until it has unwound, leaving `c` idle (an
|
|
95
|
+
aborted transaction, to be rolled back by its owner). If the cancel request itself can't be
|
|
96
|
+
delivered, the connection's state is unknown, so it is closed (the server abandons the query as
|
|
97
|
+
soon as it next talks to the client) and a pool discards it instead of reusing it."""
|
|
98
|
+
if not work.done():
|
|
99
|
+
try:
|
|
100
|
+
await c.cancel_safe()
|
|
101
|
+
except Exception:
|
|
102
|
+
logger.warning("fetch_all: cancelling the query failed; closing the connection", exc_info=True)
|
|
103
|
+
with suppress(Exception):
|
|
104
|
+
await c.close() # first: a cancelled-but-still-busy connection would make `work` wait out the query
|
|
105
|
+
work.cancel()
|
|
106
|
+
await asyncio.gather(work, return_exceptions=True)
|
|
107
|
+
return
|
|
108
|
+
await asyncio.gather(work, return_exceptions=True) # always consume `work`'s outcome
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _abort_uninterruptibly(c: AsyncConnection, work: asyncio.Future[Any]) -> None:
|
|
112
|
+
"""`_abort`, but a further cancellation of the awaiting task can't cut it short (which would
|
|
113
|
+
leave `work` running); it is re-raised once the abort is complete."""
|
|
114
|
+
task = asyncio.ensure_future(_abort(c, work))
|
|
115
|
+
interrupted = False
|
|
116
|
+
while not task.done():
|
|
117
|
+
try:
|
|
118
|
+
await asyncio.shield(task)
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
interrupted = True
|
|
121
|
+
task.result()
|
|
122
|
+
if interrupted:
|
|
123
|
+
raise asyncio.CancelledError
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _fetch_dicts(
|
|
127
|
+
query: Any, # already rendered (see `_render`)
|
|
128
|
+
params: QueryParams,
|
|
129
|
+
con: AsyncConnection | None,
|
|
130
|
+
pool: ConnectionSource | None,
|
|
131
|
+
cancel: asyncio.Event | None,
|
|
132
|
+
) -> list[dict[str, Any]]:
|
|
133
|
+
if cancel is not None and cancel.is_set():
|
|
134
|
+
raise QueryCanceled("fetch_all: cancelled before the query started")
|
|
135
|
+
async with _acquire(con, pool) as c:
|
|
136
|
+
work = asyncio.ensure_future(_run_query(c, query, params))
|
|
137
|
+
stop = asyncio.ensure_future(cancel.wait()) if cancel is not None else None
|
|
138
|
+
try:
|
|
139
|
+
await asyncio.wait([work, *([stop] if stop else [])], return_when=asyncio.FIRST_COMPLETED)
|
|
140
|
+
if not work.done(): # `cancel` was set while the query runs: abort it on the server
|
|
141
|
+
await _abort_uninterruptibly(c, work)
|
|
142
|
+
if work.cancelled():
|
|
143
|
+
raise QueryCanceled("fetch_all: cancelled via `cancel`")
|
|
144
|
+
return work.result() # raises psycopg.errors.QueryCanceled if it was cancelled
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
# The awaiting task itself was cancelled: abandoning the query client-side would leave it
|
|
147
|
+
# running on the server (and the connection mid-query), so cancel it there and let it
|
|
148
|
+
# unwind first -- the connection is then idle for its owner/the pool.
|
|
149
|
+
await _abort_uninterruptibly(c, work)
|
|
150
|
+
raise
|
|
151
|
+
finally:
|
|
152
|
+
if stop is not None:
|
|
153
|
+
stop.cancel()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@overload
|
|
157
|
+
async def fetch_all[M: BaseModel](
|
|
158
|
+
query: SqlQueryNoTemplate,
|
|
159
|
+
params: QueryParams = None,
|
|
160
|
+
*,
|
|
161
|
+
model: type[M],
|
|
162
|
+
con: AsyncConnection | None = None,
|
|
163
|
+
pool: ConnectionSource | None = None,
|
|
164
|
+
cancel: asyncio.Event | None = None,
|
|
165
|
+
) -> list[M]: ...
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@overload
|
|
169
|
+
async def fetch_all[T](
|
|
170
|
+
query: SqlQueryNoTemplate,
|
|
171
|
+
params: QueryParams = None,
|
|
172
|
+
*,
|
|
173
|
+
row_mapper: Callable[[dict[str, Any]], T],
|
|
174
|
+
con: AsyncConnection | None = None,
|
|
175
|
+
pool: ConnectionSource | None = None,
|
|
176
|
+
cancel: asyncio.Event | None = None,
|
|
177
|
+
) -> list[T]: ...
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@overload
|
|
181
|
+
async def fetch_all(
|
|
182
|
+
query: SqlQueryNoTemplate,
|
|
183
|
+
params: QueryParams = None,
|
|
184
|
+
*,
|
|
185
|
+
model: None = None,
|
|
186
|
+
row_mapper: None = None,
|
|
187
|
+
con: AsyncConnection | None = None,
|
|
188
|
+
pool: ConnectionSource | None = None,
|
|
189
|
+
cancel: asyncio.Event | None = None,
|
|
190
|
+
) -> list[dict[str, Any]]: ...
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@overload
|
|
194
|
+
async def fetch_all[M: BaseModel](
|
|
195
|
+
query: Template,
|
|
196
|
+
*,
|
|
197
|
+
model: type[M],
|
|
198
|
+
con: AsyncConnection | None = None,
|
|
199
|
+
pool: ConnectionSource | None = None,
|
|
200
|
+
cancel: asyncio.Event | None = None,
|
|
201
|
+
) -> list[M]: ...
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@overload
|
|
205
|
+
async def fetch_all[T](
|
|
206
|
+
query: Template,
|
|
207
|
+
*,
|
|
208
|
+
row_mapper: Callable[[dict[str, Any]], T],
|
|
209
|
+
con: AsyncConnection | None = None,
|
|
210
|
+
pool: ConnectionSource | None = None,
|
|
211
|
+
cancel: asyncio.Event | None = None,
|
|
212
|
+
) -> list[T]: ...
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@overload
|
|
216
|
+
async def fetch_all(
|
|
217
|
+
query: Template,
|
|
218
|
+
*,
|
|
219
|
+
model: None = None,
|
|
220
|
+
row_mapper: None = None,
|
|
221
|
+
con: AsyncConnection | None = None,
|
|
222
|
+
pool: ConnectionSource | None = None,
|
|
223
|
+
cancel: asyncio.Event | None = None,
|
|
224
|
+
) -> list[dict[str, Any]]: ...
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def fetch_all(
|
|
228
|
+
query: SqlQuery,
|
|
229
|
+
params: QueryParams = None,
|
|
230
|
+
*,
|
|
231
|
+
model: type[BaseModel] | None = None,
|
|
232
|
+
row_mapper: Callable[[dict[str, Any]], Any] | None = None,
|
|
233
|
+
con: AsyncConnection | None = None,
|
|
234
|
+
pool: ConnectionSource | None = None,
|
|
235
|
+
cancel: asyncio.Event | None = None,
|
|
236
|
+
) -> list[Any]:
|
|
237
|
+
"""Run one query and return every row.
|
|
238
|
+
|
|
239
|
+
Rows come back as plain dicts, or, with `model=` (a Pydantic model class), validated into
|
|
240
|
+
that model; `row_mapper=` converts each dict row into any other shape. The two are exclusive.
|
|
241
|
+
|
|
242
|
+
Pass `con` to run on a connection you already hold (e.g. inside a transaction): it is
|
|
243
|
+
borrowed, never committed, closed or returned to a pool. Without it, a connection is taken
|
|
244
|
+
from `pool` or the pool registered via `set_default_pool()` and released afterwards.
|
|
245
|
+
|
|
246
|
+
Set `cancel` (an `asyncio.Event`, e.g. when the HTTP client disconnects) to abort a running
|
|
247
|
+
query on the server: `fetch_all` then raises `psycopg.errors.QueryCanceled`. If the awaiting
|
|
248
|
+
task is cancelled instead, the query is cancelled server-side too and `CancelledError`
|
|
249
|
+
propagates, so the query never keeps running unattended. `cancel` aborts whatever statement is
|
|
250
|
+
running on the connection, so don't use it on a `con` that concurrent tasks share. If the
|
|
251
|
+
cancel request itself can't be delivered, the connection is closed.
|
|
252
|
+
|
|
253
|
+
`query` must be a literal string, a sqlglot expression, a `psycopg.sql` composable or a
|
|
254
|
+
t-string -- never text built with f-strings/concatenation (a plain `str` fails type checking).
|
|
255
|
+
A sqlglot expression is only as safe as the strings it was built from (the builders parse plain
|
|
256
|
+
strings as SQL): pass user values as `exp.Placeholder` + `params`, never as literals/raw text.
|
|
257
|
+
In one, write a literal `%` as `%%` when you also pass `params`.
|
|
258
|
+
A t-string already carries its values, so it must not be combined with `params`.
|
|
259
|
+
"""
|
|
260
|
+
if model is not None and row_mapper is not None:
|
|
261
|
+
raise TypeError("Pass either `model` or `row_mapper`, not both.")
|
|
262
|
+
if isinstance(query, Template) and params is not None:
|
|
263
|
+
raise TypeError("A t-string query carries its own values; don't pass `params` with it.")
|
|
264
|
+
rows = await _fetch_dicts(_render(query), params, con, pool, cancel)
|
|
265
|
+
if model is not None:
|
|
266
|
+
return [model.model_validate(row) for row in rows]
|
|
267
|
+
if row_mapper is not None:
|
|
268
|
+
return [row_mapper(row) for row in rows]
|
|
269
|
+
return rows
|
|
@@ -11,13 +11,13 @@ packages = ["pgdevkit"]
|
|
|
11
11
|
|
|
12
12
|
[project]
|
|
13
13
|
name = "pgdevkit"
|
|
14
|
-
version = "0.
|
|
14
|
+
version = "0.13.0"
|
|
15
15
|
description = "A helper for developing with Postgres"
|
|
16
16
|
readme = "README.md"
|
|
17
17
|
requires-python = ">=3.14"
|
|
18
18
|
dependencies = [
|
|
19
19
|
"docker>=7.1.0",
|
|
20
|
-
"psycopg[binary]>=3.
|
|
20
|
+
"psycopg[binary]>=3.3.0",
|
|
21
21
|
"sqlglot>=30.11.0",
|
|
22
22
|
]
|
|
23
23
|
|
|
@@ -45,7 +45,7 @@ dev = [
|
|
|
45
45
|
"ty>=0.0.59",
|
|
46
46
|
]
|
|
47
47
|
test = [
|
|
48
|
-
"psycopg[binary]>=3.
|
|
48
|
+
"psycopg[binary]>=3.3.0",
|
|
49
49
|
"pytest>=9.1.0",
|
|
50
50
|
"pytest-asyncio>=0.24.0",
|
|
51
51
|
"pytest-env>=1.1.0",
|
|
@@ -3,8 +3,8 @@ name: pgdevkit
|
|
|
3
3
|
plugin: coding
|
|
4
4
|
description: >
|
|
5
5
|
Use pgdevkit for any PostgreSQL work in a Python project: a local
|
|
6
|
-
Docker/Podman test database (`pgdb testdb`), importable ORM-free CRUD
|
|
7
|
-
helpers (`pgdevkit.db`), and the `database/`-folder schema-as-code
|
|
6
|
+
Docker/Podman test database (`pgdb testdb`), importable ORM-free CRUD and
|
|
7
|
+
query helpers (`pgdevkit.db`: `pg_*`, `fetch_all`, `PgPool`, `SqlLoader`), and the `database/`-folder schema-as-code
|
|
8
8
|
convention. Supersedes the old postgres-test-setup, postgres-best-practices,
|
|
9
9
|
and database-in-source skills — pgdevkit is a real dependency now, not
|
|
10
10
|
copy-pasted reference files. Use whenever the user wants to set up a local
|
|
@@ -33,7 +33,7 @@ Core rules for every piece of database code in a project using pgdevkit:
|
|
|
33
33
|
- **Inline SQL** — trivial queries of **4 lines or fewer** may be written inline in Python. Anything with JOINs, subqueries, CTEs, aggregations, or multiple conditions lives in its own `.sql` file.
|
|
34
34
|
- **Named parameters** — always `%(name)s` style, never positional `%s`.
|
|
35
35
|
- **The `database/` folder is the source of truth for the schema** — see [docs/database-layout.md](../../docs/database-layout.md) for the layer/object-type/file-naming conventions.
|
|
36
|
-
- **Result mapping** — every query result maps to a Pydantic model; table-mapped models extend `pgdevkit.db.PostgresTableModel`.
|
|
36
|
+
- **Result mapping** — every query result maps to a Pydantic model (or is deliberately returned as dicts / through `row_mapper`); table-mapped models extend `pgdevkit.db.PostgresTableModel`.
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
@@ -113,12 +113,13 @@ SQL files live under `db/queries/<topic>/`. Every custom query gets its own file
|
|
|
113
113
|
|
|
114
114
|
```python
|
|
115
115
|
# db/connection.py
|
|
116
|
-
from pgdevkit.db import PgPool
|
|
116
|
+
from pgdevkit.db import PgPool, set_default_pool
|
|
117
117
|
|
|
118
118
|
pool = PgPool(env_prefix="APP_POSTGRES_") # matches ensure_testdb()'s {env_prefix}POSTGRES_* vars
|
|
119
119
|
|
|
120
120
|
async def startup():
|
|
121
121
|
await pool.open()
|
|
122
|
+
set_default_pool(pool) # lets `fetch_all()` borrow from it when no `con=`/`pool=` is given
|
|
122
123
|
```
|
|
123
124
|
|
|
124
125
|
### Models
|
|
@@ -170,7 +171,7 @@ from pgdevkit.db import pg_retrieve, pg_insert, pg_upsert, pg_delete
|
|
|
170
171
|
| `pg_insert_many` | Batch insert via `executemany` |
|
|
171
172
|
| `pg_delete` / `pg_delete_dict` | Delete by PK, returns deleted row |
|
|
172
173
|
|
|
173
|
-
Use these for simple CRUD. For custom `WHERE` clauses, joins, aggregations, or ordering, write a dedicated `.sql` file and a repository method.
|
|
174
|
+
Use these for simple CRUD. For custom `WHERE` clauses, joins, aggregations, or ordering, write a dedicated `.sql` file and run it with `fetch_all` (below) in a repository method.
|
|
174
175
|
|
|
175
176
|
### Loading `.sql` files
|
|
176
177
|
|
|
@@ -184,8 +185,7 @@ sql = SqlLoader(Path(__file__).parent / "queries")
|
|
|
184
185
|
|
|
185
186
|
```python
|
|
186
187
|
# db/repositories/user_repository.py
|
|
187
|
-
from
|
|
188
|
-
from pgdevkit.db import pg_retrieve, pg_delete
|
|
188
|
+
from pgdevkit.db import fetch_all, pg_retrieve, pg_delete
|
|
189
189
|
from db.connection import pool
|
|
190
190
|
from db.loader import sql
|
|
191
191
|
from models.user_models import UserRow, UserSummary
|
|
@@ -196,11 +196,7 @@ class UserRepository:
|
|
|
196
196
|
return await pg_retrieve(conn, UserRow, {"id": user_id})
|
|
197
197
|
|
|
198
198
|
async def list_active(self, limit: int = 100) -> list[UserSummary]:
|
|
199
|
-
|
|
200
|
-
async with conn.cursor(row_factory=dict_row) as cur:
|
|
201
|
-
await cur.execute(sql.load_sql("users", "list_active_users"), {"limit": limit})
|
|
202
|
-
rows = await cur.fetchall()
|
|
203
|
-
return [UserSummary.model_validate(r) for r in rows]
|
|
199
|
+
return await fetch_all(sql.load_sql("users", "list_active_users"), {"limit": limit}, model=UserSummary)
|
|
204
200
|
|
|
205
201
|
async def delete(self, user: UserRow) -> UserRow | None:
|
|
206
202
|
async with pool.connection() as conn:
|
|
@@ -209,6 +205,24 @@ class UserRepository:
|
|
|
209
205
|
|
|
210
206
|
Named parameters, `%(name)s` style, dict argument — never positional `%s`, never f-strings or `str.format()` for SQL text.
|
|
211
207
|
|
|
208
|
+
### `fetch_all` — custom queries without cursor boilerplate
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from pgdevkit.db import fetch_all, set_default_pool
|
|
212
|
+
|
|
213
|
+
set_default_pool(pool) # once, at startup -- the connection source when none is passed
|
|
214
|
+
|
|
215
|
+
users = await fetch_all(sql.load_sql("users", "list_active_users"), {"limit": 10}, model=UserSummary) # list[UserSummary]
|
|
216
|
+
rows = await fetch_all(sql.load_sql("users", "list_active_users"), {"limit": 10}) # list[dict]
|
|
217
|
+
async with pool.connection() as conn: # inside a transaction you already hold
|
|
218
|
+
users = await fetch_all(query, params, model=UserSummary, con=conn)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- `model=` validates each row into a Pydantic model; `row_mapper=` maps each dict row to anything else (exclusive with `model`).
|
|
222
|
+
- 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
|
+
- `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).
|
|
224
|
+
- `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
|
+
|
|
212
226
|
### Dynamic SQL
|
|
213
227
|
|
|
214
228
|
Avoid it whenever possible — a static `.sql` file is always clearer. See [`references/dynamic-sql.md`](references/dynamic-sql.md) for t-string templates (3.14+) and `psycopg.sql` (< 3.14) when column/table names genuinely vary at runtime.
|
|
@@ -316,7 +330,7 @@ Reports drift between the `database/` `.sql` files and the actual schema — tab
|
|
|
316
330
|
- [ ] Simple CRUD uses `pgdevkit.db`'s `pg_*` helpers; custom queries use `.sql` files loaded via `SqlLoader`
|
|
317
331
|
- [ ] Inline SQL only for trivial queries ≤ 4 lines; anything with JOINs/CTEs/aggregations/subqueries uses a `.sql` file
|
|
318
332
|
- [ ] All parameters use `%(name)s` style with a dict argument
|
|
319
|
-
- [ ]
|
|
333
|
+
- [ ] 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`
|
|
320
334
|
- [ ] No `LATERAL JOIN` — use a CTE that groups/aggregates first, then joins it
|
|
321
335
|
- [ ] `.<env>.sql` files (e.g. `.prod.sql`) are skipped by `pgdb testdb` unless it's run with a matching `--env`
|
|
322
336
|
- [ ] Every table (and non-obvious column) has a `COMMENT ON`, placed in the object's own `.sql` file
|
|
@@ -25,6 +25,9 @@ T-strings look like f-strings but are evaluated by psycopg — values are always
|
|
|
25
25
|
await cur.execute(t"SELECT * FROM users WHERE id = {user_id}")
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
`fetch_all` accepts t-strings and `psycopg.sql` composables too (don't also pass `params` with a t-string):
|
|
29
|
+
`await fetch_all(t"SELECT {column:i} FROM users WHERE active = {active}", model=UserRow)`.
|
|
30
|
+
|
|
28
31
|
**Dynamic identifier (`:i`):**
|
|
29
32
|
|
|
30
33
|
```python
|