sqlcrucible 0.3.6__tar.gz → 0.4.0__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.6 → sqlcrucible-0.4.0}/PKG-INFO +1 -1
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/type-conversion.md +43 -3
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/reference/api.md +6 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/__init__.py +2 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_version.py +2 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/caching.py +5 -4
- sqlcrucible-0.4.0/src/sqlcrucible/conversion/context.py +21 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/dicts.py +5 -4
- sqlcrucible-0.4.0/src/sqlcrucible/conversion/function.py +46 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/literals.py +3 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/noop.py +3 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/registry.py +7 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/sequences.py +5 -4
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/unions.py +5 -4
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/annotations.py +37 -24
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/automodel.py +40 -1
- sqlcrucible-0.4.0/src/sqlcrucible/entity/column_projection.py +168 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/core.py +146 -51
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/descriptors.py +37 -18
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/field_definitions.py +9 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/field_resolution.py +52 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/sa_conversion.py +7 -6
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/__init__.py +1 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/codegen.py +15 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/conftest.py +11 -6
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_caching.py +17 -14
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_caching_properties.py +10 -9
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_dicts.py +10 -10
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_literals.py +6 -4
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_literals_properties.py +5 -3
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_noop.py +4 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_registry.py +7 -4
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_sequences.py +2 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_unions.py +2 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_unwrap.py +8 -5
- sqlcrucible-0.4.0/tests/entity/composite/__init__.py +17 -0
- sqlcrucible-0.4.0/tests/entity/composite/conftest.py +74 -0
- sqlcrucible-0.4.0/tests/entity/composite/test_from_sa_model.py +30 -0
- sqlcrucible-0.4.0/tests/entity/composite/test_projections.py +27 -0
- sqlcrucible-0.4.0/tests/entity/composite/test_to_column_dict.py +49 -0
- sqlcrucible-0.4.0/tests/entity/composite/test_to_sa_model.py +23 -0
- sqlcrucible-0.4.0/tests/entity/generic_sti_models.py +87 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_attrs_entity.py +2 -2
- sqlcrucible-0.4.0/tests/entity/test_conversion_context.py +171 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_dataclass_entity.py +2 -2
- sqlcrucible-0.4.0/tests/entity/test_generic_entity.py +103 -0
- sqlcrucible-0.4.0/tests/entity/test_generic_sti.py +125 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_pydantic_entity.py +2 -2
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_annotated_metadata.py +2 -4
- sqlcrucible-0.3.6/src/sqlcrucible/conversion/function.py +0 -43
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.github/workflows/ci.yml +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.github/workflows/docs.yml +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.gitignore +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.python-version +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/LICENSE +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/README.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/comparison.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/getting-started.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/advanced.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/defining-entities.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/field-mapping.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/inheritance.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/orm-descriptors.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/relationships.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/index.md +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/mkdocs.yml +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/noxfile.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/pyproject.toml +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/annotations.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/forward_refs.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/match.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/params.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/transformer.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/exceptions.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/unwrap.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/sa_type.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/__main__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/discovery.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/serialization.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_annotations.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_annotations_properties.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_forward_refs.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_params.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/conftest.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_concrete_table_inheritance.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_custom_sa_model.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_explicit_table.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_joined_table_inheritance.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_readonly_field_serialisation.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_back_populates.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_cycles.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_eager.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_many_to_many.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_one_to_many_child.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_one_to_one.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_self_referential.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_sa_type.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_single_table_inheritance.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/strategies.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/__init__.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/conftest.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/sample_models.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_build_import_block.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_codegen.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_construct_model_def.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_discovery.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_generate_model_defs.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_sa_field_type.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_serialization.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_stub_generation.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_columns.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_entity_preservation.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_excluded_fields.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_relationships.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_sa_type.py +0 -0
- {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlcrucible
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Define a single model that works as both Pydantic and SQLAlchemy, with explicit conversion between the two
|
|
5
5
|
Project-URL: Homepage, https://sqlcrucible.rdrj.uk
|
|
6
6
|
Project-URL: Issues, https://github.com/RichardDRJ/sqlcrucible/issues
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Custom Type Converters
|
|
4
4
|
|
|
5
|
-
Use `ConvertToSAWith` and `ConvertFromSAWith` to customize conversion between your entity and the SQLAlchemy model:
|
|
5
|
+
Use `ConvertToSAWith` and `ConvertFromSAWith` to customize conversion between your entity and the SQLAlchemy model. The callable receives `(value, context)` — `context` is a `ConversionContext`, and `context.target_type` is the resolved entity-side field type:
|
|
6
6
|
|
|
7
7
|
```python
|
|
8
8
|
from datetime import timedelta
|
|
@@ -19,11 +19,51 @@ class Track(SQLCrucibleBaseModel):
|
|
|
19
19
|
timedelta,
|
|
20
20
|
mapped_column(),
|
|
21
21
|
SQLAlchemyField(name="length_seconds", tp=int),
|
|
22
|
-
ConvertToSAWith(lambda td: td.total_seconds()),
|
|
23
|
-
ConvertFromSAWith(lambda s: timedelta(seconds=s)),
|
|
22
|
+
ConvertToSAWith(lambda td, ctx: td.total_seconds()),
|
|
23
|
+
ConvertFromSAWith(lambda s, ctx: timedelta(seconds=s)),
|
|
24
24
|
]
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
### `target_type` and generic entities
|
|
28
|
+
|
|
29
|
+
`context.target_type` is the *resolved* type of the field — for a concrete specialisation of a generic entity it's the specialised type, not the `TypeVar`. So a JSON column holding a per-subclass Pydantic payload can be re-validated against the right model:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from typing import Annotated, Generic, TypeVar
|
|
33
|
+
from pydantic import BaseModel, TypeAdapter
|
|
34
|
+
from sqlalchemy import JSON
|
|
35
|
+
from sqlalchemy.orm import mapped_column
|
|
36
|
+
from sqlcrucible import ConvertFromSAWith, ConvertToSAWith, ExcludeSAField, SQLCrucibleBaseModel
|
|
37
|
+
|
|
38
|
+
P = TypeVar("P", bound=BaseModel)
|
|
39
|
+
|
|
40
|
+
class Event(SQLCrucibleBaseModel, Generic[P]):
|
|
41
|
+
__sqlalchemy_params__ = {
|
|
42
|
+
"__tablename__": "event",
|
|
43
|
+
"__mapper_args__": {"polymorphic_on": "kind", "polymorphic_abstract": True},
|
|
44
|
+
}
|
|
45
|
+
payload: Annotated[
|
|
46
|
+
P | None,
|
|
47
|
+
mapped_column(JSON, nullable=True),
|
|
48
|
+
ConvertToSAWith(lambda v, ctx: None if v is None else v.model_dump(mode="json")),
|
|
49
|
+
ConvertFromSAWith(
|
|
50
|
+
lambda v, ctx: None if v is None else TypeAdapter(ctx.target_type).validate_python(v)
|
|
51
|
+
),
|
|
52
|
+
] = None
|
|
53
|
+
|
|
54
|
+
class ArtistSignedPayload(BaseModel):
|
|
55
|
+
artist_name: str
|
|
56
|
+
|
|
57
|
+
class ArtistSigned(Event[ArtistSignedPayload]):
|
|
58
|
+
__sqlalchemy_params__ = {"__mapper_args__": {"polymorphic_identity": "artist.signed"}}
|
|
59
|
+
kind: Annotated[str, ExcludeSAField()] = "artist.signed"
|
|
60
|
+
|
|
61
|
+
# Loading an ArtistSigned row: ctx.target_type is `ArtistSignedPayload | None`,
|
|
62
|
+
# so `payload` comes back as an ArtistSignedPayload, not the raw dict.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Custom `Converter` implementations (registered directly in a `ConverterRegistry`) follow the same shape — `convert(self, source, context)` and `safe_convert(self, source, context)`.
|
|
66
|
+
|
|
27
67
|
## How the Conversion System Works
|
|
28
68
|
|
|
29
69
|
When converting between Pydantic and SQLAlchemy models, SQLCrucible uses a registry of type converters. Understanding how this works helps when dealing with complex nested types.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
1
2
|
from sqlcrucible.entity.core import SQLCrucibleBaseModel, SQLCrucibleEntity, SQLAlchemyParameters
|
|
2
3
|
from sqlcrucible.entity.sa_type import SAType
|
|
3
4
|
from sqlcrucible.entity.annotations import (
|
|
@@ -18,6 +19,7 @@ __all__ = [
|
|
|
18
19
|
"ExcludeSAField",
|
|
19
20
|
"ConvertFromSAWith",
|
|
20
21
|
"ConvertToSAWith",
|
|
22
|
+
"ConversionContext",
|
|
21
23
|
"readonly_field",
|
|
22
24
|
"ReadonlyFieldDescriptor",
|
|
23
25
|
"__version__",
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -4,6 +4,7 @@ from contextlib import contextmanager
|
|
|
4
4
|
from contextvars import ContextVar
|
|
5
5
|
from typing import Any, Generator, TypeVar, Generic
|
|
6
6
|
|
|
7
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
7
8
|
from sqlcrucible.conversion.registry import Converter, ConverterFactory, ConverterRegistry
|
|
8
9
|
|
|
9
10
|
IdentityMap = dict[int, Any]
|
|
@@ -45,16 +46,16 @@ class CachingConverter(Generic[_T, _R], Converter[_T, _R]):
|
|
|
45
46
|
def matches(self, source_tp: Any, target_tp: Any) -> bool:
|
|
46
47
|
return self._inner.matches(source_tp, target_tp)
|
|
47
48
|
|
|
48
|
-
def convert(self, source: _T) -> _R:
|
|
49
|
+
def convert(self, source: _T, context: ConversionContext) -> _R:
|
|
49
50
|
with _identity_map() as identity_map:
|
|
50
51
|
if id(source) not in identity_map:
|
|
51
|
-
identity_map[id(source)] = self._inner.convert(source)
|
|
52
|
+
identity_map[id(source)] = self._inner.convert(source, context)
|
|
52
53
|
return identity_map[id(source)]
|
|
53
54
|
|
|
54
|
-
def safe_convert(self, source: _T) -> _R:
|
|
55
|
+
def safe_convert(self, source: _T, context: ConversionContext) -> _R:
|
|
55
56
|
with _identity_map() as identity_map:
|
|
56
57
|
if id(source) not in identity_map:
|
|
57
|
-
identity_map[id(source)] = self._inner.safe_convert(source)
|
|
58
|
+
identity_map[id(source)] = self._inner.safe_convert(source, context)
|
|
58
59
|
return identity_map[id(source)]
|
|
59
60
|
|
|
60
61
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Context handed to value converters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class ConversionContext:
|
|
11
|
+
"""Context handed to ``ConvertToSAWith`` / ``ConvertFromSAWith`` callables.
|
|
12
|
+
|
|
13
|
+
``target_type`` is the *resolved* entity-side field type — for a concrete
|
|
14
|
+
specialisation of a generic entity it's the specialised type, not the
|
|
15
|
+
``TypeVar`` — so a converter can validate against it (e.g. with
|
|
16
|
+
``pydantic.TypeAdapter``) without having to guess. New fields can be added
|
|
17
|
+
here as more context is needed; the callable signature stays
|
|
18
|
+
``Callable[[Any, ConversionContext], Any]``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
target_type: Any
|
|
@@ -21,6 +21,7 @@ from typing import (
|
|
|
21
21
|
)
|
|
22
22
|
from typing_extensions import is_typeddict, NoExtraItems
|
|
23
23
|
|
|
24
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
24
25
|
from sqlcrucible.conversion.registry import Converter, ConverterFactory, ConverterRegistry
|
|
25
26
|
from sqlcrucible._types.annotations import TypeAnnotation, unwrap
|
|
26
27
|
|
|
@@ -167,18 +168,18 @@ class DictConverter(Converter[dict, dict]):
|
|
|
167
168
|
if missing:
|
|
168
169
|
raise TypeError(f"Missing required key '{missing}' for {self._target_info.tp}")
|
|
169
170
|
|
|
170
|
-
def convert(self, source: dict) -> dict:
|
|
171
|
+
def convert(self, source: dict, context: ConversionContext) -> dict:
|
|
171
172
|
result = {
|
|
172
|
-
key: conv.convert(value)
|
|
173
|
+
key: conv.convert(value, context)
|
|
173
174
|
for key, value in source.items()
|
|
174
175
|
if (conv := self._get_converter(key))
|
|
175
176
|
}
|
|
176
177
|
self._check_required_fields(result)
|
|
177
178
|
return result
|
|
178
179
|
|
|
179
|
-
def safe_convert(self, source: dict) -> dict:
|
|
180
|
+
def safe_convert(self, source: dict, context: ConversionContext) -> dict:
|
|
180
181
|
result = {
|
|
181
|
-
key: conv.safe_convert(value)
|
|
182
|
+
key: conv.safe_convert(value, context)
|
|
182
183
|
for key, value in source.items()
|
|
183
184
|
if (conv := self._get_converter(key))
|
|
184
185
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Function-based converter for custom type transformations.
|
|
2
|
+
|
|
3
|
+
This module provides a converter that wraps a user-supplied function to perform
|
|
4
|
+
type conversion. It's used when fields are annotated with ConvertToSAWith or
|
|
5
|
+
ConvertFromSAWith to specify custom conversion logic. The function is called as
|
|
6
|
+
``fn(value, context)``, where ``context`` is the
|
|
7
|
+
:class:`~sqlcrucible.conversion.context.ConversionContext` for the field being
|
|
8
|
+
converted (it carries the resolved field type).
|
|
9
|
+
|
|
10
|
+
Example::
|
|
11
|
+
|
|
12
|
+
converter = FunctionConverter(lambda x, ctx: x.total_seconds())
|
|
13
|
+
converter.convert(timedelta(minutes=5), ConversionContext(target_type=float)) # 300.0
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
20
|
+
from sqlcrucible.conversion.registry import Converter
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FunctionConverter(Converter[Any, Any]):
|
|
24
|
+
"""Converter that applies a custom function to transform values.
|
|
25
|
+
|
|
26
|
+
Reports a match for any type pair — the wrapped function decides what's
|
|
27
|
+
valid; type checking is its responsibility. The function is invoked as
|
|
28
|
+
``fn(value, context)`` with the :class:`ConversionContext` supplied by the
|
|
29
|
+
caller.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, fn: Callable[[Any, ConversionContext], Any]) -> None:
|
|
33
|
+
self._fn = fn
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def fn(self) -> Callable[[Any, ConversionContext], Any]:
|
|
37
|
+
return self._fn
|
|
38
|
+
|
|
39
|
+
def matches(self, source_tp: Any, target_tp: Any) -> bool:
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def convert(self, source: Any, context: ConversionContext) -> Any:
|
|
43
|
+
return self._fn(source, context)
|
|
44
|
+
|
|
45
|
+
def safe_convert(self, source: Any, context: ConversionContext) -> Any:
|
|
46
|
+
return self.convert(source, context)
|
|
@@ -12,6 +12,7 @@ Example:
|
|
|
12
12
|
|
|
13
13
|
from typing import Any, Literal, get_args, get_origin
|
|
14
14
|
|
|
15
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
15
16
|
from sqlcrucible.conversion.exceptions import TypeMismatchError
|
|
16
17
|
from sqlcrucible.conversion.registry import Converter, ConverterFactory, ConverterRegistry
|
|
17
18
|
from sqlcrucible._types.annotations import unwrap
|
|
@@ -62,10 +63,10 @@ class LiteralConverter(Converter[Any, Any]):
|
|
|
62
63
|
target_values = _get_literal_values(target_tp)
|
|
63
64
|
return source_values <= target_values
|
|
64
65
|
|
|
65
|
-
def convert(self, source: Any) -> Any:
|
|
66
|
+
def convert(self, source: Any, context: ConversionContext) -> Any:
|
|
66
67
|
return source
|
|
67
68
|
|
|
68
|
-
def safe_convert(self, source: Any) -> Any:
|
|
69
|
+
def safe_convert(self, source: Any, context: ConversionContext) -> Any:
|
|
69
70
|
if source not in self._allowed_values:
|
|
70
71
|
raise TypeMismatchError(
|
|
71
72
|
source,
|
|
@@ -16,6 +16,7 @@ from sqlcrucible._types.annotations import (
|
|
|
16
16
|
from typing import Any, get_origin
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
19
20
|
from sqlcrucible.conversion.exceptions import TypeMismatchError
|
|
20
21
|
from sqlcrucible.conversion.registry import Converter, ConverterFactory
|
|
21
22
|
from sqlcrucible.conversion.registry import ConverterRegistry
|
|
@@ -39,10 +40,10 @@ class NoOpConverter(Converter[Any, Any]):
|
|
|
39
40
|
def matches(self, source_tp: Any, target_tp: Any) -> bool:
|
|
40
41
|
return types_are_non_parameterised_and_equal(source_tp, target_tp)
|
|
41
42
|
|
|
42
|
-
def convert(self, source: Any) -> Any:
|
|
43
|
+
def convert(self, source: Any, context: ConversionContext) -> Any:
|
|
43
44
|
return source
|
|
44
45
|
|
|
45
|
-
def safe_convert(self, source: Any) -> Any:
|
|
46
|
+
def safe_convert(self, source: Any, context: ConversionContext) -> Any:
|
|
46
47
|
if not (self._target_origin is Any or isinstance(source, self._target_origin)):
|
|
47
48
|
raise TypeMismatchError(source, self._target_tp)
|
|
48
49
|
return source
|
|
@@ -16,6 +16,8 @@ list[CustomType] needs a converter for CustomType).
|
|
|
16
16
|
|
|
17
17
|
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
18
18
|
|
|
19
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
20
|
+
|
|
19
21
|
_I = TypeVar("_I", contravariant=True)
|
|
20
22
|
_O = TypeVar("_O", covariant=True)
|
|
21
23
|
|
|
@@ -34,7 +36,7 @@ class Converter(Protocol[_I, _O]):
|
|
|
34
36
|
"""
|
|
35
37
|
...
|
|
36
38
|
|
|
37
|
-
def convert(self, source: _I) -> _O:
|
|
39
|
+
def convert(self, source: _I, context: ConversionContext) -> _O:
|
|
38
40
|
"""Convert a value from source type to target type (fast path).
|
|
39
41
|
|
|
40
42
|
This method assumes the input is valid based on static type resolution.
|
|
@@ -43,13 +45,15 @@ class Converter(Protocol[_I, _O]):
|
|
|
43
45
|
|
|
44
46
|
Args:
|
|
45
47
|
source: The value to convert.
|
|
48
|
+
context: The conversion context for the field being converted
|
|
49
|
+
(carries the resolved field type, for converters that need it).
|
|
46
50
|
|
|
47
51
|
Returns:
|
|
48
52
|
The converted value.
|
|
49
53
|
"""
|
|
50
54
|
...
|
|
51
55
|
|
|
52
|
-
def safe_convert(self, source: _I) -> _O:
|
|
56
|
+
def safe_convert(self, source: _I, context: ConversionContext) -> _O:
|
|
53
57
|
"""Convert a value with runtime type validation.
|
|
54
58
|
|
|
55
59
|
This method validates the input at runtime and raises ConversionError
|
|
@@ -58,6 +62,7 @@ class Converter(Protocol[_I, _O]):
|
|
|
58
62
|
|
|
59
63
|
Args:
|
|
60
64
|
source: The value to convert.
|
|
65
|
+
context: The conversion context for the field being converted.
|
|
61
66
|
|
|
62
67
|
Returns:
|
|
63
68
|
The converted value.
|
|
@@ -16,6 +16,7 @@ Example:
|
|
|
16
16
|
|
|
17
17
|
from typing import Any, Sequence, get_args, get_origin
|
|
18
18
|
|
|
19
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
19
20
|
from sqlcrucible.conversion.registry import Converter, ConverterFactory, ConverterRegistry
|
|
20
21
|
from sqlcrucible._types.params import get_type_params_for_base
|
|
21
22
|
|
|
@@ -58,11 +59,11 @@ class SequenceConverter(Converter[Sequence, KnownSequenceType]):
|
|
|
58
59
|
def matches(self, source_tp: Any, target_tp: Any) -> bool:
|
|
59
60
|
return True
|
|
60
61
|
|
|
61
|
-
def convert(self, source: Sequence) -> Any:
|
|
62
|
-
return self._target(self._inner.convert(it) for it in source)
|
|
62
|
+
def convert(self, source: Sequence, context: ConversionContext) -> Any:
|
|
63
|
+
return self._target(self._inner.convert(it, context) for it in source)
|
|
63
64
|
|
|
64
|
-
def safe_convert(self, source: Sequence) -> Any:
|
|
65
|
-
return self._target(self._inner.safe_convert(it) for it in source)
|
|
65
|
+
def safe_convert(self, source: Sequence, context: ConversionContext) -> Any:
|
|
66
|
+
return self._target(self._inner.safe_convert(it, context) for it in source)
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
class SequenceConverterFactory(ConverterFactory[Sequence, KnownSequenceType]):
|
|
@@ -19,6 +19,7 @@ from collections.abc import Iterable, Sequence
|
|
|
19
19
|
from types import UnionType
|
|
20
20
|
from typing import Any, TypeVar, Union, get_args, get_origin
|
|
21
21
|
|
|
22
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
22
23
|
from sqlcrucible.conversion.exceptions import ConversionError, NoConverterFoundError
|
|
23
24
|
from sqlcrucible.conversion.noop import NoOpConverter
|
|
24
25
|
from sqlcrucible.conversion.registry import Converter, ConverterFactory, ConverterRegistry
|
|
@@ -105,7 +106,7 @@ class UnionConverter(Converter[Any, Any]):
|
|
|
105
106
|
def matches(self, source_tp: Any, target_tp: Any) -> bool:
|
|
106
107
|
return _is_union(source_tp) or _is_union(target_tp)
|
|
107
108
|
|
|
108
|
-
def convert(self, source: Any) -> Any:
|
|
109
|
+
def convert(self, source: Any, context: ConversionContext) -> Any:
|
|
109
110
|
source_type = type(source)
|
|
110
111
|
candidates = [
|
|
111
112
|
conv
|
|
@@ -115,15 +116,15 @@ class UnionConverter(Converter[Any, Any]):
|
|
|
115
116
|
|
|
116
117
|
for converter in candidates:
|
|
117
118
|
try:
|
|
118
|
-
return converter.safe_convert(source)
|
|
119
|
+
return converter.safe_convert(source, context)
|
|
119
120
|
except ConversionError:
|
|
120
121
|
continue
|
|
121
122
|
|
|
122
123
|
raise NoConverterFoundError(source, self._target_tp)
|
|
123
124
|
|
|
124
|
-
def safe_convert(self, source: Any) -> Any:
|
|
125
|
+
def safe_convert(self, source: Any, context: ConversionContext) -> Any:
|
|
125
126
|
# Union conversion always uses safe_convert internally
|
|
126
|
-
return self.convert(source)
|
|
127
|
+
return self.convert(source, context)
|
|
127
128
|
|
|
128
129
|
|
|
129
130
|
class UnionConverterFactory(ConverterFactory[Any, Any]):
|
|
@@ -8,8 +8,15 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from sqlalchemy.orm import ORMDescriptor
|
|
10
10
|
|
|
11
|
-
from sqlcrucible.conversion.
|
|
12
|
-
|
|
11
|
+
from sqlcrucible.conversion.context import ConversionContext
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SQLAlchemyField",
|
|
15
|
+
"ExcludeSAField",
|
|
16
|
+
"ConvertFromSAWith",
|
|
17
|
+
"ConvertToSAWith",
|
|
18
|
+
"ConversionContext",
|
|
19
|
+
]
|
|
13
20
|
|
|
14
21
|
|
|
15
22
|
@dataclass(frozen=True, slots=True)
|
|
@@ -49,39 +56,48 @@ class ExcludeSAField:
|
|
|
49
56
|
value: bool = True
|
|
50
57
|
|
|
51
58
|
|
|
52
|
-
@dataclass(slots=True)
|
|
59
|
+
@dataclass(slots=True, frozen=True)
|
|
53
60
|
class ConvertFromSAWith:
|
|
54
|
-
"""Annotation specifying custom converter from SQLAlchemy to entity.
|
|
61
|
+
"""Annotation specifying a custom converter from SQLAlchemy to entity.
|
|
55
62
|
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
The callable receives ``(value, context)``. ``context.target_type`` is the
|
|
64
|
+
resolved entity-side field type — for a concrete specialisation of a generic
|
|
65
|
+
entity it's the specialised type on the subclass, not the ``TypeVar`` — so a
|
|
66
|
+
converter can validate against it without having to guess.
|
|
58
67
|
|
|
59
68
|
Example:
|
|
60
69
|
```python
|
|
61
70
|
from typing import Annotated
|
|
71
|
+
from pydantic import TypeAdapter
|
|
62
72
|
|
|
63
73
|
|
|
64
74
|
class MyEntity(SQLCrucibleEntity):
|
|
65
75
|
created_at: Annotated[
|
|
66
|
-
datetime,
|
|
76
|
+
datetime,
|
|
77
|
+
mapped_column(),
|
|
78
|
+
ConvertFromSAWith(lambda dt, ctx: dt.astimezone(timezone.utc)),
|
|
79
|
+
]
|
|
80
|
+
payload: Annotated[
|
|
81
|
+
SomeModel | None,
|
|
82
|
+
mapped_column(JSON),
|
|
83
|
+
ConvertFromSAWith(
|
|
84
|
+
lambda v, ctx: (
|
|
85
|
+
None if v is None else TypeAdapter(ctx.target_type).validate_python(v)
|
|
86
|
+
)
|
|
87
|
+
),
|
|
67
88
|
]
|
|
68
89
|
```
|
|
69
90
|
"""
|
|
70
91
|
|
|
71
|
-
fn: Callable[[Any], Any]
|
|
92
|
+
fn: Callable[[Any, ConversionContext], Any]
|
|
72
93
|
|
|
73
|
-
@property
|
|
74
|
-
def converter(self) -> Converter:
|
|
75
|
-
"""Get the Converter instance for this function."""
|
|
76
|
-
return FunctionConverter(self.fn)
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
@dataclass(slots=True)
|
|
95
|
+
@dataclass(slots=True, frozen=True)
|
|
80
96
|
class ConvertToSAWith:
|
|
81
|
-
"""Annotation specifying custom converter from entity to SQLAlchemy.
|
|
97
|
+
"""Annotation specifying a custom converter from entity to SQLAlchemy.
|
|
82
98
|
|
|
83
|
-
|
|
84
|
-
entity
|
|
99
|
+
The callable receives ``(value, context)``. ``context.target_type`` is the
|
|
100
|
+
resolved entity-side field type of the value being converted.
|
|
85
101
|
|
|
86
102
|
Example:
|
|
87
103
|
```python
|
|
@@ -90,14 +106,11 @@ class ConvertToSAWith:
|
|
|
90
106
|
|
|
91
107
|
class MyEntity(SQLCrucibleEntity):
|
|
92
108
|
created_at: Annotated[
|
|
93
|
-
datetime,
|
|
109
|
+
datetime,
|
|
110
|
+
mapped_column(),
|
|
111
|
+
ConvertToSAWith(lambda dt, ctx: dt.astimezone(timezone.utc)),
|
|
94
112
|
]
|
|
95
113
|
```
|
|
96
114
|
"""
|
|
97
115
|
|
|
98
|
-
fn: Callable[[Any], Any]
|
|
99
|
-
|
|
100
|
-
@property
|
|
101
|
-
def converter(self) -> Converter:
|
|
102
|
-
"""Get the Converter instance for this function."""
|
|
103
|
-
return FunctionConverter(self.fn)
|
|
116
|
+
fn: Callable[[Any, ConversionContext], Any]
|
|
@@ -67,6 +67,40 @@ def _public_fields(it: dict[str, Any]) -> dict[str, Any]:
|
|
|
67
67
|
return {name: it[name] for name in names}
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def _transparent_parameterisation_origin(
|
|
71
|
+
source: type[SQLCrucibleEntity],
|
|
72
|
+
) -> type[SQLCrucibleEntity] | None:
|
|
73
|
+
"""If ``source`` is a Pydantic generic parameterisation (``Origin[Args...]``)
|
|
74
|
+
that adds no SQLAlchemy configuration of its own, return ``Origin``.
|
|
75
|
+
|
|
76
|
+
Such a class is transparent at the SQLAlchemy layer — same table, same
|
|
77
|
+
columns, same mapper — because the type parameters only constrain the
|
|
78
|
+
entity-side (Pydantic) field types, which the parameterised class already
|
|
79
|
+
carries in its own ``model_fields`` / converters. So it should reuse the
|
|
80
|
+
origin's automodel rather than mint a fresh one. Minting a fresh one is
|
|
81
|
+
not just wasteful: in single-table inheritance it lands as an identity-less
|
|
82
|
+
polymorphic intermediate (SQLAlchemy warns), and its ``__name__`` contains
|
|
83
|
+
``[`` ``]`` which the stub generator can't emit as a valid identifier.
|
|
84
|
+
|
|
85
|
+
Returns ``None`` (build a fresh automodel as usual) when ``source`` adds
|
|
86
|
+
its own ``__sqlalchemy_params__``, ``__sqlalchemy_base__``, or registered
|
|
87
|
+
fields — e.g. a concrete subclass of a parameterised generic that supplies
|
|
88
|
+
its own ``polymorphic_identity``.
|
|
89
|
+
"""
|
|
90
|
+
pydantic_meta = getattr(source, "__pydantic_generic_metadata__", None)
|
|
91
|
+
origin = pydantic_meta.get("origin") if pydantic_meta else None
|
|
92
|
+
is_parameterisation = origin is not None and origin is not source
|
|
93
|
+
|
|
94
|
+
own = vars(source)
|
|
95
|
+
adds_own_sa_config = bool(
|
|
96
|
+
own.get("__sqlalchemy_params__")
|
|
97
|
+
or own.get("__sqlalchemy_base__")
|
|
98
|
+
or own.get("__own_sqlcrucible_fields__")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return origin if is_parameterisation and not adds_own_sa_config else None
|
|
102
|
+
|
|
103
|
+
|
|
70
104
|
def _create_automodel(source: type[SQLCrucibleEntity]) -> type[Any]:
|
|
71
105
|
"""Create a SQLAlchemy automodel class for an entity.
|
|
72
106
|
|
|
@@ -75,10 +109,15 @@ def _create_automodel(source: type[SQLCrucibleEntity]) -> type[Any]:
|
|
|
75
109
|
mapper configuration, except when a cycle is detected (in which case string
|
|
76
110
|
forward refs are used to break the cycle).
|
|
77
111
|
"""
|
|
112
|
+
if (origin := _transparent_parameterisation_origin(source)) is not None:
|
|
113
|
+
return origin.__sqlalchemy_type__
|
|
114
|
+
|
|
78
115
|
params = vars(source).get("__sqlalchemy_params__", {})
|
|
79
116
|
base = _get_sa_base(source)
|
|
80
117
|
|
|
81
|
-
own_fields: dict[str, SQLCrucibleField] =
|
|
118
|
+
own_fields: dict[str, SQLCrucibleField] = (
|
|
119
|
+
source.__dict__.get("__own_sqlcrucible_fields__") or {}
|
|
120
|
+
)
|
|
82
121
|
field_defs = [decl for decl in own_fields.values() if not decl.excluded]
|
|
83
122
|
|
|
84
123
|
# Determine which fields need type annotations based on SQLAlchemy's Mapped type.
|