sqlcrucible 0.3.3__tar.gz → 0.3.5__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 (115) hide show
  1. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/.gitignore +1 -0
  2. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/PKG-INFO +4 -1
  3. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/comparison.md +6 -6
  4. sqlcrucible-0.3.5/docs/getting-started.md +169 -0
  5. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/defining-entities.md +5 -5
  6. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/field-mapping.md +2 -2
  7. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/inheritance.md +9 -9
  8. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/orm-descriptors.md +8 -8
  9. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/relationships.md +6 -6
  10. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/index.md +2 -2
  11. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/mkdocs.yml +1 -0
  12. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/pyproject.toml +10 -0
  13. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/_types/forward_refs.py +1 -1
  14. sqlcrucible-0.3.5/src/sqlcrucible/_version.py +24 -0
  15. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/annotations.py +1 -1
  16. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/automodel.py +5 -4
  17. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/core.py +52 -52
  18. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/descriptors.py +20 -28
  19. sqlcrucible-0.3.5/src/sqlcrucible/entity/field_definitions.py +275 -0
  20. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/field_resolution.py +4 -4
  21. sqlcrucible-0.3.5/tests/_types/test_annotations_properties.py +78 -0
  22. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/_types/test_forward_refs.py +14 -0
  23. sqlcrucible-0.3.5/tests/conversion/test_caching_properties.py +104 -0
  24. sqlcrucible-0.3.5/tests/conversion/test_literals_properties.py +80 -0
  25. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_pydantic_entity.py +18 -1
  26. sqlcrucible-0.3.5/tests/strategies.py +162 -0
  27. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/conftest.py +59 -26
  28. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/uv.lock +25 -0
  29. sqlcrucible-0.3.3/docs/getting-started.md +0 -124
  30. sqlcrucible-0.3.3/src/sqlcrucible/_version.py +0 -34
  31. sqlcrucible-0.3.3/src/sqlcrucible/entity/field_definitions.py +0 -277
  32. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/.github/workflows/ci.yml +0 -0
  33. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/.github/workflows/docs.yml +0 -0
  34. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/.python-version +0 -0
  35. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/LICENSE +0 -0
  36. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/README.md +0 -0
  37. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/advanced.md +0 -0
  38. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/guide/type-conversion.md +0 -0
  39. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/docs/reference/api.md +0 -0
  40. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/noxfile.py +0 -0
  41. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/__init__.py +0 -0
  42. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/_types/__init__.py +0 -0
  43. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/_types/annotations.py +0 -0
  44. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/_types/match.py +0 -0
  45. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/_types/params.py +0 -0
  46. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/_types/transformer.py +0 -0
  47. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/__init__.py +0 -0
  48. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/caching.py +0 -0
  49. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/dicts.py +0 -0
  50. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/exceptions.py +0 -0
  51. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/function.py +0 -0
  52. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/literals.py +0 -0
  53. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/noop.py +0 -0
  54. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/registry.py +0 -0
  55. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/sequences.py +0 -0
  56. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/conversion/unions.py +0 -0
  57. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/__init__.py +0 -0
  58. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/sa_conversion.py +0 -0
  59. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/entity/sa_type.py +0 -0
  60. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/stubs/__init__.py +0 -0
  61. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/stubs/__main__.py +0 -0
  62. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/stubs/codegen.py +0 -0
  63. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/stubs/discovery.py +0 -0
  64. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/src/sqlcrucible/stubs/serialization.py +0 -0
  65. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/__init__.py +0 -0
  66. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/_types/__init__.py +0 -0
  67. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/_types/test_annotations.py +0 -0
  68. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/_types/test_params.py +0 -0
  69. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/__init__.py +0 -0
  70. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/conftest.py +0 -0
  71. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_caching.py +0 -0
  72. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_dicts.py +0 -0
  73. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_literals.py +0 -0
  74. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_noop.py +0 -0
  75. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_registry.py +0 -0
  76. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_sequences.py +0 -0
  77. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/conversion/test_unions.py +0 -0
  78. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/__init__.py +0 -0
  79. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/orm_descriptors/__init__.py +0 -0
  80. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/orm_descriptors/conftest.py +0 -0
  81. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
  82. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
  83. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
  84. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_attrs_entity.py +0 -0
  85. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_concrete_table_inheritance.py +0 -0
  86. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_custom_sa_model.py +0 -0
  87. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_dataclass_entity.py +0 -0
  88. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_explicit_table.py +0 -0
  89. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_joined_table_inheritance.py +0 -0
  90. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_readonly_field_serialisation.py +0 -0
  91. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_back_populates.py +0 -0
  92. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_cycles.py +0 -0
  93. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_eager.py +0 -0
  94. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_many_to_many.py +0 -0
  95. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_one_to_many_child.py +0 -0
  96. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
  97. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_one_to_one.py +0 -0
  98. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_relationships_self_referential.py +0 -0
  99. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_sa_type.py +0 -0
  100. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/entity/test_single_table_inheritance.py +0 -0
  101. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/__init__.py +0 -0
  102. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/sample_models.py +0 -0
  103. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_build_import_block.py +0 -0
  104. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_codegen.py +0 -0
  105. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_construct_model_def.py +0 -0
  106. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_discovery.py +0 -0
  107. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_generate_model_defs.py +0 -0
  108. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_sa_field_type.py +0 -0
  109. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_serialization.py +0 -0
  110. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_stub_generation.py +0 -0
  111. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_typecheck_columns.py +0 -0
  112. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_typecheck_entity_preservation.py +0 -0
  113. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_typecheck_excluded_fields.py +0 -0
  114. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_typecheck_relationships.py +0 -0
  115. {sqlcrucible-0.3.3 → sqlcrucible-0.3.5}/tests/stubs/test_typecheck_sa_type.py +0 -0
@@ -23,3 +23,4 @@ htmlcov/
23
23
 
24
24
  # MkDocs
25
25
  site/
26
+ /.hypothesis/
@@ -1,7 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlcrucible
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Define a single model that works as both Pydantic and SQLAlchemy, with explicit conversion between the two
5
+ Project-URL: Homepage, https://sqlcrucible.rdrj.uk
6
+ Project-URL: Issues, https://github.com/RichardDRJ/sqlcrucible/issues
5
7
  Author-email: Richard Rae-Jones <sqlcrucible@richard.rdrj.uk>
6
8
  License-Expression: MIT
7
9
  License-File: LICENSE
@@ -10,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
10
12
  Classifier: Programming Language :: Python :: 3.12
11
13
  Classifier: Programming Language :: Python :: 3.13
12
14
  Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Database
13
16
  Requires-Python: >=3.11
14
17
  Requires-Dist: pydantic~=2.10
15
18
  Requires-Dist: sqlalchemy~=2.0
@@ -49,7 +49,7 @@
49
49
 
50
50
  ```python
51
51
  from typing import Annotated
52
- from uuid import UUID, uuid4
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=uuid4)
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, uuid4
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=uuid4, primary_key=True)
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=uuid4)
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=uuid4, primary_key=True)
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, uuid4
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=uuid4)
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=uuid4)
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=uuid4)
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=uuid4)
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, uuid4
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=uuid4)
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, uuid4
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=uuid4)
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, uuid4
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=uuid4)
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=uuid4)
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, uuid4
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=uuid4)
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=uuid4)
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=uuid4(), name="Fido", type="dog", bones_chewed=42)
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, uuid4
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=uuid4)
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=uuid4)
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=uuid4)
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=uuid4)
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=uuid4)
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=uuid4)
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=uuid4(), name=name),
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, uuid4
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=uuid4)
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=uuid4)
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
 
@@ -81,7 +81,7 @@ from pydantic import computed_field
81
81
 
82
82
  class Track(SQLCrucibleBaseModel):
83
83
  __sqlalchemy_params__ = {"__tablename__": "track"}
84
- id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
84
+ id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
85
85
  name: str
86
86
  artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
87
87
 
@@ -131,7 +131,7 @@ first is not second # True — different calls, different instances
131
131
  ```python
132
132
  class Artist(SQLCrucibleBaseModel):
133
133
  __sqlalchemy_params__ = {"__tablename__": "artist"}
134
- id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
134
+ id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
135
135
  name: str
136
136
 
137
137
  # One-to-many: artist has many tracks
@@ -142,7 +142,7 @@ class Artist(SQLCrucibleBaseModel):
142
142
 
143
143
  class Track(SQLCrucibleBaseModel):
144
144
  __sqlalchemy_params__ = {"__tablename__": "track"}
145
- id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
145
+ id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
146
146
  name: str
147
147
  artist_id: Annotated[UUID, mapped_column(ForeignKey("artist.id"))]
148
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, uuid4
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=uuid4)
32
+ id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid7)
33
33
  name: Annotated[str, mapped_column()]
34
34
  ```
35
35
 
@@ -21,6 +21,7 @@ theme:
21
21
  features:
22
22
  - content.code.copy
23
23
  - content.code.annotate
24
+ - content.tabs.link
24
25
  - navigation.sections
25
26
  - navigation.expand
26
27
  - search.highlight
@@ -8,7 +8,9 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.11"
10
10
  license = "MIT"
11
+ license-files = ["LICEN[CS]E*"]
11
12
  classifiers = [
13
+ "Topic :: Database",
12
14
  "License :: OSI Approved :: MIT License",
13
15
  "Programming Language :: Python :: 3.11",
14
16
  "Programming Language :: Python :: 3.12",
@@ -21,6 +23,10 @@ dependencies = [
21
23
  "typing-extensions ~= 4.15",
22
24
  ]
23
25
 
26
+ [project.urls]
27
+ Homepage = "https://sqlcrucible.rdrj.uk"
28
+ Issues = "https://github.com/RichardDRJ/sqlcrucible/issues"
29
+
24
30
  [build-system]
25
31
  requires = ["hatchling", "hatch-vcs"]
26
32
  build-backend = "hatchling.build"
@@ -36,6 +42,7 @@ version-file = "src/sqlcrucible/_version.py"
36
42
  test = [
37
43
  "pytest>=8.0.0",
38
44
  "pytest-cov>=6.0.0",
45
+ "hypothesis>=6.100.0",
39
46
  "pyright",
40
47
  "ty",
41
48
  ]
@@ -79,6 +86,9 @@ exclude_lines = [
79
86
  ]
80
87
  show_missing = true
81
88
 
89
+ [tool.hypothesis]
90
+ deadline = 400
91
+
82
92
  [tool.ruff]
83
93
  line-length = 100
84
94
  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
 
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.5'
22
+ __version_tuple__ = version_tuple = (0, 3, 5)
23
+
24
+ __commit_id__ = commit_id = None
@@ -30,7 +30,7 @@ class SQLAlchemyField:
30
30
  tp: Any | None = None
31
31
 
32
32
  @classmethod
33
- def merge_all(cls, *fields: "SQLAlchemyField | None") -> "SQLAlchemyField":
33
+ def merge_all(cls, *fields: SQLAlchemyField | None) -> SQLAlchemyField:
34
34
  """Merge multiple SQLAlchemyField annotations, with later values taking precedence."""
35
35
  result = SQLAlchemyField()
36
36
  for field in fields:
@@ -9,7 +9,7 @@ import sqlalchemy.orm
9
9
  from sqlalchemy.orm.attributes import Mapped
10
10
 
11
11
  from sqlcrucible.entity.core import SQLAlchemyBase, SQLCrucibleEntity
12
- from sqlcrucible.entity.field_definitions import SQLAlchemyFieldDefinition
12
+ from sqlcrucible.entity.field_definitions import SQLCrucibleField
13
13
  from sqlcrucible._types.forward_refs import resolve_forward_refs
14
14
  from sqlcrucible._types.transformer import (
15
15
  TypeTransformerChain,
@@ -77,14 +77,15 @@ def _create_automodel(source: type[SQLCrucibleEntity]) -> type[Any]:
77
77
  params = vars(source).get("__sqlalchemy_params__", {})
78
78
  base = _get_sa_base(source)
79
79
 
80
- field_defs = source.__sqlalchemy_field_definitions__().values()
80
+ own_fields: dict[str, SQLCrucibleField] = source.__dict__.get("__sqlcrucible_fields__") or {}
81
+ field_defs = [decl for decl in own_fields.values() if not decl.excluded]
81
82
 
82
83
  # Determine which fields need type annotations based on SQLAlchemy's Mapped type.
83
84
  # Mapped represents attributes instrumented by the Mapper (columns, relationships),
84
85
  # which require annotations for SQLAlchemy to configure them. Non-Mapped descriptors
85
86
  # like hybrid_property and association_proxy are extensions that provide their own
86
87
  # functionality without mapper instrumentation, so they only need class attributes.
87
- def needs_annotation(f: SQLAlchemyFieldDefinition) -> bool:
88
+ def needs_annotation(f: SQLCrucibleField) -> bool:
88
89
  return f.mapped_attr is None or isinstance(f.mapped_attr, Mapped)
89
90
 
90
91
  annotated_field_defs = [f for f in field_defs if needs_annotation(f)]
@@ -152,7 +153,7 @@ def _get_sa_base(annotation: type[SQLCrucibleEntity]) -> type[Any]:
152
153
 
153
154
  def _transform_field_type(
154
155
  owner: type[SQLCrucibleEntity],
155
- field_def: SQLAlchemyFieldDefinition,
156
+ field_def: SQLCrucibleField,
156
157
  ) -> TypeTransformerResult:
157
158
  # Resolve any forward references in the source type first
158
159
  resolved_tp = resolve_forward_refs(field_def.source_tp, owner)