iron-sql 0.4.2__tar.gz → 0.4.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iron-sql
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: iron_sql generates typed async PostgreSQL clients and runtime helpers from schemas and SQL queries
5
5
  Keywords: postgresql,sql,sqlc,psycopg,codegen,async
6
6
  Author: Ilia Ablamonov
@@ -46,6 +46,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
46
46
  - **Query discovery.** `generate_sql_package` scans your codebase for calls like `<package>_sql("SELECT ...")`, runs `sqlc` for type analysis, and emits a typed module.
47
47
  - **Strong typing.** Generated dataclasses and method signatures flow through your IDE and type checker.
48
48
  - **Async runtime.** Built on `psycopg` v3 with pooled connections, context-based connection reuse, and transaction helpers.
49
+ - **Streaming.** `query_stream()` uses server-side cursors for memory-efficient iteration over large result sets.
49
50
  - **Safe by default.** Helper methods enforce expected row counts instead of returning silent `None`.
50
51
 
51
52
  ## Package Layout
@@ -98,7 +99,8 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
98
99
  - `ConnectionPool` opens lazily and reopens after `close()`, with `ContextVar`-based connection reuse for nested contexts.
99
100
  - `*_listen_session()` uses a dedicated pooled connection and doesn't reuse `ContextVar` transaction connections.
100
101
  - `query_single_row()` raises `NoRowsError`; `query_optional_row()` returns `None`. Both raise `TooManyRowsError` on 2+ rows.
101
- - JSONB params are sent with `pgjson.Jsonb`; JSON with `pgjson.Json`. Scalar row factories validate types at runtime.
102
+ - `query_stream()` returns an async context manager yielding an `AsyncIterator`; uses server-side cursors with automatic transaction management.
103
+ - JSONB params are sent with `psycopg.types.json.Jsonb`; JSON with `psycopg.types.json.Json`. Scalar row factories validate types at runtime.
102
104
  - `json_validated` decorator applies Pydantic model validation to dataclass fields on construction.
103
105
 
104
106
  ## Example
@@ -20,6 +20,7 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
20
20
  - **Query discovery.** `generate_sql_package` scans your codebase for calls like `<package>_sql("SELECT ...")`, runs `sqlc` for type analysis, and emits a typed module.
21
21
  - **Strong typing.** Generated dataclasses and method signatures flow through your IDE and type checker.
22
22
  - **Async runtime.** Built on `psycopg` v3 with pooled connections, context-based connection reuse, and transaction helpers.
23
+ - **Streaming.** `query_stream()` uses server-side cursors for memory-efficient iteration over large result sets.
23
24
  - **Safe by default.** Helper methods enforce expected row counts instead of returning silent `None`.
24
25
 
25
26
  ## Package Layout
@@ -72,7 +73,8 @@ The `sqlc` binary is bundled automatically via the `sqlc` Python package.
72
73
  - `ConnectionPool` opens lazily and reopens after `close()`, with `ContextVar`-based connection reuse for nested contexts.
73
74
  - `*_listen_session()` uses a dedicated pooled connection and doesn't reuse `ContextVar` transaction connections.
74
75
  - `query_single_row()` raises `NoRowsError`; `query_optional_row()` returns `None`. Both raise `TooManyRowsError` on 2+ rows.
75
- - JSONB params are sent with `pgjson.Jsonb`; JSON with `pgjson.Json`. Scalar row factories validate types at runtime.
76
+ - `query_stream()` returns an async context manager yielding an `AsyncIterator`; uses server-side cursors with automatic transaction management.
77
+ - JSONB params are sent with `psycopg.types.json.Jsonb`; JSON with `psycopg.types.json.Json`. Scalar row factories validate types at runtime.
76
78
  - `json_validated` decorator applies Pydantic model validation to dataclass fields on construction.
77
79
 
78
80
  ## Example
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iron-sql"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
 
5
5
  description = "iron_sql generates typed async PostgreSQL clients and runtime helpers from schemas and SQL queries"
6
6
  readme = "README.md"
@@ -55,9 +55,9 @@ class ParamSpec:
55
55
 
56
56
  match self.db_type:
57
57
  case "json":
58
- expr = f"pgjson.Json({self.name})"
58
+ expr = f"psycopg.types.json.Json({self.name})"
59
59
  case "jsonb":
60
- expr = f"pgjson.Jsonb({self.name})"
60
+ expr = f"psycopg.types.json.Jsonb({self.name})"
61
61
  case _:
62
62
  return self.name
63
63
 
@@ -312,7 +312,6 @@ def generate_sql_package( # noqa: PLR0913, PLR0914
312
312
  render_query_class(
313
313
  q.name,
314
314
  q.text,
315
- package_name,
316
315
  [
317
316
  resolver.param_spec(
318
317
  p.column,
@@ -385,16 +384,20 @@ import uuid
385
384
  from collections.abc import AsyncGenerator
386
385
  from collections.abc import AsyncIterator
387
386
  from collections.abc import Sequence
387
+ from contextlib import AbstractAsyncContextManager
388
388
  from contextlib import asynccontextmanager
389
389
  from contextvars import ContextVar
390
390
  from dataclasses import dataclass
391
391
  from enum import StrEnum
392
+ from typing import ClassVar
392
393
  from typing import Literal
393
394
  from typing import overload
394
395
 
395
396
  import psycopg
397
+ import psycopg.abc
396
398
  import psycopg.rows
397
- from psycopg.types import json as pgjson
399
+ import psycopg.sql
400
+ import psycopg.types.json
398
401
 
399
402
  from iron_sql import runtime
400
403
 
@@ -447,8 +450,28 @@ async def {package_name}_notify(channel: str, payload: str = "") -> None:
447
450
  {"\n\n\n".join(entities)}
448
451
 
449
452
 
450
- class Query:
451
- pass
453
+ class Query[T]:
454
+ _stmt: ClassVar[psycopg.sql.SQL]
455
+ _row_factory: psycopg.rows.BaseRowFactory[T]
456
+
457
+ @asynccontextmanager
458
+ async def _client_cursor(self, params: psycopg.abc.Params | None):
459
+ async with (
460
+ {package_name}_connection() as conn,
461
+ psycopg.AsyncRawCursor(conn, row_factory=self._row_factory) as cur,
462
+ ):
463
+ await cur.execute(self._stmt, params)
464
+ yield cur
465
+
466
+ @asynccontextmanager
467
+ async def _server_cursor(self, params: psycopg.abc.Params | None):
468
+ async with (
469
+ {package_name}_connection() as conn,
470
+ runtime.ensure_transaction(conn),
471
+ psycopg.AsyncRawServerCursor(conn, row_factory=self._row_factory, name=runtime.next_cursor_name()) as cur,
472
+ ):
473
+ await cur.execute(self._stmt, params)
474
+ yield cur
452
475
 
453
476
 
454
477
  {"\n\n\n".join(query_classes)}
@@ -470,7 +493,7 @@ def {sql_fn_name}(stmt: str, row_type: str | None = None) -> Query:
470
493
  msg = f"Unknown statement: {{stmt!r}}"
471
494
  raise KeyError(msg)
472
495
 
473
- """.strip()
496
+ """.strip() # noqa: E501
474
497
 
475
498
 
476
499
  def render_enum_class(
@@ -536,7 +559,6 @@ def deduplicate_params(params: list[ParamSpec]) -> list[ParamSpec]:
536
559
  def render_query_class(
537
560
  query_name: str,
538
561
  stmt: str,
539
- package_name: str,
540
562
  query_params: list[ParamSpec],
541
563
  result: str,
542
564
  columns_num: int,
@@ -582,39 +604,35 @@ def render_query_class(
582
604
  methods = f"""
583
605
 
584
606
  async def query_all_rows({", ".join(query_fn_params)}) -> list[{result}]:
585
- async with self._execute({params_arg}) as cur:
607
+ async with self._client_cursor({params_arg}) as cur:
586
608
  return await cur.fetchall()
587
609
 
588
610
  async def query_single_row({", ".join(query_fn_params)}) -> {result}:
589
- async with self._execute({params_arg}) as cur:
611
+ async with self._client_cursor({params_arg}) as cur:
590
612
  return runtime.get_one_row(await cur.fetchmany(2))
591
613
 
592
614
  async def query_optional_row({", ".join(query_fn_params)}) -> {base_result} | None:
593
- async with self._execute({params_arg}) as cur:
615
+ async with self._client_cursor({params_arg}) as cur:
594
616
  return runtime.get_one_row_or_none(await cur.fetchmany(2))
595
617
 
596
- """.strip()
618
+ def query_stream({", ".join(query_fn_params)}) -> AbstractAsyncContextManager[AsyncIterator[{result}]]:
619
+ return self._server_cursor({params_arg})
620
+
621
+ """.strip() # noqa: E501
597
622
  else:
598
623
  methods = f"""
599
624
 
600
625
  async def execute({", ".join(query_fn_params)}) -> None:
601
- async with self._execute({params_arg}):
626
+ async with self._client_cursor({params_arg}):
602
627
  pass
603
628
 
604
629
  """.strip()
605
630
 
606
631
  return f"""
607
632
 
608
- class {query_name}(Query):
609
- @asynccontextmanager
610
- async def _execute(self, params) -> AsyncIterator[psycopg.AsyncRawCursor[{result}]]:
611
- stmt = {stmt!r}
612
- async with (
613
- {package_name}_connection() as conn,
614
- psycopg.AsyncRawCursor(conn, row_factory={row_factory}) as cur,
615
- ):
616
- await cur.execute(stmt, params)
617
- yield cur
633
+ class {query_name}(Query[{result}]):
634
+ _stmt = psycopg.sql.SQL({stmt!r})
635
+ _row_factory = staticmethod({row_factory})
618
636
 
619
637
  {indent_block(methods, " ")}
620
638
 
@@ -1,4 +1,5 @@
1
1
  import contextlib
2
+ import itertools
2
3
  import types
3
4
  from collections.abc import AsyncGenerator
4
5
  from collections.abc import AsyncIterator
@@ -14,9 +15,9 @@ from typing import overload
14
15
 
15
16
  import psycopg
16
17
  import psycopg.rows
18
+ import psycopg.sql
19
+ import psycopg.types.json
17
20
  import psycopg_pool
18
- from psycopg import sql
19
- from psycopg.types import json as pgjson
20
21
  from pydantic import TypeAdapter
21
22
 
22
23
  _adapter_cache: dict[object, TypeAdapter[object]] = {}
@@ -63,21 +64,25 @@ async def listen(
63
64
  async def notify(conn: psycopg.AsyncConnection, channel: str, payload: str) -> None:
64
65
  _validate_channel(channel)
65
66
  await conn.execute(
66
- sql.SQL("NOTIFY {}, {}").format(
67
- sql.Identifier(channel),
68
- sql.Literal(payload),
67
+ psycopg.sql.SQL("NOTIFY {}, {}").format(
68
+ psycopg.sql.Identifier(channel),
69
+ psycopg.sql.Literal(payload),
69
70
  )
70
71
  )
71
72
 
72
73
 
73
74
  async def execute_listen(conn: psycopg.AsyncConnection, channel: str) -> None:
74
75
  _validate_channel(channel)
75
- await conn.execute(sql.SQL("LISTEN {}").format(sql.Identifier(channel)))
76
+ await conn.execute(
77
+ psycopg.sql.SQL("LISTEN {}").format(psycopg.sql.Identifier(channel))
78
+ )
76
79
 
77
80
 
78
81
  async def execute_unlisten(conn: psycopg.AsyncConnection, channel: str) -> None:
79
82
  _validate_channel(channel)
80
- await conn.execute(sql.SQL("UNLISTEN {}").format(sql.Identifier(channel)))
83
+ await conn.execute(
84
+ psycopg.sql.SQL("UNLISTEN {}").format(psycopg.sql.Identifier(channel))
85
+ )
81
86
 
82
87
 
83
88
  async def _has_active_listen_subscriptions(conn: psycopg.AsyncConnection) -> bool:
@@ -96,6 +101,26 @@ def _validate_channel(name: str) -> None:
96
101
  raise ValueError(msg)
97
102
 
98
103
 
104
+ _cursor_seq = itertools.count()
105
+
106
+
107
+ def next_cursor_name() -> str:
108
+ return f"_c{next(_cursor_seq)}"
109
+
110
+
111
+ @asynccontextmanager
112
+ async def ensure_transaction(conn: psycopg.AsyncConnection) -> AsyncIterator[None]:
113
+ match conn.info.transaction_status:
114
+ case psycopg.pq.TransactionStatus.IDLE:
115
+ async with conn.transaction():
116
+ yield
117
+ case psycopg.pq.TransactionStatus.INTRANS:
118
+ yield
119
+ case status:
120
+ msg = f"Cannot use server-side cursor: connection is in {status.name} state"
121
+ raise psycopg.InterfaceError(msg)
122
+
123
+
99
124
  class ConnectionPool:
100
125
  def __init__(
101
126
  self,
@@ -196,9 +221,9 @@ def serialize_json_param(typ: object, value: object, db_type: str) -> object:
196
221
  adapter = get_adapter(typ)
197
222
  match db_type:
198
223
  case "json":
199
- return pgjson.Json(adapter.dump_python(value, mode="json"))
224
+ return psycopg.types.json.Json(adapter.dump_python(value, mode="json"))
200
225
  case "jsonb":
201
- return pgjson.Jsonb(adapter.dump_python(value, mode="json"))
226
+ return psycopg.types.json.Jsonb(adapter.dump_python(value, mode="json"))
202
227
  case _:
203
228
  return adapter.dump_json(value).decode()
204
229
 
File without changes
File without changes