Repository: edgedb/edgedb

Issue:
on target delete delete source does nothing when deleted target is one of many possible types (type union operator)
- EdgeDB Version: 3.2+bb17a05
- EdgeDB CLI Version: 3.4.0+160d07d
- OS Version: Windows 11

Note: a user on Discord discovered this behavior  https://discord.com/channels/841451783728529451/849377751370432573/1148285494711570452

Steps to Reproduce:

1. Start with the following schema:

```
module default {
  type ContactBookEntry;
  type LinkedinUpload;

    type ContactBookEntrySource {
        required of_entry: ContactBookEntry {
          on target delete delete source
          };
        required from_source: IcalCalendar::Calendar | GoogleCalendar::Calendar | LinkedinUpload {
          on target delete delete source
          };
        required auto_approachability: int16;
        constraint exclusive on ((.of_entry, .from_source));
    }
}

module IcalCalendar {
  type Calendar;
}

module GoogleCalendar {
  type Calendar;
}
```

3. Insert some data:

```
edgedb> insert IcalCalendar::Calendar;
{IcalCalendar::Calendar {id: ace1e76a-4b6b-11ee-a7ee-8f2128275441}}

edgedb> insert GoogleCalendar::Calendar;
{GoogleCalendar::Calendar {id: b0634cee-4b6b-11ee-a7ee-93878d0dddce}}

edgedb> insert ContactBookEntry;
{default::ContactBookEntry {id: d430f068-4b6b-11ee-a7ee-c73f0f7f1e6e}}

edgedb> insert LinkedinUpload;
{default::LinkedinUpload {id: da87363e-4b6b-11ee-a7ee-d3047fc8d9cc}}

edgedb> insert ContactBookEntrySource {
of_entry := assert_single((select ContactBookEntry)),
from_source := assert_single((select GoogleCalendar::Calendar)),
auto_approachability := 0
};
{default::ContactBookEntrySource {id: 0f92ed50-4b6c-11ee-a1c8-c7042898a37b}}
```

3. Now delete the linked GoogleCalendar::Calender, which should delete the ContactBookEntrySource but does not. Instead, ContactBookEntrySource ends up with an empty set even though the link is required.

```
edgedb> delete GoogleCalendar::Calendar;
{GoogleCalendar::Calendar {id: b0634cee-4b6b-11ee-a7ee-93878d0dddce}}

edgedb> select ContactBookEntrySource {**};
{
  default::ContactBookEntrySource {
    id: 0f92ed50-4b6c-11ee-a1c8-c7042898a37b,
    auto_approachability: 0,
    from_source: {},
    of_entry: default::ContactBookEntry {id: d430f068-4b6b-11ee-a7ee-c73f0f7f1e6e},
  },
}
```

4. Delete the ContactBookEntry to show that on contact delete delete source does indeed work if the type union operator is not involved.

```
edgedb> delete ContactBookEntry;
{default::ContactBookEntry {id: d430f068-4b6b-11ee-a7ee-c73f0f7f1e6e}}

edgedb> select ContactBookEntrySource {**};
{}
```

Note: I have encountered some unexpected behaviour with the type union operator as well which may provide some extra context:

https://github.com/edgedb/edgedb/issues/5823


Discussion:
It's not just `delete source` it seems. We fail to enforce `restrict` (the default).

Resolving patch:
diff --git a/edb/pgsql/delta.py b/edb/pgsql/delta.py
--- a/edb/pgsql/delta.py
+++ b/edb/pgsql/delta.py
@@ -6563,7 +6563,13 @@ def apply(
                 target, scls_type=s_links.Link, field_name='target')
 
             # We need to look at all inbound links to all ancestors
-            for ancestor in target.get_ancestors(schema).objects(schema):
+            for ancestor in itertools.chain(
+                target.get_ancestors(schema).objects(schema),
+                schema.get_referrers(
+                    target, scls_type=s_objtypes.ObjectType,
+                    field_name='union_of'
+                ),
+            ):
                 inbound_links |= schema.get_referrers(
                     ancestor, scls_type=s_links.Link, field_name='target')
 


Test patch:
diff --git a/tests/schemas/link_tgt_del.esdl b/tests/schemas/link_tgt_del.esdl
--- a/tests/schemas/link_tgt_del.esdl
+++ b/tests/schemas/link_tgt_del.esdl
@@ -27,11 +27,15 @@ type Target1 extending Named {
      multi link extra_tgt -> Target1;
 };
 type Target1Child extending Target1;
+type Target2 extending Named;
 
 type Source1 extending Named {
     link tgt1_restrict -> Target1 {
         on target delete restrict;
     }
+    link tgt_union_restrict -> Target1 | Target2 {
+        on target delete restrict;
+    }
     link tgt1_allow -> Target1 {
         on target delete allow;
     }
@@ -50,6 +54,9 @@ type Source1 extending Named {
     multi link tgt1_m2m_del_source -> Target1 {
         on target delete delete source;
     }
+    multi link tgt_union_m2m_del_source -> Target1 | Target2 {
+        on target delete delete source;
+    }
 
     link tgt1_del_target -> Target1 {
         on source delete delete target;
diff --git a/tests/test_link_target_delete.py b/tests/test_link_target_delete.py
--- a/tests/test_link_target_delete.py
+++ b/tests/test_link_target_delete.py
@@ -284,6 +284,29 @@ async def test_link_on_target_delete_restrict_07(self):
 
         self.assertTrue(success)
 
+    async def test_link_on_target_delete_restrict_08(self):
+        async with self._run_and_rollback():
+            await self.con.execute("""
+                INSERT Target1 {
+                    name := 'Target1.1'
+                };
+
+                INSERT Source1 {
+                    name := 'Source1.1',
+                    tgt_union_restrict := (
+                        SELECT Target1
+                        FILTER .name = 'Target1.1'
+                    )
+                };
+            """)
+
+            with self.assertRaisesRegex(
+                    edgedb.ConstraintViolationError,
+                    'deletion of default::Target1.* is prohibited by link'):
+                await self.con.execute("""
+                    DELETE (SELECT Target1 FILTER .name = 'Target1.1');
+                """)
+
     async def test_link_on_target_delete_deferred_restrict_01(self):
         exception_is_deferred = False
 
@@ -1002,6 +1025,36 @@ async def test_link_on_target_delete_delete_source_05(self):
                 []
             )
 
+    async def test_link_on_target_delete_delete_source_06(self):
+        async with self._run_and_rollback():
+            await self.con.execute("""
+                INSERT Target1 {
+                    name := 'Target1.1'
+                };
+
+                INSERT Source1 {
+                    name := 'Source1.1',
+                    tgt_union_m2m_del_source := (
+                        SELECT Target1
+                        FILTER .name = 'Target1.1'
+                    )
+                };
+            """)
+
+            await self.con.execute("""
+                DELETE (SELECT Target1 FILTER .name = 'Target1.1');
+            """)
+
+            await self.assert_query_result(
+                r'''
+                    SELECT
+                        Source1
+                    FILTER
+                        .name = 'Source1.1';
+                ''',
+                []
+            )
+
     async def test_link_on_target_delete_loop_01(self):
         async with self._run_and_rollback():
             await self.con.execute("""