Repository: edgedb/edgedb

Issue:
[Triggers] False positive recursive trigger
<!-- Please search existing issues to avoid creating duplicates. -->

<!--
For the EdgeDB Version: run `edgedb query 'select sys::get_version_as_str()'` from your project directory (or run `select sys::get_version_as_str();` in the EdgeDB interactive shell).
For the EdgeDB CLI Version: Run `edgedb --version` from anywhere
-->

- EdgeDB Version: "3.1+a33a828"
- EdgeDB CLI Version: `EdgeDB CLI 3.4.0+4d95a2c`
- OS Version: MacOS

Steps to Reproduce:

1. Migrate
2. see the error

The trigger should happen on update only, and only inserts an object. This wouldn't trigger another update.
```
error: trigger on default::Theme is recursive
```

<!-- If the issue is about a query error, please also provide your schema -->

Schema:
```
abstract type WithRevisions {}
	type Revision {
		required object: WithRevisions;
	}
type Theme extending WithRevisions {
	        required name: str;
		trigger revision_create after update for each do (
			insert Revision {
				object := (
					insert Theme {
						name := __old__.name
					}
				)
			}
		);
	}
```


Discussion:
I have same error when using `when` clause (https://github.com/edgedb/edgedb/pull/6184)

```edgeql
type User {
  link chats := .<members[is ChatBase];
  multi chat_memberships: ChatMembership {
     on source delete delete target;
     constraint exclusive;
  };
  trigger RequireChatsMembership after insert, update for each when (count(__new__.chats) > count(__new__.chat_memberships)) do (
     for new_chat in __new__.chats
     union (
        update User 
        filter .num = __new__.num
        set {
           chat_memberships += (
              insert ChatMembership {
                 chat := new_chat,
              }
           )
        }
     )
  );
}
```

```
error: trigger on default::User is recursive
```
@MrFoxPro's example is being rejected correctly. It is doing an update from within an update trigger, and so should be rejected (and is).

The original issue does seem like a false positive, though.

Resolving patch:
diff --git a/edb/edgeql/compiler/options.py b/edb/edgeql/compiler/options.py
--- a/edb/edgeql/compiler/options.py
+++ b/edb/edgeql/compiler/options.py
@@ -32,6 +32,7 @@
     from edb.schema import types as s_types
     from edb.schema import pointers as s_pointers
     from edb.ir import pathid
+    from edb.edgeql import qltypes
 
     SourceOrPathId = s_types.Type | s_pointers.Pointer | pathid.PathId
 
@@ -146,3 +147,9 @@ class CompilerOptions(GlobalCompilerOptions):
     )
 
     detached: bool = False
+
+    #: In order to prevent recursive triggers, these fields are used to track
+    #: the sources of a given trigger. These will only be present if
+    #: schema_object_context is set to Trigger.
+    trigger_type: Optional[s_types.Type] = None
+    trigger_kinds: Optional[Collection[qltypes.TriggerKind]] = None
diff --git a/edb/edgeql/compiler/triggers.py b/edb/edgeql/compiler/triggers.py
--- a/edb/edgeql/compiler/triggers.py
+++ b/edb/edgeql/compiler/triggers.py
@@ -38,6 +38,7 @@
 
 from . import context
 from . import dispatch
+from . import options
 from . import schemactx
 from . import setgen
 from . import typegen
@@ -138,6 +139,7 @@ def compile_trigger(
 def compile_triggers_phase(
     dml_stmts: Collection[irast.MutatingStmt],
     defining_trigger_on: Optional[s_types.Type],
+    defining_trigger_kinds: Optional[Collection[qltypes.TriggerKind]],
     *,
     ctx: context.ContextLevel,
 ) -> tuple[irast.Trigger, ...]:
@@ -165,11 +167,13 @@ def compile_triggers_phase(
 
         # Process all the types, starting with the base type
         for subtype in sorted(stypes, key=lambda t: t != stype):
-            if defining_trigger_on and subtype.issubclass(
-                    ctx.env.schema, defining_trigger_on):
+            if (defining_trigger_on and defining_trigger_kinds
+                and kind in defining_trigger_kinds
+                and subtype.issubclass(ctx.env.schema, defining_trigger_on)
+            ):
                 name = str(defining_trigger_on.get_name(ctx.env.schema))
                 raise errors.SchemaDefinitionError(
-                    f"trigger on {name} is recursive"
+                    f"trigger on {name} after {kind.lower()} is recursive"
                 )
 
             for trigger in subtype.get_relevant_triggers(kind, schema):
@@ -201,31 +205,47 @@ def compile_triggers(
     defining_trigger = (
         ctx.env.options.schema_object_context == s_triggers.Trigger)
     defining_trigger_on = None
-    if defining_trigger:
-        defining_trigger_on = setgen.get_set_type(
-            ctx.anchors['__trigger_type__'], ctx=ctx)
+    defining_trigger_kinds = None
+    if (
+        defining_trigger and
+        isinstance(ctx.env.options, options.CompilerOptions)
+    ):
+        defining_trigger_on = ctx.env.options.trigger_type
+        defining_trigger_kinds = ctx.env.options.trigger_kinds
 
     ir_triggers: list[tuple[irast.Trigger, ...]] = []
     start = 0
-    all_trigger_types: set[irast.TypeRef] = set()
+    all_trigger_causes: set[tuple[irast.TypeRef, qltypes.TriggerKind]] = set()
     while start < len(ctx.env.dml_stmts):
         end = len(ctx.env.dml_stmts)
-        new = compile_triggers_phase(
-            ctx.env.dml_stmts[start:], defining_trigger_on, ctx=ctx)
-        new_types = {x for t in new for x in t.all_affected_types}
+        compiled_triggers = compile_triggers_phase(
+            ctx.env.dml_stmts[start:],
+            defining_trigger_on,
+            defining_trigger_kinds,
+            ctx=ctx
+        )
+        new_causes: set[tuple[irast.TypeRef, qltypes.TriggerKind]] = {
+            (affected_type, kind)
+            for compiled_trigger in compiled_triggers
+            for affected_type in compiled_trigger.all_affected_types
+            for kind in compiled_trigger.kinds
+        }
 
         # Any given type is allowed allowed to have its triggers fire
         # in *one* phase of trigger execution, since the semantics get
         # a little unclear otherwise. We might relax this later.
-        overlap = new_types & all_trigger_types
+        overlap = new_causes & all_trigger_causes
         if overlap:
-            names = sorted(str(t.name_hint) for t in overlap)
+            names: Collection[str] = sorted(
+                f"{str(cause[0].name_hint)} after {cause[1].lower()}"
+                for cause in overlap
+            )
             raise errors.QueryError(
                 f"trigger would need to be executed in multiple stages on "
                 f"{', '.join(names)}"
             )
-        all_trigger_types |= new_types
-        ir_triggers.append(new)
+        all_trigger_causes |= new_causes
+        ir_triggers.append(compiled_triggers)
         start = end
 
     return tuple(ir_triggers)
diff --git a/edb/schema/triggers.py b/edb/schema/triggers.py
--- a/edb/schema/triggers.py
+++ b/edb/schema/triggers.py
@@ -204,7 +204,7 @@ def compile_expr_field(
             scope = self._get_scope(schema)
             kinds = self._get_kinds(schema)
 
-            anchors: dict[str, pathid.PathId | s_types.Type] = {}
+            anchors: dict[str, pathid.PathId] = {}
             if qltypes.TriggerKind.Insert not in kinds:
                 anchors['__old__'] = pathid.PathId.from_type(
                     schema,
@@ -224,7 +224,6 @@ def compile_expr_field(
                 frozenset(anchors.values())
                 if scope == qltypes.TriggerScope.Each else frozenset()
             )
-            anchors['__trigger_type__'] = source
 
             assert isinstance(source, s_types.Type)
 
@@ -241,6 +240,8 @@ def compile_expr_field(
                         track_schema_ref_exprs=track_schema_ref_exprs,
                         # in_ddl_context_name=in_ddl_context_name,
                         detached=True,
+                        trigger_type=source,
+                        trigger_kinds=kinds,
                     ),
                 )
             except errors.QueryError as e:


Test patch:
diff --git a/tests/test_edgeql_triggers.py b/tests/test_edgeql_triggers.py
--- a/tests/test_edgeql_triggers.py
+++ b/tests/test_edgeql_triggers.py
@@ -1077,7 +1077,7 @@ async def test_edgeql_triggers_chain_01(self):
     async def test_edgeql_triggers_chain_02(self):
         async with self.assertRaisesRegexTx(
                 edgedb.SchemaDefinitionError,
-                'trigger on default::InsertTest is recursive'):
+                'trigger on default::InsertTest after insert is recursive'):
             await self.con.execute('''
                 alter type InsertTest {
                   create trigger log after insert for each do (
@@ -1102,7 +1102,8 @@ async def test_edgeql_triggers_chain_03(self):
 
         async with self.assertRaisesRegexTx(
                 edgedb.QueryError,
-                'would need to be executed in multiple stages'):
+                'would need to be executed in multiple stages on default::Note '
+                'after insert'):
             await self.con.execute('''
                 select {
                     (insert InsertTest { name := "foo" }),
@@ -1110,6 +1111,67 @@ async def test_edgeql_triggers_chain_03(self):
                 }
             ''')
 
+    async def test_edgeql_triggers_chain_04(self):
+        await self.con.execute('''
+            alter type InsertTest {
+              create trigger log after update for each do (
+                insert InsertTest { name := __new__.name ++ "!" }
+              );
+            };
+        ''')
+
+        await self.con.execute('''
+            insert InsertTest { name := "test" }
+        ''')
+
+        await self.con.execute('''
+            update InsertTest
+            filter InsertTest.name = "test"
+            set { name := "updated" }
+        ''')
+
+        await self.assert_query_result(
+            '''
+            select InsertTest { name }
+            ''',
+            [{'name': 'updated'}, {'name': 'updated!'}],
+        )
+
+    async def test_edgeql_triggers_chain_05(self):
+        await self.con.execute('''
+            alter type InsertTest {
+              create trigger log after insert for each do (
+                insert Note { name := "insert", note := __new__.name }
+              );
+            };
+        ''')
+
+        await self.con.execute('''
+            select {
+                (insert Note { name := "foo" }),
+            }
+        ''')
+
+        await self.assert_notes([
+            {'name': "foo", 'notes': set()},
+        ])
+
+        await self.con.execute('''
+            select {
+                (insert InsertTest { name := "foo_insert" }),
+                (
+                  update Note
+                  filter Note.name = "foo"
+                  set { name := "foo_update" }
+                ),
+            }
+        ''')
+
+        await self.assert_notes([
+            {'name': "foo_update", 'notes': set()},
+            {'name': "insert", 'notes': set(["foo_insert"])},
+        ])
+
     async def test_edgeql_triggers_tricky_01(self):
         await self.con.execute('''
             alter type InsertTest {