diff --git a/packages/typemap/src/typemap/type_eval/_eval_operators.py b/packages/typemap/src/typemap/type_eval/_eval_operators.py index 26e2591..72cd5c3 100644 --- a/packages/typemap/src/typemap/type_eval/_eval_operators.py +++ b/packages/typemap/src/typemap/type_eval/_eval_operators.py @@ -49,6 +49,8 @@ Param, Partial, Pick, + PropsOnly, + ConvertField, RaiseError, Required, Slice, @@ -1478,6 +1480,142 @@ def _eval_Partial(tp, *, ctx): return type(class_name, (), {"__annotations__": new_annotations}) +@type_eval.register_evaluator(PropsOnly) +def _eval_PropsOnly(tp, *, ctx): + """Evaluate PropsOnly[T] to extract only Property fields. + + Creates a new class containing only Property fields, + excluding relation fields (Link, MultiLink). + """ + from typing import get_args + + tp = _eval_types(tp, ctx) + + # Get all attributes + attrs_result = _eval_Attrs(tp, ctx=ctx) + attrs_args = get_args(attrs_result) + + if not attrs_args: + # No attributes, return the type as-is + return tp + + new_annotations = {} + + for member in attrs_args: + member_type = _eval_types(member.type, ctx) + + # Get the origin type (e.g., Property from Property[int]) + origin = typing.get_origin(member_type) + + if origin is not None: + # Check if origin is Property or a subclass of Pointer but not Link + origin_name = getattr(origin, "__name__", "") + + # Include if it's a Property (not a Link or MultiLink) + if "Property" in origin_name or ( + hasattr(origin, "__mro__") + and Pointer in origin.__mro__ + and Link not in origin.__mro__ + ): + # Extract the type argument (e.g., int from Property[int]) + type_arg = get_args(member_type) + if type_arg: + # Get the member name + name_result = _eval_types(member.name, ctx) + name = ( + get_args(name_result)[0] + if hasattr(name_result, "__args__") + else name_result + ) + new_annotations[name] = type_arg[0] + + if not new_annotations: + return tp + + class_name = ( + f"PropsOnly_{tp.__name__ if hasattr(tp, '__name__') else 'Anonymous'}" + ) + return type(class_name, (), {"__annotations__": new_annotations}) + + +@type_eval.register_evaluator(ConvertField) +def _eval_ConvertField(tp, key, *, ctx): + """Evaluate ConvertField[T, K] to extract underlying type from field. + + Converts ORM field descriptors to their underlying Python types: + - Property[T] -> T + - Link[T] -> T (wrapped in PropsOnly for relations) + - MultiLink[T] -> list[T] (wrapped in PropsOnly) + """ + from typing import get_args, Literal + + tp = _eval_types(tp, ctx) + key = _eval_types(key, ctx) + + # Get the key name (should be a Literal) + key_name = get_args(key)[0] if hasattr(key, "__args__") else key + + # Get the field type using GetMemberType + field_type = _eval_types(GetMemberType[tp, Literal[key_name]], ctx) + + # Get the origin (e.g., Property from Property[int]) + origin = typing.get_origin(field_type) + + if origin is None: + # Not a generic type, return as-is + return field_type + + # Get the type argument (e.g., int from Property[int]) + type_arg = get_args(field_type) + if not type_arg: + return field_type + + underlying_type = type_arg[0] + + # Check if it's a Link/MultiLink (relation) + is_link = False + is_multilink = False + + if hasattr(origin, "__mro__"): + if Link in origin.__mro__: + is_link = True + if MultiLink in origin.__mro__: + is_multilink = True + + if is_link: + # It's a relation - apply PropsOnly to get only scalar fields + props_only_type = _eval_PropsOnly(underlying_type, ctx=ctx) + + # If MultiLink, wrap in list + if is_multilink: + return list[props_only_type] + + # Otherwise return the props only type + return props_only_type + + # Regular Property - return the underlying type + return underlying_type + + +# Base classes for Link type detection +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 + + @type_eval.register_evaluator(Required) def _eval_Required(tp, *, ctx): """Evaluate Required[T] to remove Optional from all fields.""" diff --git a/packages/typemap/src/typemap/typing.py b/packages/typemap/src/typemap/typing.py index 573a8bf..c5fd369 100644 --- a/packages/typemap/src/typemap/typing.py +++ b/packages/typemap/src/typemap/typing.py @@ -418,6 +418,40 @@ class Omit[T, K]: pass +class PropsOnly[T]: + """Extract only Property fields, excluding Link/MultiLink relations. + + Creates a new class containing only Property fields, + excluding relation fields (Link, MultiLink). + + Usage: + type UserProps = PropsOnly[User] + # Results in: {id: int, name: str, email: str} + # (posts and profile relations are excluded) + """ + + pass + + +class ConvertField[T, K]: + """Convert field type to underlying Python type. + + Extracts the underlying type from ORM field descriptors: + - Property[int] -> int + - Link[User] -> User + - MultiLink[Post] -> list[Post] (via AdjustLink) + + Usage: + type UserId = ConvertField[User, Literal['id']] + # If id: Property[int], returns: int + + type UserPosts = ConvertField[User, Literal['posts']] + # If posts: MultiLink[Post], returns: list[Post] + """ + + pass + + ################################################################## # TODO: type better diff --git a/packages/typemap/tests/test_convert_field.py b/packages/typemap/tests/test_convert_field.py new file mode 100644 index 0000000..5b90830 --- /dev/null +++ b/packages/typemap/tests/test_convert_field.py @@ -0,0 +1,80 @@ +"""Tests for ConvertField 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 + + +# Define target types first to avoid forward references +class Profile: + id: Property[int] + bio: Property[str] + + +class Post: + id: Property[int] + title: Property[str] + content: Property[str] + author: Link[Profile] + + +# Test model with relations to already-defined types +class User: + id: Property[int] + name: Property[str] + email: Property[str] + posts: MultiLink[Post] # one-to-many + profile: Link[Profile] # one-to-one + + +def test_convertfield_property(): + """ConvertField should return underlying type for Property fields.""" + result = eval_typing(typing.ConvertField[User, Literal["id"]]) + + assert result is int + + +def test_convertfield_property_string(): + """ConvertField should return underlying type for string Property.""" + result = eval_typing(typing.ConvertField[User, Literal["name"]]) + + assert result is str + + +def test_convertfield_with_nonexistent_key(): + """ConvertField should handle non-existent keys gracefully.""" + # This tests edge cases + result = eval_typing(typing.ConvertField[User, Literal["nonexistent"]]) + + # Should return something (the GetMemberType result) + assert result is not None