Repository: edgedb/edgedb

Issue:
Unless conflict does not work on two-field indexes?
I get this error with `UNLESS CONFLICT ON (.field1, .field)`:
```
edgedb.errors.QueryError: ON CONFLICT argument must be a property
```
And this error on `UNLESS CONFLICT ON .field1, .field2`:
```
edgedb.errors.EdgeQLSyntaxError: Unexpected ','
```
I.e. it look like not a bug, but just syntactically incorrect


Discussion:
In the meantime, what's the official recommendation?
I have `constraint exclusive on ((.field1, .field2, .field3));` 
For now, that's the only index I have on this table.
But even if I do `UNLESS CONFLICT ON .field1`, it fails with
`edgedb.errors.QueryError: UNLESS CONFLICT property must have a single exclusive constraint`
I'm using `try...except`:
```
try:
    await pool.query("INSERT ...")
except ConstraintViolationError:
    await pool.query("UPDATE...")
```
This might be wrapped in transaction or a loop if object can be deleted in the meantime. Also note: that this relies on the fact that this is the only constraint can be violated which is exclusivity on respective index. But in your case it might be more complex.
Thanks for the suggestion. I'll probably do that in the meantime.

Too bad, as I'm doing bulk inserts.

```
FOR x IN {
    json_array_unpack(<json>$data)
}
UNION (
    INSERT MyTable {
        # ...
    }
    UNLESS CONFLICT ON (.field1, .field2, .field3)
    ELSE (
        UPDATE MyTable SET {
            # ...
        }
    )
);
```

So if it fails once, I won't know where it would have failed.
Oh, I had meant to update this: we don't yet support a direct syntax for this, but there is a simple workaround: use a computed property.

So with a schema like
```
type Entry {                                                              
    required property field1 -> str;                                      
    required property field2 -> int64;                                    
    required property field3 -> bool;                                     
    property key {                                                        
        using ((.field1, .field2));                                       
        constraint exclusive;                                             
    };                                                                    
}
```

You can do queries like
```
INSERT Entry { field1 := "test", field2 := 42, field3 := false } 
UNLESS CONFLICT ON (.key)
ELSE (UPDATE Entry SET { field3 := true });
```
@msullivan Thanks for your proposition. However, when I tried it I got the following error message:
```
edgedb.errors.InvalidPropertyTargetError: invalid property type: expected a scalar type, or a scalar collection, got collection 'tuple<default::SubTable, default::MyEnum, std::datetime>'
```

I think it comes from `default::SubTable` being a type and not a scalar type.
Is there another trick around it?
Oh, yeah, darn, that is going to be more of a pain.

I think you can probably make something work by duplicating the `id` of the target object in the source table, and using that in your constraint. That should work but it's super unsatisfying, since you shouldn't ever have to think about `id`s in edgedb!
Seems like a  pain :-(.

Thanks @msullivan . Do we know when a tuple `UNLESS CONFLICT ON` will be on the roadmap?
@Mulugruntz, this is currently set for the 1.0 milestone, but chances are we can get support for matching constraints with expressions in `UNLESS CONFLICT` into the next beta.
Thanks for your hard work guys!

Hopefully in the meantime, @msullivan 's solution will be useful for other people. :-)

Resolving patch:
diff --git a/edb/edgeql/compiler/stmt.py b/edb/edgeql/compiler/stmt.py
--- a/edb/edgeql/compiler/stmt.py
+++ b/edb/edgeql/compiler/stmt.py
@@ -417,55 +417,75 @@ def compile_insert_unless_conflict_on(
         cspec_res = setgen.ensure_set(dispatch.compile(
             constraint_spec, ctx=constraint_ctx), ctx=constraint_ctx)
 
-    if not cspec_res.rptr:
-        raise errors.QueryError(
-            'UNLESS CONFLICT argument must be a property',
-            context=constraint_spec.context,
-        )
+    # We accept a property, link, or a list of them in the form of a
+    # tuple.
+    if cspec_res.rptr is None and isinstance(cspec_res.expr, irast.Tuple):
+        cspec_args = [elem.val for elem in cspec_res.expr.elements]
+    else:
+        cspec_args = [cspec_res]
 
-    if cspec_res.rptr.source.path_id != stmt.subject.path_id:
-        raise errors.QueryError(
-            'UNLESS CONFLICT argument must be a property of the '
-            'type being inserted',
-            context=constraint_spec.context,
-        )
+    for cspec_arg in cspec_args:
+        if not cspec_arg.rptr:
+            raise errors.QueryError(
+                'UNLESS CONFLICT argument must be a property, link, '
+                'or tuple of properties and links',
+                context=constraint_spec.context,
+            )
+
+        if cspec_arg.rptr.source.path_id != stmt.subject.path_id:
+            raise errors.QueryError(
+                'UNLESS CONFLICT argument must be a property of the '
+                'type being inserted',
+                context=constraint_spec.context,
+            )
 
     schema = ctx.env.schema
     schema, typ = typeutils.ir_typeref_to_type(schema, stmt.subject.typeref)
     assert isinstance(typ, s_objtypes.ObjectType)
     real_typ = typ.get_nearest_non_derived_parent(schema)
 
-    schema, ptr = (
-        typeutils.ptrcls_from_ptrref(cspec_res.rptr.ptrref,
-                                     schema=schema))
-    if not isinstance(ptr, s_pointers.Pointer):
-        raise errors.QueryError(
-            'UNLESS CONFLICT property must be a property',
-            context=constraint_spec.context,
-        )
+    ptrs = []
+    exclusive_constr = schema.get('std::exclusive', type=s_constr.Constraint)
+    for cspec_arg in cspec_args:
+        assert cspec_arg.rptr is not None
+        schema, ptr = (
+            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',
+                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,
-        )
+        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,
+            )
 
-    exclusive_constr = schema.get('std::exclusive', type=s_constr.Constraint)
-    ex_cnstrs = [c for c in ptr.get_constraints(schema).objects(schema)
-                 if c.issubclass(schema, exclusive_constr)]
+        ptrs.append(ptr)
+
+    obj_constrs = inference.cardinality.get_object_exclusive_constraints(
+        real_typ, set(ptrs), ctx.env)
+
+    field_constrs = []
+    if len(ptrs) == 1:
+        field_constrs = [
+            c for c in ptrs[0].get_constraints(schema).objects(schema)
+            if c.issubclass(schema, exclusive_constr)]
 
-    if len(ex_cnstrs) != 1:
+    all_constrs = list(obj_constrs) + field_constrs
+    if len(all_constrs) != 1:
         raise errors.QueryError(
             'UNLESS CONFLICT property must have a single exclusive constraint',
             context=constraint_spec.context,
         )
 
-    field_name = cspec_res.rptr.ptrref.shortname
-    ds = {field_name.name: (ptr, ex_cnstrs)}
+    ds = {ptr.get_shortname(schema).name: (ptr, field_constrs)
+          for ptr in ptrs}
     select_ir = compile_insert_unless_conflict_select(
-        stmt, insert_subject, real_typ, constrs=ds, obj_constrs=[],
+        stmt, insert_subject, real_typ, constrs=ds, obj_constrs=obj_constrs,
         parser_context=stmt.context, ctx=ctx)
 
     # Compile an else branch
@@ -481,7 +501,7 @@ def compile_insert_unless_conflict_on(
         assert isinstance(else_ir, irast.Set)
 
     return irast.OnConflictClause(
-        constraint=irast.ConstraintRef(id=ex_cnstrs[0].id),
+        constraint=irast.ConstraintRef(id=all_constrs[0].id),
         select_ir=select_ir,
         else_ir=else_ir
     )


Test patch:
diff --git a/tests/schemas/insert.esdl b/tests/schemas/insert.esdl
--- a/tests/schemas/insert.esdl
+++ b/tests/schemas/insert.esdl
@@ -73,6 +73,8 @@ type Person2 {
 type Person2a extending Person2 {
     required single property last -> std::str;
     constraint exclusive on ((__subject__.first, __subject__.last));
+    single link bff -> Person;
+    constraint exclusive on ((.first, .bff));
 }
 
 type Person2b extending Person2 {
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
@@ -2712,6 +2712,95 @@ async def test_edgeql_insert_unless_conflict_13(self):
 
         self.assertEqual(list(res1)[0].id, list(res2)[0].id)
 
+    async def test_edgeql_insert_unless_conflict_14(self):
+        query = r'''
+            WITH MODULE test
+            SELECT (
+                INSERT Person2a {first := "Phil", last := "Emarg"}
+                UNLESS CONFLICT ON (.first, .last) ELSE (SELECT Person2a)
+            ) {first, last};
+        '''
+
+        await self.assert_query_result(
+            query,
+            [{"first": "Phil", "last": "Emarg"}],
+        )
+
+        await self.assert_query_result(
+            query,
+            [{"first": "Phil", "last": "Emarg"}],
+        )
+
+        await self.assert_query_result(
+            r'''SELECT test::Person2a {first, last}''',
+            [{"first": "Phil", "last": "Emarg"}],
+        )
+
+    async def test_edgeql_insert_unless_conflict_15(self):
+        # test using a tuple object constraint with a link in it
+        await self.con.execute(r'''
+            WITH MODULE test
+            INSERT Person {
+                name := "Phil Emarg",
+            };
+
+            WITH MODULE test
+            INSERT Person {
+                name := "Madeline Hatch",
+            };
+        ''')
+
+        query = r'''
+            WITH MODULE test
+            SELECT (
+                INSERT Person2a {
+                    first := "Emmanuel",
+                    last := "Villip",
+                    bff := (SELECT Person FILTER .name = "Phil Emarg")
+                }
+                UNLESS CONFLICT ON (.first, .bff) ELSE (SELECT Person2a)
+            ) {first, last};
+        '''
+
+        await self.assert_query_result(
+            query,
+            [{"first": "Emmanuel", "last": "Villip"}],
+        )
+
+        await self.assert_query_result(
+            query,
+            [{"first": "Emmanuel", "last": "Villip"}],
+        )
+
+        await self.assert_query_result(
+            query.replace("Villip", "Vi11ip"),
+            [{"first": "Emmanuel", "last": "Villip"}],
+        )
+
+        await self.assert_query_result(
+            r'''SELECT test::Person2a {first, last, friend := .bff.name}''',
+            [{"first": "Emmanuel", "last": "Villip", "friend": "Phil Emarg"}],
+        )
+
+        await self.assert_query_result(
+            query.replace("Villip", "Vi11ip").replace(
+                "Phil Emarg", "Madeline Hatch"),
+            [{"first": "Emmanuel", "last": "Vi11ip"}],
+        )
+
+        await self.assert_query_result(
+            r'''
+                SELECT test::Person2a {first, last, friend := .bff.name}
+                ORDER BY .last
+            ''',
+            [
+                {"first": "Emmanuel", "last": "Vi11ip",
+                 "friend": "Madeline Hatch"},
+                {"first": "Emmanuel", "last": "Villip",
+                 "friend": "Phil Emarg"},
+            ],
+        )
+
     async def test_edgeql_insert_dependent_01(self):
         query = r'''
             WITH MODULE test