Repository: edgedb/edgedb

Issue:
single links with link properties can get into inconsistent states
single links with link properties get stored twice in the database:
 * Inline as a column in the table
 * In a link table, with the link properties

In updates and inserts, we duplicate the computation for each of those, and it's possible for them to skew:
```
create type N { create required property n -> int64; };
insert N { n := 1 };
insert N { n := 2 };
create type L { create link foo -> N { create property x -> str; } };
```
Then we do some inserts to `L` that pick a random `N` (run this a few times):
```
insert L { foo := (select N { @x := "!" } order by random() limit 1) };
```
And check the results:
```
edgedb> select L { foo: { n, @x } };
[
  {"foo": {"@x": "!", "n": 1}},
  {"foo": {"@x": null, "n": 2}},
  {"foo": {"@x": null, "n": 1}},
  {"foo": {"@x": null, "n": 2}},
  {"foo": {"@x": null, "n": 2}},
  {"foo": {"@x": "!", "n": 1}}
]
```
and we see `@x` is missing in a bunch of places because the link table target doesn't match.

On one hand, this is pretty bad, but on the other hand, using link properties on single links is a pointless exercise anyway.


Discussion:
For future reference, this is SQL generated:
```sql
   WITH
    "t~1" AS NOT MATERIALIZED
        ((
            FROM LATERAL (
                FROM edgedbstd."a508c852-7fcc-11ed-8e02-9913d671e2ac_t" AS "ObjectType~2"
                CROSS JOIN LATERAL (
                    FROM LATERAL (
                        SELECT/*<pg.SelectStmt at 0x7ff9ae5c8690>*/
                            (NOT "ObjectType~2"."a509e038-7fcc-11ed-91bc-a1abfccdd2ab") AS "expr~11_value~1"
                    ) AS "expr-11~2"
                    CROSS JOIN (SELECT (NULL)::uuid AS "v~3") AS "v~2"
                    CROSS JOIN LATERAL (
                        SELECT/*<pg.SelectStmt at 0x7ff9acb1cf50>*/
                            ("ObjectType~2".id IS NOT DISTINCT FROM "v~2"."v~3") AS "expr~12_value~1"
                    ) AS "expr-12~2"
                    SELECT/*<pg.SelectStmt at 0x7ff9ae228450>*/
                        ("expr-11~2"."expr~11_value~1" OR
                          "expr-12~2"."expr~12_value~1") AS "expr~13_value~1"
                ) AS "expr-13~2"
                SELECT/*<pg.SelectStmt at 0x7ff9ad0e6790>*/
                    "ObjectType~2".id AS "ObjectType_value~1",
                    "ObjectType~2"."a50a027e-7fcc-11ed-80b8-f759c77b8314" AS "name_value~1"
                WHERE
                    "expr-13~2"."expr~13_value~1"
            ) AS "expr-14~2"
            SELECT/*<pg.SelectStmt at 0x7ff9ae537a50>*/
                "expr-14~2"."ObjectType_value~1" AS "expr~14_identity~1",
                "expr-14~2"."name_value~1" AS "name_value~2"
        )),
    "ins_contents~1" AS
        ((
            FROM LATERAL (
                FROM LATERAL (
                    FROM LATERAL (
                    (
                        FROM LATERAL (
                            FROM LATERAL (
                                FROM edgedbpub."cb7d084f-7fd1-11ed-8814-0391fc3d49e6_t" AS "N~3"
                                SELECT/*<pg.SelectStmt at 0x7ff9ade7fb50>*/
                                    "N~3".id AS "N_value~1"
                                ORDER BY
                                    (
                                        FROM LATERAL (
                                            FROM LATERAL (
                                                SELECT/*<pg.SelectStmt at 0x7ff9ad7b8410>*/
                                                    random() AS "expr~3_value~1"
                                                WHERE
                                                    ("N~3".id IS NOT NULL)
                                            ) AS "q~1"
                                            SELECT/*<pg.SelectStmt at 0x7ff9ad7ac990>*/
                                                "q~1"."expr~3_value~1" AS "expr~3_value~2"
                                        ) AS "expr-3~2"
                                        SELECT/*<pg.SelectStmt at 0x7ff9add9ab90>*/
                                            "expr-3~2"."expr~3_value~2"
                                    ) ASC NULLS FIRST
                            ) AS "N~4"
                            SELECT/*<pg.SelectStmt at 0x7ff9adda8c50>*/
                                "N~4"."N_value~1" AS "N_value~2"
                            LIMIT (
                                SELECT/*<pg.SelectStmt at 0x7ff9ade7d410>*/
                                    (1)::int8 AS "expr~5_value~1"
                            )
                        ) AS "q~2"
                        SELECT/*<pg.SelectStmt at 0x7ff9adda8650>*/
                            1 AS "m~3",
                            "q~2"."N_value~2" AS "foo_value~1"
                    ) UNION ALL (
                        FROM (SELECT (NULL)::uuid AS "id~1") AS "v~1"
                        SELECT/*<pg.SelectStmt at 0x7ff9adda8b50>*/
                            2 AS "m~3",
                            "v~1"."id~1" AS "foo_value~2"
                    )
                    ) AS "q~3"
                    SELECT/*<pg.SelectStmt at 0x7ff9ae031e10>*/
                        ("m~3" = first_value("m~3") OVER ()) AS "m~3",
                        "q~3"."foo_value~1" AS "foo_value~3"
                ) AS "q~4"
                SELECT/*<pg.SelectStmt at 0x7ff9ae031a10>*/
                    "q~4"."foo_value~3" AS "foo_value~4"
                WHERE
                    "q~4"."m~3"
            ) AS "N_foo~2"
            CROSS JOIN LATERAL (
                edgedbext.uuid_generate_v1mc() AS "expr~9_value~1"
            ) AS "uuid_id~2"
            SELECT/*<pg.SelectStmt at 0x7ff9b17b5710>*/
                ('cc88195e-7fd1-11ed-9696-e7145ca8bdf3')::uuid AS __type__,
                "N_foo~2"."foo_value~4" AS "cc8f493a-7fd1-11ed-85d9-9399d48b44c5",
                "uuid_id~2"."expr~9_value~1" AS id
        )),
    "r~1" AS
        ((
            FROM "ins_contents~1" AS "m~4"
            CROSS JOIN LATERAL (
                FROM LATERAL (
                    FROM LATERAL (
                        FROM edgedbpub."cb7d084f-7fd1-11ed-8814-0391fc3d49e6_t" AS "N~7"
                        SELECT/*<pg.SelectStmt at 0x7ff9acc4ffd0>*/
                            "N~7".id AS "N_value~3",
                            (
                                FROM LATERAL (
                                    SELECT/*<pg.SelectStmt at 0x7ff9b0388a10>*/
                                        ('!')::text AS "expr~1_value~1"
                                ) AS "str_x~2"
                                SELECT/*<pg.SelectStmt at 0x7ff9b109d590>*/
                                    "str_x~2"."expr~1_value~1"
                            ) AS "x_value~2"
                        ORDER BY
                            (
                                FROM LATERAL (
                                    FROM LATERAL (
                                        SELECT/*<pg.SelectStmt at 0x7ff9ae951850>*/
                                            random() AS "expr~3_value~4"
                                        WHERE
                                            ((("m~4".id IS NOT NULL) AND
                                              ("m~4".id IS NOT NULL)) AND
                                              ("N~7".id IS NOT NULL))
                                    ) AS "q~6"
                                    SELECT/*<pg.SelectStmt at 0x7ff9ae7eea90>*/
                                        "q~6"."expr~3_value~4" AS "expr~3_value~5"
                                ) AS "expr-3~4"
                                SELECT/*<pg.SelectStmt at 0x7ff9af18c490>*/
                                    "expr-3~4"."expr~3_value~5"
                            ) ASC NULLS FIRST
                    ) AS "N~8"
                    SELECT/*<pg.SelectStmt at 0x7ff9ae23c990>*/
                        "N~8"."N_value~3" AS "N_value~4",
                        "N~8"."x_value~2" AS "x_value~3"
                    LIMIT (
                        SELECT/*<pg.SelectStmt at 0x7ff9b132dd10>*/
                            (1)::int8 AS "expr~5_value~2"
                    )
                ) AS "N_foo~4"
                SELECT/*<pg.SelectStmt at 0x7ff9ae360050>*/
                    "N_foo~4"."x_value~3" AS "x_value~4",
                    "N_foo~4"."N_value~4" AS "foo_identity~1"
            ) AS "val~1"
            SELECT/*<pg.SelectStmt at 0x7ff9add8a850>*/
                "m~4".id AS source,
                "val~1"."x_value~4" AS "cc8f8b16-7fd1-11ed-bdaf-893e42491054",
                "val~1"."foo_identity~1" AS target
        )),
    "i~1" AS
        (INSERT INTO edgedbpub."cc8f493a-7fd1-11ed-85d9-9399d48b44c5" AS "foo~1"
            (source, "cc8f8b16-7fd1-11ed-bdaf-893e42491054", target)
            ((
                FROM "r~1"
                SELECT/*<pg.SelectStmt at 0x7ff9ae851050>*/
                    "r~1".*
            ))
            RETURNING
                *),
    "ins~1" AS
        (INSERT INTO edgedbpub."cc88195e-7fd1-11ed-9696-e7145ca8bdf3" AS "L~1"
            (__type__, "cc8f493a-7fd1-11ed-85d9-9399d48b44c5", id)
            ((
                FROM "ins_contents~1" AS "ins_contents~2"
                SELECT/*<pg.SelectStmt at 0x7ff9adf5f210>*/
                    __type__,
                    "cc8f493a-7fd1-11ed-85d9-9399d48b44c5",
                    id
            ))
            RETURNING
                "L~1".id AS "L_identity~1",
                "L~1".__type__ AS "__type___identity~1"),
    "m~1" AS
        ((
            FROM "ins~1" AS "ins~2"
            JOIN "ins_contents~1" AS "ins_contents~2"
                ON ("ins~2"."L_identity~1" = "ins_contents~2".id)
            SELECT/*<pg.SelectStmt at 0x7ff9ade892d0>*/
                "ins~2"."L_identity~1" AS "L_value~1",
                "ins~2"."__type___identity~1" AS "__type___identity~2"
        ))
    FROM "m~1" AS "m~2"
    SELECT/*<pg.SelectStmt at 0x7ff9ae567e90>*/
        ((
            FROM LATERAL (
                FROM LATERAL (
                    FROM LATERAL (
                        FROM LATERAL (
                            FROM LATERAL (
                                SELECT/*<pg.SelectStmt at 0x7ff9acb80b90>*/
                                    "m~2"."__type___identity~2" AS "__type___identity~3"
                            ) AS "q~7"
                            JOIN LATERAL (
                                FROM "t~1" AS "t~2"
                                SELECT/*<pg.SelectStmt at 0x7ff9ad0e5790>*/
                                    "t~2"."expr~14_identity~1" AS "expr~14_identity~2",
                                    "t~2"."name_value~2" AS "name_value~3"
                            ) AS "q~8"
                                ON ("q~7"."__type___identity~3" = "q~8"."expr~14_identity~2")
                            SELECT/*<pg.SelectStmt at 0x7ff9acb83cd0>*/
                                "q~8"."name_value~3" AS "name_value~4"
                        ) AS "q~9"
                        SELECT/*<pg.SelectStmt at 0x7ff9ae7a8190>*/
                            "q~9"."name_value~4" AS "name_value~5"
                    ) AS "str_name~2"
                    SELECT/*<pg.SelectStmt at 0x7ff9ad2abf10>*/
                        "str_name~2"."name_value~5" AS "name_value~6"
                    LIMIT (
                        SELECT/*<pg.SelectStmt at 0x7ff9ae021310>*/
                            (101)::int8 AS "expr~15_value~1"
                    )
                ) AS "expr-16~2"
                SELECT/*<pg.SelectStmt at 0x7ff9ae1c1a90>*/
                    "expr-16~2"."name_value~6" AS "expr~16_value~1"
                LIMIT (
                    SELECT/*<pg.SelectStmt at 0x7ff9ad2a9b10>*/
                        (101)::int8 AS "expr~17_value~1"
                )
            ) AS "str___tname__~2"
            SELECT/*<pg.SelectStmt at 0x7ff9ae185810>*/
                "str___tname__~2"."expr~16_value~1" AS "__tname___serialized~1"
        ), "m~2"."L_value~1") AS "L_serialized~1"
```
I had kind of just wanted to just get rid of the inline column storage of single links with link properties, and only store them as link tables to just dodge this whole issue, but that doesn't work, since a link property may be added by a subclass, and the parent type needs to be able to access the property through the view.

Resolving patch:
diff --git a/edb/pgsql/compiler/dml.py b/edb/pgsql/compiler/dml.py
--- a/edb/pgsql/compiler/dml.py
+++ b/edb/pgsql/compiler/dml.py
@@ -690,6 +690,10 @@ def process_insert_body(
         ir_stmt, select, ptr_map, elements, typeref, iterator, inner_iterator,
         ctx
     )
+    single_external = [
+        ir for ir in external_inserts
+        if ir.rptr and ir.rptr.dir_cardinality.is_single()
+    ]
 
     # Put the select that builds the tuples to insert into its own CTE.
     # We do this for two reasons:
@@ -711,7 +715,7 @@ def process_insert_body(
 
     pol_expr = ir_stmt.write_policies.get(typeref.id)
     pol_ctx = None
-    if pol_expr or rewrites:
+    if pol_expr or rewrites or single_external:
         # Create a context for handling policies/rewrites that we will
         # use later. We do this in advance so that the link update code
         # can populate overlay fields in it.
@@ -735,6 +739,10 @@ def process_insert_body(
     # Similar issues obtain with access policies: we can't use ON
     # CONFLICT if there are access policies, since we can't "see" all
     # possible conflicting objects.
+    #
+    # We *also* need link tables to go first if there are any single links
+    # with link properties. We do the actual computation for those in a link
+    # table and then join it in to the main table, where it is duplicated.
     link_ctes = []
 
     def _update_link_tables(inp_cte: pgast.CommonTableExpr) -> None:
@@ -754,13 +762,16 @@ def _update_link_tables(inp_cte: pgast.CommonTableExpr) -> None:
             if check_cte is not None:
                 ctx.env.check_ctes.append(check_cte)
 
+    if not needs_insert_on_conflict:
+        _update_link_tables(contents_cte)
+
     # compile rewrites CTE
-    if rewrites:
+    if rewrites or single_external:
+        rewrites = rewrites or {}
         assert not needs_insert_on_conflict
-        _update_link_tables(contents_cte)
 
         assert pol_ctx
-        with pol_ctx.reenter(), pol_ctx.new() as rctx:
+        with pol_ctx.reenter(), pol_ctx.newrel() as rctx:
             # Pull in ptr rel overlays, so we can see the pointers
             merge_overlays_globally((ir_stmt,), ctx=rctx)
 
@@ -771,14 +782,17 @@ def _update_link_tables(inp_cte: pgast.CommonTableExpr) -> None:
                 iterator=iterator,
                 inner_iterator=inner_iterator,
                 rewrites=rewrites,
+                single_external=single_external,
                 elements=elements,
                 ctx=rctx,
             )
 
     # Populate the real insert statement based on the select we generated
     insert_stmt.cols = [
-        pgast.InsertTarget(name=not_none(value.name))
+        pgast.InsertTarget(name=name)
         for value in contents_cte.query.target_list
+        # Filter out generated columns; only keep concrete ones
+        if '~' not in (name := not_none(value.name))
     ]
     insert_stmt.select_stmt = pgast.SelectStmt(
         target_list=[
@@ -816,7 +830,7 @@ def _update_link_tables(inp_cte: pgast.CommonTableExpr) -> None:
     else:
         link_op_cte = contents_cte
 
-    if not rewrites:
+    if needs_insert_on_conflict:
         _update_link_tables(link_op_cte)
 
     if pol_expr:
@@ -858,6 +872,7 @@ def process_insert_rewrites(
     iterator: Optional[pgast.IteratorCTE],
     inner_iterator: Optional[pgast.IteratorCTE],
     rewrites: irast.RewritesOfType,
+    single_external: List[irast.Set],
     elements: List[Tuple[irast.Set, irast.BasePointerRef]],
     ctx: context.CompilerContextLevel,
 ) -> tuple[pgast.CommonTableExpr, pgast.PathRangeVar]:
@@ -868,7 +883,7 @@ def process_insert_rewrites(
     subject_path_id = ir_stmt.rewrites.subject_path_id
     object_path_id = ir_stmt.subject.path_id
 
-    rew_stmt = pgast.SelectStmt(target_list=[])
+    rew_stmt = ctx.rel
 
     # Use the original contents as the iterator.
     # FIXME: Having both subject_path_id and object_path_id is messy
@@ -892,7 +907,8 @@ def process_insert_rewrites(
     nptr_map: Dict[sn.Name, pgast.BaseExpr] = {}
     process_insert_shape(
         ir_stmt, rew_stmt, nptr_map, rewrite_elements, typeref, iterator,
-        inner_iterator, ctx, force_optional=True
+        inner_iterator, ctx,
+        force_optional=True,
     )
 
     iterator_rvar = pathctx.get_path_rvar(
@@ -905,10 +921,30 @@ def process_insert_rewrites(
     pathctx.put_path_value_rvar(
         rew_stmt, object_path_id, fallback_rvar, env=ctx.env)
 
+    # If there are any single links that were compiled externally,
+    # populate the field from the link overlays.
+    handled = set(rewrites)
+    for ext_ir in single_external:
+        assert ext_ir.rptr
+        handled.add(ext_ir.rptr.ptrref.shortname.name)
+        with ctx.subrel() as ectx:
+            ext_rvar = relctx.new_pointer_rvar(
+                ext_ir.rptr, link_bias=True, src_rvar=iterator_rvar, ctx=ectx)
+            relctx.include_rvar(ectx.rel, ext_rvar, ext_ir.path_id, ctx=ectx)
+            # Make the subquery output the target
+            pathctx.get_path_value_output(
+                ectx.rel, ext_ir.path_id, env=ctx.env)
+
+        ptr_info = pg_types.get_ptrref_storage_info(
+            ext_ir.rptr.ptrref, resolve_type=True, link_bias=False)
+        rew_stmt.target_list.append(pgast.ResTarget(
+            name=ptr_info.column_name, val=ectx.rel))
+        nptr_map[ext_ir.rptr.ptrref.real_material_ptr.name] = ectx.rel
+
     # Pull in pointers that were not rewritten
     not_rewritten = {
         (e, ptrref) for e, ptrref in elements
-        if ptrref.shortname.name not in rewrites
+        if ptrref.shortname.name not in handled
     }
     for e, ptrref in not_rewritten:
         # FIXME: Duplicates some with process_insert_shape
@@ -942,7 +978,7 @@ def process_insert_shape(
     iterator: Optional[pgast.IteratorCTE],
     inner_iterator: Optional[pgast.IteratorCTE],
     ctx: context.CompilerContextLevel,
-    force_optional: bool=False
+    force_optional: bool=False,
 ) -> List[irast.Set]:
     # Compile the shape
     external_inserts = []
@@ -967,9 +1003,14 @@ def process_insert_shape(
 
             ptr_info = pg_types.get_ptrref_storage_info(
                 ptrref, resolve_type=True, link_bias=False)
+            link_ptr_info = pg_types.get_ptrref_storage_info(
+                ptrref, resolve_type=False, link_bias=True)
 
-            # First, process all local link inserts.
-            if ptr_info.table_type == 'ObjectType':
+            # First, process all local link inserts. Single link with
+            # link properties are not processed here; we compile those
+            # in link tables and then select those back into the main
+            # table as a rewrite.
+            if not link_ptr_info and ptr_info.table_type == 'ObjectType':
                 compile_insert_shape_element(
                     element,
                     ir_stmt=ir_stmt,
@@ -994,14 +1035,6 @@ def process_insert_shape(
                 select.target_list.append(pgast.ResTarget(
                     name=ptr_info.column_name, val=insvalue))
 
-            # Register all link table inserts to be run after the main
-            # insert.  Note that single links with link properties are
-            # processed both as a local link insert (for the inline
-            # pointer) and as a link table insert (because lprops are
-            # stored in link tables).
-            link_ptr_info = pg_types.get_ptrref_storage_info(
-                ptrref, resolve_type=False, link_bias=True)
-
             if link_ptr_info and link_ptr_info.table_type == 'link':
                 external_inserts.append(element)
 
@@ -1314,6 +1347,15 @@ def insert_needs_conflict_cte(
         ):
             return True
 
+        # If there are any single links with link properties, we need
+        # a conflict CTE, since the link tables have to go before the
+        # insert.
+        if ptr_info.table_type == 'ObjectType':
+            link_ptr_info = pg_types.get_ptrref_storage_info(
+                ptrref, resolve_type=True, link_bias=True)
+            if link_ptr_info:
+                return True
+
     return False
 
 
@@ -1617,18 +1659,23 @@ def process_update_body(
 
     update_stmt = None
 
+    single_external = [
+        ir for ir, _ in external_updates
+        if ir.rptr and ir.rptr.dir_cardinality.is_single()
+    ]
+
     rewrites = ir_stmt.rewrites and ir_stmt.rewrites.by_type.get(typeref)
 
     pol_expr = ir_stmt.write_policies.get(typeref.id)
     pol_ctx = None
-    if pol_expr or rewrites:
+    if pol_expr or rewrites or single_external:
         # Create a context for handling policies/rewrites that we will
         # use later. We do this in advance so that the link update code
         # can populate overlay fields in it.
         with ctx.new() as pol_ctx:
             pass
 
-    no_update = not values and not rewrites
+    no_update = not values and not rewrites and not single_external
     if no_update:
         # No updates directly to the set target table,
         # so convert the UPDATE statement into a SELECT.
@@ -1670,7 +1717,8 @@ def process_update_body(
         subject_path_id = ir_stmt.subject.path_id
 
         # Compile rewrites CTE
-        if rewrites:
+        if rewrites or single_external:
+            rewrites = rewrites or {}
             assert pol_ctx
             with pol_ctx.reenter(), pol_ctx.new() as rctx:
                 merge_overlays_globally((ir_stmt,), ctx=rctx)
@@ -1683,6 +1731,7 @@ def process_update_body(
                     contents_select=contents_select,
                     table_relation=table_relation,
                     range_relation=range_relation,
+                    single_external=single_external,
                     rewrites=rewrites,
                     elements=elements,
                     ctx=rctx,
@@ -1761,6 +1810,7 @@ def process_update_rewrites(
     contents_select: pgast.SelectStmt,
     table_relation: pgast.RelRangeVar,
     range_relation: pgast.PathRangeVar,
+    single_external: List[irast.Set],
     rewrites: irast.RewritesOfType,
     elements: List[Tuple[irast.Set, irast.BasePointerRef, qlast.ShapeOp]],
     ctx: context.CompilerContextLevel,
@@ -1851,10 +1901,36 @@ def process_update_rewrites(
             ir_stmt, rewrites_stmt, rewrite_elements, typeref, rctx,
         )
 
+        # If there are any single links that were compiled externally,
+        # populate the field from the link overlays.
+        handled = set(rewrites)
+        for ext_ir in single_external:
+            assert ext_ir.rptr
+            handled.add(ext_ir.rptr.ptrref.shortname.name)
+            actual_ptrref = irtyputils.find_actual_ptrref(
+                typeref, ext_ir.rptr.ptrref)
+            with rctx.subrel() as ectx:
+                ext_rvar = relctx.new_pointer_rvar(
+                    ext_ir.rptr, link_bias=True, src_rvar=contents_rvar,
+                    ctx=ectx)
+                relctx.include_rvar(
+                    ectx.rel, ext_rvar, ext_ir.path_id, ctx=ectx)
+                # Make the subquery output the target
+                pathctx.get_path_value_output(
+                    ectx.rel, ext_ir.path_id, env=ctx.env)
+
+            ptr_info = pg_types.get_ptrref_storage_info(
+                actual_ptrref, resolve_type=True, link_bias=False)
+            updval = pgast.ResTarget(
+                name=ptr_info.column_name, val=ectx.rel)
+            rewrites_stmt.target_list.append(updval)
+            values.append((updval, ext_ir.path_id))
+            nptr_map[actual_ptrref.name] = ectx.rel
+
         # Pull in pointers that were not rewritten
         not_rewritten = {
             (e, ptrref) for e, ptrref, _ in elements
-            if ptrref.shortname.name not in rewrites
+            if ptrref.shortname.name not in handled
         }
         for e, ptrref in not_rewritten:
             # FIXME: Duplicates some with process_update_shape
@@ -1908,9 +1984,16 @@ def process_update_shape(
         ptr_info = pg_types.get_ptrref_storage_info(
             actual_ptrref, resolve_type=True, link_bias=False
         )
+        link_ptr_info = pg_types.get_ptrref_storage_info(
+            actual_ptrref, resolve_type=False, link_bias=True
+        )
         updvalue = element.expr
 
-        if ptr_info.table_type == "ObjectType" and updvalue is not None:
+        if (
+            ptr_info.table_type == "ObjectType"
+            and not link_ptr_info
+            and updvalue is not None
+        ):
             with ctx.newscope() as scopectx:
                 val: pgast.BaseExpr
 
@@ -1996,15 +2079,6 @@ def process_update_shape(
                     env=ctx.env,
                 )
 
-        # Register all link table inserts to be run after the main
-        # insert.  Note that single links with link properties are
-        # processed both as a local link update (for the inline
-        # pointer) and as a link table update (because lprops are
-        # stored in link tables).
-        link_ptr_info = pg_types.get_ptrref_storage_info(
-            actual_ptrref, resolve_type=False, link_bias=True
-        )
-
         if link_ptr_info and link_ptr_info.table_type == "link":
             external_updates.append((element, shape_op))
 
@@ -2800,8 +2874,8 @@ def process_link_values(
         if ptrref.material_ptr is not None:
             ptrref = ptrref.material_ptr
         assert isinstance(ptrref, irast.PointerRef)
-        ptr_is_required = (
-            not ptrref.dir_cardinality(ir_rptr.direction).can_be_zero()
+        ptr_is_multi_required = (
+            ptrref.out_cardinality == qltypes.Cardinality.AT_LEAST_ONE
         )
 
         with subrelctx.newscope() as sctx, sctx.subrel() as input_rel_ctx:
@@ -2819,7 +2893,7 @@ def process_link_values(
             # so that the values will be there for us to grab later.
             input_rel_ctx.shapes_needed_by_dml.add(shape_expr)
 
-            if ptr_is_required and enforce_cardinality:
+            if ptr_is_multi_required and enforce_cardinality:
                 input_rel_ctx.force_optional |= {ir_expr.path_id}
 
             dispatch.visit(ir_expr, ctx=input_rel_ctx)
@@ -2892,7 +2966,7 @@ def process_link_values(
             ctx=ctx,
         )
 
-    if ptr_is_required and enforce_cardinality:
+    if ptr_is_multi_required and enforce_cardinality:
         target_ref = pgast.FuncCall(
             name=('edgedb', 'raise_on_null'),
             args=[


Test patch:
diff --git a/tests/test_edgeql_explain.py b/tests/test_edgeql_explain.py
--- a/tests/test_edgeql_explain.py
+++ b/tests/test_edgeql_explain.py
@@ -319,17 +319,12 @@ async def test_edgeql_explain_computed_backlink_01(self):
                         }
                     ],
                     "nearest_context_plan": {
-                        "node_type": "Bitmap Heap Scan",
+                        # I've seen this as both Index Scan and Bitmap
+                        # Heap Scan with a subnode Bitmap Index Scan.
+                        # Just don't check, it's fine.
+                        # "node_type": "Index Scan",
                         "parent_relationship": "Outer",
                         "relation_name": "default::Issue",
-                        "plans": [
-                            {
-                                "node_type": "Bitmap Index Scan",
-                                "parent_relationship": "Outer",
-                                "index_name": (
-                                    "default::Issue.owner index"),
-                            }
-                        ],
                         # We get a stack of contexts back
                         "contexts": [
                             {
diff --git a/tests/test_edgeql_insert.py b/tests/test_edgeql_insert.py
--- a/tests/test_edgeql_insert.py
+++ b/tests/test_edgeql_insert.py
@@ -5940,3 +5940,39 @@ async def test_edgeql_insert_bogus_correlation_typenames(self):
         await self.con._fetchall(
             query, __typenames__=True,
         )
+
+    async def test_edgeql_insert_single_linkprop(self):
+        await self.con.execute('''
+            insert Subordinate { name := "1" };
+            insert Subordinate { name := "2" };
+        ''')
+
+        for _ in range(10):
+            await self.con.execute('''
+                insert InsertTest {
+                    l2 := -1,
+                    sub := (select Subordinate { @note := "!" }
+                             order by random() limit 1)
+                };
+            ''')
+
+        await self.assert_query_result(
+            '''
+            select InsertTest { sub: {name, @note} };
+            ''',
+            [{"sub": {"name": str, "@note": "!"}}] * 10,
+        )
+
+        await self.con.execute('''
+            update InsertTest set {
+                sub := (select Subordinate { @note := "!" }
+                         order by random() limit 1)
+            };
+        ''')
+
+        await self.assert_query_result(
+            '''
+            select InsertTest { sub: {name, @note} };
+            ''',
+            [{"sub": {"name": str, "@note": "!"}}] * 10,
+        )