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.
Files changed (128) hide show
  1. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/PKG-INFO +1 -1
  2. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/type-conversion.md +43 -3
  3. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/reference/api.md +6 -0
  4. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/__init__.py +2 -0
  5. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_version.py +2 -2
  6. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/caching.py +5 -4
  7. sqlcrucible-0.4.0/src/sqlcrucible/conversion/context.py +21 -0
  8. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/dicts.py +5 -4
  9. sqlcrucible-0.4.0/src/sqlcrucible/conversion/function.py +46 -0
  10. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/literals.py +3 -2
  11. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/noop.py +3 -2
  12. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/registry.py +7 -2
  13. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/sequences.py +5 -4
  14. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/unions.py +5 -4
  15. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/annotations.py +37 -24
  16. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/automodel.py +40 -1
  17. sqlcrucible-0.4.0/src/sqlcrucible/entity/column_projection.py +168 -0
  18. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/core.py +146 -51
  19. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/descriptors.py +37 -18
  20. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/field_definitions.py +9 -2
  21. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/field_resolution.py +52 -2
  22. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/sa_conversion.py +7 -6
  23. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/__init__.py +1 -2
  24. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/codegen.py +15 -0
  25. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/conftest.py +11 -6
  26. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_caching.py +17 -14
  27. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_caching_properties.py +10 -9
  28. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_dicts.py +10 -10
  29. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_literals.py +6 -4
  30. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_literals_properties.py +5 -3
  31. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_noop.py +4 -2
  32. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_registry.py +7 -4
  33. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_sequences.py +2 -2
  34. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_unions.py +2 -2
  35. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/test_unwrap.py +8 -5
  36. sqlcrucible-0.4.0/tests/entity/composite/__init__.py +17 -0
  37. sqlcrucible-0.4.0/tests/entity/composite/conftest.py +74 -0
  38. sqlcrucible-0.4.0/tests/entity/composite/test_from_sa_model.py +30 -0
  39. sqlcrucible-0.4.0/tests/entity/composite/test_projections.py +27 -0
  40. sqlcrucible-0.4.0/tests/entity/composite/test_to_column_dict.py +49 -0
  41. sqlcrucible-0.4.0/tests/entity/composite/test_to_sa_model.py +23 -0
  42. sqlcrucible-0.4.0/tests/entity/generic_sti_models.py +87 -0
  43. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_attrs_entity.py +2 -2
  44. sqlcrucible-0.4.0/tests/entity/test_conversion_context.py +171 -0
  45. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_dataclass_entity.py +2 -2
  46. sqlcrucible-0.4.0/tests/entity/test_generic_entity.py +103 -0
  47. sqlcrucible-0.4.0/tests/entity/test_generic_sti.py +125 -0
  48. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_pydantic_entity.py +2 -2
  49. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_annotated_metadata.py +2 -4
  50. sqlcrucible-0.3.6/src/sqlcrucible/conversion/function.py +0 -43
  51. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.github/workflows/ci.yml +0 -0
  52. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.github/workflows/docs.yml +0 -0
  53. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.gitignore +0 -0
  54. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/.python-version +0 -0
  55. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/LICENSE +0 -0
  56. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/README.md +0 -0
  57. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/comparison.md +0 -0
  58. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/getting-started.md +0 -0
  59. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/advanced.md +0 -0
  60. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/defining-entities.md +0 -0
  61. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/field-mapping.md +0 -0
  62. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/inheritance.md +0 -0
  63. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/orm-descriptors.md +0 -0
  64. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/guide/relationships.md +0 -0
  65. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/docs/index.md +0 -0
  66. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/mkdocs.yml +0 -0
  67. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/noxfile.py +0 -0
  68. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/pyproject.toml +0 -0
  69. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/__init__.py +0 -0
  70. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/annotations.py +0 -0
  71. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/forward_refs.py +0 -0
  72. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/match.py +0 -0
  73. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/params.py +0 -0
  74. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/_types/transformer.py +0 -0
  75. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/__init__.py +0 -0
  76. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/exceptions.py +0 -0
  77. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/conversion/unwrap.py +0 -0
  78. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/__init__.py +0 -0
  79. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/entity/sa_type.py +0 -0
  80. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/__main__.py +0 -0
  81. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/discovery.py +0 -0
  82. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/src/sqlcrucible/stubs/serialization.py +0 -0
  83. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/__init__.py +0 -0
  84. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/__init__.py +0 -0
  85. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_annotations.py +0 -0
  86. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_annotations_properties.py +0 -0
  87. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_forward_refs.py +0 -0
  88. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/_types/test_params.py +0 -0
  89. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/conversion/__init__.py +0 -0
  90. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/__init__.py +0 -0
  91. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/__init__.py +0 -0
  92. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/conftest.py +0 -0
  93. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
  94. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
  95. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
  96. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_concrete_table_inheritance.py +0 -0
  97. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_custom_sa_model.py +0 -0
  98. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_explicit_table.py +0 -0
  99. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_joined_table_inheritance.py +0 -0
  100. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_readonly_field_serialisation.py +0 -0
  101. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_back_populates.py +0 -0
  102. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_cycles.py +0 -0
  103. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_eager.py +0 -0
  104. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_many_to_many.py +0 -0
  105. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_one_to_many_child.py +0 -0
  106. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
  107. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_one_to_one.py +0 -0
  108. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_relationships_self_referential.py +0 -0
  109. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_sa_type.py +0 -0
  110. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/entity/test_single_table_inheritance.py +0 -0
  111. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/strategies.py +0 -0
  112. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/__init__.py +0 -0
  113. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/conftest.py +0 -0
  114. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/sample_models.py +0 -0
  115. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_build_import_block.py +0 -0
  116. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_codegen.py +0 -0
  117. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_construct_model_def.py +0 -0
  118. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_discovery.py +0 -0
  119. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_generate_model_defs.py +0 -0
  120. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_sa_field_type.py +0 -0
  121. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_serialization.py +0 -0
  122. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_stub_generation.py +0 -0
  123. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_columns.py +0 -0
  124. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_entity_preservation.py +0 -0
  125. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_excluded_fields.py +0 -0
  126. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_relationships.py +0 -0
  127. {sqlcrucible-0.3.6 → sqlcrucible-0.4.0}/tests/stubs/test_typecheck_sa_type.py +0 -0
  128. {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.6
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.
@@ -56,6 +56,12 @@
56
56
  options:
57
57
  show_bases: false
58
58
 
59
+ ### ConversionContext
60
+
61
+ ::: sqlcrucible.conversion.context.ConversionContext
62
+ options:
63
+ show_bases: false
64
+
59
65
  ## Fields
60
66
 
61
67
  ### readonly_field
@@ -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.3.6'
22
- __version_tuple__ = version_tuple = (0, 3, 6)
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.function import FunctionConverter
12
- from sqlcrucible.conversion.registry import Converter
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
- Use this annotation to provide a custom conversion function when loading
57
- values from SQLAlchemy models into entity instances.
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, mapped_column(), ConvertFromSAWith(lambda dt: dt.astimezone(timezone.utc))
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
- Use this annotation to provide a custom conversion function when saving
84
- entity values into SQLAlchemy models.
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, mapped_column(), ConvertToSAWith(lambda dt: dt.astimezone(timezone.utc))
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] = source.__dict__.get("__sqlcrucible_fields__") or {}
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.