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.
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/PKG-INFO +1 -1
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_version.py +2 -2
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/__init__.py +2 -0
- sqlcrucible-0.3.6/src/sqlcrucible/conversion/unwrap.py +36 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/automodel.py +8 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/sa_type.py +11 -5
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/codegen.py +3 -0
- sqlcrucible-0.3.6/tests/conversion/test_unwrap.py +81 -0
- sqlcrucible-0.3.6/tests/entity/test_relationships_annotated_metadata.py +75 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.github/workflows/ci.yml +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.github/workflows/docs.yml +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.gitignore +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/.python-version +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/LICENSE +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/README.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/comparison.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/getting-started.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/advanced.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/defining-entities.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/field-mapping.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/inheritance.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/orm-descriptors.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/relationships.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/guide/type-conversion.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/index.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/docs/reference/api.md +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/mkdocs.yml +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/noxfile.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/pyproject.toml +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/annotations.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/forward_refs.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/match.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/params.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/_types/transformer.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/caching.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/dicts.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/exceptions.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/function.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/literals.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/noop.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/registry.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/sequences.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/conversion/unions.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/annotations.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/core.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/descriptors.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/field_definitions.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/field_resolution.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/entity/sa_conversion.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/__main__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/discovery.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/src/sqlcrucible/stubs/serialization.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_annotations.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_annotations_properties.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_forward_refs.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/_types/test_params.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/conftest.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_caching.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_caching_properties.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_dicts.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_literals.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_literals_properties.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_noop.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_registry.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_sequences.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/conversion/test_unions.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/conftest.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_association_proxy.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_hybrid_property.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_writable_descriptors.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_attrs_entity.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_concrete_table_inheritance.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_custom_sa_model.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_dataclass_entity.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_explicit_table.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_joined_table_inheritance.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_pydantic_entity.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_readonly_field_serialisation.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_back_populates.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_cycles.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_eager.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_many_to_many.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_many_child.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_many_parent.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_one.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_self_referential.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_sa_type.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_single_table_inheritance.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/strategies.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/__init__.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/conftest.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/sample_models.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_build_import_block.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_codegen.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_construct_model_def.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_discovery.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_generate_model_defs.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_sa_field_type.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_serialization.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_stub_generation.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_columns.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_entity_preservation.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_excluded_fields.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_relationships.py +0 -0
- {sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/stubs/test_typecheck_sa_type.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
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
|
|
3
|
+
from typing import Any, Protocol, TYPE_CHECKING, TypeVar
|
|
4
4
|
|
|
5
|
-
_S = TypeVar("_S"
|
|
5
|
+
_S = TypeVar("_S")
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class HasSAType(Protocol[_S]):
|
|
9
|
-
"""Protocol for
|
|
9
|
+
"""Protocol for entity classes that expose ``__sqlalchemy_type__``.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_association_proxy.py
RENAMED
|
File without changes
|
{sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_hybrid_property.py
RENAMED
|
File without changes
|
{sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/orm_descriptors/test_writable_descriptors.py
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_many_child.py
RENAMED
|
File without changes
|
{sqlcrucible-0.3.5 → sqlcrucible-0.3.6}/tests/entity/test_relationships_one_to_many_parent.py
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|