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.
- {iceaxe-0.12.0.dev1/iceaxe.egg-info → iceaxe-0.12.2}/PKG-INFO +49 -1
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/README.md +48 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/conftest.py +11 -1
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +25 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_db_serializer.py +55 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_base.py +45 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_session.py +34 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/base.py +5 -3
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/field.py +14 -1
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/db_memory_serializer.py +16 -4
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/db_serializer.py +24 -12
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/session_optimized.c +31 -35
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/typing.py +24 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2/iceaxe.egg-info}/PKG-INFO +49 -1
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/pyproject.toml +1 -1
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/LICENSE +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/MANIFEST.in +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/benchmarks/test_bulk_insert.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/conf_models.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/docker_helpers.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/helpers.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/conftest.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/test_action_sorter.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/test_generator.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/migrations/test_generics.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/mountaineer/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/mountaineer/dependencies/test_core.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_actions.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_cli.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_alias.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_comparison.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_field.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_helpers.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_magic_migrate.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_modifications.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_queries.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_queries_str.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/__tests__/test_text_search.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/alias_values.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/comparison.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/custom_typehints.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/exceptions.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/functions.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/generics.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/io.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/logging.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/action_sorter.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/cli.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/client_io.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/generator.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/migration.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/migrations/migrator.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/modifications.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/cli.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/config.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/mountaineer/dependencies/core.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/postgres.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/py.typed +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/queries.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/queries_str.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/__init__.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/actions.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/cli.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/schemas/db_stubs.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/session.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/session_optimized.pyx +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe/sql_types.py +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/SOURCES.txt +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/dependency_links.txt +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/requires.txt +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/iceaxe.egg-info/top_level.txt +0 -0
- {iceaxe-0.12.0.dev1 → iceaxe-0.12.2}/setup.cfg +0 -0
- {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.
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
resolved_annotation = resolve_typehint(
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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 "
|
|
39
|
-
#define CYTHON_HEX_VERSION
|
|
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
|
|
2158
|
-
#define
|
|
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
|
|
2163
|
+
#define __PYX_GET_STRUCT_ALIGNMENT_3_2_5(s) alignof(s)
|
|
2164
2164
|
#else
|
|
2165
|
-
#define
|
|
2165
|
+
#define __PYX_GET_STRUCT_ALIGNMENT_3_2_5(s) sizeof(void*)
|
|
2166
2166
|
#endif
|
|
2167
|
-
enum
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
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 *
|
|
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
|
|
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 =
|
|
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),
|
|
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),
|
|
5489
|
+
sizeof(PyHeapTypeObject), __PYX_GET_STRUCT_ALIGNMENT_3_2_5(PyHeapTypeObject),
|
|
5490
5490
|
#endif
|
|
5491
|
-
|
|
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
|
|
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 !
|
|
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 !
|
|
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
|
|
8577
|
-
#define
|
|
8578
|
-
static PyTypeObject *
|
|
8579
|
-
size_t size, size_t alignment, enum
|
|
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 ==
|
|
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 ==
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|