Repository: geldata/gel
Pull request: Prevent illegal UPDATEs to covariantly overloaded link types
Insert dynamic type checks in situations where it can occur.

One slightly fiddly part is making sure that we can extract the
`__type__` from the places we need it (since doing this check can
require `__type__` in places where it otherwise wouldn't appear anywhere
in the AST). This is solved with a bit of a hack: UpdateStmt is
augmented with a PointerRef to `BaseObject.__type__`, from which we can
extract whatever we need.

Also fixes creation of covariantly overloaded multi links.

Fixes #2324.

Problem statement:
Prevent illegal UPDATEs to covariantly overloaded link types
If we have a schema like
```
type Tgt;
type SubTgt extending Tgt {
    required property foo -> str;
};

type Foo {
    property name -> str;
    required link x -> Tgt;
};
type Bar extending Foo {
    overloaded required link x -> SubTgt;
};
```

we can do `UPDATE Foo FILTER .name = "whatever" SET { x := (INSERT Tgt) }`
and we will wind up in an illegal state.

This is because we allow covariant overloading of pointer types but that is unsound since they are mutable.

Options:
1. Statically disallow UPDATEs that SET pointers that have their types overridden in subclasses.
2. Disallow these illegal UPDATEs with run-time checks.

My gut reaction is prefer static checks, and I think most cases wouldn't be inconvenienced. But @elprans argued for dynamic checks, suggesting the case of an API endpoint where "you only get an id of an object to update and an id of a new link target", and I find this reasonably compelling.

There will be a runtime cost, but it shouldn't be that bad, and we only need to pay the cost in situations that would be prohibited if we went the static route.

Hints from issue discussion:

Commit message: Prevent illegal UPDATEs to covariantly overloaded link types

Insert dynamic type checks in situations where it can occur.

One slightly fiddly part is making sure that we can extract the
__type__ from the places we need it (since doing this check can
require __type__ in places where it otherwise wouldn't appear anywhere
in the AST). This is solved with a bit of a hack: UpdateStmt is
augmented with a PointerRef to BaseObject.__type__, from which we can
extract whatever we need.

Also fixes creation of covariantly overloaded multi links.

Fixes #2324.

Patch to review:
diff --git a/edb/edgeql/compiler/pathctx.py b/edb/edgeql/compiler/pathctx.py
index 6ff109f90d5..eacdf1a03b0 100644
--- a/edb/edgeql/compiler/pathctx.py
+++ b/edb/edgeql/compiler/pathctx.py
@@ -75,7 +75,7 @@ def get_tuple_indirection_path_id(
         # typeref_cache=ctx.env.type_ref_cache,
     )
 
-    return tuple_path_id.extend(schema=ctx.env.schema, ptrref=ptrref)
+    return tuple_path_id.extend(ptrref=ptrref)
 
 
 def get_expression_path_id(
@@ -160,8 +160,7 @@ def extend_path_id(
         typeref_cache=ctx.env.type_ref_cache,
     )
 
-    return path_id.extend(ptrref=ptrref, direction=direction,
-                          ns=ns, schema=ctx.env.schema)
+    return path_id.extend(ptrref=ptrref, direction=direction, ns=ns)
 
 
 def ban_path(
diff --git a/edb/edgeql/compiler/setgen.py b/edb/edgeql/compiler/setgen.py
index 4ff9fc7da24..33bcb595a3f 100644
--- a/edb/edgeql/compiler/setgen.py
+++ b/edb/edgeql/compiler/setgen.py
@@ -881,8 +881,7 @@ def type_intersection_set(
         typeref_cache=ctx.env.type_ref_cache,
     )
 
-    poly_set.path_id = source_set.path_id.extend(
-        schema=ctx.env.schema, ptrref=ptrref,)
+    poly_set.path_id = source_set.path_id.extend(ptrref=ptrref)
 
     ptr = irast.TypeIntersectionPointer(
         source=source_set,
diff --git a/edb/edgeql/compiler/stmt.py b/edb/edgeql/compiler/stmt.py
index b8215ead386..b35beb34795 100644
--- a/edb/edgeql/compiler/stmt.py
+++ b/edb/edgeql/compiler/stmt.py
@@ -606,6 +606,16 @@ def compile_InsertQuery(
     return result
 
 
+def _get_dunder_type_ptrref(ctx: context.ContextLevel) -> irast.PointerRef:
+    return typeutils.lookup_obj_ptrref(
+        ctx.env.schema,
+        s_name.QualName('std', 'BaseObject'),
+        s_name.UnqualName('__type__'),
+        cache=ctx.env.ptr_ref_cache,
+        typeref_cache=ctx.env.type_ref_cache,
+    )
+
+
 @dispatch.compile.register(qlast.UpdateQuery)
 def compile_UpdateQuery(
         expr: qlast.UpdateQuery, *, ctx: context.ContextLevel) -> irast.Set:
@@ -623,6 +633,8 @@ def compile_UpdateQuery(
         stmt = irast.UpdateStmt(context=expr.context)
         init_stmt(stmt, expr, ctx=ictx, parent_ctx=ctx)
 
+        stmt.dunder_type_ptrref = _get_dunder_type_ptrref(ctx)
+
         subject = dispatch.compile(expr.subject, ctx=ictx)
         assert isinstance(subject, irast.Set)
 
diff --git a/edb/ir/ast.py b/edb/ir/ast.py
index cdfb5bb9e81..a3609c553f6 100644
--- a/edb/ir/ast.py
+++ b/edb/ir/ast.py
@@ -772,7 +772,12 @@ class InsertStmt(MutatingStmt):
 
 
 class UpdateStmt(MutatingStmt, FilteredStmt):
-    pass
+    # The pgsql DML compilation needs to be able to access __type__
+    # fields on link fields for doing covariant assignment checking.
+    # To enable this, we just make sure that update has access to
+    # BaseObject's __type__, from which we can derive whatever we need.
+    # This is at least a bit of a hack.
+    dunder_type_ptrref: BasePointerRef
 
 
 class DeleteStmt(MutatingStmt, FilteredStmt):
diff --git a/edb/ir/pathid.py b/edb/ir/pathid.py
index 46b5b341499..df32346ffcd 100644
--- a/edb/ir/pathid.py
+++ b/edb/ir/pathid.py
@@ -210,7 +210,7 @@ def from_pointer(
             raise AssertionError(f'unexpected pointer source: {source!r}')
 
         ptrref = typeutils.ptrref_from_ptrcls(schema=schema, ptrcls=pointer)
-        return prefix.extend(ptrref=ptrref, schema=schema)
+        return prefix.extend(ptrref=ptrref)
 
     @classmethod
     def from_typeref(
@@ -286,7 +286,6 @@ def extend(
         direction: s_pointers.PointerDirection = (
             s_pointers.PointerDirection.Outbound),
         ns: AbstractSet[AnyNamespace] = frozenset(),
-        schema: s_schema.Schema,
     ) -> PathId:
         """Return a new ``PathId`` that is a *path step* from this ``PathId``.
 
diff --git a/edb/ir/typeutils.py b/edb/ir/typeutils.py
index 22bcb0e2973..ab3efb429d8 100644
--- a/edb/ir/typeutils.py
+++ b/edb/ir/typeutils.py
@@ -31,6 +31,7 @@
 from edb.schema import pseudo as s_pseudo
 from edb.schema import scalars as s_scalars
 from edb.schema import types as s_types
+from edb.schema import objtypes as s_objtypes
 from edb.schema import utils as s_utils
 
 from . import ast as irast
@@ -912,3 +913,29 @@ def get_typeref_descendants(typeref: irast.TypeRef) -> List[irast.TypeRef]:
             result.extend(get_typeref_descendants(child))
 
     return result
+
+
+def maybe_lookup_obj_pointer(
+    schema: s_schema.Schema,
+    name: s_name.QualName,
+    ptr_name: s_name.UnqualName,
+) -> Optional[s_pointers.Pointer]:
+    base_object = schema.get(name, type=s_objtypes.ObjectType, default=None)
+    if not base_object:
+        return None
+    ptr = base_object.maybe_get_ptr(schema, ptr_name)
+    return ptr
+
+
+def lookup_obj_ptrref(
+    schema: s_schema.Schema,
+    name: s_name.QualName,
+    ptr_name: s_name.UnqualName,
+    cache: Optional[Dict[PtrRefCacheKey, irast.BasePointerRef]] = None,
+    typeref_cache: Optional[Dict[TypeRefCacheKey, irast.TypeRef]] = None,
+) -> irast.PointerRef:
+    ptr = maybe_lookup_obj_pointer(schema, name, ptr_name)
+    assert ptr
+    return ptrref_from_ptrcls(
+        ptrcls=ptr, schema=schema, cache=cache, typeref_cache=typeref_cache,
+    )
diff --git a/edb/pgsql/compiler/dml.py b/edb/pgsql/compiler/dml.py
index 9dc19b7e52f..1b11a266b2b 100644
--- a/edb/pgsql/compiler/dml.py
+++ b/edb/pgsql/compiler/dml.py
@@ -917,7 +917,7 @@ def compile_insert_shape_element(
 
 def process_update_body(
     *,
-    ir_stmt: irast.MutatingStmt,
+    ir_stmt: irast.UpdateStmt,
     update_cte: pgast.CommonTableExpr,
     dml_parts: DMLParts,
     typeref: irast.TypeRef,
@@ -1000,6 +1000,19 @@ def process_update_body(
                         else:
                             val = dispatch.compile(updvalue, ctx=scopectx)
 
+                        assert isinstance(updvalue, irast.Stmt)
+                        val = check_update_type(
+                            val,
+                            val,
+                            is_subquery=True,
+                            ir_stmt=ir_stmt,
+                            ir_set=updvalue.result,
+                            subject_typeref=typeref,
+                            shape_ptrref=ptrref,
+                            actual_ptrref=actual_ptrref,
+                            ctx=scopectx,
+                        )
+
                         val = pgast.TypeCast(
                             arg=val,
                             type_name=pgast.TypeName(name=ptr_info.column_type)
@@ -1068,6 +1081,104 @@ def process_update_body(
             dml_parts.check_ctes.append(check_cte)
 
 
+def check_update_type(
+    val: pgast.BaseExpr,
+    rel_or_rvar: Union[pgast.BaseExpr, pgast.PathRangeVar],
+    *,
+    is_subquery: bool,
+    ir_stmt: irast.UpdateStmt,
+    ir_set: irast.Set,
+    subject_typeref: irast.TypeRef,
+    shape_ptrref: irast.BasePointerRef,
+    actual_ptrref: irast.BasePointerRef,
+    ctx: context.CompilerContextLevel,
+) -> pgast.BaseExpr:
+    """Possibly insert a type check on an UPDATE to a link
+
+    Because edgedb allows subtypes to covariantly override the target
+    types of links, we need to insert runtime type checks when
+    the target in a base type being UPDATEd does not match the
+    target type for this concrete subtype being handled.
+    """
+
+    base_ptrref = irtyputils.find_actual_ptrref(
+        ir_stmt.subject.typeref, shape_ptrref)
+    # We skip the check if either the base type matches exactly
+    # or the shape type matches exactly. FIXME: *Really* we want to do
+    # a subtype check, here, though, since this could do a needless
+    # check if we have multiple levels of overloading, but we don't
+    # have the infrastructure here.
+    if (
+        not irtyputils.is_object(ir_set.typeref)
+        or base_ptrref.out_target == actual_ptrref.out_target
+        or shape_ptrref.out_target == actual_ptrref.out_target
+    ):
+        return val
+
+    if isinstance(rel_or_rvar, pgast.PathRangeVar):
+        rvar = rel_or_rvar
+    else:
+        assert isinstance(rel_or_rvar, pgast.BaseRelation)
+        rvar = relctx.rvar_for_rel(rel_or_rvar, ctx=ctx)
+
+    # Find the ptrref for the __type__ link on our actual target type
+    # and make up a new path_id to access it
+    assert isinstance(actual_ptrref, irast.PointerRef)
+    actual_type_ptrref = irtyputils.find_actual_ptrref(
+        actual_ptrref.out_target, ir_stmt.dunder_type_ptrref)
+    type_pathid = ir_set.path_id.extend(ptrref=actual_type_ptrref)
+
+    # Grab the actual value we have inserted and pull the __type__ out
+    rval = pathctx.get_rvar_path_identity_var(
+        rvar, ir_set.path_id, env=ctx.env)
+    typ = pathctx.get_rvar_path_identity_var(rvar, type_pathid, env=ctx.env)
+
+    typeref_val = dispatch.compile(actual_ptrref.out_target, ctx=ctx)
+
+    # Do the check! Include the ptrref for this concrete class and
+    # also the (dynamic) type of the argument, so that we can produce
+    # a good error message.
+    check_result = pgast.FuncCall(
+        name=('edgedb', 'issubclass'),
+        args=[typ, typeref_val],
+    )
+    maybe_null = pgast.CaseExpr(
+        args=[pgast.CaseWhen(expr=check_result, result=rval)])
+    maybe_raise = pgast.FuncCall(
+        name=('edgedb', 'raise_on_null'),
+        args=[
+            maybe_null,
+            pgast.StringConstant(val='wrong_object_type'),
+            pgast.NamedFuncArg(
+                name='msg',
+                val=pgast.StringConstant(val='covariance error')
+            ),
+            pgast.NamedFuncArg(
+                name='column',
+                val=pgast.StringConstant(val=str(actual_ptrref.id)),
+            ),
+            pgast.NamedFuncArg(
+                name='table',
+                val=pgast.TypeCast(
+                    arg=typ, type_name=pgast.TypeName(name=('text',))
+                ),
+            ),
+        ],
+    )
+
+    if is_subquery:
+        # If this is supposed to be a subquery (because it is an
+        # update of a single link), wrap the result query in a new one,
+        # since we need to access two outputs from it and produce just one
+        # from this query
+        return pgast.SelectStmt(
+            from_clause=[rvar],
+            target_list=[pgast.ResTarget(val=maybe_raise)],
+        )
+    else:
+        return maybe_raise
+
+
 def process_link_update(
     *,
     ir_stmt: irast.MutatingStmt,
@@ -1611,6 +1722,20 @@ def process_link_values(
 
         source_data['target'] = target_ref
 
+    if isinstance(ir_stmt, irast.UpdateStmt) and not target_is_scalar:
+        actual_ptrref = irtyputils.find_actual_ptrref(source_typeref, ptrref)
+        source_data['target'] = check_update_type(
+            source_data['target'],
+            input_rvar,
+            is_subquery=False,
+            ir_stmt=ir_stmt,
+            ir_set=ir_expr,
+            subject_typeref=source_typeref,
+            shape_ptrref=ptrref,
+            actual_ptrref=actual_ptrref,
+            ctx=ctx,
+        )
+
     if ptr_is_required and enforce_cardinality:
         source_data['target'] = pgast.FuncCall(
             name=('edgedb', 'raise_on_null'),
diff --git a/edb/pgsql/delta.py b/edb/pgsql/delta.py
index ff58007bc96..e2d5b752738 100644
--- a/edb/pgsql/delta.py
+++ b/edb/pgsql/delta.py
@@ -2894,18 +2894,18 @@ def _alter_pointer_type(self, pointer, schema, orig_schema, context):
         is_required = pointer.get_required(schema)
         changing_col_type = not is_link
 
+        source_ctx = self.get_referrer_context_or_die(context)
         if is_multi:
             if isinstance(self, sd.AlterObjectFragment):
                 source_op = self.get_parent_op(context)
             else:
                 source_op = self
         else:
-            source_ctx = self.get_referrer_context_or_die(context)
             source_op = source_ctx.op
 
         # Ignore type narrowing resulting from a creation of a subtype
         # as there isn't any data in the link yet.
-        if is_link and isinstance(source_op, sd.CreateObject):
+        if is_link and isinstance(source_ctx.op, sd.CreateObject):
             return
 
         new_target = pointer.get_target(schema)
diff --git a/edb/server/compiler/errormech.py b/edb/server/compiler/errormech.py
index 5a8fb355c96..ec50eb6ca06 100644
--- a/edb/server/compiler/errormech.py
+++ b/edb/server/compiler/errormech.py
@@ -315,6 +315,9 @@ def static_interpret_backend_error(fields):
         )
 
     elif err_details.code == PGErrorCode.WrongObjectType:
+        if err_details.column_name:
+            return SchemaRequired
+
         return errors.InvalidValueError(
             err_details.message,
             details=err_details.detail if err_details.detail else None
@@ -446,4 +449,17 @@ def interpret_backend_error(schema, fields):
             translate_pgtype(schema, err_details.message),
             hint=hint)
 
+    elif (
+        err_details.code == PGErrorCode.WrongObjectType
+        and err_details.message == 'covariance error'
+    ):
+        ptr = schema.get_by_id(uuidgen.UUID(err_details.column_name))
+        wrong_obj = schema.get_by_id(uuidgen.UUID(err_details.table_name))
+
+        vn = ptr.get_verbosename(schema, with_parent=True)
+        return errors.InvalidLinkTargetError(
+            f"invalid target for {vn}: '{wrong_obj.get_name(schema)}'"
+            f" (expecting '{ptr.get_target(schema).get_name(schema)}')"
+        )
+
     return errors.InternalServerError(err_details.message)
diff --git a/tests/schemas/updates.edgeql b/tests/schemas/updates.edgeql
index f2156ca43c2..2942b5ff0e1 100644
--- a/tests/schemas/updates.edgeql
+++ b/tests/schemas/updates.edgeql
@@ -25,6 +25,14 @@ INSERT test::Status {
     name := 'Closed'
 };
 
+INSERT test::MajorLifeEvent {
+    name := 'Broke a Type System'
+};
+
+INSERT test::MajorLifeEvent {
+    name := 'Downloaded a Car'
+};
+
 INSERT test::Tag {
     name := 'fun'
 };
diff --git a/tests/schemas/updates.esdl b/tests/schemas/updates.esdl
index 8f87936670b..864b274aef5 100644
--- a/tests/schemas/updates.esdl
+++ b/tests/schemas/updates.esdl
@@ -22,6 +22,7 @@ type Status {
         constraint exclusive;
     }
 }
+type MajorLifeEvent extending Status;
 
 type Tag {
     required property name -> str {
@@ -50,6 +51,7 @@ type UpdateTest {
             readonly := true;
         }
     }
+    multi link statuses -> Status;
 
     # for testing links to sets of the same type as originator
     multi link related -> UpdateTest;
@@ -70,7 +72,10 @@ type UpdateTest {
 
 type UpdateTestSubType extending UpdateTest;
 
-type UpdateTestSubSubType extending UpdateTestSubType;
+type UpdateTestSubSubType extending UpdateTestSubType {
+    overloaded link status -> MajorLifeEvent;
+    overloaded multi link statuses -> MajorLifeEvent;
+};
 
 type CollectionTest {
     required property name -> str;
diff --git a/tests/test_edgeql_update.py b/tests/test_edgeql_update.py
index ba06bb325d5..0ae3d05252e 100644
--- a/tests/test_edgeql_update.py
+++ b/tests/test_edgeql_update.py
@@ -2977,3 +2977,157 @@ async def test_edgeql_update_add_dupes_04(self):
                 ],
             }]
         )
+
+    async def test_edgeql_update_covariant_01(self):
+        await self.con.execute("""
+            WITH MODULE test
+            INSERT UpdateTestSubSubType {
+                name := 'update-covariant',
+            };
+        """)
+
+        # Covariant updates should work if the types actually work at
+        # runtime
+        await self.con.execute("""
+            WITH MODULE test
+            UPDATE UpdateTestSubType
+            FILTER .name = "update-covariant"
+            SET {
+                status := (SELECT Status FILTER .name = "Broke a Type System")
+            }
+        """)
+
+        # But fail if they don't
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidLinkTargetError,
+                r"invalid target for link 'status' of object type "
+                r"'test::UpdateTestSubSubType': 'test::Status' "
+                r"\(expecting 'test::MajorLifeEvent'\)"):
+            await self.con.execute("""
+                WITH MODULE test
+                UPDATE UpdateTestSubType
+                FILTER .name = "update-covariant"
+                SET {
+                    status := (SELECT Status FILTER .name = "Open")
+                }
+            """)
+
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidLinkTargetError,
+                r"invalid target for link 'status' of object type "
+                r"'test::UpdateTestSubSubType': 'test::Status' "
+                r"\(expecting 'test::MajorLifeEvent'\)"):
+            await self.con.execute("""
+                WITH MODULE test
+                UPDATE UpdateTestSubType
+                FILTER .name = "update-covariant"
+                SET {
+                    status := (INSERT Status { name := "Yolo" })
+                }
+            """)
+
+    async def test_edgeql_update_covariant_02(self):
+        await self.con.execute("""
+            WITH MODULE test
+            INSERT UpdateTestSubSubType {
+                name := 'update-covariant',
+            };
+        """)
+
+        # Covariant updates should work if the types actually work at
+        # runtime
+        await self.con.execute("""
+            WITH MODULE test
+            UPDATE UpdateTestSubType
+            FILTER .name = "update-covariant"
+            SET {
+                statuses := (
+                    SELECT Status FILTER .name = "Broke a Type System")
+            }
+        """)
+
+        # But fail if they don't
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidLinkTargetError,
+                r"invalid target for link 'statuses' of object type "
+                r"'test::UpdateTestSubSubType': 'test::Status' "
+                r"\(expecting 'test::MajorLifeEvent'\)"):
+            await self.con.execute("""
+                WITH MODULE test
+                UPDATE UpdateTestSubType
+                FILTER .name = "update-covariant"
+                SET {
+                    statuses := (SELECT Status FILTER .name = "Open")
+                }
+            """)
+
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidLinkTargetError,
+                r"invalid target for link 'statuses' of object type "
+                r"'test::UpdateTestSubSubType': 'test::Status' "
+                r"\(expecting 'test::MajorLifeEvent'\)"):
+            await self.con.execute("""
+                WITH MODULE test
+                UPDATE UpdateTestSubType
+                FILTER .name = "update-covariant"
+                SET {
+                    statuses := (INSERT Status { name := "Yolo" })
+                }
+            """)
+
+    async def test_edgeql_update_covariant_03(self):
+        await self.con.execute("""
+            WITH MODULE test
+            INSERT UpdateTestSubSubType {
+                name := 'update-covariant',
+            };
+        """)
+
+        # Tests with a multi link, actually using multiple things
+
+        # Covariant updates should work if the types actually work at
+        # runtime
+        await self.con.execute("""
+            WITH MODULE test
+            UPDATE UpdateTestSubType
+            FILTER .name = "update-covariant"
+            SET {
+                statuses := (SELECT Status FILTER .name = {
+                                 "Broke a Type System",
+                                 "Downloaded a Car",
+                             })
+            }
+        """)
+
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidLinkTargetError,
+                r"invalid target for link 'statuses' of object type "
+                r"'test::UpdateTestSubSubType': 'test::Status' "
+                r"\(expecting 'test::MajorLifeEvent'\)"):
+            await self.con.execute("""
+                WITH MODULE test
+                UPDATE UpdateTestSubType
+                FILTER .name = "update-covariant"
+                SET {
+                    statuses := (SELECT Status FILTER .name = {
+                                     "Broke a Type System",
+                                     "Downloaded a Car",
+                                     "Open",
+                                 })
+                }
+            """)
+
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidLinkTargetError,
+                r"invalid target for link 'statuses' of object type "
+                r"'test::UpdateTestSubSubType': 'test::Status' "
+                r"\(expecting 'test::MajorLifeEvent'\)"):
+            await self.con.execute("""
+                WITH MODULE test
+                UPDATE UpdateTestSubType
+                FILTER .name = "update-covariant"
+                SET {
+                    statuses := (FOR x in {"Foo", "Bar"} UNION (
+                                     INSERT Status {name := x}))
+                }
+            """)


Code review comments:
diff_hunk: @@ -1068,6 +1081,104 @@ def process_update_body(
             dml_parts.check_ctes.append(check_cte)
 
 
+def check_update_type(
+    val: pgast.BaseExpr,
+    rel_or_rvar: Union[pgast.BaseExpr, pgast.PathRangeVar],
+    *,
+    is_subquery: bool,
+    ir_stmt: irast.UpdateStmt,
+    ir_set: irast.Set,
+    subject_typeref: irast.TypeRef,
+    shape_ptrref: irast.BasePointerRef,
+    actual_ptrref: irast.BasePointerRef,
+    ctx: context.CompilerContextLevel,
+) -> pgast.BaseExpr:
+    """Possibly insert a type check on an UPDATE to a link
+
+    Because edgedb allows subtypes to covariantly override the target
+    types of links, we need to insert runtime type checks when
+    the target in a base type being UPDATEd does not match the
+    target type for this concrete subtype being handled.
+    """
+
+    base_ptrref = irtyputils.find_actual_ptrref(
+        ir_stmt.subject.typeref, shape_ptrref)
+    # We skip the check if either the base type matches exactly
+    # or the shape type matches exactly. FIXME: *Really* we want to do
+    # a subtype check, here, though, since this could do a needless
+    # check if we have multiple levels of overloading, but we don't
+    # have the infrastructure here.
+    if (
+        not irtyputils.is_object(ir_set.typeref)
+        or base_ptrref.out_target == actual_ptrref.out_target
path: edb/pgsql/compiler/dml.py
text: @user1:
Perhaps check `.id` equality instead?  Should be more reliable.