diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 26e2591..028804c 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -20,6 +20,7 @@ EvalContext, ) from typemap.typing import ( + AdjustLink, Attrs, Bool, Capitalize, @@ -1612,3 +1613,67 @@ def _eval_Omit(tp, keys, *, ctx): f"Omit_{tp.__name__ if hasattr(tp, '__name__') else 'Anonymous'}" ) return type(class_name, (), {"__annotations__": new_annotations}) + + +@type_eval.register_evaluator(AdjustLink) +def _eval_AdjustLink(tgt, link_ty, *, ctx): + """Evaluate AdjustLink[Tgt, LinkTy] to wrap in list if MultiLink. + + Returns list[tgt] if LinkTy is a MultiLink (one-to-many), + otherwise returns tgt directly (one-to-one). + + Note: This uses heuristic detection based on the LinkTy origin. + For precise behavior, use as a type alias with IsAssignable. + """ + # Evaluate the arguments first + tgt = _eval_types(tgt, ctx) + link_ty = _eval_types(link_ty, ctx) + + # Get the origin type (e.g., Link from Link[SomeType]) + origin = typing.get_origin(link_ty) + + if origin is None: + # Not a generic type, return as-is + return tgt + + # Check the origin's name to determine if it's a MultiLink + # MultiLink types typically have "Multi" or "List" or are subclasses + # that are not the base Link + origin_name = getattr(origin, "__name__", "") + + # Heuristic: if origin name contains "Multi" or ends with "s" (common for lists) + # or is explicitly a multi-link pattern + if "Multi" in origin_name or origin_name.endswith("s"): + return list[tgt] + + # Check MRO for Link/MultiLink pattern + if hasattr(origin, "__mro__"): + mro = origin.__mro__ + # Check if it's a Link but has something beyond Link in MRO + # (indicating it's a specialized variant like MultiLink) + if Link in mro and len(mro) > mro.index(Link) + 1: + # Has additional classes beyond Link - likely MultiLink + return list[tgt] + + # Default: return as-is (one-to-one relationship) + return tgt + + +# Base classes for Link type detection in AdjustLink +# Users would typically subclass these in their own code +class Pointer[T]: + """Base class for pointer types (Property, Link, MultiLink).""" + + pass + + +class Link(Pointer): + """Base class for linked types (one-to-one or one-to-many).""" + + pass + + +class MultiLink(Link): + """Base class for multi-link types (one-to-many relationships).""" + + pass diff --git a/packages/typemap/src/typemap/typing.py b/packages/typemap/src/typemap/typing.py index 573a8bf..c8cb8d7 100644 --- a/packages/typemap/src/typemap/typing.py +++ b/packages/typemap/src/typemap/typing.py @@ -418,6 +418,24 @@ class Omit[T, K]: pass +class AdjustLink[Tgt, LinkTy]: + """Wrap type in list if LinkTy is MultiLink, otherwise return as-is. + + Used for ORM-style relations where: + - Link (one-to-one) -> returns Tgt directly + - MultiLink (one-to-many) -> returns list[Tgt] + + Usage: + type UserPosts = AdjustLink[Post, MultiLink[Post]] + # Returns: list[Post] + + type UserProfile = AdjustLink[Profile, Link[Profile]] + # Returns: Profile + """ + + pass + + ################################################################## # TODO: type better diff --git a/packages/typemap/tests/test_adjust_link.py b/packages/typemap/tests/test_adjust_link.py new file mode 100644 index 0000000..db1190e --- /dev/null +++ b/packages/typemap/tests/test_adjust_link.py @@ -0,0 +1,101 @@ +"""Tests for AdjustLink type operator.""" + +import textwrap + +from typing import Literal + +from typemap.type_eval import eval_typing +import typemap_extensions as typing + +from . import format_helper + + +# Define ORM-like types +class Pointer[T]: + """Base pointer type.""" + + pass + + +class Property[T](Pointer[T]): + """Property type for scalar fields.""" + + pass + + +class Link[T](Pointer[T]): + """Link type for one-to-one relationships.""" + + pass + + +class MultiLink[T](Link[T]): + """MultiLink type for one-to-many relationships.""" + + pass + + +# Test models +class User: + id: Property[int] + name: Property[str] + posts: MultiLink["Post"] # one-to-many + profile: Link["Profile"] # one-to-one + + +class Post: + id: Property[int] + title: Property[str] + author: Link[User] # one-to-one + + +class Profile: + id: Property[int] + bio: Property[str] + + +def test_adjustlink_with_multilink(): + """AdjustLink should wrap in list when LinkTy is MultiLink.""" + result = eval_typing(typing.AdjustLink[Post, MultiLink[Post]]) + + assert result.__origin__ is list + assert result.__args__[0] is Post + + +def test_adjustlink_with_link(): + """AdjustLink should return type directly when LinkTy is Link (not MultiLink).""" + result = eval_typing(typing.AdjustLink[Profile, Link[Profile]]) + + assert result is Profile + + +def test_adjustlink_with_property(): + """AdjustLink should return type directly for Property (not a Link).""" + result = eval_typing(typing.AdjustLink[int, Property[int]]) + + # Property is not a Link, so should return as-is + assert result is int + + +def test_adjustlink_with_sublcass_of_multilink(): + """AdjustLink should work with subclasses of MultiLink.""" + + class MyMultiLink[T](MultiLink[T]): + pass + + result = eval_typing(typing.AdjustLink[Post, MyMultiLink[Post]]) + + assert result.__origin__ is list + assert result.__args__[0] is Post + + +def test_adjustlink_with_singlelink(): + """AdjustLink should return type directly for SingleLink (subclass of Link but not MultiLink).""" + + class SingleLink[T](Link[T]): + pass + + result = eval_typing(typing.AdjustLink[Profile, SingleLink[Profile]]) + + # SingleLink is not MultiLink, so should return as-is + assert result is Profile