asyncpg-typed 0.1.3__py3-none-any.whl → 0.1.4__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.
asyncpg_typed/__init__.py CHANGED
@@ -4,9 +4,9 @@ Type-safe queries for asyncpg.
4
4
  :see: https://github.com/hunyadi/asyncpg_typed
5
5
  """
6
6
 
7
- __version__ = "0.1.3"
7
+ __version__ = "0.1.4"
8
8
  __author__ = "Levente Hunyadi"
9
- __copyright__ = "Copyright 2025, Levente Hunyadi"
9
+ __copyright__ = "Copyright 2025-2026, Levente Hunyadi"
10
10
  __license__ = "MIT"
11
11
  __maintainer__ = "Levente Hunyadi"
12
12
  __status__ = "Production"
@@ -43,10 +43,18 @@ TargetType: TypeAlias = type[Any] | UnionType
43
43
  Connection: TypeAlias = asyncpg.Connection | asyncpg.pool.PoolConnectionProxy
44
44
 
45
45
 
46
+ class CountMismatchError(TypeError):
47
+ "Raised when a prepared statement takes or returns a different number of parameters or columns than declared in Python."
48
+
49
+
46
50
  class TypeMismatchError(TypeError):
47
51
  "Raised when a prepared statement takes or returns a PostgreSQL type incompatible with the declared Python type."
48
52
 
49
53
 
54
+ class NameMismatchError(TypeError):
55
+ "Raised when the name of a result-set column differs from what is declared in Python."
56
+
57
+
50
58
  class EnumMismatchError(TypeError):
51
59
  "Raised when a prepared statement takes or returns a PostgreSQL enum type whose permitted set of values differs from what is declared in Python."
52
60
 
@@ -333,15 +341,29 @@ class _TypeVerifier:
333
341
  if is_enum_type(data_type):
334
342
  if pg_type.name not in ["bpchar", "varchar", "text"]:
335
343
  raise TypeMismatchError(f"expected: Python enum type `{type_to_str(data_type)}` for {pg_name}; got: PostgreSQL type `{pg_type.kind}` of `{pg_type.name}` instead of `char`, `varchar` or `text`")
344
+ elif pg_type.kind == "array" and get_origin(data_type) is list:
345
+ if not pg_type.name.endswith("[]"):
346
+ raise TypeMismatchError(f"expected: Python list type `{type_to_str(data_type)}` for {pg_name}; got: PostgreSQL type `{pg_type.kind}` of `{pg_type.name}` instead of array")
347
+
348
+ expected_types = _NAME_TO_TYPE.get(pg_type.name[:-2])
349
+ if expected_types is None:
350
+ raise TypeMismatchError(f"expected: Python list type `{type_to_str(data_type)}` for {pg_name}; got: unrecognized PostgreSQL type `{pg_type.kind}` of `{pg_type.name}`")
351
+ elif get_args(data_type)[0] not in expected_types:
352
+ if len(expected_types) == 1:
353
+ target = f"the Python type `{type_to_str(expected_types[0])}`"
354
+ else:
355
+ target = f"one of the Python types {', '.join(f'`{type_to_str(tp)}`' for tp in expected_types)}"
356
+ raise TypeMismatchError(f"expected: Python list type `{type_to_str(data_type)}` for {pg_name}; got: incompatible PostgreSQL type `{pg_type.kind}` of `{pg_type.name}` whose elements convert to {target}")
336
357
  else:
337
358
  expected_types = _NAME_TO_TYPE.get(pg_type.name)
338
359
  if expected_types is None:
339
360
  raise TypeMismatchError(f"expected: Python type `{type_to_str(data_type)}` for {pg_name}; got: unrecognized PostgreSQL type `{pg_type.kind}` of `{pg_type.name}`")
340
361
  elif data_type not in expected_types:
341
- raise TypeMismatchError(
342
- f"expected: Python type `{type_to_str(data_type)}` for {pg_name}; "
343
- f"got: incompatible PostgreSQL type `{pg_type.kind}` of `{pg_type.name}`, which converts to one of the Python types {', '.join(f'`{type_to_str(tp)}`' for tp in expected_types)}"
344
- )
362
+ if len(expected_types) == 1:
363
+ target = f"the Python type `{type_to_str(expected_types[0])}`"
364
+ else:
365
+ target = f"one of the Python types {', '.join(f'`{type_to_str(tp)}`' for tp in expected_types)}"
366
+ raise TypeMismatchError(f"expected: Python type `{type_to_str(data_type)}` for {pg_name}; got: incompatible PostgreSQL type `{pg_type.kind}` of `{pg_type.name}` which converts to {target}")
345
367
  elif pg_type.kind == "composite": # PostgreSQL composite types
346
368
  # user-defined composite types registered with `conn.set_type_codec()` typically using `format="tuple"`
347
369
  pass
@@ -356,7 +378,7 @@ class _TypeVerifier:
356
378
 
357
379
 
358
380
  @dataclass(frozen=True)
359
- class _SQLPlaceholder:
381
+ class _Placeholder:
360
382
  ordinal: int
361
383
  data_type: TargetType
362
384
 
@@ -364,13 +386,33 @@ class _SQLPlaceholder:
364
386
  return f"{self.__class__.__name__}({self.ordinal}, {self.data_type!r})"
365
387
 
366
388
 
389
+ class _ResultsetWrapper:
390
+ "Wraps result-set rows into a tuple or named tuple."
391
+
392
+ init: Callable[..., tuple[Any, ...]] | None
393
+ iterable: Callable[[Iterable[Any]], tuple[Any, ...]]
394
+
395
+ def __init__(self, init: Callable[..., tuple[Any, ...]] | None, iterable: Callable[[Iterable[Any]], tuple[Any, ...]]) -> None:
396
+ """
397
+ Initializes a result-set wrapper.
398
+
399
+ :param init: Initializer function that takes as many arguments as columns in the result-set.
400
+ :param iterable: Initializer function that takes an iterable over columns of a result-set row.
401
+ """
402
+
403
+ self.init = init
404
+ self.iterable = iterable
405
+
406
+
367
407
  class _SQLObject:
368
408
  """
369
409
  Associates input and output type information with a SQL statement.
370
410
  """
371
411
 
372
- _parameter_data_types: tuple[_SQLPlaceholder, ...]
412
+ _parameter_data_types: tuple[_Placeholder, ...]
373
413
  _resultset_data_types: tuple[TargetType, ...]
414
+ _resultset_column_names: tuple[str, ...] | None
415
+ _resultset_wrapper: _ResultsetWrapper
374
416
  _parameter_cast: int
375
417
  _parameter_converters: tuple[Callable[[Any], Any], ...]
376
418
  _required: int
@@ -378,20 +420,35 @@ class _SQLObject:
378
420
  _resultset_converters: tuple[Callable[[Any], Any], ...]
379
421
 
380
422
  @property
381
- def parameter_data_types(self) -> tuple[_SQLPlaceholder, ...]:
423
+ def parameter_data_types(self) -> tuple[_Placeholder, ...]:
424
+ "Expected inbound parameter data types."
425
+
382
426
  return self._parameter_data_types
383
427
 
384
428
  @property
385
429
  def resultset_data_types(self) -> tuple[TargetType, ...]:
430
+ "Expected column data types in the result-set."
431
+
386
432
  return self._resultset_data_types
387
433
 
434
+ @property
435
+ def resultset_column_names(self) -> tuple[str, ...] | None:
436
+ "Expected column names in the result-set."
437
+
438
+ return self._resultset_column_names
439
+
388
440
  def __init__(
389
441
  self,
390
- input_data_types: tuple[TargetType, ...],
391
- output_data_types: tuple[TargetType, ...],
442
+ *,
443
+ args: tuple[TargetType, ...],
444
+ resultset: tuple[TargetType, ...],
445
+ names: tuple[str, ...] | None,
446
+ wrapper: _ResultsetWrapper,
392
447
  ) -> None:
393
- self._parameter_data_types = tuple(_SQLPlaceholder(ordinal, get_required_type(arg)) for ordinal, arg in enumerate(input_data_types, start=1))
394
- self._resultset_data_types = tuple(get_required_type(data_type) for data_type in output_data_types)
448
+ self._parameter_data_types = tuple(_Placeholder(ordinal, get_required_type(arg)) for ordinal, arg in enumerate(args, start=1))
449
+ self._resultset_data_types = tuple(get_required_type(data_type) for data_type in resultset)
450
+ self._resultset_column_names = names
451
+ self._resultset_wrapper = wrapper
395
452
 
396
453
  # create a bit-field of types that require cast or serialization (1: apply conversion; 0: forward value as-is)
397
454
  parameter_cast = 0
@@ -403,7 +460,7 @@ class _SQLObject:
403
460
 
404
461
  # create a bit-field of required types (1: required; 0: optional)
405
462
  required = 0
406
- for index, data_type in enumerate(output_data_types):
463
+ for index, data_type in enumerate(resultset):
407
464
  required |= (not is_optional_type(data_type)) << index
408
465
  self._required = required
409
466
 
@@ -528,6 +585,16 @@ class _SQLObject:
528
585
  case _:
529
586
  self._raise_required_is_none(row)
530
587
 
588
+ def check_column(self, column: list[Any]) -> None:
589
+ """
590
+ Verifies if the declared type matches the actual value type of a single-column resultset.
591
+ """
592
+
593
+ if self._required:
594
+ for i, value in enumerate(column):
595
+ if value is None:
596
+ raise NoneTypeError(f"expected: {self._resultset_data_types[0]} in row #{i}; got: NULL")
597
+
531
598
  def check_value(self, value: Any) -> None:
532
599
  """
533
600
  Verifies if the declared type matches the actual value type.
@@ -568,12 +635,95 @@ class _SQLObject:
568
635
  :returns: List of tuples with each tuple element having the configured Python target type.
569
636
  """
570
637
 
638
+ if not rows:
639
+ return []
640
+
641
+ init_wrapper = self._resultset_wrapper.init
642
+ iterable_wrapper = self._resultset_wrapper.iterable
571
643
  cast = self._resultset_cast
572
- if cast:
573
- converters = self._resultset_converters
574
- return [tuple((converters[i](value) if (value := row[i]) is not None and cast >> i & 1 else value) for i in range(len(row))) for row in rows]
575
- else:
576
- return [tuple(value for value in row) for row in rows]
644
+ if not cast:
645
+ return [iterable_wrapper(row.values()) for row in rows]
646
+
647
+ columns = len(rows[0])
648
+ match columns:
649
+ case 1:
650
+ converter = self._resultset_converters[0]
651
+ if init_wrapper is not None:
652
+ return [init_wrapper(converter(value) if (value := row[0]) is not None else value) for row in rows]
653
+ else:
654
+ return [(converter(value) if (value := row[0]) is not None else value,) for row in rows]
655
+ case 2:
656
+ conv_a, conv_b = self._resultset_converters
657
+ cast_a = cast >> 0 & 1
658
+ cast_b = cast >> 1 & 1
659
+ if init_wrapper is not None:
660
+ return [
661
+ init_wrapper(
662
+ conv_a(value) if (value := row[0]) is not None and cast_a else value,
663
+ conv_b(value) if (value := row[1]) is not None and cast_b else value,
664
+ )
665
+ for row in rows
666
+ ]
667
+ else:
668
+ return [
669
+ (
670
+ conv_a(value) if (value := row[0]) is not None and cast_a else value,
671
+ conv_b(value) if (value := row[1]) is not None and cast_b else value,
672
+ )
673
+ for row in rows
674
+ ]
675
+ case 3:
676
+ conv_a, conv_b, conv_c = self._resultset_converters
677
+ cast_a = cast >> 0 & 1
678
+ cast_b = cast >> 1 & 1
679
+ cast_c = cast >> 2 & 1
680
+ if init_wrapper is not None:
681
+ return [
682
+ init_wrapper(
683
+ conv_a(value) if (value := row[0]) is not None and cast_a else value,
684
+ conv_b(value) if (value := row[1]) is not None and cast_b else value,
685
+ conv_c(value) if (value := row[2]) is not None and cast_c else value,
686
+ )
687
+ for row in rows
688
+ ]
689
+ else:
690
+ return [
691
+ (
692
+ conv_a(value) if (value := row[0]) is not None and cast_a else value,
693
+ conv_b(value) if (value := row[1]) is not None and cast_b else value,
694
+ conv_c(value) if (value := row[2]) is not None and cast_c else value,
695
+ )
696
+ for row in rows
697
+ ]
698
+ case 4:
699
+ conv_a, conv_b, conv_c, conv_d = self._resultset_converters
700
+ cast_a = cast >> 0 & 1
701
+ cast_b = cast >> 1 & 1
702
+ cast_c = cast >> 2 & 1
703
+ cast_d = cast >> 3 & 1
704
+ if init_wrapper is not None:
705
+ return [
706
+ init_wrapper(
707
+ conv_a(value) if (value := row[0]) is not None and cast_a else value,
708
+ conv_b(value) if (value := row[1]) is not None and cast_b else value,
709
+ conv_c(value) if (value := row[2]) is not None and cast_c else value,
710
+ conv_d(value) if (value := row[3]) is not None and cast_d else value,
711
+ )
712
+ for row in rows
713
+ ]
714
+ else:
715
+ return [
716
+ (
717
+ conv_a(value) if (value := row[0]) is not None and cast_a else value,
718
+ conv_b(value) if (value := row[1]) is not None and cast_b else value,
719
+ conv_c(value) if (value := row[2]) is not None and cast_c else value,
720
+ conv_d(value) if (value := row[3]) is not None and cast_d else value,
721
+ )
722
+ for row in rows
723
+ ]
724
+ case _:
725
+ converters = self._resultset_converters
726
+ return [iterable_wrapper((converters[i](value) if (value := row[i]) is not None and cast >> i & 1 else value) for i in range(columns)) for row in rows]
577
727
 
578
728
  def convert_row(self, row: asyncpg.Record) -> tuple[Any, ...]:
579
729
  """
@@ -583,12 +733,29 @@ class _SQLObject:
583
733
  :returns: A tuple with each tuple element having the configured Python target type.
584
734
  """
585
735
 
736
+ wrapper = self._resultset_wrapper.iterable
586
737
  cast = self._resultset_cast
587
738
  if cast:
588
739
  converters = self._resultset_converters
589
- return tuple((converters[i](value) if (value := row[i]) is not None and cast >> i & 1 else value) for i in range(len(row)))
740
+ columns = len(row)
741
+ return wrapper((converters[i](value) if (value := row[i]) is not None and cast >> i & 1 else value) for i in range(columns))
590
742
  else:
591
- return tuple(value for value in row)
743
+ return wrapper(row.values())
744
+
745
+ def convert_column(self, rows: list[asyncpg.Record]) -> list[Any]:
746
+ """
747
+ Converts a single column in the PostgreSQL result-set to its corresponding Python target type.
748
+
749
+ :param rows: List of rows returned by PostgreSQL.
750
+ :returns: List of values having the configured Python target type.
751
+ """
752
+
753
+ cast = self._resultset_cast
754
+ if cast:
755
+ converter = self._resultset_converters[0]
756
+ return [(converter(value) if (value := row[0]) is not None else value) for row in rows]
757
+ else:
758
+ return [row[0] for row in rows]
592
759
 
593
760
  def convert_value(self, value: Any) -> Any:
594
761
  """
@@ -625,7 +792,7 @@ if sys.version_info >= (3, 14):
625
792
  """
626
793
 
627
794
  _strings: tuple[str, ...]
628
- _placeholders: tuple[_SQLPlaceholder, ...]
795
+ _placeholders: tuple[_Placeholder, ...]
629
796
 
630
797
  def __init__(
631
798
  self,
@@ -633,8 +800,10 @@ if sys.version_info >= (3, 14):
633
800
  *,
634
801
  args: tuple[TargetType, ...],
635
802
  resultset: tuple[TargetType, ...],
803
+ names: tuple[str, ...] | None,
804
+ wrapper: _ResultsetWrapper,
636
805
  ) -> None:
637
- super().__init__(args, resultset)
806
+ super().__init__(args=args, resultset=resultset, names=names, wrapper=wrapper)
638
807
 
639
808
  for ip in template.interpolations:
640
809
  if ip.conversion is not None:
@@ -648,7 +817,7 @@ if sys.version_info >= (3, 14):
648
817
 
649
818
  if len(self.parameter_data_types) > 0:
650
819
 
651
- def _to_placeholder(ip: Interpolation) -> _SQLPlaceholder:
820
+ def _to_placeholder(ip: Interpolation) -> _Placeholder:
652
821
  ordinal = int(ip.value)
653
822
  if not (0 < ordinal <= len(self.parameter_data_types)):
654
823
  raise IndexError(f"interpolation `{ip.expression}` is an ordinal out of range; expected: 0 < value <= {len(self.parameter_data_types)}")
@@ -683,8 +852,10 @@ class _SQLString(_SQLObject):
683
852
  *,
684
853
  args: tuple[TargetType, ...],
685
854
  resultset: tuple[TargetType, ...],
855
+ names: tuple[str, ...] | None,
856
+ wrapper: _ResultsetWrapper,
686
857
  ) -> None:
687
- super().__init__(args, resultset)
858
+ super().__init__(args=args, resultset=resultset, names=names, wrapper=wrapper)
688
859
  self._sql = sql
689
860
 
690
861
  def query(self) -> str:
@@ -717,8 +888,22 @@ class _SQLImpl(_SQL):
717
888
  stmt = await connection.prepare(self._sql.query())
718
889
 
719
890
  verifier = _TypeVerifier(connection)
891
+
892
+ input_count = len(self._sql.parameter_data_types)
893
+ parameter_count = len(stmt.get_parameters())
894
+ if parameter_count != input_count:
895
+ raise CountMismatchError(f"expected: PostgreSQL query to take {input_count} parameter(s); got: {parameter_count}")
720
896
  for param, placeholder in zip(stmt.get_parameters(), self._sql.parameter_data_types, strict=True):
721
897
  await verifier.check_data_type(f"parameter ${placeholder.ordinal}", param, placeholder.data_type)
898
+
899
+ output_count = len(self._sql.resultset_data_types)
900
+ column_count = len(stmt.get_attributes())
901
+ if column_count != output_count:
902
+ raise CountMismatchError(f"expected: PostgreSQL query to return {output_count} column(s) in result-set; got: {column_count}")
903
+ if self._sql.resultset_column_names is not None:
904
+ for index, attr, name in zip(range(output_count), stmt.get_attributes(), self._sql.resultset_column_names, strict=True):
905
+ if attr.name != name:
906
+ raise NameMismatchError(f"expected: Python field name `{name}` to match PostgreSQL result-set column name `{attr.name}` for index #{index}")
722
907
  for attr, data_type in zip(stmt.get_attributes(), self._sql.resultset_data_types, strict=True):
723
908
  await verifier.check_data_type(f"column `{attr.name}`", attr.type, data_type)
724
909
 
@@ -755,6 +940,13 @@ class _SQLImpl(_SQL):
755
940
  self._sql.check_row(resultset)
756
941
  return resultset
757
942
 
943
+ async def fetchcol(self, connection: asyncpg.Connection, *args: Any) -> list[Any]:
944
+ stmt = await self._prepare(connection)
945
+ rows = await stmt.fetch(*self._sql.convert_arg_list(args))
946
+ column = self._sql.convert_column(rows)
947
+ self._sql.check_column(column)
948
+ return column
949
+
758
950
  async def fetchval(self, connection: asyncpg.Connection, *args: Any) -> Any:
759
951
  stmt = await self._prepare(connection)
760
952
  value = await stmt.fetchval(*self._sql.convert_arg_list(args))
@@ -784,6 +976,8 @@ class SQL_R1_P0(SQL_P0, Protocol[R1]):
784
976
  @abstractmethod
785
977
  async def fetchrow(self, connection: Connection) -> tuple[R1] | None: ...
786
978
  @abstractmethod
979
+ async def fetchcol(self, connection: Connection) -> list[R1]: ...
980
+ @abstractmethod
787
981
  async def fetchval(self, connection: Connection) -> R1: ...
788
982
 
789
983
 
@@ -809,6 +1003,8 @@ class SQL_R1_PX(SQL_PX[Unpack[PX]], Protocol[R1, Unpack[PX]]):
809
1003
  @abstractmethod
810
1004
  async def fetchrow(self, connection: Connection, *args: Unpack[PX]) -> tuple[R1] | None: ...
811
1005
  @abstractmethod
1006
+ async def fetchcol(self, connection: Connection, *args: Unpack[PX]) -> list[R1]: ...
1007
+ @abstractmethod
812
1008
  async def fetchval(self, connection: Connection, *args: Unpack[PX]) -> R1: ...
813
1009
 
814
1010
 
@@ -823,6 +1019,8 @@ class SQL_RX_PX(SQL_PX[Unpack[PX]], Protocol[RT, Unpack[PX]]):
823
1019
 
824
1020
  ### END OF AUTO-GENERATED BLOCK FOR Protocol ###
825
1021
 
1022
+ RS = TypeVar("RS", bound=tuple[Any, ...])
1023
+
826
1024
 
827
1025
  class SQLFactory:
828
1026
  """
@@ -835,25 +1033,19 @@ class SQLFactory:
835
1033
  @overload
836
1034
  def sql(self, stmt: SQLExpression, *, result: type[R1]) -> SQL_R1_P0[R1]: ...
837
1035
  @overload
838
- def sql(self, stmt: SQLExpression, *, resultset: type[tuple[R1]]) -> SQL_R1_P0[R1]: ...
839
- @overload
840
- def sql(self, stmt: SQLExpression, *, resultset: type[tuple[R1, R2, Unpack[RX]]]) -> SQL_RX_P0[tuple[R1, R2, Unpack[RX]]]: ...
1036
+ def sql(self, stmt: SQLExpression, *, resultset: type[RS]) -> SQL_RX_P0[RS]: ...
841
1037
  @overload
842
1038
  def sql(self, stmt: SQLExpression, *, arg: type[P1]) -> SQL_PX[P1]: ...
843
1039
  @overload
844
1040
  def sql(self, stmt: SQLExpression, *, arg: type[P1], result: type[R1]) -> SQL_R1_PX[R1, P1]: ...
845
1041
  @overload
846
- def sql(self, stmt: SQLExpression, *, arg: type[P1], resultset: type[tuple[R1]]) -> SQL_R1_PX[R1, P1]: ...
847
- @overload
848
- def sql(self, stmt: SQLExpression, *, arg: type[P1], resultset: type[tuple[R1, R2, Unpack[RX]]]) -> SQL_RX_PX[tuple[R1, R2, Unpack[RX]], P1]: ...
1042
+ def sql(self, stmt: SQLExpression, *, arg: type[P1], resultset: type[RS]) -> SQL_RX_PX[RS, P1]: ...
849
1043
  @overload
850
1044
  def sql(self, stmt: SQLExpression, *, args: type[tuple[P1, Unpack[PX]]]) -> SQL_PX[P1, Unpack[PX]]: ...
851
1045
  @overload
852
1046
  def sql(self, stmt: SQLExpression, *, args: type[tuple[P1, Unpack[PX]]], result: type[R1]) -> SQL_R1_PX[R1, P1, Unpack[PX]]: ...
853
1047
  @overload
854
- def sql(self, stmt: SQLExpression, *, args: type[tuple[P1, Unpack[PX]]], resultset: type[tuple[R1]]) -> SQL_R1_PX[R1, P1, Unpack[PX]]: ...
855
- @overload
856
- def sql(self, stmt: SQLExpression, *, args: type[tuple[P1, Unpack[PX]]], resultset: type[tuple[R1, R2, Unpack[RX]]]) -> SQL_RX_PX[tuple[R1, R2, Unpack[RX]], P1, Unpack[PX]]: ...
1048
+ def sql(self, stmt: SQLExpression, *, args: type[tuple[P1, Unpack[PX]]], resultset: type[RS]) -> SQL_RX_PX[RS, P1, Unpack[PX]]: ...
857
1049
 
858
1050
  ### END OF AUTO-GENERATED BLOCK FOR sql ###
859
1051
 
@@ -868,17 +1060,17 @@ class SQLFactory:
868
1060
  :param result: Type signature for a single result column (e.g. `UUID`).
869
1061
  """
870
1062
 
871
- input_data_types, output_data_types = _sql_args_resultset(args=args, resultset=resultset, arg=arg, result=result)
1063
+ input_data_types, output_data_types, names, wrapper = _sql_args_resultset(args=args, resultset=resultset, arg=arg, result=result)
872
1064
 
873
1065
  obj: _SQLObject
874
1066
  if sys.version_info >= (3, 14):
875
1067
  match stmt:
876
1068
  case Template():
877
- obj = _SQLTemplate(stmt, args=input_data_types, resultset=output_data_types)
1069
+ obj = _SQLTemplate(stmt, args=input_data_types, resultset=output_data_types, names=names, wrapper=wrapper)
878
1070
  case str():
879
- obj = _SQLString(stmt, args=input_data_types, resultset=output_data_types)
1071
+ obj = _SQLString(stmt, args=input_data_types, resultset=output_data_types, names=names, wrapper=wrapper)
880
1072
  else:
881
- obj = _SQLString(stmt, args=input_data_types, resultset=output_data_types)
1073
+ obj = _SQLString(stmt, args=input_data_types, resultset=output_data_types, names=names, wrapper=wrapper)
882
1074
 
883
1075
  return _SQLImpl(obj)
884
1076
 
@@ -888,25 +1080,19 @@ class SQLFactory:
888
1080
  @overload
889
1081
  def unsafe_sql(self, stmt: str, *, result: type[R1]) -> SQL_R1_P0[R1]: ...
890
1082
  @overload
891
- def unsafe_sql(self, stmt: str, *, resultset: type[tuple[R1]]) -> SQL_R1_P0[R1]: ...
892
- @overload
893
- def unsafe_sql(self, stmt: str, *, resultset: type[tuple[R1, R2, Unpack[RX]]]) -> SQL_RX_P0[tuple[R1, R2, Unpack[RX]]]: ...
1083
+ def unsafe_sql(self, stmt: str, *, resultset: type[RS]) -> SQL_RX_P0[RS]: ...
894
1084
  @overload
895
1085
  def unsafe_sql(self, stmt: str, *, arg: type[P1]) -> SQL_PX[P1]: ...
896
1086
  @overload
897
1087
  def unsafe_sql(self, stmt: str, *, arg: type[P1], result: type[R1]) -> SQL_R1_PX[R1, P1]: ...
898
1088
  @overload
899
- def unsafe_sql(self, stmt: str, *, arg: type[P1], resultset: type[tuple[R1]]) -> SQL_R1_PX[R1, P1]: ...
900
- @overload
901
- def unsafe_sql(self, stmt: str, *, arg: type[P1], resultset: type[tuple[R1, R2, Unpack[RX]]]) -> SQL_RX_PX[tuple[R1, R2, Unpack[RX]], P1]: ...
1089
+ def unsafe_sql(self, stmt: str, *, arg: type[P1], resultset: type[RS]) -> SQL_RX_PX[RS, P1]: ...
902
1090
  @overload
903
1091
  def unsafe_sql(self, stmt: str, *, args: type[tuple[P1, Unpack[PX]]]) -> SQL_PX[P1, Unpack[PX]]: ...
904
1092
  @overload
905
1093
  def unsafe_sql(self, stmt: str, *, args: type[tuple[P1, Unpack[PX]]], result: type[R1]) -> SQL_R1_PX[R1, P1, Unpack[PX]]: ...
906
1094
  @overload
907
- def unsafe_sql(self, stmt: str, *, args: type[tuple[P1, Unpack[PX]]], resultset: type[tuple[R1]]) -> SQL_R1_PX[R1, P1, Unpack[PX]]: ...
908
- @overload
909
- def unsafe_sql(self, stmt: str, *, args: type[tuple[P1, Unpack[PX]]], resultset: type[tuple[R1, R2, Unpack[RX]]]) -> SQL_RX_PX[tuple[R1, R2, Unpack[RX]], P1, Unpack[PX]]: ...
1095
+ def unsafe_sql(self, stmt: str, *, args: type[tuple[P1, Unpack[PX]]], resultset: type[RS]) -> SQL_RX_PX[RS, P1, Unpack[PX]]: ...
910
1096
 
911
1097
  ### END OF AUTO-GENERATED BLOCK FOR unsafe_sql ###
912
1098
 
@@ -924,12 +1110,14 @@ class SQLFactory:
924
1110
  :param result: Type signature for a single result column (e.g. `UUID`).
925
1111
  """
926
1112
 
927
- input_data_types, output_data_types = _sql_args_resultset(args=args, resultset=resultset, arg=arg, result=result)
928
- obj = _SQLString(stmt, args=input_data_types, resultset=output_data_types)
1113
+ input_data_types, output_data_types, names, wrapper = _sql_args_resultset(args=args, resultset=resultset, arg=arg, result=result)
1114
+ obj = _SQLString(stmt, args=input_data_types, resultset=output_data_types, names=names, wrapper=wrapper)
929
1115
  return _SQLImpl(obj)
930
1116
 
931
1117
 
932
- def _sql_args_resultset(*, args: type[Any] | None = None, resultset: type[Any] | None = None, arg: type[Any] | None = None, result: type[Any] | None = None) -> tuple[tuple[Any, ...], tuple[Any, ...]]:
1118
+ def _sql_args_resultset(
1119
+ *, args: type[Any] | None = None, resultset: type[Any] | None = None, arg: type[Any] | None = None, result: type[Any] | None = None
1120
+ ) -> tuple[tuple[Any, ...], tuple[Any, ...], tuple[str, ...] | None, _ResultsetWrapper]:
933
1121
  "Parses an argument/resultset signature into input/output types."
934
1122
 
935
1123
  if args is not None and arg is not None:
@@ -938,24 +1126,41 @@ def _sql_args_resultset(*, args: type[Any] | None = None, resultset: type[Any] |
938
1126
  raise TypeError("expected: either `resultset` or `result`; got: both")
939
1127
 
940
1128
  if args is not None:
941
- if get_origin(args) is not tuple:
942
- raise TypeError(f"expected: `type[tuple[T, ...]]` for `args`; got: {type(args)}")
943
- input_data_types = get_args(args)
1129
+ if hasattr(args, "_asdict") and hasattr(args, "_fields"):
1130
+ # named tuple
1131
+ input_data_types = tuple(tp for tp in args.__annotations__.values())
1132
+ else:
1133
+ # regular tuple
1134
+ if get_origin(args) is not tuple:
1135
+ raise TypeError(f"expected: `type[tuple[T, ...]]` for `args`; got: {args}")
1136
+ input_data_types = get_args(args)
944
1137
  elif arg is not None:
945
1138
  input_data_types = (arg,)
946
1139
  else:
947
1140
  input_data_types = ()
948
1141
 
949
1142
  if resultset is not None:
950
- if get_origin(resultset) is not tuple:
951
- raise TypeError(f"expected: `type[tuple[T, ...]]` for `resultset`; got: {type(resultset)}")
952
- output_data_types = get_args(resultset)
953
- elif result is not None:
954
- output_data_types = (result,)
1143
+ if hasattr(resultset, "_asdict") and hasattr(resultset, "_fields") and hasattr(resultset, "_make"):
1144
+ # named tuple
1145
+ output_data_types = tuple(tp for tp in resultset.__annotations__.values())
1146
+ names = tuple(f for f in resultset._fields)
1147
+ wrapper = _ResultsetWrapper(resultset, resultset._make)
1148
+ else:
1149
+ # regular tuple
1150
+ if get_origin(resultset) is not tuple:
1151
+ raise TypeError(f"expected: `type[tuple[T, ...]]` for `resultset`; got: {resultset}")
1152
+ output_data_types = get_args(resultset)
1153
+ names = None
1154
+ wrapper = _ResultsetWrapper(None, tuple)
955
1155
  else:
956
- output_data_types = ()
1156
+ if result is not None:
1157
+ output_data_types = (result,)
1158
+ else:
1159
+ output_data_types = ()
1160
+ names = None
1161
+ wrapper = _ResultsetWrapper(None, tuple)
957
1162
 
958
- return input_data_types, output_data_types
1163
+ return input_data_types, output_data_types, names, wrapper
959
1164
 
960
1165
 
961
1166
  FACTORY: SQLFactory = SQLFactory()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asyncpg_typed
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Type-safe queries for asyncpg
5
5
  Author-email: Levente Hunyadi <hunyadi@gmail.com>
6
6
  Maintainer-email: Levente Hunyadi <hunyadi@gmail.com>
@@ -45,6 +45,8 @@ This Python library provides "compile-time" validation for SQL queries that lint
45
45
 
46
46
  ## Motivating example
47
47
 
48
+ ### Using a plain tuple
49
+
48
50
  ```python
49
51
  # create a typed object, setting expected and returned types
50
52
  select_where_sql = sql(
@@ -66,7 +68,7 @@ try:
66
68
  # ✅ Type of "rows" is "list[tuple[bool, int, str | None]]"
67
69
  reveal_type(rows)
68
70
 
69
- # ⚠️ Argument missing for parameter "arg2"
71
+ # ⚠️ Expected 1 more positional argument
70
72
  rows = await select_where_sql.fetch(conn, False)
71
73
 
72
74
  # ⚠️ Argument of type "float" cannot be assigned to parameter "arg2" of type "int" in function "fetch"; "float" is not assignable to "int"
@@ -74,27 +76,52 @@ try:
74
76
 
75
77
  finally:
76
78
  await conn.close()
79
+ ```
77
80
 
78
- # create a list of data-class instances from a list of typed tuples
79
- @dataclass
80
- class DataObject:
81
+ ### Using a named tuple
82
+
83
+ ```python
84
+ # capture resultset column names and data types as fields of a named tuple
85
+ class Resultset(NamedTuple):
81
86
  boolean_value: bool
82
87
  integer_value: int
83
88
  string_value: str | None
84
89
 
85
- # ✅ Valid initializer call
86
- items = [DataObject(*row) for row in rows]
87
90
 
88
- @dataclass
89
- class MismatchedObject:
90
- boolean_value: bool
91
- integer_value: int
92
- string_value: str
91
+ # create a typed object, declaring return types with the named tuple
92
+ select_sql = sql(
93
+ """--sql
94
+ SELECT boolean_value, integer_value, string_value
95
+ FROM sample_data
96
+ ORDER BY integer_value;
97
+ """,
98
+ resultset=Resultset,
99
+ )
93
100
 
94
- # ⚠️ Argument of type "int | None" cannot be assigned to parameter "integer_value" of type "int" in function "__init__"; "None" is not assignable to "int"
95
- items = [MismatchedObject(*row) for row in rows]
96
- ```
101
+ conn = await asyncpg.connect(host="localhost", port=5432, user="postgres", password="postgres")
102
+ try:
103
+ rows = await select_sql.fetch(conn)
104
+
105
+ # ✅ Type of "rows" is "list[Resultset]"
106
+ reveal_type(rows)
107
+
108
+ for row in rows:
109
+ # use dot notation to access properties
110
+ if row.string_value is not None:
111
+ print(f"#{row.integer_value}: {row.string_value}")
112
+
113
+ # ✅ Type of "row.boolean_value" is "bool"
114
+ reveal_type(row.boolean_value)
97
115
 
116
+ # unpack named tuple
117
+ b, i, s = row
118
+
119
+ # ✅ Type of "s" is "str | None"
120
+ reveal_type(s)
121
+
122
+ finally:
123
+ await conn.close()
124
+ ```
98
125
 
99
126
  ## Syntax
100
127
 
@@ -284,10 +311,12 @@ Only those functions are prompted on code completion that make sense in the cont
284
311
 
285
312
  #### Run-time behavior
286
313
 
287
- When a call such as `sql.executemany(conn, records)` or `sql.fetch(conn, param1, param2)` is made on a `SQL` object at run time, the library invokes `connection.prepare(sql)` to create a `PreparedStatement` and compares the actual statement signature against the expected Python types. If the expected and actual signatures don't match, an exception `TypeMismatchError` (subclass of `TypeError`) is raised.
314
+ When a call such as `sql.executemany(conn, records)` or `sql.fetch(conn, param1, param2)` is made on a `SQL` object at run time, the library invokes `connection.prepare(sql)` to create a `PreparedStatement` and compares the actual statement signature against the expected Python types. If the expected and actual signatures don't match, a `TypeMismatchError` exception is raised.
315
+
316
+ If the resultset type has been declared with a subclass of `NamedTuple`, the field names of the tuple are compared against the column names of the PostgreSQL resultset. Should there be a mismatch, a `NameMismatchError` is raised. Field and column order is relevant.
288
317
 
289
- The set of values for an enumeration type is validated when a prepared statement is created. The string values declared in a Python `StrEnum` are compared against the values listed in PostgreSQL `CREATE TYPE ... AS ENUM` by querying the system table `pg_enum`. If there are missing or extra values on either side, an exception `EnumMismatchError` (subclass of `TypeError`) is raised.
318
+ The set of values for an enumeration type is validated when a prepared statement is created. The string values declared in a Python `StrEnum` are compared against the values listed in PostgreSQL `CREATE TYPE ... AS ENUM` by querying the system table `pg_enum`. If there are missing or extra values on either side, an `EnumMismatchError` exception is raised.
290
319
 
291
- Unfortunately, PostgreSQL doesn't propagate nullability via prepared statements: resultset types that are declared as required (e.g. `T` as opposed to `T | None`) are validated at run time. When a `None` value is encountered for a required type, an exception `NoneTypeError` (subclass of `TypeError`) is raised.
320
+ Unfortunately, PostgreSQL doesn't propagate nullability via prepared statements: resultset types that are declared as required (e.g. `T` as opposed to `T | None`) have to be validated at run time. When a `None` value is encountered for a required type, a `NoneTypeError` exception is raised.
292
321
 
293
322
  PostgreSQL doesn't differentiate between IPv4 and IPv6 network definitions, or IPv4 and IPv6 addresses in the types `cidr` and `inet`. This means that semantically a union type is returned. If you specify a more restrictive type, the resultset data is validated dynamically at run time.
@@ -0,0 +1,8 @@
1
+ asyncpg_typed/__init__.py,sha256=gGyWS0ChY1PcCDpSiyycqjE5xDrPcOCH8BjeMzBPlLc,48610
2
+ asyncpg_typed/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ asyncpg_typed-0.1.4.dist-info/licenses/LICENSE,sha256=fsxatgMrv7G5R_IJYOb7FqBNbXIvLidWG9blKAir8k8,1098
4
+ asyncpg_typed-0.1.4.dist-info/METADATA,sha256=cTwlTJjzBFHz9FYH8Ozk3S_z4uVWA0ivnszY-8tZD6A,16126
5
+ asyncpg_typed-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ asyncpg_typed-0.1.4.dist-info/top_level.txt,sha256=T0X1nWnXRTi5a5oTErGy572ORDbM9UV9wfhRXWLsaoY,14
7
+ asyncpg_typed-0.1.4.dist-info/zip-safe,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
8
+ asyncpg_typed-0.1.4.dist-info/RECORD,,
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Levente Hunyadi
3
+ Copyright (c) 2025-2026 Levente Hunyadi
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,8 +0,0 @@
1
- asyncpg_typed/__init__.py,sha256=pDwWTWeNqXtw0Z0YrHRu_kneHu20X2SggFWK6aczbY8,38766
2
- asyncpg_typed/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- asyncpg_typed-0.1.3.dist-info/licenses/LICENSE,sha256=rx4jD36wX8TyLZaR2HEOJ6TphFPjKUqoCSSYWzwWNRk,1093
4
- asyncpg_typed-0.1.3.dist-info/METADATA,sha256=LTGsagnYy0YHn33DUpIEfkRh63mNyH1rdRxCnpyTNZk,15353
5
- asyncpg_typed-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- asyncpg_typed-0.1.3.dist-info/top_level.txt,sha256=T0X1nWnXRTi5a5oTErGy572ORDbM9UV9wfhRXWLsaoY,14
7
- asyncpg_typed-0.1.3.dist-info/zip-safe,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
8
- asyncpg_typed-0.1.3.dist-info/RECORD,,