Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/typemap/src/typemap/type_eval/_eval_operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
EvalContext,
)
from typemap.typing import (
AdjustLink,
Attrs,
Bool,
Capitalize,
Expand Down Expand Up @@ -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
18 changes: 18 additions & 0 deletions packages/typemap/src/typemap/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions packages/typemap/tests/test_adjust_link.py
Original file line number Diff line number Diff line change
@@ -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