sqlcrucible 0.3.2__tar.gz → 0.3.4__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.
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/.github/workflows/ci.yml +6 -2
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/.gitignore +1 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/PKG-INFO +1 -1
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/comparison.md +6 -6
- sqlcrucible-0.3.4/docs/getting-started.md +169 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/defining-entities.md +5 -5
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/field-mapping.md +2 -2
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/inheritance.md +9 -9
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/orm-descriptors.md +8 -8
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/relationships.md +61 -7
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/index.md +2 -2
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/mkdocs.yml +1 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/noxfile.py +15 -2
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/pyproject.toml +4 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/_types/forward_refs.py +1 -1
- sqlcrucible-0.3.4/src/sqlcrucible/_version.py +24 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/__init__.py +6 -5
- sqlcrucible-0.3.4/src/sqlcrucible/conversion/caching.py +82 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/annotations.py +1 -1
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/automodel.py +5 -4
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/core.py +72 -65
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/descriptors.py +68 -49
- sqlcrucible-0.3.4/src/sqlcrucible/entity/field_definitions.py +275 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/field_resolution.py +4 -4
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/sa_conversion.py +3 -6
- sqlcrucible-0.3.4/tests/_types/test_annotations_properties.py +78 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/_types/test_forward_refs.py +14 -0
- sqlcrucible-0.3.4/tests/conversion/test_caching.py +209 -0
- sqlcrucible-0.3.4/tests/conversion/test_caching_properties.py +104 -0
- sqlcrucible-0.3.4/tests/conversion/test_literals_properties.py +80 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/test_unions.py +12 -2
- sqlcrucible-0.3.4/tests/entity/test_explicit_table.py +74 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_pydantic_entity.py +73 -1
- sqlcrucible-0.3.4/tests/entity/test_readonly_field_serialisation.py +228 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_back_populates.py +36 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_self_referential.py +13 -0
- sqlcrucible-0.3.4/tests/strategies.py +162 -0
- sqlcrucible-0.3.4/tests/stubs/conftest.py +231 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_typecheck_columns.py +21 -35
- sqlcrucible-0.3.4/tests/stubs/test_typecheck_entity_preservation.py +40 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_typecheck_excluded_fields.py +12 -20
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_typecheck_relationships.py +16 -26
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_typecheck_sa_type.py +21 -33
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/uv.lock +25 -0
- sqlcrucible-0.3.2/docs/getting-started.md +0 -124
- sqlcrucible-0.3.2/src/sqlcrucible/_version.py +0 -34
- sqlcrucible-0.3.2/src/sqlcrucible/entity/field_definitions.py +0 -277
- sqlcrucible-0.3.2/tests/stubs/conftest.py +0 -67
- sqlcrucible-0.3.2/tests/stubs/test_typecheck_entity_preservation.py +0 -50
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/.github/workflows/docs.yml +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/.python-version +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/LICENSE +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/README.md +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/advanced.md +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/guide/type-conversion.md +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/docs/reference/api.md +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/_types/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/_types/annotations.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/_types/match.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/_types/params.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/_types/transformer.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/dicts.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/exceptions.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/function.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/literals.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/noop.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/registry.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/sequences.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/conversion/unions.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/entity/sa_type.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/stubs/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/stubs/__main__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/stubs/codegen.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/stubs/discovery.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/src/sqlcrucible/stubs/serialization.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/_types/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/_types/test_annotations.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/_types/test_params.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/conftest.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/test_dicts.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/test_literals.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/test_noop.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/test_registry.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/conversion/test_sequences.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/orm_descriptors/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/orm_descriptors/conftest.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_attrs_entity.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_concrete_table_inheritance.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_custom_sa_model.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_dataclass_entity.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_joined_table_inheritance.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_cycles.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_eager.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_many_to_many.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_one_to_many_child.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_relationships_one_to_one.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_sa_type.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/entity/test_single_table_inheritance.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/__init__.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/sample_models.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_build_import_block.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_codegen.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_construct_model_def.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_discovery.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_generate_model_defs.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_sa_field_type.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_serialization.py +0 -0
- {sqlcrucible-0.3.2 → sqlcrucible-0.3.4}/tests/stubs/test_stub_generation.py +0 -0
|
@@ -53,13 +53,17 @@ jobs:
|
|
|
53
53
|
- name: Install uv
|
|
54
54
|
uses: astral-sh/setup-uv@v5
|
|
55
55
|
|
|
56
|
+
- name: Derive cache key
|
|
57
|
+
id: cache-key
|
|
58
|
+
run: echo "session=$(echo '${{ matrix.session }}' | tr ',' '-')" >> $GITHUB_OUTPUT
|
|
59
|
+
|
|
56
60
|
- name: Restore uv cache
|
|
57
61
|
uses: actions/cache@v4
|
|
58
62
|
with:
|
|
59
63
|
path: ${{ env.UV_CACHE_DIR }}
|
|
60
|
-
key: uv-${{ runner.os }}-${{
|
|
64
|
+
key: uv-${{ runner.os }}-${{ steps.cache-key.outputs.session }}-${{ hashFiles('uv.lock') }}
|
|
61
65
|
restore-keys: |
|
|
62
|
-
uv-${{ runner.os }}-${{
|
|
66
|
+
uv-${{ runner.os }}-${{ steps.cache-key.outputs.session }}-
|
|
63
67
|
uv-${{ runner.os }}-
|
|
64
68
|
|
|
65
69
|
- name: Run nox session
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlcrucible
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Define a single model that works as both Pydantic and SQLAlchemy, with explicit conversion between the two
|
|
5
5
|
Author-email: Richard Rae-Jones <sqlcrucible@richard.rdrj.uk>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
|
|
50
50
|
```python
|
|
51
51
|
from typing import Annotated
|
|
52
|
-
from uuid import UUID,
|
|
52
|
+
from uuid import UUID, uuid7
|
|
53
53
|
from pydantic import Field
|
|
54
54
|
from sqlalchemy.orm import mapped_column
|
|
55
55
|
from sqlcrucible import SQLCrucibleBaseModel
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
class User(SQLCrucibleBaseModel):
|
|
58
58
|
__sqlalchemy_params__ = {"__tablename__": "user"}
|
|
59
59
|
|
|
60
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
60
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
61
61
|
name: str
|
|
62
62
|
email: str
|
|
63
63
|
```
|
|
@@ -65,11 +65,11 @@
|
|
|
65
65
|
=== "SQLModel"
|
|
66
66
|
|
|
67
67
|
```python
|
|
68
|
-
from uuid import UUID,
|
|
68
|
+
from uuid import UUID, uuid7
|
|
69
69
|
from sqlmodel import SQLModel, Field
|
|
70
70
|
|
|
71
71
|
class User(SQLModel, table=True):
|
|
72
|
-
id: UUID = Field(default_factory=
|
|
72
|
+
id: UUID = Field(default_factory=uuid7, primary_key=True)
|
|
73
73
|
name: str
|
|
74
74
|
email: str
|
|
75
75
|
```
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
|
|
114
114
|
class Author(SQLCrucibleBaseModel):
|
|
115
115
|
__sqlalchemy_params__ = {"__tablename__": "author"}
|
|
116
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
116
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
117
117
|
name: str
|
|
118
118
|
|
|
119
119
|
books = readonly_field(
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
from sqlmodel import Relationship
|
|
132
132
|
|
|
133
133
|
class Author(SQLModel, table=True):
|
|
134
|
-
id: UUID = Field(default_factory=
|
|
134
|
+
id: UUID = Field(default_factory=uuid7, primary_key=True)
|
|
135
135
|
name: str
|
|
136
136
|
|
|
137
137
|
books: list["Book"] = Relationship(back_populates="author")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Getting Started
|
|
2
|
+
|
|
3
|
+
## Installation
|
|
4
|
+
|
|
5
|
+
Install SQLCrucible with pip:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install sqlcrucible
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or with uv:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv add sqlcrucible
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
Here's a complete example showing the typical workflow:
|
|
20
|
+
|
|
21
|
+
=== "Python 3.14+"
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from typing import Annotated
|
|
25
|
+
from uuid import UUID, uuid7
|
|
26
|
+
|
|
27
|
+
from pydantic import Field
|
|
28
|
+
from sqlalchemy import create_engine, select
|
|
29
|
+
from sqlalchemy.orm import Session, mapped_column
|
|
30
|
+
|
|
31
|
+
from sqlcrucible import SAType, SQLCrucibleBaseModel
|
|
32
|
+
|
|
33
|
+
# 1. Define your entity
|
|
34
|
+
class Artist(SQLCrucibleBaseModel):
|
|
35
|
+
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
36
|
+
|
|
37
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
# 2. Create the database tables
|
|
41
|
+
engine = create_engine("sqlite:///:memory:")
|
|
42
|
+
SAType[Artist].__table__.metadata.create_all(engine)
|
|
43
|
+
|
|
44
|
+
# 3. Create an entity and save it
|
|
45
|
+
artist = Artist(name="Bob Dylan")
|
|
46
|
+
with Session(engine) as session:
|
|
47
|
+
session.add(artist.to_sa_model()) # Convert to SQLAlchemy model
|
|
48
|
+
session.commit()
|
|
49
|
+
|
|
50
|
+
# 4. Query and convert back
|
|
51
|
+
with Session(engine) as session:
|
|
52
|
+
sa_artist = session.scalar(
|
|
53
|
+
select(SAType[Artist]).where(SAType[Artist].name == "Bob Dylan")
|
|
54
|
+
)
|
|
55
|
+
artist = Artist.from_sa_model(sa_artist) # Convert back to entity
|
|
56
|
+
print(artist.name) # "Bob Dylan"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
=== "Python < 3.14"
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from typing import Annotated
|
|
63
|
+
from uuid import UUID
|
|
64
|
+
|
|
65
|
+
from uuid_utils.compat import uuid7
|
|
66
|
+
|
|
67
|
+
from pydantic import Field
|
|
68
|
+
from sqlalchemy import create_engine, select
|
|
69
|
+
from sqlalchemy.orm import Session, mapped_column
|
|
70
|
+
|
|
71
|
+
from sqlcrucible import SAType, SQLCrucibleBaseModel
|
|
72
|
+
|
|
73
|
+
# 1. Define your entity
|
|
74
|
+
class Artist(SQLCrucibleBaseModel):
|
|
75
|
+
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
76
|
+
|
|
77
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
78
|
+
name: str
|
|
79
|
+
|
|
80
|
+
# 2. Create the database tables
|
|
81
|
+
engine = create_engine("sqlite:///:memory:")
|
|
82
|
+
SAType[Artist].__table__.metadata.create_all(engine)
|
|
83
|
+
|
|
84
|
+
# 3. Create an entity and save it
|
|
85
|
+
artist = Artist(name="Bob Dylan")
|
|
86
|
+
with Session(engine) as session:
|
|
87
|
+
session.add(artist.to_sa_model()) # Convert to SQLAlchemy model
|
|
88
|
+
session.commit()
|
|
89
|
+
|
|
90
|
+
# 4. Query and convert back
|
|
91
|
+
with Session(engine) as session:
|
|
92
|
+
sa_artist = session.scalar(
|
|
93
|
+
select(SAType[Artist]).where(SAType[Artist].name == "Bob Dylan")
|
|
94
|
+
)
|
|
95
|
+
artist = Artist.from_sa_model(sa_artist) # Convert back to entity
|
|
96
|
+
print(artist.name) # "Bob Dylan"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
!!! note "UUID7 on older Python versions"
|
|
100
|
+
UUID7 is preferred over UUID4 for database primary keys because its time-ordered values improve index performance. `uuid.uuid7` is available in the standard library from Python 3.14. On earlier versions, install [`uuid-utils`](https://pypi.org/project/uuid-utils/) and import with `from uuid_utils.compat import uuid7`. All remaining examples in these docs use the stdlib import for brevity.
|
|
101
|
+
|
|
102
|
+
The `Artist` class is a standard Pydantic model — it works with FastAPI, has validation, and serializes to JSON. When you need to interact with the database, you explicitly convert to and from the SQLAlchemy model.
|
|
103
|
+
|
|
104
|
+
## What This Replaces
|
|
105
|
+
|
|
106
|
+
Without SQLCrucible, you'd need to write equivalent code like this:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from uuid import UUID, uuid7
|
|
110
|
+
from pydantic import BaseModel, Field
|
|
111
|
+
from sqlalchemy.orm import mapped_column, DeclarativeBase, Mapped
|
|
112
|
+
from typing import Any, Self
|
|
113
|
+
|
|
114
|
+
class Base(DeclarativeBase): ...
|
|
115
|
+
|
|
116
|
+
class ArtistSQLAlchemyModel(Base):
|
|
117
|
+
__tablename__ = "artist"
|
|
118
|
+
id: Mapped[UUID] = mapped_column(primary_key=True)
|
|
119
|
+
name: Mapped[str]
|
|
120
|
+
|
|
121
|
+
class Artist(BaseModel):
|
|
122
|
+
__sqlalchemy_type__: type[Any] = ArtistSQLAlchemyModel
|
|
123
|
+
|
|
124
|
+
id: UUID = Field(default_factory=uuid7)
|
|
125
|
+
name: str
|
|
126
|
+
|
|
127
|
+
def to_sa_model(self) -> ArtistSQLAlchemyModel:
|
|
128
|
+
return ArtistSQLAlchemyModel(
|
|
129
|
+
id=self.id,
|
|
130
|
+
name=self.name
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_sa_model(cls, sa_model: ArtistSQLAlchemyModel) -> Self:
|
|
135
|
+
return cls(
|
|
136
|
+
id=sa_model.id,
|
|
137
|
+
name=sa_model.name
|
|
138
|
+
)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Framework Support
|
|
142
|
+
|
|
143
|
+
SQLCrucible works with multiple Python model frameworks:
|
|
144
|
+
|
|
145
|
+
- **Pydantic**: Inherit from `SQLCrucibleBaseModel`
|
|
146
|
+
- **dataclasses**: Use `@dataclass` with `SQLCrucibleEntity`
|
|
147
|
+
- **attrs**: Use `@define` with `SQLCrucibleEntity`
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from dataclasses import dataclass, field
|
|
151
|
+
|
|
152
|
+
@dataclass
|
|
153
|
+
class Artist(SQLCrucibleEntity):
|
|
154
|
+
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
155
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = field(default_factory=uuid7)
|
|
156
|
+
name: str
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Caveats
|
|
160
|
+
|
|
161
|
+
1. **Cyclical references between model instances are not supported.** Use `readonly_field` on one side to break the cycle.
|
|
162
|
+
|
|
163
|
+
2. **Pydantic and `readonly_field`**: Either inherit from `SQLCrucibleBaseModel` (which includes the necessary config), or add `model_config = ConfigDict(ignored_types=(ReadonlyFieldDescriptor,))` to your model (import from `sqlcrucible`).
|
|
164
|
+
|
|
165
|
+
3. **Forward references in relationships**: Use lambdas to avoid circular import issues: `relationship(lambda: SAType[OtherEntity])`.
|
|
166
|
+
|
|
167
|
+
4. **Concrete table inheritance** requires redefining ALL columns in each subclass.
|
|
168
|
+
|
|
169
|
+
5. **`readonly_field` requires a backing SQLAlchemy model**: Accessing a `readonly_field` on an entity not loaded via `from_sa_model()` raises `RuntimeError`.
|
|
@@ -6,7 +6,7 @@ An entity is a class that serves as both a Pydantic model and a SQLAlchemy table
|
|
|
6
6
|
|
|
7
7
|
```python
|
|
8
8
|
from typing import Annotated
|
|
9
|
-
from uuid import UUID,
|
|
9
|
+
from uuid import UUID, uuid7
|
|
10
10
|
from pydantic import Field
|
|
11
11
|
from sqlalchemy.orm import mapped_column
|
|
12
12
|
from sqlcrucible import SQLCrucibleBaseModel
|
|
@@ -14,7 +14,7 @@ from sqlcrucible import SQLCrucibleBaseModel
|
|
|
14
14
|
class Artist(SQLCrucibleBaseModel):
|
|
15
15
|
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
16
16
|
|
|
17
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
17
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
18
18
|
name: Annotated[str, mapped_column()]
|
|
19
19
|
```
|
|
20
20
|
|
|
@@ -32,12 +32,12 @@ class BaseEntity(SQLCrucibleBaseModel):
|
|
|
32
32
|
|
|
33
33
|
class Artist(BaseEntity):
|
|
34
34
|
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
35
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
35
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
36
36
|
name: str
|
|
37
37
|
|
|
38
38
|
class Album(BaseEntity):
|
|
39
39
|
__sqlalchemy_params__ = {"__tablename__": "album"}
|
|
40
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
40
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
41
41
|
title: str
|
|
42
42
|
```
|
|
43
43
|
|
|
@@ -72,7 +72,7 @@ class User(SQLCrucibleBaseModel):
|
|
|
72
72
|
),
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
75
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
76
76
|
email: str
|
|
77
77
|
```
|
|
78
78
|
|
|
@@ -45,7 +45,7 @@ This is useful when:
|
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
47
|
from typing import Annotated
|
|
48
|
-
from uuid import UUID,
|
|
48
|
+
from uuid import UUID, uuid7
|
|
49
49
|
from pydantic import Field
|
|
50
50
|
from sqlalchemy.orm import mapped_column
|
|
51
51
|
from sqlcrucible import SQLCrucibleBaseModel
|
|
@@ -54,7 +54,7 @@ from sqlcrucible import SQLAlchemyField
|
|
|
54
54
|
class User(SQLCrucibleBaseModel):
|
|
55
55
|
__sqlalchemy_params__ = {"__tablename__": "user"}
|
|
56
56
|
|
|
57
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
57
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
58
58
|
# Entity field 'email' maps to column 'email_address' in the database
|
|
59
59
|
email: Annotated[str, SQLAlchemyField(name="email_address")]
|
|
60
60
|
```
|
|
@@ -8,7 +8,7 @@ All subclasses share one table with a discriminator column:
|
|
|
8
8
|
|
|
9
9
|
```python
|
|
10
10
|
from typing import Annotated
|
|
11
|
-
from uuid import UUID,
|
|
11
|
+
from uuid import UUID, uuid7
|
|
12
12
|
from pydantic import Field
|
|
13
13
|
from sqlalchemy import String
|
|
14
14
|
from sqlalchemy.orm import mapped_column
|
|
@@ -20,7 +20,7 @@ class Animal(SQLCrucibleBaseModel):
|
|
|
20
20
|
"__tablename__": "animal",
|
|
21
21
|
"__mapper_args__": {"polymorphic_on": "type", "polymorphic_identity": "animal"},
|
|
22
22
|
}
|
|
23
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
23
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
24
24
|
type: Annotated[str, mapped_column(String(50))]
|
|
25
25
|
name: str
|
|
26
26
|
|
|
@@ -42,7 +42,7 @@ Each subclass has its own table with a foreign key to the parent:
|
|
|
42
42
|
|
|
43
43
|
```python
|
|
44
44
|
from typing import Annotated
|
|
45
|
-
from uuid import UUID,
|
|
45
|
+
from uuid import UUID, uuid7
|
|
46
46
|
from pydantic import Field
|
|
47
47
|
from sqlalchemy import ForeignKey, String
|
|
48
48
|
from sqlalchemy.orm import mapped_column
|
|
@@ -54,7 +54,7 @@ class Animal(SQLCrucibleBaseModel):
|
|
|
54
54
|
"__tablename__": "animal",
|
|
55
55
|
"__mapper_args__": {"polymorphic_on": "type", "polymorphic_identity": "animal"},
|
|
56
56
|
}
|
|
57
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
57
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
58
58
|
type: Annotated[str, mapped_column(String(50))]
|
|
59
59
|
name: str
|
|
60
60
|
|
|
@@ -63,7 +63,7 @@ class Dog(Animal):
|
|
|
63
63
|
"__tablename__": "dog",
|
|
64
64
|
"__mapper_args__": {"polymorphic_identity": "dog"},
|
|
65
65
|
}
|
|
66
|
-
id: Annotated[UUID, mapped_column(ForeignKey("animal.id"), primary_key=True)] = Field(default_factory=
|
|
66
|
+
id: Annotated[UUID, mapped_column(ForeignKey("animal.id"), primary_key=True)] = Field(default_factory=uuid7)
|
|
67
67
|
bones_chewed: int | None = None
|
|
68
68
|
type: Annotated[str, ExcludeSAField()] = Field(default="dog")
|
|
69
69
|
```
|
|
@@ -74,7 +74,7 @@ Each subclass is a completely independent table:
|
|
|
74
74
|
|
|
75
75
|
```python
|
|
76
76
|
from typing import Annotated
|
|
77
|
-
from uuid import UUID,
|
|
77
|
+
from uuid import UUID, uuid7
|
|
78
78
|
from pydantic import Field
|
|
79
79
|
from sqlalchemy import String
|
|
80
80
|
from sqlalchemy.orm import mapped_column
|
|
@@ -85,7 +85,7 @@ class Animal(SQLCrucibleBaseModel):
|
|
|
85
85
|
"__abstract__": True,
|
|
86
86
|
"__mapper_args__": {"polymorphic_on": "type"},
|
|
87
87
|
}
|
|
88
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
88
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
89
89
|
type: Annotated[str, mapped_column(String(50))]
|
|
90
90
|
name: str
|
|
91
91
|
|
|
@@ -95,7 +95,7 @@ class Dog(Animal):
|
|
|
95
95
|
"__mapper_args__": {"polymorphic_identity": "dog", "concrete": True},
|
|
96
96
|
}
|
|
97
97
|
# Must redefine ALL columns for concrete table inheritance
|
|
98
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
98
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
99
99
|
type: Annotated[str, mapped_column(String(50))] = Field(default="dog")
|
|
100
100
|
name: str
|
|
101
101
|
bones_chewed: int | None = None
|
|
@@ -109,7 +109,7 @@ class Dog(Animal):
|
|
|
109
109
|
When using inheritance, `from_sa_model()` automatically returns the correct subclass:
|
|
110
110
|
|
|
111
111
|
```python
|
|
112
|
-
dog_sa = Dog.__sqlalchemy_type__(id=
|
|
112
|
+
dog_sa = Dog.__sqlalchemy_type__(id=uuid7(), name="Fido", type="dog", bones_chewed=42)
|
|
113
113
|
|
|
114
114
|
# Load via the base class — returns Dog, not Animal
|
|
115
115
|
animal = Animal.from_sa_model(dog_sa)
|
|
@@ -12,7 +12,7 @@ Define the function **outside** the class body and use `readonly_field` to mark
|
|
|
12
12
|
|
|
13
13
|
```python
|
|
14
14
|
from typing import Annotated
|
|
15
|
-
from uuid import UUID,
|
|
15
|
+
from uuid import UUID, uuid7
|
|
16
16
|
from pydantic import Field
|
|
17
17
|
from sqlalchemy.orm import mapped_column
|
|
18
18
|
from sqlalchemy.ext.hybrid import hybrid_property
|
|
@@ -25,7 +25,7 @@ def _full_name(self) -> str:
|
|
|
25
25
|
class Person(SQLCrucibleBaseModel):
|
|
26
26
|
__sqlalchemy_params__ = {"__tablename__": "person"}
|
|
27
27
|
|
|
28
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
28
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
29
29
|
first_name: Annotated[str, mapped_column()]
|
|
30
30
|
last_name: Annotated[str, mapped_column()]
|
|
31
31
|
|
|
@@ -62,7 +62,7 @@ You can also pass the descriptor directly to `readonly_field` instead of using `
|
|
|
62
62
|
class Person(SQLCrucibleBaseModel):
|
|
63
63
|
__sqlalchemy_params__ = {"__tablename__": "person"}
|
|
64
64
|
|
|
65
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
65
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
66
66
|
first_name: Annotated[str, mapped_column()]
|
|
67
67
|
last_name: Annotated[str, mapped_column()]
|
|
68
68
|
|
|
@@ -98,7 +98,7 @@ _full_name_hybrid = hybrid_property(_get_full_name).setter(_set_full_name)
|
|
|
98
98
|
class Person(SQLCrucibleBaseModel):
|
|
99
99
|
__sqlalchemy_params__ = {"__tablename__": "person"}
|
|
100
100
|
|
|
101
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
101
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
102
102
|
first_name: Annotated[str, mapped_column()]
|
|
103
103
|
last_name: Annotated[str, mapped_column()]
|
|
104
104
|
|
|
@@ -125,13 +125,13 @@ from sqlcrucible import SQLAlchemyField
|
|
|
125
125
|
class Department(SQLCrucibleBaseModel):
|
|
126
126
|
__sqlalchemy_params__ = {"__tablename__": "department"}
|
|
127
127
|
|
|
128
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
128
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
129
129
|
name: Annotated[str, mapped_column()]
|
|
130
130
|
|
|
131
131
|
class Employee(SQLCrucibleBaseModel):
|
|
132
132
|
__sqlalchemy_params__ = {"__tablename__": "employee"}
|
|
133
133
|
|
|
134
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
134
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
135
135
|
name: Annotated[str, mapped_column()]
|
|
136
136
|
department_id: Annotated[UUID, mapped_column(ForeignKey("department.id"))]
|
|
137
137
|
|
|
@@ -170,7 +170,7 @@ Use a `creator` function to make the proxy writable:
|
|
|
170
170
|
class Employee(SQLCrucibleBaseModel):
|
|
171
171
|
__sqlalchemy_params__ = {"__tablename__": "employee"}
|
|
172
172
|
|
|
173
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
173
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
174
174
|
department_id: Annotated[UUID, mapped_column(ForeignKey("department.id"))]
|
|
175
175
|
|
|
176
176
|
department = readonly_field(
|
|
@@ -187,7 +187,7 @@ class Employee(SQLCrucibleBaseModel):
|
|
|
187
187
|
association_proxy(
|
|
188
188
|
"department",
|
|
189
189
|
"name",
|
|
190
|
-
creator=lambda name: SAType[Department](id=
|
|
190
|
+
creator=lambda name: SAType[Department](id=uuid7(), name=name),
|
|
191
191
|
),
|
|
192
192
|
]
|
|
193
193
|
```
|
|
@@ -6,7 +6,7 @@ Use `readonly_field` to define relationship fields that are loaded from the SQLA
|
|
|
6
6
|
|
|
7
7
|
```python
|
|
8
8
|
from typing import Annotated
|
|
9
|
-
from uuid import UUID,
|
|
9
|
+
from uuid import UUID, uuid7
|
|
10
10
|
from pydantic import Field
|
|
11
11
|
from sqlalchemy import ForeignKey
|
|
12
12
|
from sqlalchemy.orm import mapped_column, relationship
|
|
@@ -15,12 +15,12 @@ from sqlcrucible import readonly_field
|
|
|
15
15
|
|
|
16
16
|
class Artist(SQLCrucibleBaseModel):
|
|
17
17
|
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
18
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
18
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
19
19
|
name: str
|
|
20
20
|
|
|
21
21
|
class Track(SQLCrucibleBaseModel):
|
|
22
22
|
__sqlalchemy_params__ = {"__tablename__": "track"}
|
|
23
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
23
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
24
24
|
name: str
|
|
25
25
|
artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
|
|
26
26
|
|
|
@@ -66,18 +66,72 @@ artist = readonly_field(
|
|
|
66
66
|
|
|
67
67
|
## Important Notes
|
|
68
68
|
|
|
69
|
-
- **Cyclical references are not supported.** If `Artist` has a `tracks` relationship and `Track` has an `artist` relationship, use `readonly_field` on at least one side to break the cycle.
|
|
70
|
-
|
|
71
69
|
- **Pydantic compatibility**: Either inherit from `SQLCrucibleBaseModel` (which includes the necessary config), or add `model_config = ConfigDict(ignored_types=(ReadonlyFieldDescriptor,))` to your model (import from `sqlcrucible`).
|
|
72
70
|
|
|
73
71
|
- **Accessing without a backing model**: Accessing a `readonly_field` on an entity not loaded via `from_sa_model()` raises `RuntimeError`.
|
|
74
72
|
|
|
73
|
+
## Serialisation
|
|
74
|
+
|
|
75
|
+
By default, `readonly_field` values are **excluded** from `model_dump()` and `model_dump_json()`. This is by design — they are not Pydantic model fields.
|
|
76
|
+
|
|
77
|
+
To include a readonly relationship in serialised output, wrap it with `computed_field`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from pydantic import computed_field
|
|
81
|
+
|
|
82
|
+
class Track(SQLCrucibleBaseModel):
|
|
83
|
+
__sqlalchemy_params__ = {"__tablename__": "track"}
|
|
84
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
85
|
+
name: str
|
|
86
|
+
artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
|
|
87
|
+
|
|
88
|
+
artist = computed_field(readonly_field(
|
|
89
|
+
Artist,
|
|
90
|
+
relationship(lambda: SAType[Artist]),
|
|
91
|
+
))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`ReadonlyFieldDescriptor` is a `property` subclass, so Pydantic's `computed_field` can infer the return type directly — no intermediate `@property` wrapper needed.
|
|
95
|
+
|
|
96
|
+
!!! warning
|
|
97
|
+
If **both** sides of a bidirectional relationship wrap `readonly_field` with `computed_field`, Pydantic will detect a circular reference during `model_dump()` and raise a `ValueError`. Only expose one side of a cycle via `computed_field`.
|
|
98
|
+
|
|
99
|
+
## Caching and Identity
|
|
100
|
+
|
|
101
|
+
### Per-instance caching
|
|
102
|
+
|
|
103
|
+
`readonly_field` caches the converted value per instance, so repeated access returns the same object:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
track = Track.from_sa_model(track_sa)
|
|
107
|
+
track.artist is track.artist # True — same object
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This applies to all field types — entities, lists, and scalars.
|
|
111
|
+
|
|
112
|
+
### Identity preservation
|
|
113
|
+
|
|
114
|
+
Within a single `from_sa_model()` call, an identity map ensures that the same SQLAlchemy model instance always converts to the same entity. This means bidirectional relationships preserve object identity:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
author = Author.from_sa_model(author_sa)
|
|
118
|
+
author.tracks[0].artist is author # True — same entity, not a copy
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Each top-level `from_sa_model()` call creates a fresh identity map, so separate calls produce independent instances:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
first = Author.from_sa_model(author_sa)
|
|
125
|
+
second = Author.from_sa_model(author_sa)
|
|
126
|
+
first is not second # True — different calls, different instances
|
|
127
|
+
```
|
|
128
|
+
|
|
75
129
|
## Example: One-to-Many Relationship
|
|
76
130
|
|
|
77
131
|
```python
|
|
78
132
|
class Artist(SQLCrucibleBaseModel):
|
|
79
133
|
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
80
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
134
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
81
135
|
name: str
|
|
82
136
|
|
|
83
137
|
# One-to-many: artist has many tracks
|
|
@@ -88,7 +142,7 @@ class Artist(SQLCrucibleBaseModel):
|
|
|
88
142
|
|
|
89
143
|
class Track(SQLCrucibleBaseModel):
|
|
90
144
|
__sqlalchemy_params__ = {"__tablename__": "track"}
|
|
91
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
145
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
92
146
|
name: str
|
|
93
147
|
artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
|
|
94
148
|
|
|
@@ -21,7 +21,7 @@ With SQLCrucible, you define your model once:
|
|
|
21
21
|
|
|
22
22
|
```python
|
|
23
23
|
from typing import Annotated
|
|
24
|
-
from uuid import UUID,
|
|
24
|
+
from uuid import UUID, uuid7
|
|
25
25
|
from pydantic import Field
|
|
26
26
|
from sqlalchemy.orm import mapped_column
|
|
27
27
|
from sqlcrucible import SQLCrucibleBaseModel
|
|
@@ -29,7 +29,7 @@ from sqlcrucible import SQLCrucibleBaseModel
|
|
|
29
29
|
class Artist(SQLCrucibleBaseModel):
|
|
30
30
|
__sqlalchemy_params__ = {"__tablename__": "artist"}
|
|
31
31
|
|
|
32
|
-
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=
|
|
32
|
+
id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
|
|
33
33
|
name: Annotated[str, mapped_column()]
|
|
34
34
|
```
|
|
35
35
|
|
|
@@ -11,6 +11,17 @@ nox.options.reuse_existing_virtualenvs = True
|
|
|
11
11
|
|
|
12
12
|
SUPPORTED_PYTHON_VERSIONS = nox.project.python_versions(nox.project.load_toml("pyproject.toml"))
|
|
13
13
|
MIN_PYTHON_VERSION = SUPPORTED_PYTHON_VERSIONS[0]
|
|
14
|
+
PYDANTIC_VERSIONS = ["2.10", "2.11", "2.12"]
|
|
15
|
+
PYDANTIC_MAX_PYTHON: dict[str, str] = {
|
|
16
|
+
"2.10": "3.13",
|
|
17
|
+
"2.11": "3.13",
|
|
18
|
+
}
|
|
19
|
+
_TEST_MATRIX = [
|
|
20
|
+
(python, pydantic)
|
|
21
|
+
for python in SUPPORTED_PYTHON_VERSIONS
|
|
22
|
+
for pydantic in PYDANTIC_VERSIONS
|
|
23
|
+
if pydantic not in PYDANTIC_MAX_PYTHON or python <= PYDANTIC_MAX_PYTHON[pydantic]
|
|
24
|
+
]
|
|
14
25
|
|
|
15
26
|
|
|
16
27
|
def uv(*args: str, session: nox.Session) -> None:
|
|
@@ -62,10 +73,12 @@ def depcheck(session: nox.Session) -> None:
|
|
|
62
73
|
session.run("deptry", "src", "tests")
|
|
63
74
|
|
|
64
75
|
|
|
65
|
-
@nox.session
|
|
66
|
-
|
|
76
|
+
@nox.session
|
|
77
|
+
@nox.parametrize(("python", "pydantic"), _TEST_MATRIX)
|
|
78
|
+
def test(session: nox.Session, pydantic: str) -> None:
|
|
67
79
|
"""Run tests with pytest."""
|
|
68
80
|
uv("sync", "--group", "test", session=session)
|
|
81
|
+
session.install(f"pydantic~={pydantic}.0")
|
|
69
82
|
session.run("pytest", *session.posargs)
|
|
70
83
|
|
|
71
84
|
|
|
@@ -36,6 +36,7 @@ version-file = "src/sqlcrucible/_version.py"
|
|
|
36
36
|
test = [
|
|
37
37
|
"pytest>=8.0.0",
|
|
38
38
|
"pytest-cov>=6.0.0",
|
|
39
|
+
"hypothesis>=6.100.0",
|
|
39
40
|
"pyright",
|
|
40
41
|
"ty",
|
|
41
42
|
]
|
|
@@ -79,6 +80,9 @@ exclude_lines = [
|
|
|
79
80
|
]
|
|
80
81
|
show_missing = true
|
|
81
82
|
|
|
83
|
+
[tool.hypothesis]
|
|
84
|
+
deadline = 400
|
|
85
|
+
|
|
82
86
|
[tool.ruff]
|
|
83
87
|
line-length = 100
|
|
84
88
|
extend-exclude = ["_version.py"]
|
|
@@ -45,7 +45,7 @@ def resolve_forward_refs(tp: Any, owner: type) -> Any:
|
|
|
45
45
|
)
|
|
46
46
|
globalns = vars(sys.modules[owner.__module__])
|
|
47
47
|
localns = vars(owner)
|
|
48
|
-
hints = get_type_hints(temp, globalns=globalns, localns=localns)
|
|
48
|
+
hints = get_type_hints(temp, globalns=globalns, localns=localns, include_extras=True)
|
|
49
49
|
return hints["_"]
|
|
50
50
|
|
|
51
51
|
|