From ecfec38a8e1c85d406dea2528da52e906f2a016f Mon Sep 17 00:00:00 2001 From: dnwpark Date: Mon, 6 Oct 2025 09:17:01 -0700 Subject: [PATCH 01/13] Add expr_uses_auto_splat. --- gel/_internal/_qb/__init__.py | 2 ++ gel/_internal/_qb/_expressions.py | 11 ++++++++++- gel/_internal/_qbmodel/_abstract/_expressions.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/gel/_internal/_qb/__init__.py b/gel/_internal/_qb/__init__.py index 1c0805c0..30b2fbde 100644 --- a/gel/_internal/_qb/__init__.py +++ b/gel/_internal/_qb/__init__.py @@ -57,6 +57,7 @@ construct_infix_op_chain, empty_set, empty_set_if_none, + expr_uses_auto_splat, get_object_type_splat, toplevel_edgeql, ) @@ -171,6 +172,7 @@ "edgeql_qb_expr", "empty_set", "empty_set_if_none", + "expr_uses_auto_splat", "exprmethod", "get_object_type_splat", "get_origin", diff --git a/gel/_internal/_qb/_expressions.py b/gel/_internal/_qb/_expressions.py index 7d78a623..f3c1c724 100644 --- a/gel/_internal/_qb/_expressions.py +++ b/gel/_internal/_qb/_expressions.py @@ -661,7 +661,7 @@ def wrap( kwargs = {} if isinstance(expr, ShapeOp): kwargs["body_scope"] = expr.scope - elif isinstance(expr, (SchemaSet, Path)): + elif expr_uses_auto_splat(expr): if splat_cb is not None: shape = splat_cb() else: @@ -823,6 +823,15 @@ def splat( return cls(elements=elements) +def expr_uses_auto_splat( + expr: Expr +) -> bool: + if isinstance(expr, (SchemaSet, Path)): + return True + else: + return False + + def _render_shape( shape: Shape, source: Expr, diff --git a/gel/_internal/_qbmodel/_abstract/_expressions.py b/gel/_internal/_qbmodel/_abstract/_expressions.py index b98b8e21..a6841c6d 100644 --- a/gel/_internal/_qbmodel/_abstract/_expressions.py +++ b/gel/_internal/_qbmodel/_abstract/_expressions.py @@ -189,7 +189,7 @@ def select( shape_elements.append(shape_el) else: el_expr = _qb.edgeql_qb_expr(kwarg, var=prefix_alias) - if isinstance(el_expr, (_qb.SchemaSet, _qb.Path)): + if _qb.expr_uses_auto_splat(el_expr): # If the expression is a schema set or path without an explicit # select, apply a splat. if ( From 350c11ea1939f5bf7b11a8e4c6306f2760960a3d Mon Sep 17 00:00:00 2001 From: dnwpark Date: Mon, 6 Oct 2025 09:21:36 -0700 Subject: [PATCH 02/13] Rename PrefixOp to UnaryOp. --- gel/_internal/_codegen/_models/_pydantic.py | 8 ++++---- gel/_internal/_qb/__init__.py | 4 ++-- gel/_internal/_qb/_expressions.py | 4 ++-- gel/models/pydantic.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gel/_internal/_codegen/_models/_pydantic.py b/gel/_internal/_codegen/_models/_pydantic.py index fc0edd32..2e299a2e 100644 --- a/gel/_internal/_codegen/_models/_pydantic.py +++ b/gel/_internal/_codegen/_models/_pydantic.py @@ -2909,7 +2909,7 @@ def _write_prefix_op_method_node_ctor( ) -> None: """Generate the query node constructor for a prefix operator method. - Creates the code that builds a PrefixOp query node for unary operator + Creates the code that builds a UnaryOp query node for unary operator methods like __neg__. The operator is applied to 'self' as the operand. @@ -2917,7 +2917,7 @@ def _write_prefix_op_method_node_ctor( op: The operator reflection object containing metadata """ op_name = op.schemapath.name - node_cls = self.import_name(BASE_IMPL, "PrefixOp") + node_cls = self.import_name(BASE_IMPL, "UnaryOp") args = [ "expr=self", # The operand is always 'self' for method calls @@ -2967,7 +2967,7 @@ def _write_prefix_op_func_node_ctor( ) -> None: """Generate the query node constructor for a prefix operator function. - Creates the code that builds a PrefixOp query node for unary operator + Creates the code that builds a UnaryOp query node for unary operator functions. Unlike method versions, this takes the operand from function arguments and applies special type casting for tuple parameters. @@ -2975,7 +2975,7 @@ def _write_prefix_op_func_node_ctor( op: The operator reflection object containing metadata """ op_name = op.schemapath.name - node_cls = self.import_name(BASE_IMPL, "PrefixOp") + node_cls = self.import_name(BASE_IMPL, "UnaryOp") expr_compat = self.import_name(BASE_IMPL, "ExprCompatible") cast_ = self.import_name("typing", "cast") diff --git a/gel/_internal/_qb/__init__.py b/gel/_internal/_qb/__init__.py index 30b2fbde..8391dc09 100644 --- a/gel/_internal/_qb/__init__.py +++ b/gel/_internal/_qb/__init__.py @@ -43,7 +43,6 @@ OrderDirection, OrderEmptyDirection, Path, - PrefixOp, SchemaSet, SelectStmt, SetLiteral, @@ -52,6 +51,7 @@ ShapeOp, Splat, StringLiteral, + UnaryOp, UpdateStmt, Variable, construct_infix_op_chain, @@ -151,7 +151,6 @@ "Path", "PathAlias", "PathPrefix", - "PrefixOp", "SchemaSet", "Scope", "ScopeContext", @@ -164,6 +163,7 @@ "Splat", "Stmt", "StringLiteral", + "UnaryOp", "UpdateStmt", "VarAlias", "Variable", diff --git a/gel/_internal/_qb/_expressions.py b/gel/_internal/_qb/_expressions.py index f3c1c724..8ccbae23 100644 --- a/gel/_internal/_qb/_expressions.py +++ b/gel/_internal/_qb/_expressions.py @@ -252,7 +252,7 @@ def precedence(self) -> _edgeql.Precedence: @dataclass(kw_only=True, frozen=True) -class PrefixOp(Op): +class UnaryOp(Op): expr: Expr def __init__( @@ -276,7 +276,7 @@ def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: @dataclass(kw_only=True, frozen=True) -class CastOp(PrefixOp): +class CastOp(UnaryOp): def __init__( self, *, diff --git a/gel/models/pydantic.py b/gel/models/pydantic.py index 543d7627..14bd5780 100644 --- a/gel/models/pydantic.py +++ b/gel/models/pydantic.py @@ -34,10 +34,10 @@ ExprCompatible, IndexOp, InfixOp, - PrefixOp, FuncCall, PathAlias, SetLiteral, + UnaryOp, construct_infix_op_chain, ) @@ -167,7 +167,6 @@ "ParametricTypeName", "PathAlias", "PointerKind", - "PrefixOp", "PrivateAttr", "Property", "ProxyModel", @@ -188,6 +187,7 @@ "TupleMeta", "TypeName", "UUIDImpl", + "UnaryOp", "Unspecified", "UnspecifiedType", "classonlymethod", From 1b66c70ec9da2827f6a5b9f2498d85478d1219fb Mon Sep 17 00:00:00 2001 From: dnwpark Date: Mon, 6 Oct 2025 11:40:25 -0700 Subject: [PATCH 03/13] Add WhenType. --- gel/_internal/_qb/__init__.py | 2 ++ gel/_internal/_qb/_expressions.py | 27 +++++++++++++++++++ .../_qbmodel/_abstract/_expressions.py | 25 +++++++++++++++++ gel/_internal/_qbmodel/_abstract/_methods.py | 21 +++++++++++++++ gel/models/pydantic.py | 2 ++ 5 files changed, 77 insertions(+) diff --git a/gel/_internal/_qb/__init__.py b/gel/_internal/_qb/__init__.py index 8391dc09..a2e0a876 100644 --- a/gel/_internal/_qb/__init__.py +++ b/gel/_internal/_qb/__init__.py @@ -36,6 +36,7 @@ IntLiteral, Limit, Literal, + ObjectWhenType, Offset, OrderBy, OrderByElem, @@ -142,6 +143,7 @@ "IntLiteral", "Limit", "Literal", + "ObjectWhenType", "Offset", "OrderBy", "OrderByElem", diff --git a/gel/_internal/_qb/_expressions.py b/gel/_internal/_qb/_expressions.py index 8ccbae23..6318e502 100644 --- a/gel/_internal/_qb/_expressions.py +++ b/gel/_internal/_qb/_expressions.py @@ -300,6 +300,31 @@ def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: return f"<{self.type.as_quoted_schema_name()}>{expr}" +@dataclass(kw_only=True, frozen=True) +class ObjectWhenType(UnaryOp): + def __init__( + self, + *, + expr: ExprCompatible, + type_: TypeName, + ) -> None: + op = _edgeql.Token.RANGBRACKET + super().__init__(expr=expr, op=op, type_=type_) + + @property + def precedence(self) -> _edgeql.Precedence: + return _edgeql.PRECEDENCE[_edgeql.Token.LBRACKET] + + def subnodes(self) -> Iterable[Node]: + return (self.expr,) + + def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: + expr = edgeql(self.expr, ctx=ctx) + if _need_left_parens(self.precedence, self.expr): + expr = f"({expr})" + return f"{expr} [is {self.type.as_quoted_schema_name()}]" + + def empty_set(type_: TypeName) -> CastOp: return CastOp(expr=SetLiteral(items=(), type_=type_), type_=type_) @@ -828,6 +853,8 @@ def expr_uses_auto_splat( ) -> bool: if isinstance(expr, (SchemaSet, Path)): return True + elif isinstance(expr, ObjectWhenType): + return expr_uses_auto_splat(expr.expr) else: return False diff --git a/gel/_internal/_qbmodel/_abstract/_expressions.py b/gel/_internal/_qbmodel/_abstract/_expressions.py index a6841c6d..1ade7ba8 100644 --- a/gel/_internal/_qbmodel/_abstract/_expressions.py +++ b/gel/_internal/_qbmodel/_abstract/_expressions.py @@ -33,6 +33,7 @@ from collections.abc import Callable from ._base import GelType + from gel._internal._schemapath import TypeName _T = TypeVar("_T") @@ -424,6 +425,30 @@ def add_offset( return dataclasses.replace(stmt, offset=_qb.Offset(offset=offset)) +def add_object_type_filter( + cls: type[GelType], + /, + type_filter: type[AbstractGelModel], + *, + __operand__: _qb.ExprAlias | None = None, +) -> _qb.Expr: + + subject = _qb.edgeql_qb_expr(cls if __operand__ is None else __operand__) + + splat_cb = functools.partial(_qb.get_object_type_splat, cls) + + expr = _qb.ObjectWhenType( + expr=subject, + type_=cls.__gel_reflection__.type_name, + ) + + stmt = _qb.SelectStmt.wrap( + expr, + splat_cb=splat_cb, + ) + return stmt + + @overload def empty_set_if_none( val: None, diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index f5f4d165..f18d8f5e 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -9,6 +9,7 @@ TYPE_CHECKING, Any, Literal, + TypeVar, ) from typing_extensions import Self @@ -22,6 +23,7 @@ add_filter, add_limit, add_offset, + add_object_type_filter, delete, order_by, select, @@ -34,6 +36,8 @@ if TYPE_CHECKING: from collections.abc import Callable +_T_OtherModel = TypeVar("_T_OtherModel", bound="type[BaseGelModel]") + class BaseGelModel(AbstractGelModel): if TYPE_CHECKING: @@ -73,6 +77,11 @@ def limit(cls, /, expr: Any) -> type[Self]: ... @classmethod def offset(cls, /, expr: Any) -> type[Self]: ... + @classmethod + def when_type( + cls, /, other_model: _T_OtherModel + ) -> type[_T_OtherModel]: ... + @classmethod def __gel_assert_single__( cls, @@ -187,6 +196,18 @@ def offset( add_offset(cls, value, __operand__=__operand__), ) + @classmethod + def when_type( + cls, + /, + value: _T_OtherModel, + __operand__: _qb.ExprAlias | None = None, + ) -> type[_T_OtherModel]: + return _qb.AnnotatedExpr( # type: ignore [return-value] + value, + add_object_type_filter(cls, value, __operand__=__operand__), + ) + @classonlymethod @_qb.exprmethod @classmethod diff --git a/gel/models/pydantic.py b/gel/models/pydantic.py index 14bd5780..b46701d3 100644 --- a/gel/models/pydantic.py +++ b/gel/models/pydantic.py @@ -35,6 +35,7 @@ IndexOp, InfixOp, FuncCall, + ObjectWhenType, PathAlias, SetLiteral, UnaryOp, @@ -156,6 +157,7 @@ "MultiProperty", "MultiRange", "MultiRangeMeta", + "ObjectWhenType", "OptionalComputedLink", "OptionalComputedLinkWithProps", "OptionalComputedProperty", From 10f4672ccbd8146a0e403d94246607dfd15cba9a Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 7 Oct 2025 17:24:03 -0700 Subject: [PATCH 04/13] Add TypeNameExpr. --- gel/_internal/_qb/_abstract.py | 10 +++--- gel/_internal/_qb/_expressions.py | 36 ++++++++++--------- .../_qbmodel/_abstract/_expressions.py | 11 ++++++ gel/_internal/_schemapath.py | 36 +++++++++++++++++++ 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/gel/_internal/_qb/_abstract.py b/gel/_internal/_qb/_abstract.py index b0772986..4e789210 100644 --- a/gel/_internal/_qb/_abstract.py +++ b/gel/_internal/_qb/_abstract.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping - from gel._internal._schemapath import TypeName + from gel._internal._schemapath import TypeNameExpr @dataclass(kw_only=True, frozen=True) @@ -89,7 +89,7 @@ class Expr(Node): def precedence(self) -> _edgeql.Precedence: ... @abc.abstractproperty - def type(self) -> TypeName: ... + def type(self) -> TypeNameExpr: ... @abc.abstractmethod def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: ... @@ -100,10 +100,10 @@ def __edgeql_qb_expr__(self) -> Self: @dataclass(kw_only=True, frozen=True) class TypedExpr(Expr): - type_: TypeName + type_: TypeNameExpr @property - def type(self) -> TypeName: + def type(self) -> TypeNameExpr: return self.type_ @@ -499,7 +499,7 @@ class ImplicitIteratorStmt(IteratorExpr, Stmt): """Base class for statements that are implicit iterators""" @property - def type(self) -> TypeName: + def type(self) -> TypeNameExpr: return self.iter_expr.type @property diff --git a/gel/_internal/_qb/_expressions.py b/gel/_internal/_qb/_expressions.py index 6318e502..9421c55d 100644 --- a/gel/_internal/_qb/_expressions.py +++ b/gel/_internal/_qb/_expressions.py @@ -20,7 +20,7 @@ from gel._internal import _edgeql from gel._internal._polyfills import _strenum -from gel._internal._schemapath import SchemaPath, TypeName +from gel._internal._schemapath import SchemaPath, TypeName, TypeNameExpr from ._abstract import ( AtomicExpr, @@ -239,7 +239,7 @@ def __init__( /, *, op: _edgeql.Token | str, - type_: TypeName, + type_: TypeNameExpr, ) -> None: super().__init__(type_=type_) if not isinstance(op, _edgeql.Token): @@ -260,7 +260,7 @@ def __init__( *, expr: ExprCompatible, op: _edgeql.Token | str, - type_: TypeName, + type_: TypeNameExpr, ) -> None: object.__setattr__(self, "expr", edgeql_qb_expr(expr)) super().__init__(op=op, type_=type_) @@ -306,7 +306,7 @@ def __init__( self, *, expr: ExprCompatible, - type_: TypeName, + type_: TypeNameExpr, ) -> None: op = _edgeql.Token.RANGBRACKET super().__init__(expr=expr, op=op, type_=type_) @@ -344,7 +344,7 @@ def __init__( lexpr: ExprCompatible, rexpr: ExprCompatible, op: _edgeql.Token | str, - type_: TypeName, + type_: TypeNameExpr, ) -> None: object.__setattr__(self, "lexpr", edgeql_qb_expr(lexpr)) object.__setattr__(self, "rexpr", edgeql_qb_expr(rexpr)) @@ -362,7 +362,7 @@ def __init__( lexpr: ExprCompatible, rexpr: ExprCompatible, op: _edgeql.Token | str, - type_: TypeName, + type_: TypeNameExpr, ) -> None: super().__init__(lexpr=lexpr, rexpr=rexpr, op=op, type_=type_) @@ -431,7 +431,7 @@ def __init__( fname: str, args: list[ExprCompatible] | None = None, kwargs: dict[str, ExprCompatible] | None = None, - type_: TypeName, + type_: TypeNameExpr, ) -> None: object.__setattr__(self, "fname", fname) if args is not None: @@ -533,7 +533,7 @@ def subnodes(self) -> Iterable[Node]: return (self.expr,) @property - def type(self) -> TypeName: + def type(self) -> TypeNameExpr: return self.expr.type @property @@ -613,9 +613,15 @@ def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: @dataclass(kw_only=True, frozen=True) class InsertStmt(Stmt, TypedExpr): + type_: TypeName # insert can only deal with simple type names + stmt: _edgeql.Token = _edgeql.Token.INSERT shape: Shape | None = None + @property + def type(self) -> TypeName: + return self.type_ + def subnodes(self) -> Iterable[Node | None]: return (self.shape,) @@ -782,7 +788,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "var", var) @property - def type(self) -> TypeName: + def type(self) -> TypeNameExpr: return self.body.type def subnodes(self) -> Iterable[Node]: @@ -811,7 +817,7 @@ class Splat(_strenum.StrEnum): @dataclass(kw_only=True, frozen=True) class ShapeElement(Node): name: str | Splat - origin: TypeName + origin: TypeNameExpr expr: Expr | None = None def subnodes(self) -> Iterable[Node]: @@ -823,7 +829,7 @@ def subnodes(self) -> Iterable[Node]: @classmethod def splat( cls, - source: TypeName, + source: TypeNameExpr, *, kind: Splat = Splat.STAR, ) -> Self: @@ -840,7 +846,7 @@ def subnodes(self) -> Iterable[Node]: @classmethod def splat( cls, - source: TypeName, + source: TypeNameExpr, *, kind: Splat = Splat.STAR, ) -> Self: @@ -848,9 +854,7 @@ def splat( return cls(elements=elements) -def expr_uses_auto_splat( - expr: Expr -) -> bool: +def expr_uses_auto_splat(expr: Expr) -> bool: if isinstance(expr, (SchemaSet, Path)): return True elif isinstance(expr, ObjectWhenType): @@ -909,7 +913,7 @@ def subnodes(self) -> Iterable[Node]: return (self.iter_expr, self.shape) @property - def type(self) -> TypeName: + def type(self) -> TypeNameExpr: return self.iter_expr.type @property diff --git a/gel/_internal/_qbmodel/_abstract/_expressions.py b/gel/_internal/_qbmodel/_abstract/_expressions.py index 1ade7ba8..b962ed99 100644 --- a/gel/_internal/_qbmodel/_abstract/_expressions.py +++ b/gel/_internal/_qbmodel/_abstract/_expressions.py @@ -13,6 +13,7 @@ import inspect from gel._internal import _qb +from gel._internal._schemapath import TypeNameIntersection, TypeNameUnion from gel._internal._utils import Unspecified from ._base import AbstractGelModel @@ -380,6 +381,11 @@ def add_limit( limit = _qb.IntLiteral(val=expr) if stmt.limit is not None: + if isinstance(limit.type, (TypeNameIntersection, TypeNameUnion)): + raise ValueError( + f"Invalid type for limit: '{limit.type.as_schema_name()}'" + ) + limit = _qb.FuncCall( fname="std::min", args=[ @@ -410,6 +416,11 @@ def add_offset( offset = _qb.IntLiteral(val=expr) if stmt.offset is not None: + if isinstance(offset.type, (TypeNameIntersection, TypeNameUnion)): + raise ValueError( + f"Invalid type for offset: '{offset.type.as_schema_name()}'" + ) + offset = _qb.FuncCall( fname="std::min", args=[ diff --git a/gel/_internal/_schemapath.py b/gel/_internal/_schemapath.py index 9927c59d..a2dc21ae 100644 --- a/gel/_internal/_schemapath.py +++ b/gel/_internal/_schemapath.py @@ -290,3 +290,39 @@ def name(self) -> str: TypeName = TypeAliasType("TypeName", SchemaPath | ParametricTypeName) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class TypeNameIntersection: + args: tuple[TypeNameExpr, ...] + + def as_schema_name(self) -> str: + return f"({' & '.join(a.as_schema_name() for a in self.args)})" + + def as_quoted_schema_name(self) -> str: + return f"({' & '.join(a.as_quoted_schema_name() for a in self.args)})" + + @property + def name(self) -> str: + return f"({' & '.join(a.name for a in self.args)})" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class TypeNameUnion: + args: tuple[TypeNameExpr, ...] + + def as_schema_name(self) -> str: + return f"({' | '.join(a.as_schema_name() for a in self.args)})" + + def as_quoted_schema_name(self) -> str: + return f"({' | '.join(a.as_quoted_schema_name() for a in self.args)})" + + @property + def name(self) -> str: + return f"({' | '.join(a.name for a in self.args)})" + + +TypeNameExpr = TypeAliasType( + "TypeNameExpr", + TypeName | TypeNameIntersection | TypeNameUnion, +) From 5f20675806d36721e31b02b1252968875036fa1e Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 7 Oct 2025 18:03:12 -0700 Subject: [PATCH 05/13] Add BaseGelModelIntersection to support querying and decoding. --- gel/_internal/_qb/__init__.py | 2 + gel/_internal/_qb/_abstract.py | 8 + gel/_internal/_qb/_expressions.py | 6 +- gel/_internal/_qbmodel/_abstract/__init__.py | 2 + .../_qbmodel/_abstract/_expressions.py | 6 +- gel/_internal/_qbmodel/_abstract/_methods.py | 188 +++++++++++++++++- gel/_internal/_qbmodel/_pydantic/_models.py | 18 ++ gel/protocol/codecs/object.pyx | 120 ++++++----- 8 files changed, 289 insertions(+), 61 deletions(-) diff --git a/gel/_internal/_qb/__init__.py b/gel/_internal/_qb/__init__.py index a2e0a876..05eaf971 100644 --- a/gel/_internal/_qb/__init__.py +++ b/gel/_internal/_qb/__init__.py @@ -11,6 +11,7 @@ AbstractFieldDescriptor, Expr, PathPrefix, + PathTypeIntersectionPrefix, Scope, ScopeContext, Stmt, @@ -153,6 +154,7 @@ "Path", "PathAlias", "PathPrefix", + "PathTypeIntersectionPrefix", "SchemaSet", "Scope", "ScopeContext", diff --git a/gel/_internal/_qb/_abstract.py b/gel/_internal/_qb/_abstract.py index 4e789210..749e1882 100644 --- a/gel/_internal/_qb/_abstract.py +++ b/gel/_internal/_qb/_abstract.py @@ -494,6 +494,14 @@ def compute_must_bind_refs( return (self,) +@dataclass(frozen=True, kw_only=True) +class PathTypeIntersectionPrefix(IdentLikeExpr): + type_filter: TypeNameExpr + + def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: + return f"[is {self.type_filter.as_quoted_schema_name()}]" + + @dataclass(kw_only=True, frozen=True) class ImplicitIteratorStmt(IteratorExpr, Stmt): """Base class for statements that are implicit iterators""" diff --git a/gel/_internal/_qb/_expressions.py b/gel/_internal/_qb/_expressions.py index 9421c55d..23801473 100644 --- a/gel/_internal/_qb/_expressions.py +++ b/gel/_internal/_qb/_expressions.py @@ -302,13 +302,17 @@ def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: @dataclass(kw_only=True, frozen=True) class ObjectWhenType(UnaryOp): + type_filter: TypeNameExpr + def __init__( self, *, expr: ExprCompatible, + type_filter: TypeNameExpr, type_: TypeNameExpr, ) -> None: op = _edgeql.Token.RANGBRACKET + object.__setattr__(self, "type_filter", type_filter) super().__init__(expr=expr, op=op, type_=type_) @property @@ -322,7 +326,7 @@ def __edgeql_expr__(self, *, ctx: ScopeContext) -> str: expr = edgeql(self.expr, ctx=ctx) if _need_left_parens(self.precedence, self.expr): expr = f"({expr})" - return f"{expr} [is {self.type.as_quoted_schema_name()}]" + return f"{expr} [is {self.type_filter.as_quoted_schema_name()}]" def empty_set(type_: TypeName) -> CastOp: diff --git a/gel/_internal/_qbmodel/_abstract/__init__.py b/gel/_internal/_qbmodel/_abstract/__init__.py index 5f727148..30079e6e 100644 --- a/gel/_internal/_qbmodel/_abstract/__init__.py +++ b/gel/_internal/_qbmodel/_abstract/__init__.py @@ -64,6 +64,7 @@ from ._methods import ( BaseGelModel, + BaseGelModelIntersection, ) @@ -130,6 +131,7 @@ "Array", "ArrayMeta", "BaseGelModel", + "BaseGelModelIntersection", "ComputedLinkSet", "ComputedLinkWithPropsSet", "ComputedMultiLinkDescriptor", diff --git a/gel/_internal/_qbmodel/_abstract/_expressions.py b/gel/_internal/_qbmodel/_abstract/_expressions.py index b962ed99..3f6f33e8 100644 --- a/gel/_internal/_qbmodel/_abstract/_expressions.py +++ b/gel/_internal/_qbmodel/_abstract/_expressions.py @@ -34,7 +34,6 @@ from collections.abc import Callable from ._base import GelType - from gel._internal._schemapath import TypeName _T = TypeVar("_T") @@ -437,19 +436,22 @@ def add_offset( def add_object_type_filter( - cls: type[GelType], + cls: type[AbstractGelModel], /, type_filter: type[AbstractGelModel], *, __operand__: _qb.ExprAlias | None = None, ) -> _qb.Expr: + from ._methods import create_intersection # noqa: PLC0415 subject = _qb.edgeql_qb_expr(cls if __operand__ is None else __operand__) + cls = create_intersection(cls, type_filter) splat_cb = functools.partial(_qb.get_object_type_splat, cls) expr = _qb.ObjectWhenType( expr=subject, + type_filter=type_filter.__gel_reflection__.type_name, type_=cls.__gel_reflection__.type_name, ) diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index f18d8f5e..b9458534 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -8,6 +8,8 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, + Generic, Literal, TypeVar, ) @@ -15,6 +17,10 @@ from gel._internal import _qb +from gel._internal._lazyprop import LazyClassProperty +from gel._internal._schemapath import ( + TypeNameIntersection, +) from gel._internal._xmethod import classonlymethod from ._base import AbstractGelModel @@ -36,6 +42,8 @@ if TYPE_CHECKING: from collections.abc import Callable + +_T_SelfModel = TypeVar("_T_SelfModel", bound="type[BaseGelModel]") _T_OtherModel = TypeVar("_T_OtherModel", bound="type[BaseGelModel]") @@ -79,8 +87,8 @@ def offset(cls, /, expr: Any) -> type[Self]: ... @classmethod def when_type( - cls, /, other_model: _T_OtherModel - ) -> type[_T_OtherModel]: ... + cls: _T_SelfModel, /, other_model: _T_OtherModel + ) -> type[BaseGelModelIntersection[_T_SelfModel, _T_OtherModel]]: ... @classmethod def __gel_assert_single__( @@ -198,13 +206,13 @@ def offset( @classmethod def when_type( - cls, + cls: _T_SelfModel, /, value: _T_OtherModel, __operand__: _qb.ExprAlias | None = None, - ) -> type[_T_OtherModel]: + ) -> type[BaseGelModelIntersection[_T_SelfModel, _T_OtherModel]]: return _qb.AnnotatedExpr( # type: ignore [return-value] - value, + create_intersection(cls, value), add_object_type_filter(cls, value, __operand__=__operand__), ) @@ -227,3 +235,173 @@ def __gel_assert_single__( def __edgeql_qb_expr__(cls) -> _qb.Expr: # pyright: ignore [reportIncompatibleMethodOverride] this_type = cls.__gel_reflection__.type_name return _qb.SchemaSet(type_=this_type) + + +_T_Lhs = TypeVar("_T_Lhs", bound="type[AbstractGelModel]") +_T_Rhs = TypeVar("_T_Rhs", bound="type[AbstractGelModel]") + + +class BaseGelModelIntersection( + BaseGelModel, + Generic[_T_Lhs, _T_Rhs], +): + __gel_type_class__: ClassVar[type] + + lhs: ClassVar[type[AbstractGelModel]] + rhs: ClassVar[type[AbstractGelModel]] + + +T = TypeVar('T') +U = TypeVar('U') + + +def unchanged(l: T) -> T: + return l + + +def take_left(l: T, r: T) -> T: + return l + + +def combine_dicts( + lhs: dict[str, T], + rhs: dict[str, T], + *, + process_unique: Callable[[T], U | None] = unchanged, + process_common: Callable[[T, T], U | None] = take_left, +) -> dict[str, U]: + result: dict[str, U] = {} + + # unique pointers + result |= { + p_name: p_ref + for p_name, lhs_p_ref in lhs.items() + if p_name not in rhs + if (p_ref := process_unique(lhs_p_ref)) is not None + } + result |= { + p_name: p_ref + for p_name, rhs_p_ref in rhs.items() + if p_name not in lhs + if (p_ref := process_unique(rhs_p_ref)) is not None + } + + # common pointers + result |= { + p_name: p_ref + for p_name, lhs_p_ref in rhs.items() + if ( + (rhs_p_ref := rhs.get(p_name)) is not None + and (p_ref := process_common(lhs_p_ref, rhs_p_ref)) is not None + ) + } + + return result + + +def create_intersection( + lhs: _T_Lhs, + rhs: _T_Rhs, +) -> type[BaseGelModelIntersection[_T_Lhs, _T_Rhs]]: + # Pointer reflections + ptr_reflections: dict[str, _qb.GelPointerReflection] = combine_dicts( + lhs.__gel_reflection__.pointers, + rhs.__gel_reflection__.pointers, + process_common=lambda l, r: l if l == r else None, + ) + + class __gel_reflection__(_qb.GelObjectTypeMetadata.__gel_reflection__): # noqa: N801 + expr_object_types: set[type[AbstractGelModel]] = getattr( + lhs.__gel_reflection__, 'expr_object_types', {lhs} + ) | getattr(rhs.__gel_reflection__, 'expr_object_types', {rhs}) + + type_name = TypeNameIntersection( + args=( + lhs.__gel_reflection__.type_name, + rhs.__gel_reflection__.type_name, + ) + ) + + @LazyClassProperty["dict[str, _qb.GelPointerReflection]"] + @classmethod + def pointers( + cls, + ) -> dict[str, _qb.GelPointerReflection]: + return ptr_reflections + + @classmethod + def object( + cls, + ) -> Any: + raise NotImplementedError( + "Type expressions schema objects are inaccessible" + ) + + @classmethod + def __edgeql_qb_expr__(cls) -> _qb.Expr: # noqa: N807 + return _qb.ObjectWhenType( + expr=lhs.__edgeql_qb_expr__(), + type_filter=rhs.__gel_reflection__.type_name, + type_=__gel_reflection__.type_name, + ) + + result = type( + f"({lhs.__name__} & {rhs.__name__})", + (BaseGelModelIntersection,), + { + 'lhs': lhs, + 'rhs': rhs, + '__gel_reflection__': __gel_reflection__, + '__edgeql_qb_expr__': __edgeql_qb_expr__, + }, + ) + + lhs_prefix = _qb.PathTypeIntersectionPrefix( + type_=__gel_reflection__.type_name, + type_filter=lhs.__gel_reflection__.type_name, + ) + rhs_prefix = _qb.PathTypeIntersectionPrefix( + type_=__gel_reflection__.type_name, + type_filter=rhs.__gel_reflection__.type_name, + ) + + def process_path_alias( + p_name: str, + p_refl: _qb.GelPointerReflection, + path_alias: _qb.PathAlias, + source: _qb.Expr, + ) -> _qb.PathAlias: + return _qb.PathAlias( + path_alias.__gel_origin__, + _qb.Path( + type_=p_refl.type, + source=source, + name=p_name, + is_lprop=False, + ), + ) + + path_aliases: dict[str, _qb.PathAlias] = combine_dicts( + { + p_name: process_path_alias(p_name, p_refl, path_alias, lhs_prefix) + for p_name, p_refl in lhs.__gel_reflection__.pointers.items() + if ( + hasattr(lhs, p_name) + and (path_alias := getattr(lhs, p_name, None)) is not None + and isinstance(path_alias, _qb.PathAlias) + ) + }, + { + p_name: process_path_alias(p_name, p_refl, path_alias, rhs_prefix) + for p_name, p_refl in rhs.__gel_reflection__.pointers.items() + if ( + hasattr(rhs, p_name) + and (path_alias := getattr(rhs, p_name, None)) is not None + and isinstance(path_alias, _qb.PathAlias) + ) + }, + ) + for p_name, path_alias in path_aliases.items(): + setattr(result, p_name, path_alias) + + return result diff --git a/gel/_internal/_qbmodel/_pydantic/_models.py b/gel/_internal/_qbmodel/_pydantic/_models.py index fa0019f7..01b6d537 100644 --- a/gel/_internal/_qbmodel/_pydantic/_models.py +++ b/gel/_internal/_qbmodel/_pydantic/_models.py @@ -923,6 +923,10 @@ def __gel_validate__(cls, value: Any) -> GelSourceModel: return res +_T_SelfModel = TypeVar("_T_SelfModel", bound="type[_abstract.BaseGelModel]") +_T_OtherModel = TypeVar("_T_OtherModel", bound="type[_abstract.BaseGelModel]") + + class GelModel( GelSourceModel, _abstract.BaseGelModel, @@ -1322,6 +1326,20 @@ def model_copy( ll_setattr(copied, "__gel_new__", ll_getattr(self, "__gel_new__")) return copied + if TYPE_CHECKING: + # Pretend that when_type returns a proper GelModel + @classmethod + def when_type( + cls: _T_SelfModel, /, other_model: _T_OtherModel + ) -> type[GelModelIntersection[_T_SelfModel, _T_OtherModel]]: ... + + +class GelModelIntersection( + GelModel, _abstract.BaseGelModelIntersection[_T_SelfModel, _T_OtherModel] +): + def __init__(self) -> None: + raise NotImplementedError("Type expressions cannot be instantiated.") + class GelLinkModel( GelSourceModel, diff --git a/gel/protocol/codecs/object.pyx b/gel/protocol/codecs/object.pyx index 8b79aaaa..39884423 100644 --- a/gel/protocol/codecs/object.pyx +++ b/gel/protocol/codecs/object.pyx @@ -348,15 +348,23 @@ cdef class ObjectCodec(BaseNamedRecordCodec): ) lprops_type = None - proxy = getattr(return_type, '__proxy_of__', None) - if proxy is not None: + + expr_object_types = getattr(return_type.__gel_reflection__, 'expr_object_types', None) + + if proxy := getattr(return_type, '__proxy_of__', None): self.cached_return_type_proxy = return_type self.cached_return_type = proxy assert not hasattr(proxy, '__proxy_of__') lprops_type = return_type.__linkprops__ + worklist = [self.cached_return_type] + elif expr_object_types is not None: + self.cached_return_type = return_type + self.cached_return_type_proxy = None + worklist = list(expr_object_types) else: self.cached_return_type = return_type self.cached_return_type_proxy = None + worklist = [self.cached_return_type] # Build a map of descendant types that are marked as being # canonical targets. Make sure to descend through types not @@ -364,7 +372,6 @@ cdef class ObjectCodec(BaseNamedRecordCodec): # std::Object/std::BaseObject type only get inherited via the # __shapes__ types, and we'll need to descend through that to # get to the real ones. - worklist = [self.cached_return_type] tname_map = {} while worklist: ch = worklist.pop() @@ -380,61 +387,68 @@ cdef class ObjectCodec(BaseNamedRecordCodec): if canonical: tname_map[sname] = ch + if expr_object_types is not None: + worklist = list(expr_object_types) + else: + worklist = [self.cached_return_type] + subs = [] dlists = [] origins = [] - ptrtypes = return_type.__gel_pointers__() - for i, name in enumerate(names): - if flags[i] & datatypes._EDGE_POINTER_IS_LINKPROP: - subs.append(None) - dlists.append(None) - origins.append(return_type) - elif name == "__tname__": - subs.append(None) - dlists.append(None) - self.cached_tname_index = i - origins.append(return_type) - elif name in {"__tid__", "id"}: - subs.append(None) - dlists.append(None) - origins.append(return_type) - else: - origin = return_type - if isinstance(self.source_types[i], ObjectTypeNullCodec): - tname = self.source_types[i].get_tname() - try: - origin = tname_map[tname] - except KeyError: - pass - - origins.append(origin) - - sub = inspect.getattr_static(origin, name) - subs.append(sub.get_resolved_type()) - - dlist_factory = None - - ptr = prefl.get(name) - ptrtype = ptrtypes.get(name) - if ( - ptr is not None - and ptr.cardinality.is_multi() - and ptrtype is not None - ): - if isinstance(ptrtype, typing.GenericAlias): - ptrtype = typing.get_origin(ptrtype) - + for workitem in worklist: + ptrtypes = workitem.__gel_pointers__() + + for i, name in enumerate(names): + if flags[i] & datatypes._EDGE_POINTER_IS_LINKPROP: + subs.append(None) + dlists.append(None) + origins.append(workitem) + elif name == "__tname__": + subs.append(None) + dlists.append(None) + self.cached_tname_index = i + origins.append(workitem) + elif name in {"__tid__", "id"}: + subs.append(None) + dlists.append(None) + origins.append(workitem) + elif name in ptrtypes: + origin = workitem + if isinstance(self.source_types[i], ObjectTypeNullCodec): + tname = self.source_types[i].get_tname() + try: + origin = tname_map[tname] + except KeyError: + pass + + origins.append(origin) + + sub = inspect.getattr_static(origin, name) + subs.append(sub.get_resolved_type()) + + dlist_factory = None + + ptr = prefl.get(name) + ptrtype = ptrtypes.get(name) if ( - isinstance(ptrtype, type) - and ( - issubclass( - ptrtype, - (_tracked_list.AbstractCollection, tuple), - ) - ) + ptr is not None + and ptr.cardinality.is_multi() + and ptrtype is not None ): - dlist_factory = ptrtype - dlists.append(dlist_factory) + if isinstance(ptrtype, typing.GenericAlias): + ptrtype = typing.get_origin(ptrtype) + + if ( + isinstance(ptrtype, type) + and ( + issubclass( + ptrtype, + (_tracked_list.AbstractCollection, tuple), + ) + ) + ): + dlist_factory = ptrtype + dlists.append(dlist_factory) self.cached_return_type_subcodecs = tuple(subs) self.cached_return_type_dlists = tuple(dlists) From 37656c60cf5379e98277587ccd01d6cf83c3e31b Mon Sep 17 00:00:00 2001 From: dnwpark Date: Tue, 14 Oct 2025 19:11:42 -0700 Subject: [PATCH 06/13] When decoding a type expr, check the expr for pointers too. --- gel/protocol/codecs/object.pyx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/gel/protocol/codecs/object.pyx b/gel/protocol/codecs/object.pyx index 39884423..4deaf90e 100644 --- a/gel/protocol/codecs/object.pyx +++ b/gel/protocol/codecs/object.pyx @@ -390,7 +390,7 @@ cdef class ObjectCodec(BaseNamedRecordCodec): if expr_object_types is not None: worklist = list(expr_object_types) else: - worklist = [self.cached_return_type] + worklist = [return_type] subs = [] dlists = [] @@ -412,7 +412,7 @@ cdef class ObjectCodec(BaseNamedRecordCodec): subs.append(None) dlists.append(None) origins.append(workitem) - elif name in ptrtypes: + else: origin = workitem if isinstance(self.source_types[i], ObjectTypeNullCodec): tname = self.source_types[i].get_tname() @@ -423,7 +423,14 @@ cdef class ObjectCodec(BaseNamedRecordCodec): origins.append(origin) - sub = inspect.getattr_static(origin, name) + if expr_object_types is not None: + sub = inspect.getattr_static(origin, name, None) + if sub is None: + # This pointer is from a different part of the type expr + continue + else: + sub = inspect.getattr_static(origin, name) + subs.append(sub.get_resolved_type()) dlist_factory = None From d8e2941017674e8d2627ce8a3b47bb30acf9d156 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 15 Oct 2025 14:21:26 -0700 Subject: [PATCH 07/13] Add missing function decorators for when_type. --- gel/_internal/_qbmodel/_abstract/_methods.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index b9458534..a68d8717 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -204,6 +204,8 @@ def offset( add_offset(cls, value, __operand__=__operand__), ) + @classonlymethod + @_qb.exprmethod @classmethod def when_type( cls: _T_SelfModel, From 136346c4ff45c25aa34da496baa5d221f78449de Mon Sep 17 00:00:00 2001 From: dnwpark Date: Wed, 15 Oct 2025 16:36:11 -0700 Subject: [PATCH 08/13] Fix interaction between when_type and auto splats. --- .../_qbmodel/_abstract/_expressions.py | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/gel/_internal/_qbmodel/_abstract/_expressions.py b/gel/_internal/_qbmodel/_abstract/_expressions.py index 3f6f33e8..b0f48d8f 100644 --- a/gel/_internal/_qbmodel/_abstract/_expressions.py +++ b/gel/_internal/_qbmodel/_abstract/_expressions.py @@ -440,26 +440,37 @@ def add_object_type_filter( /, type_filter: type[AbstractGelModel], *, - __operand__: _qb.ExprAlias | None = None, + __operand__: _qb.ExprAlias | _qb.Expr | None = None, ) -> _qb.Expr: from ._methods import create_intersection # noqa: PLC0415 - subject = _qb.edgeql_qb_expr(cls if __operand__ is None else __operand__) - - cls = create_intersection(cls, type_filter) - splat_cb = functools.partial(_qb.get_object_type_splat, cls) - - expr = _qb.ObjectWhenType( - expr=subject, - type_filter=type_filter.__gel_reflection__.type_name, - type_=cls.__gel_reflection__.type_name, - ) + subject: _qb.Expr + if isinstance(__operand__, _qb.Expr): + subject = __operand__ + elif __operand__ is not None: + subject = _qb.edgeql_qb_expr(__operand__) + else: + subject = _qb.edgeql_qb_expr(cls) + + result_cls = create_intersection(cls, type_filter) + + if isinstance(subject, (_qb.SelectStmt, _qb.ShapeOp)): + # Apply the type intersection to the subject. + return dataclasses.replace( + subject, + iter_expr=add_object_type_filter( + cls, + type_filter, + __operand__=subject.iter_expr, + ), + ) - stmt = _qb.SelectStmt.wrap( - expr, - splat_cb=splat_cb, - ) - return stmt + else: + return _qb.ObjectWhenType( + expr=subject, + type_filter=type_filter.__gel_reflection__.type_name, + type_=result_cls.__gel_reflection__.type_name, + ) @overload From b6149568f18c13480417b9b30adb1102ceeb8c89 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Thu, 9 Oct 2025 17:51:50 -0700 Subject: [PATCH 09/13] Add tests. --- gel/_internal/_testbase/_models.py | 4 + tests/dbsetup/orm_qb.edgeql | 68 ++ tests/dbsetup/orm_qb.gel | 43 ++ tests/test_qb.py | 1045 ++++++++++++++++++++++++---- 4 files changed, 1038 insertions(+), 122 deletions(-) diff --git a/gel/_internal/_testbase/_models.py b/gel/_internal/_testbase/_models.py index 1eebc4a3..63bd8322 100644 --- a/gel/_internal/_testbase/_models.py +++ b/gel/_internal/_testbase/_models.py @@ -538,6 +538,8 @@ def _assertObjectsWithFields( models: Collection[GelModel], identifying_field: str, expected_obj_fields: list[tuple[type[GelModel], dict[str, Any]]], + *, + excluded_fields: set[str] | None = None, ) -> None: """Test that models match the expected object fields. Pairs models with their expected fields using the identifying field. @@ -579,6 +581,8 @@ def _assertObjectsWithFields( assert obj is not None self.assertIsInstance(obj, expected_type) self._assertHasFields(obj, expected_fields) + if excluded_fields: + self._assertNotHasFields(obj, excluded_fields) def _assertHasFields( self, diff --git a/tests/dbsetup/orm_qb.edgeql b/tests/dbsetup/orm_qb.edgeql index f64ec018..cb67dd08 100644 --- a/tests/dbsetup/orm_qb.edgeql +++ b/tests/dbsetup/orm_qb.edgeql @@ -229,3 +229,71 @@ insert Person { } ), }; + +insert Inh_A { + a := 1, +}; +insert Inh_B { + b := 2, +}; +insert Inh_C { + c := 3, +}; +insert Inh_AB { + a := 4, + b := 5, + ab := 6, +}; +insert Inh_AC { + a := 7, + c := 8, + ac := 9, +}; +insert Inh_BC { + b := 10, + c := 11, + bc := 12, +}; +insert Inh_ABC { + a := 13, + b := 14, + c := 15, + abc := 16, +}; +insert Inh_AB_AC { + a := 17, + b := 18, + c := 19, + ab := 20, + ac := 21, + ab_ac := 22, +}; + +insert Inh_XA { + a := 1000, +}; +insert Inh_AXA { + a := 1001, + axa := 10002, +}; + +insert Link_Inh_A { + n := 1, + l := assert_exists((select Inh_A filter .a = 1 limit 1)), +}; +insert Link_Inh_A { + n := 4, + l := assert_exists((select Inh_AB filter .a = 4 limit 1)), +}; +insert Link_Inh_A { + n := 7, + l := assert_exists((select Inh_AC filter .a = 7 limit 1)), +}; +insert Link_Inh_A { + n := 13, + l := assert_exists((select Inh_ABC filter .a = 13 limit 1)), +}; +insert Link_Inh_A { + n := 17, + l := assert_exists((select Inh_AB_AC filter .a = 17 limit 1)), +}; diff --git a/tests/dbsetup/orm_qb.gel b/tests/dbsetup/orm_qb.gel index 09f2a12a..3edfb5a8 100644 --- a/tests/dbsetup/orm_qb.gel +++ b/tests/dbsetup/orm_qb.gel @@ -576,3 +576,46 @@ type NoobAccount { } } + +module default { + +type Inh_A { + a: int64; +}; +type Inh_B { + b: int64; +}; +type Inh_C { + c: int64; +}; +type Inh_AB extending Inh_A, Inh_B { + ab: int64; +}; +type Inh_AC extending Inh_A, Inh_C { + ac: int64; +}; +type Inh_BC extending Inh_B, Inh_C { + bc: int64; +}; +type Inh_ABC extending Inh_A, Inh_B, Inh_C { + abc: int64; +}; +type Inh_AB_AC extending Inh_AB, Inh_AC { + ab_ac: int64; +}; + +type Inh_XA { + a: int64; +}; +type Inh_AXA extending Inh_A, Inh_XA { + axa: int64; +}; + +type Link_Inh_A { + n: int64; + l: Inh_A { + on target delete allow; + }; +}; + +} diff --git a/tests/test_qb.py b/tests/test_qb.py index 7e2c7d35..2c7ff208 100644 --- a/tests/test_qb.py +++ b/tests/test_qb.py @@ -1146,7 +1146,7 @@ def test_qb_poly_01(self): contents=lambda i: i.contents.select( "*", ).order_by(game_id=True), - ) + ), ).filter( game_id=1, ) @@ -1166,7 +1166,7 @@ def test_qb_poly_02(self): contents=lambda i: i.contents.select( "*", ).order_by(game_id=True), - ) + ), ).filter( game_id=2, ) @@ -1189,7 +1189,7 @@ def test_qb_poly_03(self): contents=lambda i: i.contents.select( "*", ).order_by(game_id=True), - ) + ), ).filter( game_id=3, ) @@ -1205,7 +1205,8 @@ def test_qb_poly_03(self): [ ("cotton candy", default.Candy), ("candy corn", default.Candy), - ], strict=False + ], + strict=False, ): self.assertEqual(c.name, name) self.assertIsInstance(c, t) @@ -1221,7 +1222,7 @@ def test_qb_poly_04(self): contents=lambda i: i.contents.select( "*", ).order_by(game_id=True), - ) + ), ).filter( game_id=4, ) @@ -1237,7 +1238,8 @@ def test_qb_poly_04(self): [ ("milk", default.Chocolate), ("dark", default.Chocolate), - ], strict=False + ], + strict=False, ): self.assertEqual(c.name, name) self.assertIsInstance(c, t) @@ -1253,7 +1255,7 @@ def test_qb_poly_05(self): contents=lambda i: i.contents.select( "*", ).order_by(game_id=True), - ) + ), ).filter( game_id=5, ) @@ -1271,7 +1273,8 @@ def test_qb_poly_05(self): ("blue bear", default.Gummy), ("sour worm", default.GummyWorm), ("almond", default.Chocolate), - ], strict=False + ], + strict=False, ): self.assertEqual(c.name, name) self.assertIsInstance(c, t) @@ -1287,7 +1290,7 @@ def test_qb_poly_06(self): contents=lambda i: i.contents.select( "*", ).order_by(game_id=True), - ) + ), ).filter( game_id=6, ) @@ -1302,7 +1305,8 @@ def test_qb_poly_06(self): p.item.contents, [ ("sour worm", default.GummyWorm), - ], strict=False + ], + strict=False, ): self.assertEqual(c.name, name) self.assertIsInstance(c, t) @@ -1329,7 +1333,8 @@ def test_qb_poly_07(self): [ ("milk", "bar"), ("dark", "truffle"), - ], strict=False + ], + strict=False, ): self.assertEqual(c.name, name) self.assertEqual(c.kind, kind) @@ -1344,19 +1349,6 @@ def test_qb_array_agg_01(self): res = self.client.query(unpack) self.assertEqual(len(res), 6) - def test_qb_cast_array_01(self): - # array[scalar] to array[scalar] - from models.orm_qb import std - - result = self.client.get( - std.array[std.str].cast( - std.array[std.int64]( - [std.int64(1), std.int64(2), std.int64(3)] - ) - ) - ) - self.assertEqual(result, ["1", "2", "3"]) - def test_qb_cast_scalar_01(self): # scalar to scalar from models.orm import std @@ -1378,6 +1370,19 @@ def test_qb_cast_scalar_03(self): result = self.client.get(default.Color.cast(std.str("Red"))) self.assertEqual(result, default.Color.Red) + def test_qb_cast_array_01(self): + # array[scalar] to array[scalar] + from models.orm_qb import std + + result = self.client.get( + std.array[std.str].cast( + std.array[std.int64]( + [std.int64(1), std.int64(2), std.int64(3)] + ) + ) + ) + self.assertEqual(result, ["1", "2", "3"]) + def test_qb_cast_array_02(self): # array[enum] to array[scalar] from models.orm_qb import default, std @@ -1475,141 +1480,676 @@ def test_qb_cast_range_01(self): ) self.assertEqual(result, _range.Range(std.int64(1), std.int64(9))) - -class TestQueryBuilderModify(tb.ModelTestCase): - """This test suite is for data manipulation using QB.""" - - SCHEMA = os.path.join(os.path.dirname(__file__), "dbsetup", "orm_qb.gel") - - SETUP = os.path.join(os.path.dirname(__file__), "dbsetup", "orm_qb.edgeql") - - ISOLATED_TEST_BRANCHES = True - - def test_qb_update_01(self): + def test_qb_when_type_basic_01(self): + # Simple TypeIntersection from models.orm_qb import default - self.client.query( - default.User.filter(name="Alice").update( - name="Cooper", - nickname="singer", - ) - ) + result = self.client.query(default.Inh_A.when_type(default.Inh_B)) - res = self.client.get(default.User.filter(name="Cooper")) - self.assertEqual(res.name, "Cooper") - self.assertEqual(res.nickname, "singer") + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + "b": 5, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + "b": 14, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + "b": 18, + }, + ), + ], + excluded_fields={'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) - def test_qb_update_02(self): - from models.orm_qb import default, std + def test_qb_when_type_basic_02(self): + # Chained TypeIntersection + from models.orm_qb import default - self.client.query( - default.UserGroup.filter(name="blue").update( - users=default.User.filter( - lambda u: std.in_(u.name, {"Zoe", "Dana"}) - ) - ) + result = self.client.query( + default.Inh_A.when_type(default.Inh_B).when_type(default.Inh_C) ) - res = self.client.get( - default.UserGroup.select("**").filter(name="blue") + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_ABC, + { + "a": 13, + "b": 14, + "c": 15, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + "b": 18, + "c": 19, + }, + ), + ], + excluded_fields={'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - self.assertEqual(res.name, "blue") - self.assertEqual({u.name for u in res.users}, {"Zoe", "Dana"}) - def test_qb_update_03(self): - from models.orm_qb import default, std + def test_qb_when_type_basic_03(self): + # TypeIntersection Select + from models.orm_qb import default - # Combine update and select of the updated object - res = self.client.get( - default.Post.filter(body="Hello") - .update( - author=std.assert_single(default.User.filter(name="Billie")) # type: ignore - ) - .select("*", author=lambda p: p.author.select("**")) + result = self.client.query( + default.Inh_A.when_type(default.Inh_B).select(a=True) ) - self.assertEqual(res.body, "Hello") - self.assertEqual(res.author.name, "Billie") - self.assertEqual({g.name for g in res.author.groups}, {"red", "green"}) + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) - # Fails at typecheck time because update's *types* dont't - # support callbacks, though runtime does. @tb.skip_typecheck - def test_qb_update_04(self): - from models.orm_qb import default, std + def test_qb_when_type_basic_04(self): + # Model Select + # with computed single prop using type intersection + from models.orm_qb import default - self.client.query( - default.UserGroup.filter(name="blue").update( - users=default.User.filter( - lambda u: std.in_(u.name, {"Zoe", "Dana"}) - ) - ) + result = self.client.query( + default.Inh_AB.select(a=lambda x: x.when_type(default.Inh_C).c) ) - res0 = self.client.get( - default.UserGroup.select("**").filter(name="blue") + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": None, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 19, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - self.assertEqual(res0.name, "blue") - self.assertEqual({u.name for u in res0.users}, {"Zoe", "Dana"}) - # Add Alice to the group - self.client.query( - default.UserGroup.filter(name="blue").update( - users=lambda g: std.assert_distinct( - std.union(g.users, default.User.filter(name="Alice")) - ) + def test_qb_when_type_basic_05(self): + # TypeIntersection Select + # with computed single prop using type intersection + from models.orm_qb import default + + result = self.client.query( + default.Inh_A.when_type(default.Inh_B).select( + ab=lambda x: x.when_type(default.Inh_AB).ab ) ) - res1 = self.client.get( - default.UserGroup.select("**").filter(name="blue") - ) - self.assertEqual(res1.name, "blue") - self.assertEqual( - {u.name for u in res1.users}, {"Zoe", "Dana", "Alice"} + self._assertObjectsWithFields( + result, + "ab", + [ + ( + default.Inh_AB, + { + "ab": 6, + }, + ), + ( + default.Inh_ABC, + { + "ab": None, + }, + ), + ( + default.Inh_AB_AC, + { + "ab": 20, + }, + ), + ], + excluded_fields={'a', 'b', 'c', 'ac', 'bc', 'abc', 'ab_ac'}, ) - # Remove Dana from the group - self.client.query( - default.UserGroup.filter(name="blue").update( - users=lambda g: std.except_( - g.users, default.User.filter(name="Dana") - ) + def test_qb_when_type_basic_06(self): + # TypeIntersection Select + # with computed multi prop using type intersection + from models.orm_qb import default, std + + result = self.client.query( + default.Inh_A.when_type(default.Inh_B).select( + a=True, + abc=lambda x: std.union( + x.when_type(default.Inh_AB).ab, + x.when_type(default.Inh_AC).ac, + ), ) ) - res2 = self.client.get( - default.UserGroup.select("**").filter(name="blue") + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + "abc": [6], + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + "abc": [], + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + "abc": [20, 21], + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'ab_ac'}, ) - self.assertEqual(res2.name, "blue") - self.assertEqual({u.name for u in res2.users}, {"Zoe", "Alice"}) - def test_qb_delete_01(self): + def test_qb_when_type_basic_07(self): + # Link TypeIntersection from models.orm_qb import default - before = self.client.query( - default.Post.select(body=True).order_by(body=True) - ) - self.assertEqual( - [p.body for p in before], - ["*magic stuff*", "Hello", "I'm Alice", "I'm Cameron"], + result = self.client.query( + default.Link_Inh_A.l.when_type(default.Inh_B) ) - # Delete a specific post - self.client.query(default.Post.filter(body="I'm Cameron").delete()) - - after = self.client.query( - default.Post.select(body=True).order_by(body=True) - ) - self.assertEqual( - [p.body for p in after], ["*magic stuff*", "Hello", "I'm Alice"] + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + "b": 5, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + "b": 14, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + "b": 18, + }, + ), + ], + excluded_fields={'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_delete_02(self): + def test_qb_when_type_basic_08(self): + # Link TypeIntersection Select + # with computed single prop using type intersection from models.orm_qb import default - before = self.client.query( - default.Post.select(body=True).order_by(body=True) + result = self.client.query( + default.Link_Inh_A.l.when_type(default.Inh_B).select( + a=True, + ab=lambda x: x.when_type(default.Inh_AB).ab, + ) + ) + + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + "ab": 6, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + "ab": None, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + "ab": 20, + }, + ), + ], + excluded_fields={'b', 'c', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + def test_qb_when_type_basic_09(self): + # Model Select + # with computed single link using type intersection + from models.orm_qb import default + + inh_a_objs = self.client.query(default.Inh_A.select(a=True)) + possible_targets = {obj.a: obj for obj in inh_a_objs} + + result = self.client.query( + default.Link_Inh_A.select( + n=True, l=lambda x: x.l.when_type(default.Inh_B) + ) + ) + + self._assertObjectsWithFields( + result, + "n", + [ + ( + default.Link_Inh_A, + { + "n": 1, + "l": None, + }, + ), + ( + default.Link_Inh_A, + { + "n": 4, + "l": possible_targets[4], + }, + ), + ( + default.Link_Inh_A, + { + "n": 7, + "l": None, + }, + ), + ( + default.Link_Inh_A, + { + "n": 13, + "l": possible_targets[13], + }, + ), + ( + default.Link_Inh_A, + { + "n": 17, + "l": possible_targets[17], + }, + ), + ], + ) + + for r in result: + if r.l is not None: + self._assertNotHasFields( + r.l, {'a', 'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'} + ) + + def test_qb_when_type_basic_10(self): + # Model Select + # with computed single link using type intersection + # with select + from models.orm_qb import default + + inh_a_objs = self.client.query(default.Inh_A.select(a=True)) + possible_targets = {obj.a: obj for obj in inh_a_objs} + + result = self.client.query( + default.Link_Inh_A.select( + n=True, l=lambda x: x.l.when_type(default.Inh_B).select(a=True) + ) + ) + + self._assertObjectsWithFields( + result, + "n", + [ + ( + default.Link_Inh_A, + { + "n": 1, + "l": None, + }, + ), + ( + default.Link_Inh_A, + { + "n": 4, + "l": possible_targets[4], + }, + ), + ( + default.Link_Inh_A, + { + "n": 7, + "l": None, + }, + ), + ( + default.Link_Inh_A, + { + "n": 13, + "l": possible_targets[13], + }, + ), + ( + default.Link_Inh_A, + { + "n": 17, + "l": possible_targets[17], + }, + ), + ], + ) + + for r in result: + if r.l is not None: + self._assertHasFields(r.l, {'a'}) + self._assertNotHasFields( + r.l, {'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'} + ) + + def test_qb_when_type_for_01(self): + # TypeIntersection in iterator + from models.orm_qb import default, std + + result = self.client.query( + std.for_( + default.Inh_A.when_type(default.Inh_B), lambda x: x + ).select(a=True) + ) + + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + @tb.xfail( + '''ISE when applying shape to for loop with type intersection + https://github.com/geldata/gel/issues/9092 + ''' + ) + def test_qb_when_type_for_02(self): + # TypeIntersection in body + from models.orm_qb import default, std + + result = self.client.query( + std.for_( + default.Inh_A, + lambda x: x.when_type(default.Inh_B).select(a=True), + ) + ) + + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + @tb.xfail( + '''ISE when applying shape to for loop with type intersection + https://github.com/geldata/gel/issues/9092 + ''' + ) + def test_qb_when_type_for_03(self): + # TypeIntersection on entire statement + from models.orm_qb import default, std + + result = self.client.query( + std.for_(default.Inh_A, lambda x: x) + .when_type(default.Inh_B) + .select(a=True) + ) + + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + +class TestQueryBuilderModify(tb.ModelTestCase): + """This test suite is for data manipulation using QB.""" + + SCHEMA = os.path.join(os.path.dirname(__file__), "dbsetup", "orm_qb.gel") + + SETUP = os.path.join(os.path.dirname(__file__), "dbsetup", "orm_qb.edgeql") + + ISOLATED_TEST_BRANCHES = True + + def test_qb_update_01(self): + from models.orm_qb import default + + self.client.query( + default.User.filter(name="Alice").update( + name="Cooper", + nickname="singer", + ) + ) + + res = self.client.get(default.User.filter(name="Cooper")) + self.assertEqual(res.name, "Cooper") + self.assertEqual(res.nickname, "singer") + + def test_qb_update_02(self): + from models.orm_qb import default, std + + self.client.query( + default.UserGroup.filter(name="blue").update( + users=default.User.filter( + lambda u: std.in_(u.name, {"Zoe", "Dana"}) + ) + ) + ) + + res = self.client.get( + default.UserGroup.select("**").filter(name="blue") + ) + self.assertEqual(res.name, "blue") + self.assertEqual({u.name for u in res.users}, {"Zoe", "Dana"}) + + def test_qb_update_03(self): + from models.orm_qb import default, std + + # Combine update and select of the updated object + res = self.client.get( + default.Post.filter(body="Hello") + .update( + author=std.assert_single(default.User.filter(name="Billie")) # type: ignore + ) + .select("*", author=lambda p: p.author.select("**")) + ) + + self.assertEqual(res.body, "Hello") + self.assertEqual(res.author.name, "Billie") + self.assertEqual({g.name for g in res.author.groups}, {"red", "green"}) + + # Fails at typecheck time because update's *types* dont't + # support callbacks, though runtime does. + @tb.skip_typecheck + def test_qb_update_04(self): + from models.orm_qb import default, std + + self.client.query( + default.UserGroup.filter(name="blue").update( + users=default.User.filter( + lambda u: std.in_(u.name, {"Zoe", "Dana"}) + ) + ) + ) + + res0 = self.client.get( + default.UserGroup.select("**").filter(name="blue") + ) + self.assertEqual(res0.name, "blue") + self.assertEqual({u.name for u in res0.users}, {"Zoe", "Dana"}) + + # Add Alice to the group + self.client.query( + default.UserGroup.filter(name="blue").update( + users=lambda g: std.assert_distinct( + std.union(g.users, default.User.filter(name="Alice")) + ) + ) + ) + + res1 = self.client.get( + default.UserGroup.select("**").filter(name="blue") + ) + self.assertEqual(res1.name, "blue") + self.assertEqual( + {u.name for u in res1.users}, {"Zoe", "Dana", "Alice"} + ) + + # Remove Dana from the group + self.client.query( + default.UserGroup.filter(name="blue").update( + users=lambda g: std.except_( + g.users, default.User.filter(name="Dana") + ) + ) + ) + + res2 = self.client.get( + default.UserGroup.select("**").filter(name="blue") + ) + self.assertEqual(res2.name, "blue") + self.assertEqual({u.name for u in res2.users}, {"Zoe", "Alice"}) + + def test_qb_delete_01(self): + from models.orm_qb import default + + before = self.client.query( + default.Post.select(body=True).order_by(body=True) + ) + self.assertEqual( + [p.body for p in before], + ["*magic stuff*", "Hello", "I'm Alice", "I'm Cameron"], + ) + + # Delete a specific post + self.client.query(default.Post.filter(body="I'm Cameron").delete()) + + after = self.client.query( + default.Post.select(body=True).order_by(body=True) + ) + self.assertEqual( + [p.body for p in after], ["*magic stuff*", "Hello", "I'm Alice"] + ) + + def test_qb_delete_02(self): + from models.orm_qb import default + + before = self.client.query( + default.Post.select(body=True).order_by(body=True) ) self.assertEqual( [p.body for p in before], @@ -1671,3 +2211,264 @@ def test_qb_enum_edit_02(self): self.assertEqual(e.color, default.Color.Violet) self.assertEqual(e.name, "red") + + def test_qb_update_when_type_01(self): + # Type Intersection Update + from models.orm_qb import default + + result = self.client.query( + default.Inh_A.when_type(default.Inh_B) + .update(a=lambda x: x.a + 1000) + .select(a=True) + ) + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 1004, + }, + ), + ( + default.Inh_ABC, + { + "a": 1013, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 1017, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + updated = self.client.query(default.Inh_A) + self._assertObjectsWithFields( + updated, + "a", + [ + ( + default.Inh_A, + { + "a": 1, + }, + ), + ( + default.Inh_AB, + { + "a": 1004, + }, + ), + ( + default.Inh_AC, + { + "a": 7, + }, + ), + ( + default.Inh_ABC, + { + "a": 1013, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 1017, + }, + ), + ( + default.Inh_AXA, + { + "a": 1001, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + def test_qb_update_when_type_02(self): + # Update Type Intersection + from models.orm_qb import default + + result = self.client.query( + default.Inh_A.update(a=lambda x: x.a + 1000) + .when_type(default.Inh_B) + .select(a=True) + ) + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 1004, + }, + ), + ( + default.Inh_ABC, + { + "a": 1013, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 1017, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + updated = self.client.query(default.Inh_A) + self._assertObjectsWithFields( + updated, + "a", + [ + ( + default.Inh_A, + { + "a": 1001, + }, + ), + ( + default.Inh_AB, + { + "a": 1004, + }, + ), + ( + default.Inh_AC, + { + "a": 1007, + }, + ), + ( + default.Inh_ABC, + { + "a": 1013, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 1017, + }, + ), + ( + default.Inh_AXA, + { + "a": 2001, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + def test_qb_delete_when_type_01(self): + # Type Intersection Delete + from models.orm_qb import default + + result = self.client.query( + default.Inh_A.when_type(default.Inh_B).delete().select(a=True) + ) + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + updated = self.client.query(default.Inh_A) + self._assertObjectsWithFields( + updated, + "a", + [ + ( + default.Inh_A, + { + "a": 1, + }, + ), + ( + default.Inh_AC, + { + "a": 7, + }, + ), + ( + default.Inh_AXA, + { + "a": 1001, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + def test_qb_delete_when_type_02(self): + # Delete Type Intersection + from models.orm_qb import default + + result = self.client.query( + default.Inh_A.delete().when_type(default.Inh_B).select(a=True) + ) + self._assertObjectsWithFields( + result, + "a", + [ + ( + default.Inh_AB, + { + "a": 4, + }, + ), + ( + default.Inh_ABC, + { + "a": 13, + }, + ), + ( + default.Inh_AB_AC, + { + "a": 17, + }, + ), + ], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) + + updated = self.client.query(default.Inh_A) + self._assertObjectsWithFields( + updated, + "a", + [], + excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, + ) From a61355c9180e49a0fd9629951452500cc8d2dec9 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Thu, 16 Oct 2025 16:22:02 -0700 Subject: [PATCH 10/13] Fix type checking. --- gel/_internal/_qb/__init__.py | 2 ++ gel/_internal/_qb/_reflection.py | 28 ++++++++++++++- gel/_internal/_qbmodel/_abstract/_methods.py | 36 +++++++++++--------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/gel/_internal/_qb/__init__.py b/gel/_internal/_qb/__init__.py index 05eaf971..e4881317 100644 --- a/gel/_internal/_qb/__init__.py +++ b/gel/_internal/_qb/__init__.py @@ -98,6 +98,7 @@ GelSchemaMetadata, GelSourceMetadata, GelTypeMetadata, + GelObjectTypeExprMetadata, GelObjectTypeMetadata, ) @@ -131,6 +132,7 @@ "ForStmt", "FuncCall", "GelLinkMetadata", + "GelObjectTypeExprMetadata", "GelObjectTypeMetadata", "GelPointerReflection", "GelReflectionProto", diff --git a/gel/_internal/_qb/_reflection.py b/gel/_internal/_qb/_reflection.py index 5442c9cd..79bb74d8 100644 --- a/gel/_internal/_qb/_reflection.py +++ b/gel/_internal/_qb/_reflection.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: import abc from gel._internal import _edgeql - from gel._internal._schemapath import SchemaPath, TypeName + from gel._internal._schemapath import SchemaPath, TypeName, TypeNameExpr @dataclasses.dataclass(frozen=True, kw_only=True) @@ -50,6 +50,11 @@ class __gel_reflection__(GelSchemaMetadata.__gel_reflection__): # noqa: N801 type_name: ClassVar[TypeName] +class GelTypeExprMetadata(GelSchemaMetadata): + class __gel_reflection__(GelSchemaMetadata.__gel_reflection__): # noqa: N801 + type_name: ClassVar[TypeNameExpr] + + if TYPE_CHECKING: class GelObjectTypeMetadata(abc.ABC, GelSourceMetadata, GelTypeMetadata): @@ -69,6 +74,20 @@ class __gel_reflection__( # noqa: N801 @abc.abstractmethod def __gel_not_abstract__(self) -> None: ... + class GelObjectTypeExprMetadata( + abc.ABC, + GelSourceMetadata, + GelTypeExprMetadata, + ): + class __gel_reflection__( # noqa: N801 + GelSourceMetadata.__gel_reflection__, + GelTypeExprMetadata.__gel_reflection__, + ): + abstract: ClassVar[bool] + + @abc.abstractmethod + def __gel_not_abstract__(self) -> None: ... + else: class GelObjectTypeMetadata(GelSourceMetadata, GelTypeMetadata): @@ -78,6 +97,13 @@ class __gel_reflection__( # noqa: N801 ): abstract: ClassVar[bool] + class GelObjectTypeExprMetadata(GelSourceMetadata, GelTypeExprMetadata): + class __gel_reflection__( # noqa: N801 + GelSourceMetadata.__gel_reflection__, + GelTypeExprMetadata.__gel_reflection__, + ): + abstract: ClassVar[bool] + class GelLinkMetadata(GelSourceMetadata): pass diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index a68d8717..95881c7e 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -17,7 +17,6 @@ from gel._internal import _qb -from gel._internal._lazyprop import LazyClassProperty from gel._internal._schemapath import ( TypeNameIntersection, ) @@ -269,8 +268,8 @@ def combine_dicts( lhs: dict[str, T], rhs: dict[str, T], *, - process_unique: Callable[[T], U | None] = unchanged, - process_common: Callable[[T, T], U | None] = take_left, + process_unique: Callable[[T], U | None] = unchanged, # type: ignore[assignment] + process_common: Callable[[T, T], U | None] = take_left, # type: ignore[assignment] ) -> dict[str, U]: result: dict[str, U] = {} @@ -312,7 +311,7 @@ def create_intersection( process_common=lambda l, r: l if l == r else None, ) - class __gel_reflection__(_qb.GelObjectTypeMetadata.__gel_reflection__): # noqa: N801 + class __gel_reflection__(_qb.GelObjectTypeExprMetadata.__gel_reflection__): # noqa: N801 expr_object_types: set[type[AbstractGelModel]] = getattr( lhs.__gel_reflection__, 'expr_object_types', {lhs} ) | getattr(rhs.__gel_reflection__, 'expr_object_types', {rhs}) @@ -324,12 +323,7 @@ class __gel_reflection__(_qb.GelObjectTypeMetadata.__gel_reflection__): # noqa: ) ) - @LazyClassProperty["dict[str, _qb.GelPointerReflection]"] - @classmethod - def pointers( - cls, - ) -> dict[str, _qb.GelPointerReflection]: - return ptr_reflections + pointers = ptr_reflections @classmethod def object( @@ -339,13 +333,21 @@ def object( "Type expressions schema objects are inaccessible" ) - @classmethod - def __edgeql_qb_expr__(cls) -> _qb.Expr: # noqa: N807 - return _qb.ObjectWhenType( - expr=lhs.__edgeql_qb_expr__(), - type_filter=rhs.__gel_reflection__.type_name, - type_=__gel_reflection__.type_name, - ) + if TYPE_CHECKING: + + def __edgeql_qb_expr__( # noqa: N807 + self: BaseGelModelIntersection[_T_Lhs, _T_Rhs], + ) -> _qb.Expr: ... + + else: + + @classmethod + def __edgeql_qb_expr__(cls) -> _qb.Expr: # noqa: N807 + return _qb.ObjectWhenType( + expr=lhs.__edgeql_qb_expr__(), + type_filter=rhs.__gel_reflection__.type_name, + type_=__gel_reflection__.type_name, + ) result = type( f"({lhs.__name__} & {rhs.__name__})", From ab8949220eb142d0b38a0594da7744d830efe103 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Mon, 20 Oct 2025 15:41:24 -0700 Subject: [PATCH 11/13] Add some comments and remove unused code. --- gel/_internal/_qbmodel/_abstract/_methods.py | 31 ++++++++------------ gel/protocol/codecs/object.pyx | 25 +++++++++++----- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index 95881c7e..e773f8b9 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -300,17 +300,21 @@ def combine_dicts( return result +# TODO: We should cache the results of this def create_intersection( lhs: _T_Lhs, rhs: _T_Rhs, ) -> type[BaseGelModelIntersection[_T_Lhs, _T_Rhs]]: - # Pointer reflections + """Create a runtime intersection type which acts like a GelModel.""" + + # Combine pointer reflections from args ptr_reflections: dict[str, _qb.GelPointerReflection] = combine_dicts( lhs.__gel_reflection__.pointers, rhs.__gel_reflection__.pointers, process_common=lambda l, r: l if l == r else None, ) + # Create type reflection for intersection type class __gel_reflection__(_qb.GelObjectTypeExprMetadata.__gel_reflection__): # noqa: N801 expr_object_types: set[type[AbstractGelModel]] = getattr( lhs.__gel_reflection__, 'expr_object_types', {lhs} @@ -333,22 +337,6 @@ def object( "Type expressions schema objects are inaccessible" ) - if TYPE_CHECKING: - - def __edgeql_qb_expr__( # noqa: N807 - self: BaseGelModelIntersection[_T_Lhs, _T_Rhs], - ) -> _qb.Expr: ... - - else: - - @classmethod - def __edgeql_qb_expr__(cls) -> _qb.Expr: # noqa: N807 - return _qb.ObjectWhenType( - expr=lhs.__edgeql_qb_expr__(), - type_filter=rhs.__gel_reflection__.type_name, - type_=__gel_reflection__.type_name, - ) - result = type( f"({lhs.__name__} & {rhs.__name__})", (BaseGelModelIntersection,), @@ -356,10 +344,17 @@ def __edgeql_qb_expr__(cls) -> _qb.Expr: # noqa: N807 'lhs': lhs, 'rhs': rhs, '__gel_reflection__': __gel_reflection__, - '__edgeql_qb_expr__': __edgeql_qb_expr__, }, ) + # Generate path aliases for pointers. + # + # These are used to generate the appropriate path prefix when getting + # pointers in shapes. + # + # For example, doing `Foo.select(foo=lambda x: x.when_type(Bar).bar)` + # will produce the query: + # select Foo { [is Bar].bar } lhs_prefix = _qb.PathTypeIntersectionPrefix( type_=__gel_reflection__.type_name, type_filter=lhs.__gel_reflection__.type_name, diff --git a/gel/protocol/codecs/object.pyx b/gel/protocol/codecs/object.pyx index 4deaf90e..b65146fc 100644 --- a/gel/protocol/codecs/object.pyx +++ b/gel/protocol/codecs/object.pyx @@ -387,33 +387,42 @@ cdef class ObjectCodec(BaseNamedRecordCodec): if canonical: tname_map[sname] = ch + # Iterate over the components of the return type + # (just the return type, or the individual parts of a type expression) + # to get the codecs for pointers. + # + # Type expression models do not generate their own versions of + # __gel_pointers__. + # + # See: + # -_gel._internal._qbmodel._abstract._methods.create_intersection if expr_object_types is not None: - worklist = list(expr_object_types) + return_type_components = list(expr_object_types) else: - worklist = [return_type] + return_type_components = [return_type] subs = [] dlists = [] origins = [] - for workitem in worklist: - ptrtypes = workitem.__gel_pointers__() + for component in return_type_components: + ptrtypes = component.__gel_pointers__() for i, name in enumerate(names): if flags[i] & datatypes._EDGE_POINTER_IS_LINKPROP: subs.append(None) dlists.append(None) - origins.append(workitem) + origins.append(component) elif name == "__tname__": subs.append(None) dlists.append(None) self.cached_tname_index = i - origins.append(workitem) + origins.append(component) elif name in {"__tid__", "id"}: subs.append(None) dlists.append(None) - origins.append(workitem) + origins.append(component) else: - origin = workitem + origin = component if isinstance(self.source_types[i], ObjectTypeNullCodec): tname = self.source_types[i].get_tname() try: From 7ecdd9e1fce63fff714951582d278eba1fb763c5 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Mon, 20 Oct 2025 16:27:03 -0700 Subject: [PATCH 12/13] Rename to is_. --- gel/_internal/_qbmodel/_abstract/_methods.py | 6 +- gel/_internal/_qbmodel/_pydantic/_models.py | 4 +- tests/test_qb.py | 76 ++++++++++---------- 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index e773f8b9..d8f8d352 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -85,7 +85,7 @@ def limit(cls, /, expr: Any) -> type[Self]: ... def offset(cls, /, expr: Any) -> type[Self]: ... @classmethod - def when_type( + def is_( cls: _T_SelfModel, /, other_model: _T_OtherModel ) -> type[BaseGelModelIntersection[_T_SelfModel, _T_OtherModel]]: ... @@ -206,7 +206,7 @@ def offset( @classonlymethod @_qb.exprmethod @classmethod - def when_type( + def is_( cls: _T_SelfModel, /, value: _T_OtherModel, @@ -352,7 +352,7 @@ def object( # These are used to generate the appropriate path prefix when getting # pointers in shapes. # - # For example, doing `Foo.select(foo=lambda x: x.when_type(Bar).bar)` + # For example, doing `Foo.select(foo=lambda x: x.is_(Bar).bar)` # will produce the query: # select Foo { [is Bar].bar } lhs_prefix = _qb.PathTypeIntersectionPrefix( diff --git a/gel/_internal/_qbmodel/_pydantic/_models.py b/gel/_internal/_qbmodel/_pydantic/_models.py index 01b6d537..52bf8a56 100644 --- a/gel/_internal/_qbmodel/_pydantic/_models.py +++ b/gel/_internal/_qbmodel/_pydantic/_models.py @@ -1327,9 +1327,9 @@ def model_copy( return copied if TYPE_CHECKING: - # Pretend that when_type returns a proper GelModel + # Pretend that is_ returns a proper GelModel @classmethod - def when_type( + def is_( cls: _T_SelfModel, /, other_model: _T_OtherModel ) -> type[GelModelIntersection[_T_SelfModel, _T_OtherModel]]: ... diff --git a/tests/test_qb.py b/tests/test_qb.py index 2c7ff208..0fd4fd2a 100644 --- a/tests/test_qb.py +++ b/tests/test_qb.py @@ -1480,11 +1480,11 @@ def test_qb_cast_range_01(self): ) self.assertEqual(result, _range.Range(std.int64(1), std.int64(9))) - def test_qb_when_type_basic_01(self): + def test_qb_is_type_basic_01(self): # Simple TypeIntersection from models.orm_qb import default - result = self.client.query(default.Inh_A.when_type(default.Inh_B)) + result = self.client.query(default.Inh_A.is_(default.Inh_B)) self._assertObjectsWithFields( result, @@ -1515,12 +1515,12 @@ def test_qb_when_type_basic_01(self): excluded_fields={'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_when_type_basic_02(self): + def test_qb_is_type_basic_02(self): # Chained TypeIntersection from models.orm_qb import default result = self.client.query( - default.Inh_A.when_type(default.Inh_B).when_type(default.Inh_C) + default.Inh_A.is_(default.Inh_B).is_(default.Inh_C) ) self._assertObjectsWithFields( @@ -1547,12 +1547,12 @@ def test_qb_when_type_basic_02(self): excluded_fields={'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_when_type_basic_03(self): + def test_qb_is_type_basic_03(self): # TypeIntersection Select from models.orm_qb import default result = self.client.query( - default.Inh_A.when_type(default.Inh_B).select(a=True) + default.Inh_A.is_(default.Inh_B).select(a=True) ) self._assertObjectsWithFields( @@ -1582,13 +1582,13 @@ def test_qb_when_type_basic_03(self): ) @tb.skip_typecheck - def test_qb_when_type_basic_04(self): + def test_qb_is_type_basic_04(self): # Model Select # with computed single prop using type intersection from models.orm_qb import default result = self.client.query( - default.Inh_AB.select(a=lambda x: x.when_type(default.Inh_C).c) + default.Inh_AB.select(a=lambda x: x.is_(default.Inh_C).c) ) self._assertObjectsWithFields( @@ -1611,14 +1611,14 @@ def test_qb_when_type_basic_04(self): excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_when_type_basic_05(self): + def test_qb_is_type_basic_05(self): # TypeIntersection Select # with computed single prop using type intersection from models.orm_qb import default result = self.client.query( - default.Inh_A.when_type(default.Inh_B).select( - ab=lambda x: x.when_type(default.Inh_AB).ab + default.Inh_A.is_(default.Inh_B).select( + ab=lambda x: x.is_(default.Inh_AB).ab ) ) @@ -1648,17 +1648,17 @@ def test_qb_when_type_basic_05(self): excluded_fields={'a', 'b', 'c', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_when_type_basic_06(self): + def test_qb_is_type_basic_06(self): # TypeIntersection Select # with computed multi prop using type intersection from models.orm_qb import default, std result = self.client.query( - default.Inh_A.when_type(default.Inh_B).select( + default.Inh_A.is_(default.Inh_B).select( a=True, abc=lambda x: std.union( - x.when_type(default.Inh_AB).ab, - x.when_type(default.Inh_AC).ac, + x.is_(default.Inh_AB).ab, + x.is_(default.Inh_AC).ac, ), ) ) @@ -1692,12 +1692,12 @@ def test_qb_when_type_basic_06(self): excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'ab_ac'}, ) - def test_qb_when_type_basic_07(self): + def test_qb_is_type_basic_07(self): # Link TypeIntersection from models.orm_qb import default result = self.client.query( - default.Link_Inh_A.l.when_type(default.Inh_B) + default.Link_Inh_A.l.is_(default.Inh_B) ) self._assertObjectsWithFields( @@ -1729,15 +1729,15 @@ def test_qb_when_type_basic_07(self): excluded_fields={'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_when_type_basic_08(self): + def test_qb_is_type_basic_08(self): # Link TypeIntersection Select # with computed single prop using type intersection from models.orm_qb import default result = self.client.query( - default.Link_Inh_A.l.when_type(default.Inh_B).select( + default.Link_Inh_A.l.is_(default.Inh_B).select( a=True, - ab=lambda x: x.when_type(default.Inh_AB).ab, + ab=lambda x: x.is_(default.Inh_AB).ab, ) ) @@ -1770,7 +1770,7 @@ def test_qb_when_type_basic_08(self): excluded_fields={'b', 'c', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_when_type_basic_09(self): + def test_qb_is_type_basic_09(self): # Model Select # with computed single link using type intersection from models.orm_qb import default @@ -1780,7 +1780,7 @@ def test_qb_when_type_basic_09(self): result = self.client.query( default.Link_Inh_A.select( - n=True, l=lambda x: x.l.when_type(default.Inh_B) + n=True, l=lambda x: x.l.is_(default.Inh_B) ) ) @@ -1832,7 +1832,7 @@ def test_qb_when_type_basic_09(self): r.l, {'a', 'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'} ) - def test_qb_when_type_basic_10(self): + def test_qb_is_type_basic_10(self): # Model Select # with computed single link using type intersection # with select @@ -1843,7 +1843,7 @@ def test_qb_when_type_basic_10(self): result = self.client.query( default.Link_Inh_A.select( - n=True, l=lambda x: x.l.when_type(default.Inh_B).select(a=True) + n=True, l=lambda x: x.l.is_(default.Inh_B).select(a=True) ) ) @@ -1896,13 +1896,13 @@ def test_qb_when_type_basic_10(self): r.l, {'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'} ) - def test_qb_when_type_for_01(self): + def test_qb_is_type_for_01(self): # TypeIntersection in iterator from models.orm_qb import default, std result = self.client.query( std.for_( - default.Inh_A.when_type(default.Inh_B), lambda x: x + default.Inh_A.is_(default.Inh_B), lambda x: x ).select(a=True) ) @@ -1937,14 +1937,14 @@ def test_qb_when_type_for_01(self): https://github.com/geldata/gel/issues/9092 ''' ) - def test_qb_when_type_for_02(self): + def test_qb_is_type_for_02(self): # TypeIntersection in body from models.orm_qb import default, std result = self.client.query( std.for_( default.Inh_A, - lambda x: x.when_type(default.Inh_B).select(a=True), + lambda x: x.is_(default.Inh_B).select(a=True), ) ) @@ -1979,13 +1979,13 @@ def test_qb_when_type_for_02(self): https://github.com/geldata/gel/issues/9092 ''' ) - def test_qb_when_type_for_03(self): + def test_qb_is_type_for_03(self): # TypeIntersection on entire statement from models.orm_qb import default, std result = self.client.query( std.for_(default.Inh_A, lambda x: x) - .when_type(default.Inh_B) + .is_(default.Inh_B) .select(a=True) ) @@ -2212,12 +2212,12 @@ def test_qb_enum_edit_02(self): self.assertEqual(e.color, default.Color.Violet) self.assertEqual(e.name, "red") - def test_qb_update_when_type_01(self): + def test_qb_update_is_type_01(self): # Type Intersection Update from models.orm_qb import default result = self.client.query( - default.Inh_A.when_type(default.Inh_B) + default.Inh_A.is_(default.Inh_B) .update(a=lambda x: x.a + 1000) .select(a=True) ) @@ -2292,13 +2292,13 @@ def test_qb_update_when_type_01(self): excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_update_when_type_02(self): + def test_qb_update_is_type_02(self): # Update Type Intersection from models.orm_qb import default result = self.client.query( default.Inh_A.update(a=lambda x: x.a + 1000) - .when_type(default.Inh_B) + .is_(default.Inh_B) .select(a=True) ) self._assertObjectsWithFields( @@ -2372,12 +2372,12 @@ def test_qb_update_when_type_02(self): excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_delete_when_type_01(self): + def test_qb_delete_is_type_01(self): # Type Intersection Delete from models.orm_qb import default result = self.client.query( - default.Inh_A.when_type(default.Inh_B).delete().select(a=True) + default.Inh_A.is_(default.Inh_B).delete().select(a=True) ) self._assertObjectsWithFields( result, @@ -2432,12 +2432,12 @@ def test_qb_delete_when_type_01(self): excluded_fields={'b', 'c', 'ab', 'ac', 'bc', 'abc', 'ab_ac'}, ) - def test_qb_delete_when_type_02(self): + def test_qb_delete_is_type_02(self): # Delete Type Intersection from models.orm_qb import default result = self.client.query( - default.Inh_A.delete().when_type(default.Inh_B).select(a=True) + default.Inh_A.delete().is_(default.Inh_B).select(a=True) ) self._assertObjectsWithFields( result, From 0c63af8501bff58ecc272e1cf57316dca48d4735 Mon Sep 17 00:00:00 2001 From: dnwpark Date: Mon, 20 Oct 2025 17:08:27 -0700 Subject: [PATCH 13/13] Cache create_intersection results. --- gel/_internal/_qbmodel/_abstract/_methods.py | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/gel/_internal/_qbmodel/_abstract/_methods.py b/gel/_internal/_qbmodel/_abstract/_methods.py index d8f8d352..2ead6ec2 100644 --- a/gel/_internal/_qbmodel/_abstract/_methods.py +++ b/gel/_internal/_qbmodel/_abstract/_methods.py @@ -14,7 +14,7 @@ TypeVar, ) from typing_extensions import Self - +import weakref from gel._internal import _qb from gel._internal._schemapath import ( @@ -300,13 +300,30 @@ def combine_dicts( return result -# TODO: We should cache the results of this +_type_intersection_cache: weakref.WeakKeyDictionary[ + type[AbstractGelModel], + weakref.WeakKeyDictionary[ + type[AbstractGelModel], + type[ + BaseGelModelIntersection[ + type[AbstractGelModel], type[AbstractGelModel] + ] + ], + ], +] = weakref.WeakKeyDictionary() + + def create_intersection( lhs: _T_Lhs, rhs: _T_Rhs, ) -> type[BaseGelModelIntersection[_T_Lhs, _T_Rhs]]: """Create a runtime intersection type which acts like a GelModel.""" + if (lhs_entry := _type_intersection_cache.get(lhs)) and ( + rhs_entry := lhs_entry.get(rhs) + ): + return rhs_entry # type: ignore[return-value] + # Combine pointer reflections from args ptr_reflections: dict[str, _qb.GelPointerReflection] = combine_dicts( lhs.__gel_reflection__.pointers, @@ -403,4 +420,8 @@ def process_path_alias( for p_name, path_alias in path_aliases.items(): setattr(result, p_name, path_alias) + if lhs not in _type_intersection_cache: + _type_intersection_cache[lhs] = weakref.WeakKeyDictionary() + _type_intersection_cache[lhs][rhs] = result + return result