iceaxe 0.12.0.dev1__tar.gz → 0.12.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 (84) hide show
  1. {iceaxe-0.12.0.dev1/iceaxe.egg-info → iceaxe-0.12.2}/PKG-INFO +49 -1
  2. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/README.md +48 -0
  3. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/conftest.py +11 -1
  4. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +25 -0
  5. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_db_serializer.py +55 -0
  6. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_base.py +45 -0
  7. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_session.py +34 -0
  8. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/base.py +5 -3
  9. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/field.py +14 -1
  10. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/db_memory_serializer.py +16 -4
  11. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/db_serializer.py +24 -12
  12. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/session_optimized.c +31 -35
  13. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/typing.py +24 -0
  14. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2/iceaxe.egg-info}/PKG-INFO +49 -1
  15. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/pyproject.toml +1 -1
  16. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/LICENSE +0 -0
  17. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/MANIFEST.in +0 -0
  18. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__init__.py +0 -0
  19. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/__init__.py +0 -0
  20. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
  21. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/benchmarks/test_bulk_insert.py +0 -0
  22. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
  23. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/conf_models.py +0 -0
  24. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/docker_helpers.py +0 -0
  25. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/helpers.py +0 -0
  26. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/__init__.py +0 -0
  27. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/conftest.py +0 -0
  28. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/test_action_sorter.py +0 -0
  29. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/test_generator.py +0 -0
  30. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/test_generics.py +0 -0
  31. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/mountaineer/__init__.py +0 -0
  32. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
  33. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/mountaineer/dependencies/test_core.py +0 -0
  34. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/__init__.py +0 -0
  35. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_actions.py +0 -0
  36. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_cli.py +0 -0
  37. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
  38. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_alias.py +0 -0
  39. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_comparison.py +0 -0
  40. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_field.py +0 -0
  41. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_helpers.py +0 -0
  42. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_magic_migrate.py +0 -0
  43. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_modifications.py +0 -0
  44. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_queries.py +0 -0
  45. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_queries_str.py +0 -0
  46. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_text_search.py +0 -0
  47. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/alias_values.py +0 -0
  48. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/comparison.py +0 -0
  49. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/custom_typehints.py +0 -0
  50. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/exceptions.py +0 -0
  51. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/functions.py +0 -0
  52. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/generics.py +0 -0
  53. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/io.py +0 -0
  54. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/logging.py +0 -0
  55. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/__init__.py +0 -0
  56. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/action_sorter.py +0 -0
  57. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/cli.py +0 -0
  58. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/client_io.py +0 -0
  59. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/generator.py +0 -0
  60. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/migration.py +0 -0
  61. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/migrator.py +0 -0
  62. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/modifications.py +0 -0
  63. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/__init__.py +0 -0
  64. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/cli.py +0 -0
  65. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/config.py +0 -0
  66. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
  67. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/dependencies/core.py +0 -0
  68. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/postgres.py +0 -0
  69. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/py.typed +0 -0
  70. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/queries.py +0 -0
  71. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/queries_str.py +0 -0
  72. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/__init__.py +0 -0
  73. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/actions.py +0 -0
  74. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/cli.py +0 -0
  75. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/db_stubs.py +0 -0
  76. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/session.py +0 -0
  77. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/session_optimized.pyx +0 -0
  78. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/sql_types.py +0 -0
  79. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/SOURCES.txt +0 -0
  80. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/dependency_links.txt +0 -0
  81. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/requires.txt +0 -0
  82. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/top_level.txt +0 -0
  83. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/setup.cfg +0 -0
  84. {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iceaxe
3
- Version: 0.12.0.dev1
3
+ Version: 0.12.2
4
4
  Summary: A modern, fast ORM for Python.
5
5
  Author-email: Pierce Freeman <pierce@freeman.vc>
6
6
  Requires-Python: >=3.11
@@ -76,6 +76,29 @@ class Person(TableBase):
76
76
  age: int
77
77
  ```
78
78
 
79
+ Structured JSON values and lightweight scalar subclasses also work naturally in table definitions:
80
+
81
+ ```python
82
+ from iceaxe import Field, TableBase
83
+ from pydantic import BaseModel
84
+ from uuid import UUID
85
+
86
+ class PersonId(UUID):
87
+ pass
88
+
89
+ class Preferences(BaseModel):
90
+ theme: str
91
+ notifications: bool
92
+
93
+ class Person(TableBase):
94
+ id: PersonId = Field(primary_key=True)
95
+ preferences: Preferences = Field(is_json=True)
96
+ ```
97
+
98
+ `Field(is_json=True)` will round-trip Pydantic models through a JSON column, and simple subclasses of
99
+ types like `UUID`, `str`, `int`, `date`, and `datetime` are stored using their base Postgres type while
100
+ being returned as their subclass in Python.
101
+
79
102
  Okay now you have a model. How do you interact with it?
80
103
 
81
104
  Databases are based on a few core primitives to insert data, update it, and fetch it out again.
@@ -120,6 +143,9 @@ For local development or side projects, you can use `magic_migrate` to automatic
120
143
  await conn.magic_migrate("my_project")
121
144
  ```
122
145
 
146
+ If you want to limit the sync to a subset of tables or label the generated revision, you can also pass
147
+ `models=[...]` and `message="..."`.
148
+
123
149
  This will:
124
150
  1. Compare your current database schema against your model definitions
125
151
  2. Generate a migration file if changes are detected
@@ -243,6 +269,28 @@ results = await conn.exec(query)
243
269
 
244
270
  As expected this will deliver results - and typehint - as a `list[tuple[int, FavoriteColor]]`
245
271
 
272
+ For the common "fetch the first matching model or fail" case, use `.one()`:
273
+
274
+ ```python
275
+ from iceaxe import NoObjectFound
276
+
277
+ try:
278
+ person = await conn.exec(
279
+ select(Person)
280
+ .where(Person.id == 1)
281
+ .one()
282
+ )
283
+ except NoObjectFound:
284
+ person = None
285
+ ```
286
+
287
+ `.one()` only applies to a single full-model select like `select(Person)`. It adds `LIMIT 1`,
288
+ returns a single `Person` instead of `list[Person]`, and raises `NoObjectFound` if the query
289
+ returns no rows.
290
+
291
+ When a query fails, Iceaxe raises `IceaxeQueryError` with the SQL text and variables attached to
292
+ the exception message while still preserving the original asyncpg exception type.
293
+
246
294
  ## Production
247
295
 
248
296
  Note that underlying Postgres connection wrapped by `conn` will be alive for as long as your object is in memory. This uses up one
@@ -62,6 +62,29 @@ class Person(TableBase):
62
62
  age: int
63
63
  ```
64
64
 
65
+ Structured JSON values and lightweight scalar subclasses also work naturally in table definitions:
66
+
67
+ ```python
68
+ from iceaxe import Field, TableBase
69
+ from pydantic import BaseModel
70
+ from uuid import UUID
71
+
72
+ class PersonId(UUID):
73
+ pass
74
+
75
+ class Preferences(BaseModel):
76
+ theme: str
77
+ notifications: bool
78
+
79
+ class Person(TableBase):
80
+ id: PersonId = Field(primary_key=True)
81
+ preferences: Preferences = Field(is_json=True)
82
+ ```
83
+
84
+ `Field(is_json=True)` will round-trip Pydantic models through a JSON column, and simple subclasses of
85
+ types like `UUID`, `str`, `int`, `date`, and `datetime` are stored using their base Postgres type while
86
+ being returned as their subclass in Python.
87
+
65
88
  Okay now you have a model. How do you interact with it?
66
89
 
67
90
  Databases are based on a few core primitives to insert data, update it, and fetch it out again.
@@ -106,6 +129,9 @@ For local development or side projects, you can use `magic_migrate` to automatic
106
129
  await conn.magic_migrate("my_project")
107
130
  ```
108
131
 
132
+ If you want to limit the sync to a subset of tables or label the generated revision, you can also pass
133
+ `models=[...]` and `message="..."`.
134
+
109
135
  This will:
110
136
  1. Compare your current database schema against your model definitions
111
137
  2. Generate a migration file if changes are detected
@@ -229,6 +255,28 @@ results = await conn.exec(query)
229
255
 
230
256
  As expected this will deliver results - and typehint - as a `list[tuple[int, FavoriteColor]]`
231
257
 
258
+ For the common "fetch the first matching model or fail" case, use `.one()`:
259
+
260
+ ```python
261
+ from iceaxe import NoObjectFound
262
+
263
+ try:
264
+ person = await conn.exec(
265
+ select(Person)
266
+ .where(Person.id == 1)
267
+ .one()
268
+ )
269
+ except NoObjectFound:
270
+ person = None
271
+ ```
272
+
273
+ `.one()` only applies to a single full-model select like `select(Person)`. It adds `LIMIT 1`,
274
+ returns a single `Person` instead of `list[Person]`, and raises `NoObjectFound` if the query
275
+ returns no rows.
276
+
277
+ When a query fails, Iceaxe raises `IceaxeQueryError` with the SQL text and variables attached to
278
+ the exception message while still preserving the original asyncpg exception type.
279
+
232
280
  ## Production
233
281
 
234
282
  Note that underlying Postgres connection wrapped by `conn` will be alive for as long as your object is in memory. This uses up one
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import os
2
3
 
3
4
  import asyncpg
4
5
  import pytest
@@ -20,7 +21,9 @@ def docker_postgres():
20
21
  This allows running individual tests without needing Docker Compose.
21
22
  """
22
23
  # Create and start a PostgreSQL container
23
- postgres_container = docker_helpers.PostgresContainer()
24
+ postgres_container = docker_helpers.PostgresContainer(
25
+ postgres_version=os.environ.get("ICEAXE_POSTGRES_VERSION", "16")
26
+ )
24
27
 
25
28
  # Start the container and yield connection details
26
29
  connection_info = postgres_container.start()
@@ -194,6 +197,13 @@ async def clear_all_database_objects(db_connection: DBConnection):
194
197
  )
195
198
 
196
199
 
200
+ @pytest_asyncio.fixture
201
+ async def postgres_server_version_num(db_connection: DBConnection) -> int:
202
+ server_version_value = await db_connection.conn.fetchval("SHOW server_version_num")
203
+ assert server_version_value is not None
204
+ return int(server_version_value)
205
+
206
+
197
207
  @pytest.fixture
198
208
  def clear_registry():
199
209
  current_registry = DBModelMetaclass._registry
@@ -8,6 +8,7 @@ from uuid import UUID
8
8
  import pytest
9
9
  from pydantic import BaseModel, create_model
10
10
  from pydantic.fields import FieldInfo
11
+ from typing_extensions import TypedDict
11
12
 
12
13
  from iceaxe import Field, TableBase
13
14
  from iceaxe.base import IndexConstraint, UniqueConstraint
@@ -1656,3 +1657,27 @@ def test_pydantic_model_json_field(clear_all_database_objects):
1656
1657
 
1657
1658
  assert settings_column.column_type == ColumnType.JSON
1658
1659
  assert not settings_column.nullable
1660
+
1661
+
1662
+ def test_json_container_fields_use_json_column(clear_all_database_objects):
1663
+ class ExampleOption(TypedDict):
1664
+ key: str
1665
+ label: str
1666
+
1667
+ class TestModel(TableBase):
1668
+ id: int = Field(primary_key=True)
1669
+ settings: dict = Field(is_json=True)
1670
+ tags: list[str] = Field(is_json=True)
1671
+ option: ExampleOption = Field(is_json=True)
1672
+ options: list[ExampleOption] = Field(is_json=True)
1673
+
1674
+ migrator = DatabaseMemorySerializer()
1675
+ db_objects = list(migrator.delegate([TestModel]))
1676
+
1677
+ columns = [obj for obj, _ in db_objects if isinstance(obj, DBColumn)]
1678
+
1679
+ for column_name in {"settings", "tags", "option", "options"}:
1680
+ column = next(c for c in columns if c.column_name == column_name)
1681
+ assert column.column_type == ColumnType.JSON
1682
+ assert column.column_is_list is False
1683
+ assert not column.nullable
@@ -241,6 +241,61 @@ async def test_simple_db_serializer(
241
241
  compare_db_objects(db_objects, base_db_objects + expected_db_objects)
242
242
 
243
243
 
244
+ @pytest.mark.asyncio
245
+ async def test_db_serializer_ignores_postgres_not_null_constraints(
246
+ db_connection: DBConnection,
247
+ clear_all_database_objects,
248
+ postgres_server_version_num: int,
249
+ ):
250
+ await db_connection.conn.execute(
251
+ """
252
+ CREATE TABLE pg18_not_null_model (
253
+ id SERIAL PRIMARY KEY,
254
+ required_value TEXT NOT NULL,
255
+ optional_value TEXT
256
+ );
257
+ """
258
+ )
259
+
260
+ pg_constraint_rows = await db_connection.conn.fetch(
261
+ """
262
+ SELECT c.contype
263
+ FROM pg_constraint c
264
+ INNER JOIN pg_class t ON c.conrelid = t.oid
265
+ WHERE t.relname = $1
266
+ """,
267
+ "pg18_not_null_model",
268
+ )
269
+ pg_constraint_types = {
270
+ DatabaseSerializer._unwrap_db_str(row["contype"]) for row in pg_constraint_rows
271
+ }
272
+
273
+ if postgres_server_version_num >= 180000:
274
+ assert "n" in pg_constraint_types
275
+
276
+ db_serializer = DatabaseSerializer()
277
+ db_objects = []
278
+ async for values in db_serializer.get_objects(db_connection):
279
+ db_objects.append(values)
280
+
281
+ table_constraints = [
282
+ obj
283
+ for obj, _ in db_objects
284
+ if isinstance(obj, DBConstraint) and obj.table_name == "pg18_not_null_model"
285
+ ]
286
+ assert {constraint.constraint_name for constraint in table_constraints} == {
287
+ "pg18_not_null_model_pkey"
288
+ }
289
+
290
+ columns = {
291
+ obj.column_name: obj
292
+ for obj, _ in db_objects
293
+ if isinstance(obj, DBColumn) and obj.table_name == "pg18_not_null_model"
294
+ }
295
+ assert columns["required_value"].nullable is False
296
+ assert columns["optional_value"].nullable is True
297
+
298
+
244
299
  @pytest.mark.asyncio
245
300
  async def test_db_serializer_foreign_key(
246
301
  db_connection: DBConnection,
@@ -1,11 +1,15 @@
1
+ from enum import StrEnum
1
2
  from typing import Annotated, Any, Generic, TypeVar, cast
2
3
  from uuid import UUID
3
4
 
5
+ import pytest
6
+
4
7
  from iceaxe.base import (
5
8
  DBModelMetaclass,
6
9
  TableBase,
7
10
  )
8
11
  from iceaxe.field import DBFieldInfo, Field
12
+ from iceaxe.schemas.db_memory_serializer import DatabaseMemorySerializer
9
13
 
10
14
 
11
15
  class _AnnotatedDummy:
@@ -42,6 +46,28 @@ def test_not_autodetect_generic(clear_registry):
42
46
  assert DBModelMetaclass.get_registry() == [WillAutodetect]
43
47
 
44
48
 
49
+ def test_generic_concrete_subclass_preserves_bound_annotations(clear_registry):
50
+ T = TypeVar("T", bound=StrEnum)
51
+
52
+ class MyEnum(StrEnum):
53
+ A = "A"
54
+
55
+ class GenericBase(TableBase, Generic[T], autodetect=False):
56
+ typed_value: T
57
+ user_id: UUID
58
+
59
+ class Concrete(GenericBase[MyEnum], TableBase):
60
+ pass
61
+
62
+ assert {
63
+ key: info.annotation for key, info in Concrete.get_client_fields().items()
64
+ } == {
65
+ "typed_value": MyEnum,
66
+ "user_id": UUID,
67
+ }
68
+ assert list(DatabaseMemorySerializer().delegate([Concrete]))
69
+
70
+
45
71
  def test_model_fields():
46
72
  class User(TableBase):
47
73
  id: int
@@ -118,3 +144,22 @@ def test_model_fields_with_simple_uuid_subclass():
118
144
  assert isinstance(event.id, CustomUUID)
119
145
  assert isinstance(event.maybe_id, CustomUUID)
120
146
  assert all(isinstance(value, CustomUUID) for value in event.ids)
147
+
148
+
149
+ def test_metaclass_resets_construction_state_after_model_error(clear_registry):
150
+ class BrokenType:
151
+ @classmethod
152
+ def __get_pydantic_core_schema__(cls, source_type, handler):
153
+ raise RuntimeError("boom")
154
+
155
+ with pytest.raises(RuntimeError, match="boom"):
156
+
157
+ class BrokenModel(TableBase, autodetect=False):
158
+ value: BrokenType
159
+
160
+ assert DBModelMetaclass.is_constructing is False
161
+
162
+ class WorkingModel(TableBase, autodetect=False):
163
+ id: int
164
+
165
+ assert cast(Any, WorkingModel).id.key == "id"
@@ -9,6 +9,7 @@ import asyncpg
9
9
  import pytest
10
10
  from asyncpg.connection import Connection
11
11
  from pydantic import BaseModel
12
+ from typing_extensions import TypedDict
12
13
 
13
14
  from iceaxe.__tests__.conf_models import (
14
15
  ArtifactDemo,
@@ -728,6 +729,39 @@ async def test_pydantic_json_deserialization_from_database(
728
729
  ]
729
730
 
730
731
 
732
+ @pytest.mark.asyncio
733
+ async def test_typed_dict_json_round_trip(db_connection: DBConnection):
734
+ class ExampleOption(TypedDict):
735
+ key: str
736
+ label: str
737
+
738
+ class TypedDictJsonDemo(TableBase):
739
+ id: int | None = Field(primary_key=True, default=None)
740
+ options: list[ExampleOption] = Field(is_json=True)
741
+
742
+ await db_connection.conn.execute("DROP TABLE IF EXISTS typeddictjsondemo")
743
+ await create_all(db_connection, [TypedDictJsonDemo])
744
+
745
+ options: list[ExampleOption] = [
746
+ {"key": "option_a", "label": "Option A"},
747
+ {"key": "option_b", "label": "Option B"},
748
+ ]
749
+ demo = TypedDictJsonDemo(options=options)
750
+ await db_connection.insert([demo])
751
+
752
+ full_result = await db_connection.exec(
753
+ QueryBuilder().select(TypedDictJsonDemo).where(TypedDictJsonDemo.id == demo.id)
754
+ )
755
+ assert full_result == [TypedDictJsonDemo(id=demo.id, options=options)]
756
+
757
+ column_result = await db_connection.exec(
758
+ QueryBuilder()
759
+ .select(TypedDictJsonDemo.options)
760
+ .where(TypedDictJsonDemo.id == demo.id)
761
+ )
762
+ assert column_result == [options]
763
+
764
+
731
765
  @pytest.mark.asyncio
732
766
  async def test_get(db_connection: DBConnection):
733
767
  """
@@ -70,9 +70,11 @@ class DBModelMetaclass(_model_construction.ModelMetaclass):
70
70
  raw_kwargs = {**kwargs}
71
71
 
72
72
  mcs.is_constructing = True
73
- autodetect = mcs._extract_kwarg(kwargs, "autodetect", True)
74
- cls = super().__new__(mcs, name, bases, namespace, **kwargs)
75
- mcs.is_constructing = False
73
+ try:
74
+ autodetect = mcs._extract_kwarg(kwargs, "autodetect", True)
75
+ cls = super().__new__(mcs, name, bases, namespace, **kwargs)
76
+ finally:
77
+ mcs.is_constructing = False
76
78
 
77
79
  # Allow future calls to subclasses / generic instantiations to reference the same
78
80
  # kwargs as the base class
@@ -167,7 +167,20 @@ class DBFieldInfo(FieldInfo):
167
167
  db_kwargs["autoincrement"] = autoincrement
168
168
 
169
169
  kwargs = cast(DBFieldInputs, {**field_attributes, **db_kwargs})
170
- return cls(**kwargs)
170
+ extended_field = cls(**kwargs)
171
+
172
+ # Preserve Pydantic's internal rebuild state so generic specializations can
173
+ # recreate inherited fields from their original annotations.
174
+ for attr_name in getattr(FieldInfo, "__slots__", ()):
175
+ value = getattr(field, attr_name)
176
+ if attr_name in {"metadata", "_attributes_set", "_qualifiers"}:
177
+ value = value.copy()
178
+ setattr(extended_field, attr_name, value)
179
+
180
+ extended_field._attributes_set.update(
181
+ {key: value for key, value in db_kwargs.items() if value is not _Unset}
182
+ )
183
+ return extended_field
171
184
 
172
185
  def to_db_value(self, value: Any):
173
186
  if self.is_json:
@@ -50,8 +50,8 @@ from iceaxe.sql_types import enum_to_name
50
50
  from iceaxe.typing import (
51
51
  ALL_ENUM_TYPES,
52
52
  DATE_TYPES,
53
- JSON_WRAPPER_FALLBACK,
54
53
  PRIMITIVE_WRAPPER_TYPES,
54
+ is_json_container_type,
55
55
  resolve_typehint,
56
56
  )
57
57
 
@@ -435,14 +435,26 @@ class DatabaseHandler:
435
435
  # Resolve the type of the column, if generic
436
436
  if isinstance(storage_annotation, TypeVar):
437
437
  typevar_map = get_typevar_mapping(table)
438
- storage_annotation = typevar_map[storage_annotation]
439
- resolved_annotation = resolve_typehint(storage_annotation)
438
+ annotation = typevar_map[storage_annotation]
439
+ resolved_annotation = resolve_typehint(annotation)
440
440
  storage_annotation = (
441
441
  get_simple_subclass_base_type(resolved_annotation.runtime_type)
442
442
  or resolved_annotation.runtime_type
443
443
  )
444
444
  is_list = resolved_annotation.is_list
445
445
 
446
+ # JSON-backed fields should remain JSON even when their annotation is a
447
+ # top-level list container such as `list[str]` or `list[TypedDict]`.
448
+ if info.is_json and (
449
+ is_type_compatible(annotation, BaseModel)
450
+ or is_json_container_type(annotation)
451
+ or is_type_compatible(storage_annotation, BaseModel)
452
+ or is_json_container_type(storage_annotation)
453
+ ):
454
+ return TypeDeclarationResponse(
455
+ primitive_type=ColumnType.JSON,
456
+ )
457
+
446
458
  # Should be prioritized in terms of MRO; StrEnums should be processed
447
459
  # before the str types
448
460
  if is_type_compatible(storage_annotation, ALL_ENUM_TYPES):
@@ -514,7 +526,7 @@ class DatabaseHandler:
514
526
  f"Pydantic model fields must have Field(is_json=True) specified: {storage_annotation}\n"
515
527
  f"Column: {table.__name__}.{key}"
516
528
  )
517
- elif is_type_compatible(storage_annotation, JSON_WRAPPER_FALLBACK):
529
+ elif is_json_container_type(storage_annotation):
518
530
  if info.is_json:
519
531
  return TypeDeclarationResponse(
520
532
  primitive_type=ColumnType.JSON,
@@ -29,6 +29,13 @@ class DatabaseSerializer:
29
29
 
30
30
  """
31
31
 
32
+ pg_constraint_type_map = {
33
+ "p": ConstraintType.PRIMARY_KEY,
34
+ "f": ConstraintType.FOREIGN_KEY,
35
+ "u": ConstraintType.UNIQUE,
36
+ "c": ConstraintType.CHECK,
37
+ }
38
+
32
39
  def __init__(self, ignore_tables: list[str] | None = None):
33
40
  # Internal tables used for migration management, shouldn't be managed in-memory and therefore
34
41
  # won't be mirrored by our DBMemorySerializer. We exclude them from this serialization lest there
@@ -52,6 +59,20 @@ class DatabaseSerializer:
52
59
 
53
60
  raise ValueError(f"Unexpected type for database value: {type(value)}")
54
61
 
62
+ def _pg_constraint_type(
63
+ self, value: str | bytes | bytearray | memoryview
64
+ ) -> ConstraintType | None:
65
+ contype = self._unwrap_db_str(value)
66
+ if contype == "n":
67
+ # PostgreSQL 18 reports NOT NULL as pg_constraint rows. Iceaxe
68
+ # serializes nullability from information_schema.columns instead.
69
+ return None
70
+
71
+ try:
72
+ return self.pg_constraint_type_map[contype]
73
+ except KeyError:
74
+ raise ValueError(f"Unknown constraint type: {value}") from None
75
+
55
76
  async def get_objects(self, connection: DBConnection):
56
77
  tables = []
57
78
  async for table, dependencies in self.get_tables(connection):
@@ -156,18 +177,9 @@ class DatabaseSerializer:
156
177
  """
157
178
  result = await session.conn.fetch(query, table_name)
158
179
  for row in result:
159
- contype = self._unwrap_db_str(row["contype"])
160
- # Determine type
161
- if contype == "p":
162
- ctype = ConstraintType.PRIMARY_KEY
163
- elif contype == "f":
164
- ctype = ConstraintType.FOREIGN_KEY
165
- elif contype == "u":
166
- ctype = ConstraintType.UNIQUE
167
- elif contype == "c":
168
- ctype = ConstraintType.CHECK
169
- else:
170
- raise ValueError(f"Unknown constraint type: {row['contype']}")
180
+ ctype = self._pg_constraint_type(row["contype"])
181
+ if ctype is None:
182
+ continue
171
183
 
172
184
  columns = await self.fetch_constraint_columns(
173
185
  session, row["conkey"], table_name
@@ -1,4 +1,4 @@
1
- /* Generated by Cython 3.2.4 */
1
+ /* Generated by Cython 3.2.5 */
2
2
 
3
3
  /* BEGIN: Cython Metadata
4
4
  {
@@ -35,8 +35,8 @@ END: Cython Metadata */
35
35
  #elif PY_VERSION_HEX < 0x03080000
36
36
  #error Cython requires Python 3.8+.
37
37
  #else
38
- #define __PYX_ABI_VERSION "3_2_4"
39
- #define CYTHON_HEX_VERSION 0x030204F0
38
+ #define __PYX_ABI_VERSION "3_2_5"
39
+ #define CYTHON_HEX_VERSION 0x030205F0
40
40
  #define CYTHON_FUTURE_DIVISION 1
41
41
  /* CModulePreamble */
42
42
  #include <stddef.h>
@@ -2154,22 +2154,22 @@ static int __Pyx__ArgTypeTest(PyObject *obj, PyTypeObject *type, const char *nam
2154
2154
  __Pyx__ArgTypeTest(obj, type, name, exact))
2155
2155
 
2156
2156
  /* TypeImport.proto */
2157
- #ifndef __PYX_HAVE_RT_ImportType_proto_3_2_4
2158
- #define __PYX_HAVE_RT_ImportType_proto_3_2_4
2157
+ #ifndef __PYX_HAVE_RT_ImportType_proto_3_2_5
2158
+ #define __PYX_HAVE_RT_ImportType_proto_3_2_5
2159
2159
  #if defined (__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
2160
2160
  #include <stdalign.h>
2161
2161
  #endif
2162
2162
  #if (defined (__STDC_VERSION__) && __STDC_VERSION__ >= 201112L) || __cplusplus >= 201103L
2163
- #define __PYX_GET_STRUCT_ALIGNMENT_3_2_4(s) alignof(s)
2163
+ #define __PYX_GET_STRUCT_ALIGNMENT_3_2_5(s) alignof(s)
2164
2164
  #else
2165
- #define __PYX_GET_STRUCT_ALIGNMENT_3_2_4(s) sizeof(void*)
2165
+ #define __PYX_GET_STRUCT_ALIGNMENT_3_2_5(s) sizeof(void*)
2166
2166
  #endif
2167
- enum __Pyx_ImportType_CheckSize_3_2_4 {
2168
- __Pyx_ImportType_CheckSize_Error_3_2_4 = 0,
2169
- __Pyx_ImportType_CheckSize_Warn_3_2_4 = 1,
2170
- __Pyx_ImportType_CheckSize_Ignore_3_2_4 = 2
2167
+ enum __Pyx_ImportType_CheckSize_3_2_5 {
2168
+ __Pyx_ImportType_CheckSize_Error_3_2_5 = 0,
2169
+ __Pyx_ImportType_CheckSize_Warn_3_2_5 = 1,
2170
+ __Pyx_ImportType_CheckSize_Ignore_3_2_5 = 2
2171
2171
  };
2172
- static PyTypeObject *__Pyx_ImportType_3_2_4(PyObject* module, const char *module_name, const char *class_name, size_t size, size_t alignment, enum __Pyx_ImportType_CheckSize_3_2_4 check_size);
2172
+ static PyTypeObject *__Pyx_ImportType_3_2_5(PyObject* module, const char *module_name, const char *class_name, size_t size, size_t alignment, enum __Pyx_ImportType_CheckSize_3_2_5 check_size);
2173
2173
  #endif
2174
2174
 
2175
2175
  /* HasAttr.proto (used by ImportImpl) */
@@ -5302,7 +5302,7 @@ PyObject *__pyx_args, PyObject *__pyx_kwds
5302
5302
  {
5303
5303
  PyObject ** const __pyx_pyargnames[] = {&__pyx_mstate_global->__pyx_n_u_values,&__pyx_mstate_global->__pyx_n_u_select_raws,&__pyx_mstate_global->__pyx_n_u_select_types,0};
5304
5304
  const Py_ssize_t __pyx_kwds_len = (__pyx_kwds) ? __Pyx_NumKwargs_FASTCALL(__pyx_kwds) : 0;
5305
- if (unlikely(__pyx_kwds_len) < 0) __PYX_ERR(0, 208, __pyx_L3_error)
5305
+ if (unlikely(__pyx_kwds_len < 0)) __PYX_ERR(0, 208, __pyx_L3_error)
5306
5306
  if (__pyx_kwds_len > 0) {
5307
5307
  switch (__pyx_nargs) {
5308
5308
  case 3:
@@ -5480,15 +5480,15 @@ static int __Pyx_modinit_type_import_code(__pyx_mstatetype *__pyx_mstate) {
5480
5480
  /*--- Type import code ---*/
5481
5481
  __pyx_t_1 = PyImport_ImportModule(__Pyx_BUILTIN_MODULE_NAME); if (unlikely(!__pyx_t_1)) __PYX_ERR(1, 9, __pyx_L1_error)
5482
5482
  __Pyx_GOTREF(__pyx_t_1);
5483
- __pyx_mstate->__pyx_ptype_7cpython_4type_type = __Pyx_ImportType_3_2_4(__pyx_t_1, __Pyx_BUILTIN_MODULE_NAME, "type",
5483
+ __pyx_mstate->__pyx_ptype_7cpython_4type_type = __Pyx_ImportType_3_2_5(__pyx_t_1, __Pyx_BUILTIN_MODULE_NAME, "type",
5484
5484
  #if defined(PYPY_VERSION_NUM) && PYPY_VERSION_NUM < 0x050B0000
5485
- sizeof(PyTypeObject), __PYX_GET_STRUCT_ALIGNMENT_3_2_4(PyTypeObject),
5485
+ sizeof(PyTypeObject), __PYX_GET_STRUCT_ALIGNMENT_3_2_5(PyTypeObject),
5486
5486
  #elif CYTHON_COMPILING_IN_LIMITED_API
5487
5487
  0, 0,
5488
5488
  #else
5489
- sizeof(PyHeapTypeObject), __PYX_GET_STRUCT_ALIGNMENT_3_2_4(PyHeapTypeObject),
5489
+ sizeof(PyHeapTypeObject), __PYX_GET_STRUCT_ALIGNMENT_3_2_5(PyHeapTypeObject),
5490
5490
  #endif
5491
- __Pyx_ImportType_CheckSize_Warn_3_2_4); if (!__pyx_mstate->__pyx_ptype_7cpython_4type_type) __PYX_ERR(1, 9, __pyx_L1_error)
5491
+ __Pyx_ImportType_CheckSize_Warn_3_2_5); if (!__pyx_mstate->__pyx_ptype_7cpython_4type_type) __PYX_ERR(1, 9, __pyx_L1_error)
5492
5492
  __Pyx_DECREF(__pyx_t_1); __pyx_t_1 = 0;
5493
5493
  __Pyx_RefNannyFinishContext();
5494
5494
  return 0;
@@ -6890,7 +6890,7 @@ bad:
6890
6890
  }
6891
6891
 
6892
6892
  /* dict_iter */
6893
- #if CYTHON_COMPILING_IN_PYPY
6893
+ #if CYTHON_AVOID_BORROWED_REFS
6894
6894
  #include <string.h>
6895
6895
  #endif
6896
6896
  static CYTHON_INLINE PyObject* __Pyx_dict_iterator(PyObject* iterable, int is_dict, PyObject* method_name,
@@ -6898,7 +6898,7 @@ static CYTHON_INLINE PyObject* __Pyx_dict_iterator(PyObject* iterable, int is_di
6898
6898
  is_dict = is_dict || likely(PyDict_CheckExact(iterable));
6899
6899
  *p_source_is_dict = is_dict;
6900
6900
  if (is_dict) {
6901
- #if !CYTHON_COMPILING_IN_PYPY
6901
+ #if !CYTHON_AVOID_BORROWED_REFS
6902
6902
  *p_orig_length = PyDict_Size(iterable);
6903
6903
  Py_INCREF(iterable);
6904
6904
  return iterable;
@@ -6927,7 +6927,7 @@ static CYTHON_INLINE PyObject* __Pyx_dict_iterator(PyObject* iterable, int is_di
6927
6927
  iterable = __Pyx_PyObject_CallMethod0(iterable, method_name);
6928
6928
  if (!iterable)
6929
6929
  return NULL;
6930
- #if !CYTHON_COMPILING_IN_PYPY
6930
+ #if !CYTHON_AVOID_BORROWED_REFS
6931
6931
  if (PyTuple_CheckExact(iterable) || PyList_CheckExact(iterable))
6932
6932
  return iterable;
6933
6933
  #endif
@@ -7126,8 +7126,8 @@ bad:
7126
7126
  CYTHON_UNUSED_VAR(max_char);
7127
7127
  CYTHON_UNUSED_VAR(result_ulength);
7128
7128
  for (i=0; i<value_count; i++) {
7129
- if (__Pyx_PyTuple_SET_ITEM(value_tuple, i, values[i]) != (0)) goto bad;
7130
7129
  Py_INCREF(values[i]);
7130
+ if (__Pyx_PyTuple_SET_ITEM(value_tuple, i, values[i]) != (0)) goto bad;
7131
7131
  }
7132
7132
  result = PyUnicode_Join(__pyx_mstate_global->__pyx_empty_unicode, value_tuple);
7133
7133
  bad:
@@ -7631,11 +7631,11 @@ __Pyx_PyTuple_FromArray(PyObject *const *src, Py_ssize_t n)
7631
7631
  res = PyTuple_New(n);
7632
7632
  if (unlikely(res == NULL)) return NULL;
7633
7633
  for (i = 0; i < n; i++) {
7634
+ Py_INCREF(src[i]);
7634
7635
  if (unlikely(__Pyx_PyTuple_SET_ITEM(res, i, src[i]) < (0))) {
7635
7636
  Py_DECREF(res);
7636
7637
  return NULL;
7637
7638
  }
7638
- Py_INCREF(src[i]);
7639
7639
  }
7640
7640
  return res;
7641
7641
  }
@@ -8573,10 +8573,10 @@ static int __Pyx__ArgTypeTest(PyObject *obj, PyTypeObject *type, const char *nam
8573
8573
  }
8574
8574
 
8575
8575
  /* TypeImport */
8576
- #ifndef __PYX_HAVE_RT_ImportType_3_2_4
8577
- #define __PYX_HAVE_RT_ImportType_3_2_4
8578
- static PyTypeObject *__Pyx_ImportType_3_2_4(PyObject *module, const char *module_name, const char *class_name,
8579
- size_t size, size_t alignment, enum __Pyx_ImportType_CheckSize_3_2_4 check_size)
8576
+ #ifndef __PYX_HAVE_RT_ImportType_3_2_5
8577
+ #define __PYX_HAVE_RT_ImportType_3_2_5
8578
+ static PyTypeObject *__Pyx_ImportType_3_2_5(PyObject *module, const char *module_name, const char *class_name,
8579
+ size_t size, size_t alignment, enum __Pyx_ImportType_CheckSize_3_2_5 check_size)
8580
8580
  {
8581
8581
  PyObject *result = 0;
8582
8582
  Py_ssize_t basicsize;
@@ -8632,7 +8632,7 @@ static PyTypeObject *__Pyx_ImportType_3_2_4(PyObject *module, const char *module
8632
8632
  module_name, class_name, size, basicsize+itemsize);
8633
8633
  goto bad;
8634
8634
  }
8635
- if (check_size == __Pyx_ImportType_CheckSize_Error_3_2_4 &&
8635
+ if (check_size == __Pyx_ImportType_CheckSize_Error_3_2_5 &&
8636
8636
  ((size_t)basicsize > size || (size_t)(basicsize + itemsize) < size)) {
8637
8637
  PyErr_Format(PyExc_ValueError,
8638
8638
  "%.200s.%.200s size changed, may indicate binary incompatibility. "
@@ -8640,7 +8640,7 @@ static PyTypeObject *__Pyx_ImportType_3_2_4(PyObject *module, const char *module
8640
8640
  module_name, class_name, size, basicsize, basicsize+itemsize);
8641
8641
  goto bad;
8642
8642
  }
8643
- else if (check_size == __Pyx_ImportType_CheckSize_Warn_3_2_4 && (size_t)basicsize > size) {
8643
+ else if (check_size == __Pyx_ImportType_CheckSize_Warn_3_2_5 && (size_t)basicsize > size) {
8644
8644
  if (PyErr_WarnFormat(NULL, 0,
8645
8645
  "%.200s.%.200s size changed, may indicate binary incompatibility. "
8646
8646
  "Expected %zd from C header, got %zd from PyObject",
@@ -9690,7 +9690,6 @@ __Pyx_CyFunction_get_is_coroutine_value(__pyx_CyFunctionObject *op) {
9690
9690
  PyList_SET_ITEM(fromlist, 0, marker);
9691
9691
  #else
9692
9692
  if (unlikely(PyList_SetItem(fromlist, 0, marker) < 0)) {
9693
- Py_DECREF(marker);
9694
9693
  Py_DECREF(fromlist);
9695
9694
  return NULL;
9696
9695
  }
@@ -9931,8 +9930,7 @@ __Pyx_CyFunction_clear(__pyx_CyFunctionObject *m)
9931
9930
  Py_CLEAR(m->func_doc);
9932
9931
  Py_CLEAR(m->func_globals);
9933
9932
  Py_CLEAR(m->func_code);
9934
- #if !CYTHON_COMPILING_IN_LIMITED_API
9935
- #if PY_VERSION_HEX < 0x030900B1
9933
+ #if PY_VERSION_HEX < 0x030900B1 || CYTHON_COMPILING_IN_LIMITED_API
9936
9934
  Py_CLEAR(__Pyx_CyFunction_GetClassObj(m));
9937
9935
  #else
9938
9936
  {
@@ -9940,7 +9938,6 @@ __Pyx_CyFunction_clear(__pyx_CyFunctionObject *m)
9940
9938
  ((PyCMethodObject *) (m))->mm_class = NULL;
9941
9939
  Py_XDECREF(cls);
9942
9940
  }
9943
- #endif
9944
9941
  #endif
9945
9942
  Py_CLEAR(m->defaults_tuple);
9946
9943
  Py_CLEAR(m->defaults_kwdict);
@@ -9992,11 +9989,10 @@ static int __Pyx_CyFunction_traverse(__pyx_CyFunctionObject *m, visitproc visit,
9992
9989
  Py_VISIT(m->func_doc);
9993
9990
  Py_VISIT(m->func_globals);
9994
9991
  __Pyx_VISIT_CONST(m->func_code);
9995
- #if !CYTHON_COMPILING_IN_LIMITED_API
9996
9992
  Py_VISIT(__Pyx_CyFunction_GetClassObj(m));
9997
- #endif
9998
9993
  Py_VISIT(m->defaults_tuple);
9999
9994
  Py_VISIT(m->defaults_kwdict);
9995
+ Py_VISIT(m->func_annotations);
10000
9996
  Py_VISIT(m->func_is_coroutine);
10001
9997
  Py_VISIT(m->defaults);
10002
9998
  return 0;
@@ -10708,8 +10704,8 @@ __Pyx_PyType_GetFullyQualifiedName(PyTypeObject* tp)
10708
10704
  #if CYTHON_VECTORCALL
10709
10705
  static int __Pyx_VectorcallBuilder_AddArg(PyObject *key, PyObject *value, PyObject *builder, PyObject **args, int n) {
10710
10706
  (void)__Pyx_PyObject_FastCallDict;
10711
- if (__Pyx_PyTuple_SET_ITEM(builder, n, key) != (0)) return -1;
10712
10707
  Py_INCREF(key);
10708
+ if (__Pyx_PyTuple_SET_ITEM(builder, n, key) != (0)) return -1;
10713
10709
  args[n] = value;
10714
10710
  return 0;
10715
10711
  }
@@ -16,9 +16,12 @@ from typing import (
16
16
  Union,
17
17
  get_args,
18
18
  get_origin,
19
+ is_typeddict as stdlib_is_typeddict,
19
20
  )
20
21
  from uuid import UUID
21
22
 
23
+ from typing_extensions import is_typeddict as extensions_is_typeddict
24
+
22
25
  if TYPE_CHECKING:
23
26
  from iceaxe.alias_values import Alias
24
27
  from iceaxe.base import (
@@ -140,6 +143,27 @@ def resolve_typehint(annotation: Any) -> ResolvedTypehint:
140
143
  )
141
144
 
142
145
 
146
+ def is_json_container_type(annotation: Any) -> bool:
147
+ """
148
+ Return whether an annotation represents a JSON container shape.
149
+
150
+ Iceaxe stores `dict[...]`, `list[...]`, plain `dict` / `list`, and
151
+ `TypedDict` values in JSON columns when `Field(is_json=True)` is specified.
152
+ This helper intentionally focuses on the container forms that need special
153
+ schema inference without trying to classify arbitrary JSON-serializable
154
+ scalar types.
155
+
156
+ """
157
+ annotation = unwrap_annotated(annotation)
158
+ if annotation in {dict, list}:
159
+ return True
160
+ if stdlib_is_typeddict(annotation) or extensions_is_typeddict(annotation):
161
+ return True
162
+
163
+ origin = get_origin(annotation)
164
+ return origin in {dict, list}
165
+
166
+
143
167
  def transform_typehint(
144
168
  annotation: Any,
145
169
  transform: Callable[[Any], Any],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iceaxe
3
- Version: 0.12.0.dev1
3
+ Version: 0.12.2
4
4
  Summary: A modern, fast ORM for Python.
5
5
  Author-email: Pierce Freeman <pierce@freeman.vc>
6
6
  Requires-Python: >=3.11
@@ -76,6 +76,29 @@ class Person(TableBase):
76
76
  age: int
77
77
  ```
78
78
 
79
+ Structured JSON values and lightweight scalar subclasses also work naturally in table definitions:
80
+
81
+ ```python
82
+ from iceaxe import Field, TableBase
83
+ from pydantic import BaseModel
84
+ from uuid import UUID
85
+
86
+ class PersonId(UUID):
87
+ pass
88
+
89
+ class Preferences(BaseModel):
90
+ theme: str
91
+ notifications: bool
92
+
93
+ class Person(TableBase):
94
+ id: PersonId = Field(primary_key=True)
95
+ preferences: Preferences = Field(is_json=True)
96
+ ```
97
+
98
+ `Field(is_json=True)` will round-trip Pydantic models through a JSON column, and simple subclasses of
99
+ types like `UUID`, `str`, `int`, `date`, and `datetime` are stored using their base Postgres type while
100
+ being returned as their subclass in Python.
101
+
79
102
  Okay now you have a model. How do you interact with it?
80
103
 
81
104
  Databases are based on a few core primitives to insert data, update it, and fetch it out again.
@@ -120,6 +143,9 @@ For local development or side projects, you can use `magic_migrate` to automatic
120
143
  await conn.magic_migrate("my_project")
121
144
  ```
122
145
 
146
+ If you want to limit the sync to a subset of tables or label the generated revision, you can also pass
147
+ `models=[...]` and `message="..."`.
148
+
123
149
  This will:
124
150
  1. Compare your current database schema against your model definitions
125
151
  2. Generate a migration file if changes are detected
@@ -243,6 +269,28 @@ results = await conn.exec(query)
243
269
 
244
270
  As expected this will deliver results - and typehint - as a `list[tuple[int, FavoriteColor]]`
245
271
 
272
+ For the common "fetch the first matching model or fail" case, use `.one()`:
273
+
274
+ ```python
275
+ from iceaxe import NoObjectFound
276
+
277
+ try:
278
+ person = await conn.exec(
279
+ select(Person)
280
+ .where(Person.id == 1)
281
+ .one()
282
+ )
283
+ except NoObjectFound:
284
+ person = None
285
+ ```
286
+
287
+ `.one()` only applies to a single full-model select like `select(Person)`. It adds `LIMIT 1`,
288
+ returns a single `Person` instead of `list[Person]`, and raises `NoObjectFound` if the query
289
+ returns no rows.
290
+
291
+ When a query fails, Iceaxe raises `IceaxeQueryError` with the SQL text and variables attached to
292
+ the exception message while still preserving the original asyncpg exception type.
293
+
246
294
  ## Production
247
295
 
248
296
  Note that underlying Postgres connection wrapped by `conn` will be alive for as long as your object is in memory. This uses up one
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iceaxe"
3
- version = "0.12.0.dev1"
3
+ version = "0.12.2"
4
4
  description = "A modern, fast ORM for Python."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
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