Repository: edgedb/edgedb

Issue:
Support `unless conflict on` on `multi` pointers
I think it wasn't originally implemented because it couldn't just be done using `ON CONFLICT`.
But it turned out that a ton of stuff couldn't be implemented with just `ON CONFLICT`,
and so we now have the "conflict CTE" mechanism that allows us to do unless conflict
style behavior in very flexible ways.

I think to make this work all we need to do is:
 * Remove the error
 * Make sure that we always use conflict CTEs if any of the pointers are multi

Looking at this I found an existing bug when there is an exclusive multi pointer and `UNLESS CONFLICT` with no `ON` is specified: the query is allowed, but can spuriously raise a constraint violation, because it does not properly choose to use conflict CTEs. But if some modification to the query is made that *forces* the use of conflict CTEs (such as another insert to the same type), then it works properly!


Discussion:
This one will probably get fixed very soon (especially since it has a clear bug attached), but in the mean time, here is my general workaround for cases where you wish to use `unless conflict` but cannot:
```
with
    old := (select Foo filter <whatever>),
    new := (for _ in (select () filter not exists old) union (
        insert Foo { <whatever> }
    )),
select {old, new}
```

The `select` for `old` can also be an `update`.

The idea here is that you aren't allowed to do DML (insert/update/delete) inside of a conditional (an `if/else`), but you *can* do them inside of a `for` loop, so we write a `for` loop that executes exactly once if the object does not exist and zero times otherwise.

(DML is not allowed in conditionals because there were concerns about both what the semantics should be and whether it was implementable, but the observation that `if/else` can be translated into `for` loops provides a resolution to both of those questions, so we probably will start allowing it: https://github.com/edgedb/edgedb/issues/4437)

Resolving patch:
diff --git a/edb/edgeql/compiler/conflicts.py b/edb/edgeql/compiler/conflicts.py
--- a/edb/edgeql/compiler/conflicts.py
+++ b/edb/edgeql/compiler/conflicts.py
@@ -89,7 +89,7 @@ def _compile_conflict_select(
     constrs: Dict[str, Tuple[s_pointers.Pointer, List[s_constr.Constraint]]],
     parser_context: Optional[pctx.ParserContext],
     ctx: context.ContextLevel,
-) -> Optional[qlast.Expr]:
+) -> tuple[Optional[qlast.Expr], bool]:
     """Synthesize a select of conflicting objects
 
     ... for a single object type. This gets called once for each ancestor
@@ -153,7 +153,7 @@ def _compile_conflict_select(
             ptr_anchors[name] = ctx.create_anchor(elem_set, name)
 
     if for_inheritance and not ptrs_in_shape:
-        return None
+        return None, False
 
     # Fill in empty sets for pointers that are needed but not present
     present_ptrs = set(ptr_anchors)
@@ -238,7 +238,7 @@ def _compile_conflict_select(
         conds.append(op)
 
     if not conds:
-        return None
+        return None, False
 
     # We use `any` to compute the disjunction here because some might
     # be empty.
@@ -268,7 +268,15 @@ def _compile_conflict_select(
         expr=qlast.SelectQuery(result=insert_subject, where=cond)
     )
 
-    return select_ast
+    # If one of the pointers we care about is multi, then we have to always
+    # use a conflict CTE check instead of trying to use a constraint.
+    has_multi = False
+    for ptrname in needed_ptrs:
+        ptr = subject_typ.getptr(ctx.env.schema, s_name.UnqualName(ptrname))
+        if not ptr.get_cardinality(ctx.env.schema).is_single():
+            has_multi = True
+
+    return select_ast, has_multi
 
 
 def _constr_matters(
@@ -365,22 +373,25 @@ def compile_conflict_select(
     else:
         type_maps = _split_constraints(obj_constrs, constrs, ctx=ctx)
 
+    always_check = False
+
     # Generate a separate query for each type
     from_parent = False
     frags = []
     for a_obj, (a_constrs, a_obj_constrs) in type_maps.items():
-        frag = _compile_conflict_select(
+        frag, frag_always_check = _compile_conflict_select(
             stmt, a_obj, obj_constrs=a_obj_constrs, constrs=a_constrs,
             for_inheritance=for_inheritance,
             fake_dml_set=fake_dml_set,
             parser_context=parser_context, ctx=ctx,
         )
+        always_check |= frag_always_check
         if frag:
             if a_obj != subject_typ:
                 from_parent = True
             frags.append(frag)
 
-    always_check = from_parent or any(
+    always_check |= from_parent or any(
         not child.is_view(schema) for child in subject_typ.children(schema)
     )
 
@@ -494,18 +505,12 @@ def compile_insert_unless_conflict_on(
             typeutils.ptrcls_from_ptrref(cspec_arg.rptr.ptrref, schema=schema))
         if not isinstance(ptr, s_pointers.Pointer):
             raise errors.QueryError(
-                'UNLESS CONFLICT property must be a property',
+                'UNLESS CONFLICT argument must be a property, link, '
+                'or tuple of properties and links',
                 context=constraint_spec.context,
             )
 
         ptr = ptr.get_nearest_non_derived_parent(schema)
-        ptr_card = ptr.get_cardinality(schema)
-        if not ptr_card.is_single():
-            raise errors.QueryError(
-                'UNLESS CONFLICT property must be a SINGLE property',
-                context=constraint_spec.context,
-            )
-
         ptrs.append(ptr)
 
     obj_constrs = inference.cardinality.get_object_exclusive_constraints(


Test patch:
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
@@ -2602,14 +2602,6 @@ async def test_edgeql_insert_unless_conflict_02(self):
                 UNLESS CONFLICT ON .name;
             ''')
 
-        async with self.assertRaisesRegexTx(
-                edgedb.QueryError,
-                "UNLESS CONFLICT property must be a SINGLE property"):
-            await self.con.query(r'''
-                INSERT Person {name := "hello", multi_prop := "lol"}
-                UNLESS CONFLICT ON .multi_prop;
-            ''')
-
         async with self.assertRaisesRegexTx(
                 edgedb.QueryError,
                 "object type 'std::Object' has no link or property 'name'"):
@@ -3431,6 +3423,31 @@ async def test_edgeql_insert_unless_conflict_27(self):
                 msg=f'check {(friend, person)}',
             )
 
+    async def test_edgeql_insert_unless_conflict_28(self):
+        await self.con.execute('''
+            create type T {
+                create multi property name -> str {
+                    create constraint exclusive; } };
+            insert T { name := {'foo', 'bar'} };
+        ''')
+
+        await self.assert_query_result(
+            '''
+            insert T { name := {'baz', 'bar'} } unless conflict
+            ''',
+            [],
+        )
+
+        await self.assert_query_result(
+            '''
+            select (
+                insert T { name := {'baz', 'bar'} }
+                unless conflict on (.name) else (T)
+            ) { name }
+            ''',
+            [{'name': {'foo', 'bar'}}],
+        )
+
     async def test_edgeql_insert_dependent_01(self):
         query = r'''
             SELECT (