pgdevkit 0.15.1__tar.gz → 0.15.2__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.
Files changed (150) hide show
  1. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/PKG-INFO +2 -2
  2. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/README.md +1 -1
  3. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/fetch.py +3 -1
  4. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pyproject.toml +1 -1
  5. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/skills/pgdevkit/SKILL.md +1 -1
  6. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_fetch.py +11 -0
  7. pgdevkit-0.15.2/tests/db/test_query_expr_types.py +69 -0
  8. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/uv.lock +1 -1
  9. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/.github/workflows/auto-release.yml +0 -0
  10. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/.github/workflows/python-publish.yml +0 -0
  11. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/.github/workflows/python-test.yml +0 -0
  12. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/.gitignore +0 -0
  13. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/.python-version +0 -0
  14. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/docs/database-layout.md +0 -0
  15. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/__init__.py +0 -0
  16. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/areas.py +0 -0
  17. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/backends/__init__.py +0 -0
  18. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/backends/base.py +0 -0
  19. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/backends/mssql.py +0 -0
  20. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/backends/postgres.py +0 -0
  21. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/cli.py +0 -0
  22. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/connection.py +0 -0
  23. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/__init__.py +0 -0
  24. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/complex_types.py +0 -0
  25. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/connection.py +0 -0
  26. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/crud.py +0 -0
  27. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/loader.py +0 -0
  28. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/model.py +0 -0
  29. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/mssql_crud.py +0 -0
  30. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/mssql_sql.py +0 -0
  31. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/db/transaction.py +0 -0
  32. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/dialect.py +0 -0
  33. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/diff.py +0 -0
  34. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/envtag.py +0 -0
  35. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/fastapi/__init__.py +0 -0
  36. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/fastapi/json_response.py +0 -0
  37. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/fetch_missing.py +0 -0
  38. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/introspect.py +0 -0
  39. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/lakebase.py +0 -0
  40. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/migrate.py +0 -0
  41. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/migrate_mssql.py +0 -0
  42. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/models.py +0 -0
  43. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/mssql_introspect.py +0 -0
  44. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/parser.py +0 -0
  45. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/schemas.py +0 -0
  46. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/sql_text.py +0 -0
  47. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/stats.py +0 -0
  48. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/__init__.py +0 -0
  49. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/_docker.py +0 -0
  50. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/api.py +0 -0
  51. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/config.py +0 -0
  52. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/constants.py +0 -0
  53. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/container.py +0 -0
  54. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/__init__.py +0 -0
  55. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/api.py +0 -0
  56. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/constants.py +0 -0
  57. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/mssql/container.py +0 -0
  58. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/naming.py +0 -0
  59. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/query.py +0 -0
  60. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/pgdevkit/testdb/schema.py +0 -0
  61. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  62. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/skills/pgdevkit/references/temporal-tables.md +0 -0
  63. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/__init__.py +0 -0
  64. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/_asgi.py +0 -0
  65. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/_granian_app.py +0 -0
  66. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/conftest.py +0 -0
  67. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/__init__.py +0 -0
  68. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_complex_types.py +0 -0
  69. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_connection.py +0 -0
  70. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_crud.py +0 -0
  71. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_fetch_statement_timeout.py +0 -0
  72. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_loader.py +0 -0
  73. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_mssql_crud_live.py +0 -0
  74. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_mssql_crud_sql.py +0 -0
  75. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/db/test_transaction.py +0 -0
  76. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/fixtures/01_schema.sql +0 -0
  77. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/fixtures/02_types.sql +0 -0
  78. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/fixtures/03_tables.sql +0 -0
  79. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/fixtures/04_views.sql +0 -0
  80. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/fixtures/05_functions.sql +0 -0
  81. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/fixtures/06_indexes.sql +0 -0
  82. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_areas.py +0 -0
  83. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_cli_compare.py +0 -0
  84. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_compare.py +0 -0
  85. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_compare_mssql_live.py +0 -0
  86. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_connection.py +0 -0
  87. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_dialect.py +0 -0
  88. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_diff_mssql.py +0 -0
  89. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_envtag.py +0 -0
  90. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_fetch_missing.py +0 -0
  91. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_fetch_missing_cli.py +0 -0
  92. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_lakebase.py +0 -0
  93. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate.py +0 -0
  94. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate_areas.py +0 -0
  95. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate_env.py +0 -0
  96. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate_grants.py +0 -0
  97. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate_mssql.py +0 -0
  98. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate_mssql_live.py +0 -0
  99. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_migrate_schemas.py +0 -0
  100. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_mssql_introspect.py +0 -0
  101. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_parser_areas.py +0 -0
  102. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_parser_mssql.py +0 -0
  103. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_parser_schemas.py +0 -0
  104. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_pg_json_cancel.py +0 -0
  105. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_pg_json_granian.py +0 -0
  106. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_pg_json_response.py +0 -0
  107. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_pg_json_statement_timeout.py +0 -0
  108. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_schemas.py +0 -0
  109. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_stats_cli.py +0 -0
  110. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/test_stats_mssql_live.py +0 -0
  111. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/__init__.py +0 -0
  112. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/conftest.py +0 -0
  113. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  114. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  115. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  116. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  117. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  118. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  119. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  120. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  121. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  122. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  123. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  124. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  125. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  126. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  127. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  128. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  129. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  130. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  131. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  132. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  133. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  134. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  135. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_api.py +0 -0
  136. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_api_mssql.py +0 -0
  137. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_api_mssql_live.py +0 -0
  138. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_cli.py +0 -0
  139. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_config.py +0 -0
  140. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_config_mssql.py +0 -0
  141. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_constants.py +0 -0
  142. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_container.py +0 -0
  143. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_container_mssql.py +0 -0
  144. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_docker.py +0 -0
  145. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_mssql_constants.py +0 -0
  146. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_naming.py +0 -0
  147. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_query.py +0 -0
  148. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_schema.py +0 -0
  149. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_schema_filtering.py +0 -0
  150. {pgdevkit-0.15.1 → pgdevkit-0.15.2}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.15.1
3
+ Version: 0.15.2
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -568,7 +568,7 @@ for a `Response`, and the rows are *not* validated against the model.
568
568
  starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
569
569
  with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
570
570
  closes it too early. Prefer the pool.
571
- - **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
571
+ - **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine) or a t-string, with values bound
572
572
  through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
573
573
  is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
574
574
  build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
@@ -543,7 +543,7 @@ for a `Response`, and the rows are *not* validated against the model.
543
543
  starts and released when it ends. A `con` must stay open until the response has been sent, i.e. come from a `Depends`
544
544
  with `yield` (FastAPI >= 0.118); `async with pool.connection() as conn: return PostgresJsonResponse(q, con=conn)`
545
545
  closes it too early. Prefer the pool.
546
- - **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression or a t-string, with values bound
546
+ - **Query** — a literal string, a `psycopg.sql` composable, a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine) or a t-string, with values bound
547
547
  through `params` (`%(name)s`; a t-string carries its own values and takes no `params`). Passing only literals
548
548
  is enforced by the type checker, **not at runtime**: the query is wrapped as a subquery, never parsed, so never
549
549
  build it from user input. For a data-modifying CTE, or custom JSON, pass `query_produces_json=True` and return one
@@ -28,7 +28,9 @@ QueryParams = Mapping[str, Any] | Sequence[Any] | None
28
28
  # The only things a query may be: a literal string (e.g. from `SqlLoader.load_sql()`), a sqlglot
29
29
  # expression, or psycopg's own safe-composition types (`psycopg.sql` / a t-string). A plain `str`
30
30
  # is rejected by the type checker on purpose, so user-controlled text can't be concatenated in.
31
- type SqlQueryNoTemplate = LiteralString | exp.Expression | Composable
31
+ # The sqlglot type is `exp.Expr` (any query: Select, Union, Subquery...), not `exp.Expression`: in
32
+ # sqlglot 30 `exp.Query` is an `exp.Expr` but not an `exp.Expression` (issue #56), so don't narrow it back.
33
+ type SqlQueryNoTemplate = LiteralString | exp.Expr | Composable
32
34
  type SqlQuery = SqlQueryNoTemplate | Template
33
35
 
34
36
 
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.15.1"
14
+ version = "0.15.2"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -234,7 +234,7 @@ deleted = await execute("DELETE FROM sessions WHERE user_id = %(id)s", {"id": 1}
234
234
  - 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.
235
235
  - `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). A plain value *replaces* the connection's own setting in both directions, so a per-call 900 s overrides a database-level safety net of 600 s; `statement_timeout=at_most(120)` (`from pgdevkit.db import at_most`) never loosens it: the effective timeout is `min(current, 120)`.
236
236
  - `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.
237
- - `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. A literal `%` in a sqlglot expression (`LIKE 'a%'`, modulo) just works, with or without `params`; in a plain string or `psycopg.sql` composable it is psycopg's rule: write `%%` when you pass `params`. A t-string carries its own values, so don't also pass `params`.
237
+ - `query` is typed `SqlQuery`: a literal string (`SqlLoader.load_sql()`), a sqlglot expression (any `exp.Expr`, so Select, Union, Subquery and other `exp.Query` values are fine), 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. A literal `%` in a sqlglot expression (`LIKE 'a%'`, modulo) just works, with or without `params`; in a plain string or `psycopg.sql` composable it is psycopg's rule: write `%%` when you pass `params`. A t-string carries its own values, so don't also pass `params`.
238
238
 
239
239
  ### Dynamic SQL
240
240
 
@@ -120,6 +120,17 @@ async def test_accepts_sqlglot_expression_psycopg_sql_and_t_string(pool: PgPool)
120
120
  assert await fetch_all(t"SELECT name FROM widget WHERE id = {widget_id}", pool=pool) == [{"name": "sprocket"}]
121
121
 
122
122
 
123
+ @requires_podman
124
+ async def test_accepts_exp_query_typed_union_and_subquery(pool: PgPool):
125
+ # annotated exp.Query on purpose (issue #56)
126
+ union: exp.Query = select("name").from_("widget").where("id = 1").union(select("name").from_("widget").where("id = 2"))
127
+ assert sorted(r["name"] for r in await fetch_all(union, pool=pool)) == ["cog", "sprocket"]
128
+ sub: exp.Query = select("name").from_(select("name").from_("widget").subquery("w")).where("name = 'cog'")
129
+ assert await fetch_all(sub, pool=pool) == [{"name": "cog"}]
130
+ assert await fetch_one(union, pool=pool) is not None
131
+ assert await fetch_scalar(select("count(*)").from_(union.subquery("u")), pool=pool) == 2
132
+
133
+
123
134
  async def test_already_set_cancel_raises_before_touching_a_connection():
124
135
  cancel = asyncio.Event()
125
136
  cancel.set()
@@ -0,0 +1,69 @@
1
+ """sqlglot queries are typed as `exp.Expr`, so an `exp.Query` (base of Select/Union/Subquery) is accepted too.
2
+
3
+ In sqlglot 30 `exp.Query` is an `exp.Expr` but not an `exp.Expression`; the annotations used to say
4
+ `exp.Expression`, which a type checker such as pyright rejected for `exp.Query`-typed values (issue #56).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+
12
+ import pytest
13
+ from sqlglot import exp, select
14
+
15
+ from pgdevkit.db.fetch import _render
16
+ from pgdevkit.fastapi import PostgresJsonResponse
17
+
18
+
19
+ def _union() -> exp.Query:
20
+ return select("1 AS a").union(select("2 AS a"))
21
+
22
+
23
+ def _subquery() -> exp.Query:
24
+ return select("a").from_(select("1 AS a").subquery("t"))
25
+
26
+
27
+ # --- runtime: no database needed for `_render` ---------------------------------------------------------------------
28
+
29
+
30
+ def test_render_union_and_subquery():
31
+ assert _render(_union()) == "SELECT 1 AS a UNION SELECT 2 AS a"
32
+ assert _render(_subquery()) == "SELECT a FROM (SELECT 1 AS a) AS t"
33
+ assert _render(_union(), {}) == "SELECT 1 AS a UNION SELECT 2 AS a"
34
+
35
+
36
+ def test_postgres_json_response_accepts_union_and_subquery():
37
+ for query in (_union(), _subquery()):
38
+ assert _render(query) in PostgresJsonResponse(query).query.as_string()
39
+ with pytest.raises(ValueError, match="Command"):
40
+ PostgresJsonResponse(select("1").union(select(exp.Command(this="SELECT", expression=" version()"))))
41
+
42
+
43
+ def test_render_keeps_placeholders_and_escapes_percent_inside_a_union():
44
+ query = (
45
+ select("name").from_("w").where(exp.column("id").eq(exp.Placeholder(this="id"))).where("name LIKE '50%'")
46
+ ).union(select("'x%'"))
47
+ rendered = _render(query, {"id": 1})
48
+ assert "%(id)s" in rendered
49
+ assert "'50%%'" in rendered
50
+ assert "'x%%'" in rendered
51
+
52
+
53
+ def test_render_rejects_command_and_bad_placeholder_nested_in_a_union():
54
+ command = exp.Command(this="SELECT", expression=" version()")
55
+ with pytest.raises(ValueError, match="Command"):
56
+ _render(select("1").union(select(command)))
57
+ with pytest.raises(ValueError, match="Command"):
58
+ _render(select("a").from_(select(command).subquery("t")))
59
+ bad = exp.Placeholder(this="x); DROP TABLE t;--")
60
+ with pytest.raises(ValueError, match="placeholder name"):
61
+ _render(select("1").union(select("2").where(bad)), {"x": 1})
62
+ with pytest.raises(ValueError, match="placeholder name"):
63
+ _render(select("a").from_(select("2 AS a").where(bad).subquery("t")), {"x": 1})
64
+
65
+
66
+ def test_importing_pgdevkit_db_does_not_import_sqlglot():
67
+ code = "import sys, pgdevkit.db; sys.exit('sqlglot' in sys.modules)"
68
+ assert subprocess.run([sys.executable, "-c", code], check=False).returncode == 0
69
+
@@ -451,7 +451,7 @@ wheels = [
451
451
 
452
452
  [[package]]
453
453
  name = "pgdevkit"
454
- version = "0.15.1"
454
+ version = "0.15.2"
455
455
  source = { editable = "." }
456
456
  dependencies = [
457
457
  { name = "docker" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes