sqlcrucible 0.3.5__tar.gz → 0.3.6__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.5 → sqlcrucible-0.3.6}/PKG-INFO +1 -1
  2. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_version.py +2 -2
  3. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/__init__.py +2 -0
  4. sqlcrucible-0.3.6/src/sqlcrucible/conversion/unwrap.py +36 -0
  5. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/automodel.py +8 -0
  6. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/sa_type.py +11 -5
  7. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/codegen.py +3 -0
  8. sqlcrucible-0.3.6/tests/conversion/test_unwrap.py +81 -0
  9. sqlcrucible-0.3.6/tests/entity/test_relationships_annotated_metadata.py +75 -0
  10. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.github/workflows/ci.yml +0 -0
  11. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.github/workflows/docs.yml +0 -0
  12. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.gitignore +0 -0
  13. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.python-version +0 -0
  14. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/LICENSE +0 -0
  15. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/README.md +0 -0
  16. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/comparison.md +0 -0
  17. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/getting-started.md +0 -0
  18. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/advanced.md +0 -0
  19. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/defining-entities.md +0 -0
  20. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/field-mapping.md +0 -0
  21. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/inheritance.md +0 -0
  22. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/orm-descriptors.md +0 -0
  23. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/relationships.md +0 -0
  24. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/type-conversion.md +0 -0
  25. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/index.md +0 -0
  26. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/reference/api.md +0 -0
  27. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/mkdocs.yml +0 -0
  28. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/noxfile.py +0 -0
  29. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/pyproject.toml +0 -0
  30. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/__init__.py +0 -0
  31. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/__init__.py +0 -0
  32. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/annotations.py +0 -0
  33. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/forward_refs.py +0 -0
  34. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/match.py +0 -0
  35. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/params.py +0 -0
  36. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/transformer.py +0 -0
  37. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/caching.py +0 -0
  38. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/dicts.py +0 -0
  39. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/exceptions.py +0 -0
  40. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/function.py +0 -0
  41. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/literals.py +0 -0
  42. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/noop.py +0 -0
  43. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/registry.py +0 -0
  44. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/sequences.py +0 -0
  45. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/unions.py +0 -0
  46. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/__init__.py +0 -0
  47. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/annotations.py +0 -0
  48. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/core.py +0 -0
  49. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/descriptors.py +0 -0
  50. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/field_definitions.py +0 -0
  51. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/field_resolution.py +0 -0
  52. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/sa_conversion.py +0 -0
  53. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/__init__.py +0 -0
  54. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/__main__.py +0 -0
  55. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/discovery.py +0 -0
  56. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/serialization.py +0 -0
  57. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/__init__.py +0 -0
  58. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/__init__.py +0 -0
  59. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_annotations.py +0 -0
  60. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_annotations_properties.py +0 -0
  61. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_forward_refs.py +0 -0
  62. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_params.py +0 -0
  63. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/__init__.py +0 -0
  64. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/conftest.py +0 -0
  65. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_caching.py +0 -0
  66. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_caching_properties.py +0 -0
  67. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_dicts.py +0 -0
  68. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_literals.py +0 -0
  69. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_literals_properties.py +0 -0
  70. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_noop.py +0 -0
  71. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_registry.py +0 -0
  72. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_sequences.py +0 -0
  73. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_unions.py +0 -0
  74. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/__init__.py +0 -0
  75. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/__init__.py +0 -0
  76. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/conftest.py +0 -0
  77. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
  78. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
  79. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
  80. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_attrs_entity.py +0 -0
  81. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_concrete_table_inheritance.py +0 -0
  82. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_custom_sa_model.py +0 -0
  83. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_dataclass_entity.py +0 -0
  84. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_explicit_table.py +0 -0
  85. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_joined_table_inheritance.py +0 -0
  86. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_pydantic_entity.py +0 -0
  87. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_readonly_field_serialisation.py +0 -0
  88. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_back_populates.py +0 -0
  89. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_cycles.py +0 -0
  90. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_eager.py +0 -0
  91. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_many_to_many.py +0 -0
  92. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_many_child.py +0 -0
  93. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
  94. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_one.py +0 -0
  95. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_self_referential.py +0 -0
  96. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_sa_type.py +0 -0
  97. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_single_table_inheritance.py +0 -0
  98. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/strategies.py +0 -0
  99. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/__init__.py +0 -0
  100. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/conftest.py +0 -0
  101. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/sample_models.py +0 -0
  102. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_build_import_block.py +0 -0
  103. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_codegen.py +0 -0
  104. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_construct_model_def.py +0 -0
  105. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_discovery.py +0 -0
  106. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_generate_model_defs.py +0 -0
  107. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_sa_field_type.py +0 -0
  108. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_serialization.py +0 -0
  109. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_stub_generation.py +0 -0
  110. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_columns.py +0 -0
  111. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_entity_preservation.py +0 -0
  112. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_excluded_fields.py +0 -0
  113. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_relationships.py +0 -0
  114. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_sa_type.py +0 -0
  115. {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlcrucible
3
- Version: 0.3.5
3
+ Version: 0.3.6
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
@@ -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.5'
22
- __version_tuple__ = version_tuple = (0, 3, 5)
21
+ __version__ = version = '0.3.6'
22
+ __version_tuple__ = version_tuple = (0, 3, 6)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -5,8 +5,10 @@ from sqlcrucible.conversion.noop import NoOpConverterFactory
5
5
  from sqlcrucible.conversion.registry import ConverterRegistry
6
6
  from sqlcrucible.conversion.sequences import SequenceConverterFactory
7
7
  from sqlcrucible.conversion.unions import UnionConverterFactory
8
+ from sqlcrucible.conversion.unwrap import AnnotatedUnwrappingFactory
8
9
 
9
10
  default_registry = ConverterRegistry(
11
+ CachingConverterFactory(AnnotatedUnwrappingFactory()),
10
12
  CachingConverterFactory(NoOpConverterFactory()),
11
13
  CachingConverterFactory(LiteralConverterFactory()),
12
14
  CachingConverterFactory(DictConverterFactory()),
@@ -0,0 +1,36 @@
1
+ """Converter factory for unwrapping transparent type wrappers."""
2
+
3
+ from typing import Annotated, Any, get_args, get_origin
4
+
5
+ from sqlalchemy.orm import Mapped
6
+
7
+ from sqlcrucible.conversion.registry import Converter, ConverterFactory, ConverterRegistry
8
+
9
+ _WRAPPER_ORIGINS = (Annotated, Mapped)
10
+
11
+
12
+ def _unwrap(tp: Any) -> Any:
13
+ """Strip a single Annotated[T, ...] or Mapped[T] wrapper, returning the inner type."""
14
+ if get_origin(tp) in _WRAPPER_ORIGINS:
15
+ return get_args(tp)[0]
16
+ return tp
17
+
18
+
19
+ class AnnotatedUnwrappingFactory(ConverterFactory):
20
+ """Factory that strips Annotated[T, ...] and Mapped[T] wrappers before converter lookup.
21
+
22
+ Non-SQLCrucible annotations (e.g. access-control markers) and SQLAlchemy's
23
+ Mapped wrapper should be transparent to the conversion registry. This factory
24
+ unwraps them and delegates to the registry for the inner type pair.
25
+ """
26
+
27
+ def matches(self, source_tp: Any, target_tp: Any) -> bool:
28
+ return _unwrap(source_tp) is not source_tp or _unwrap(target_tp) is not target_tp
29
+
30
+ def converter(
31
+ self,
32
+ source_tp: Any,
33
+ target_tp: Any,
34
+ registry: ConverterRegistry,
35
+ ) -> Converter | None:
36
+ return registry.resolve(_unwrap(source_tp), _unwrap(target_tp))
@@ -7,6 +7,7 @@ from typing import Any, get_args, get_origin
7
7
 
8
8
  import sqlalchemy.orm
9
9
  from sqlalchemy.orm.attributes import Mapped
10
+ from typing import Annotated
10
11
 
11
12
  from sqlcrucible.entity.core import SQLAlchemyBase, SQLCrucibleEntity
12
13
  from sqlcrucible.entity.field_definitions import SQLCrucibleField
@@ -158,6 +159,13 @@ def _transform_field_type(
158
159
  # Resolve any forward references in the source type first
159
160
  resolved_tp = resolve_forward_refs(field_def.source_tp, owner)
160
161
 
162
+ # Strip Annotated wrappers — non-SA metadata (access-control markers, Pydantic
163
+ # validators, etc.) belongs on the entity side only. Leaving it in the SA model
164
+ # annotation prevents SQLAlchemy from correctly inferring relationship config
165
+ # such as uselist (e.g. Mapped[Annotated[list[X], meta]] yields uselist=False).
166
+ while get_origin(resolved_tp) is Annotated:
167
+ resolved_tp = get_args(resolved_tp)[0]
168
+
161
169
  match (get_origin(resolved_tp), get_args(resolved_tp)):
162
170
  case (sqlalchemy.orm.Mapped, _):
163
171
  return TypeTransformerResult(result=resolved_tp)
@@ -1,15 +1,21 @@
1
1
  """SAType utility for type-safe access to entity SQLAlchemy types."""
2
2
 
3
- from typing import Protocol, TypeVar, Any, TYPE_CHECKING
3
+ from typing import Any, Protocol, TYPE_CHECKING, TypeVar
4
4
 
5
- _S = TypeVar("_S", covariant=True)
5
+ _S = TypeVar("_S")
6
6
 
7
7
 
8
8
  class HasSAType(Protocol[_S]):
9
- """Protocol for types that have a __sqlalchemy_type__ property."""
9
+ """Protocol for entity classes that expose ``__sqlalchemy_type__``.
10
10
 
11
- @property
12
- def __sqlalchemy_type__(self) -> _S: ...
11
+ The attribute is declared as ``ClassVar`` so that pyright accepts
12
+ matching against entity classes whose own declarations use the
13
+ same form (which SQLCrucibleBaseModel does). The metaclass
14
+ accessor takes the class itself (``type[HasSAType[_S]]``) and reads
15
+ the SA-type out at the class level.
16
+ """
17
+
18
+ __sqlalchemy_type__: _S
13
19
 
14
20
 
15
21
  class SATypeMeta(type):
@@ -139,6 +139,9 @@ def construct_sa_type_stub(entities: list[type[SQLCrucibleEntity]]) -> str:
139
139
 
140
140
  return (
141
141
  f"{import_block}\n\n"
142
+ f"_S = typing.TypeVar('_S')\n\n"
143
+ f"class HasSAType(typing.Protocol[_S]):\n"
144
+ f" __sqlalchemy_type__: _S\n\n"
142
145
  f"class SATypeMeta(type):\n"
143
146
  f"{overload_block}\n"
144
147
  f" @typing.overload\n"
@@ -0,0 +1,81 @@
1
+ """Tests for AnnotatedUnwrappingFactory."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import pytest
6
+ from sqlalchemy.orm import Mapped
7
+
8
+ from sqlcrucible.conversion.registry import ConverterRegistry
9
+ from sqlcrucible.conversion.unwrap import AnnotatedUnwrappingFactory
10
+ from tests.conversion.conftest import SourceItem, TargetItem
11
+
12
+
13
+ @pytest.mark.parametrize(
14
+ ("source_tp", "target_tp"),
15
+ [
16
+ (Annotated[int, "meta"], str),
17
+ (int, Annotated[str, "meta"]),
18
+ (Annotated[int, "meta"], Annotated[str, "other"]),
19
+ (Mapped[int], str),
20
+ (int, Mapped[str]),
21
+ ],
22
+ ids=[
23
+ "annotated_source",
24
+ "annotated_target",
25
+ "both_annotated",
26
+ "mapped_source",
27
+ "mapped_target",
28
+ ],
29
+ )
30
+ def test_matches_when_either_side_is_wrapped(source_tp: Any, target_tp: Any):
31
+ factory = AnnotatedUnwrappingFactory()
32
+ assert factory.matches(source_tp, target_tp)
33
+
34
+
35
+ @pytest.mark.parametrize(
36
+ ("source_tp", "target_tp"),
37
+ [
38
+ (int, str),
39
+ (list[int], set[str]),
40
+ ],
41
+ ids=["plain_types", "generic_types"],
42
+ )
43
+ def test_does_not_match_unwrapped_types(source_tp: Any, target_tp: Any):
44
+ factory = AnnotatedUnwrappingFactory()
45
+ assert not factory.matches(source_tp, target_tp)
46
+
47
+
48
+ def test_delegates_to_registry_with_unwrapped_types(registry: ConverterRegistry):
49
+ factory = AnnotatedUnwrappingFactory()
50
+ converter = factory.converter(
51
+ Annotated[SourceItem, "some_marker"],
52
+ TargetItem,
53
+ registry,
54
+ )
55
+ assert converter is not None
56
+ result = converter.convert(SourceItem(1))
57
+ assert result == TargetItem(2)
58
+
59
+
60
+ def test_registry_resolves_annotated_source_via_unwrapping(registry: ConverterRegistry):
61
+ unwrapping_registry = ConverterRegistry(AnnotatedUnwrappingFactory(), *registry)
62
+ conv = unwrapping_registry.resolve(Annotated[SourceItem, "marker"], TargetItem)
63
+ assert conv is not None
64
+ assert conv.convert(SourceItem(3)) == TargetItem(6)
65
+
66
+
67
+ def test_registry_resolves_annotated_list_via_unwrapping(registry: ConverterRegistry):
68
+ unwrapping_registry = ConverterRegistry(AnnotatedUnwrappingFactory(), *registry)
69
+ conv = unwrapping_registry.resolve(
70
+ Annotated[list[SourceItem], "marker"],
71
+ list[TargetItem],
72
+ )
73
+ assert conv is not None
74
+ assert conv.convert([SourceItem(1), SourceItem(2)]) == [TargetItem(2), TargetItem(4)]
75
+
76
+
77
+ def test_registry_resolves_mapped_source_via_unwrapping(registry: ConverterRegistry):
78
+ unwrapping_registry = ConverterRegistry(AnnotatedUnwrappingFactory(), *registry)
79
+ conv = unwrapping_registry.resolve(Mapped[SourceItem], TargetItem)
80
+ assert conv is not None
81
+ assert conv.convert(SourceItem(5)) == TargetItem(10)
@@ -0,0 +1,75 @@
1
+ """Tests for relationship fields annotated with non-SA metadata.
2
+
3
+ Pattern: A parent entity has a relationship field whose annotation includes
4
+ non-SQLAlchemy metadata (e.g. access-control markers) alongside the
5
+ relationship() descriptor.
6
+ """
7
+
8
+ from uuid import uuid4, UUID
9
+ from typing import Annotated
10
+
11
+ from pydantic import Field
12
+ from sqlalchemy import MetaData, ForeignKey
13
+ from sqlalchemy.orm import mapped_column, relationship
14
+
15
+ from sqlcrucible.entity.core import SQLCrucibleBaseModel
16
+ from sqlcrucible.entity.sa_type import SAType
17
+
18
+
19
+ metadata = MetaData()
20
+
21
+
22
+ class AnnotatedMetaBase(SQLCrucibleBaseModel):
23
+ __sqlalchemy_params__ = {"__abstract__": True, "metadata": metadata}
24
+
25
+
26
+ class _ReadOnly:
27
+ """Marker annotation that is not a SQLAlchemy or SQLCrucible type."""
28
+
29
+
30
+ READ_ONLY = _ReadOnly()
31
+
32
+
33
+ class TagAnnotated(AnnotatedMetaBase):
34
+ __sqlalchemy_params__ = {"__tablename__": "annotated_tag"}
35
+
36
+ id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
37
+ post_id: Annotated[UUID, mapped_column(ForeignKey("annotated_post.id"))]
38
+ label: str
39
+
40
+
41
+ class PostAnnotated(AnnotatedMetaBase):
42
+ __sqlalchemy_params__ = {"__tablename__": "annotated_post"}
43
+
44
+ id: Annotated[UUID, mapped_column(primary_key=True)] = Field(default_factory=uuid4)
45
+ title: str
46
+
47
+ tags: Annotated[
48
+ list[TagAnnotated],
49
+ READ_ONLY,
50
+ relationship(
51
+ lambda: SAType[TagAnnotated],
52
+ foreign_keys=lambda: [SAType[TagAnnotated].post_id],
53
+ ),
54
+ ] = Field(default_factory=list)
55
+
56
+
57
+ def test_relationship_field_with_non_sa_annotated_metadata_roundtrips():
58
+ """A relationship field with non-SA Annotated metadata round-trips correctly."""
59
+ post_id = uuid4()
60
+ tag = TagAnnotated(post_id=post_id, label="python")
61
+ post = PostAnnotated(id=post_id, title="Hello", tags=[tag])
62
+
63
+ restored = PostAnnotated.from_sa_model(post.to_sa_model())
64
+
65
+ assert len(restored.tags) == 1
66
+ assert restored.tags[0].label == "python"
67
+
68
+
69
+ def test_sa_model_annotation_for_relationship_with_annotated_metadata_uses_list():
70
+ """The SA model annotation for an Annotated relationship field should be Mapped[list[...]]."""
71
+ from sqlalchemy import inspect
72
+
73
+ mapper = inspect(SAType[PostAnnotated])
74
+ rel = next(r for r in mapper.relationships if r.key == "tags")
75
+ assert rel.uselist is True
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes