Repository: edgedb/edgedb

Issue:
UNLESS CONFLICT doesn't work when conflict is with another type
```
CREATE TYPE Foo {
    CREATE PROPERTY name -> str {
        CREATE CONSTRAINT exclusive;
    };
};
CREATE TYPE Bar EXTENDING Foo;
INSERT Foo { name := "test" };
INSERT Bar { name := "test" } UNLESS CONFLICT;
```
gives a ConstraintViolationError.

I think that probably it ought to work, and not produce an error. Not sure how high-priority this should be.

Arguably you might want to be able to ask for either behavior, but that seems like a knob that's probably not worth adding without pretty clear reason? I'm not sure; maybe if we thought of some beautiful syntax.

The basic cause for this is I think that cross-type constraint violations are raised in triggers, which don't get caught by pg-level ON CONFLICT.
I think the solution is to always generate "conflict CTEs" (a la #2181) when inheritance is in play. `compile_insert_unless_conflict_select` will need to be modified to generate SELECTs on super types and UNION them together.


Discussion:

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
@@ -231,7 +231,7 @@ def compile_GroupQuery(
         context=expr.context)
 
 
-def compile_insert_unless_conflict_select(
+def _compile_insert_unless_conflict_select(
     stmt: irast.InsertStmt,
     insert_subject: qlast.Path,
     subject_typ: s_objtypes.ObjectType,
@@ -240,9 +240,12 @@ def compile_insert_unless_conflict_select(
     constrs: Dict[str, Tuple[s_pointers.Pointer, List[s_constr.Constraint]]],
     parser_context: Optional[pctx.ParserContext],
     ctx: context.ContextLevel,
-) -> irast.Set:
+) -> Optional[qlast.Expr]:
     """Synthesize a select of conflicting objects for UNLESS CONFLICT
 
+    ... for a single object type. This gets called once for each ancestor
+    type that provides constraints to the type being inserted.
+
     `cnstrs` contains the constraints to consider.
     """
     # Find which pointers we need to grab
@@ -276,7 +279,7 @@ def compile_insert_unless_conflict_select(
             assert elem.expr
 
             if inference.infer_volatility(elem.expr, ctx.env).is_volatile():
-                raise errors.QueryError(
+                raise errors.UnsupportedFeatureError(
                     'INSERT UNLESS CONFLICT ON does not support volatile '
                     'properties',
                     context=parser_context,
@@ -339,6 +342,9 @@ def compile_insert_unless_conflict_select(
         rhs = qlutils.subject_substitute(subjectexpr.qlast, insert_subject)
         conds.append(qlast.BinOp(op='=', left=lhs, right=rhs))
 
+    if not conds:
+        return None
+
     # We use `any` to compute the disjunction here because some might
     # be empty.
     if len(conds) == 1:
@@ -355,12 +361,106 @@ def compile_insert_unless_conflict_select(
         where=cond,
     )
 
-    select_ir = dispatch.compile(select_ast, ctx=ctx)
-    select_ir = setgen.scoped_set(
-        select_ir, force_reassign=True, ctx=ctx)
-    assert isinstance(select_ir, irast.Set)
+    return select_ast
+
+
+def _constr_matters(
+    constr: s_constr.Constraint, ctx: context.ContextLevel,
+) -> bool:
+    schema = ctx.env.schema
+    return (
+        not constr.generic(schema)
+        and not constr.get_delegated(schema)
+        and (
+            constr.get_owned(schema)
+            or all(anc.get_delegated(schema) or anc.generic(schema) for anc
+                   in constr.get_ancestors(schema).objects(schema))
+        )
+    )
+
+
+def compile_insert_unless_conflict_select(
+    stmt: irast.InsertStmt,
+    insert_subject: qlast.Path,
+    subject_typ: s_objtypes.ObjectType,
+    *,
+    obj_constrs: Sequence[s_constr.Constraint],
+    constrs: Dict[str, Tuple[s_pointers.Pointer, List[s_constr.Constraint]]],
+    parser_context: Optional[pctx.ParserContext],
+    ctx: context.ContextLevel,
+) -> Tuple[irast.Set, bool, bool]:
+    """Synthesize a select of conflicting objects for UNLESS CONFLICT
+
+    This teases apart the constraints we care about based on which
+    type they originate from, generates a SELECT for each type, and
+    unions them together.
 
-    return select_ir
+    `cnstrs` contains the constraints to consider.
+    """
+    schema = ctx.env.schema
+
+    type_maps: Dict[
+        s_objtypes.ObjectType,
+        Tuple[
+            Dict[str, Tuple[s_pointers.Pointer, List[s_constr.Constraint]]],
+            List[s_constr.Constraint],
+        ],
+    ] = {}
+
+    # Split up pointer constraints by what object types they come from
+    for name, (_, p_constrs) in constrs.items():
+        for p_constr in p_constrs:
+            ancs = (p_constr,) + p_constr.get_ancestors(schema).objects(schema)
+            for anc in ancs:
+                if not _constr_matters(anc, ctx):
+                    continue
+                p_ptr = anc.get_subject(schema)
+                assert isinstance(p_ptr, s_pointers.Pointer)
+                obj = p_ptr.get_source(schema)
+                assert isinstance(obj, s_objtypes.ObjectType)
+                map, _ = type_maps.setdefault(obj, ({}, []))
+                _, entry = map.setdefault(name, (p_ptr, []))
+                entry.append(anc)
+
+    # Split up object constraints by what object typesthey come from
+    for obj_constr in obj_constrs:
+        ancs = (obj_constr,) + obj_constr.get_ancestors(schema).objects(schema)
+        for anc in ancs:
+            if not _constr_matters(anc, ctx):
+                continue
+            obj = anc.get_subject(schema)
+            assert isinstance(obj, s_objtypes.ObjectType)
+            _, o_constr_entry = type_maps.setdefault(obj, ({}, []))
+            o_constr_entry.append(anc)
+
+    # Generate a separate query for each type
+    from_parent = False
+    frags = []
+    for a_obj, (a_constrs, a_obj_constrs) in type_maps.items():
+        qname = a_obj.get_name(schema)
+        path = qlast.Path(steps=[qlast.ObjectRef(
+            module=qname.module, name=qname.name)])
+        frag = _compile_insert_unless_conflict_select(
+            stmt, path, a_obj, obj_constrs=a_obj_constrs, constrs=a_constrs,
+            parser_context=parser_context, ctx=ctx,
+        )
+        if frag:
+            if a_obj != subject_typ:
+                from_parent = True
+            frags.append(frag)
+
+    always_check = from_parent or bool(subject_typ.children(schema))
+
+    # Union them all together
+    select_ast = qlast.Set(elements=frags)
+    with ctx.new() as ectx:
+        ectx.implicit_limit = 0
+        select_ir = dispatch.compile(select_ast, ctx=ectx)
+        select_ir = setgen.scoped_set(
+            select_ir, force_reassign=True, ctx=ectx)
+        assert isinstance(select_ir, irast.Set)
+
+    return select_ir, always_check, from_parent
 
 
 def compile_insert_unless_conflict(
@@ -395,14 +495,15 @@ def compile_insert_unless_conflict(
 
     obj_constrs = real_typ.get_constraints(schema).objects(schema)
 
-    select_ir = compile_insert_unless_conflict_select(
+    select_ir, always_check, _ = compile_insert_unless_conflict_select(
         stmt, insert_subject, real_typ,
         constrs=pointers,
         obj_constrs=obj_constrs,
         parser_context=stmt.context, ctx=ctx)
 
     return irast.OnConflictClause(
-        constraint=None, select_ir=select_ir, else_ir=None)
+        constraint=None, select_ir=select_ir, always_check=always_check,
+        else_ir=None)
 
 
 def compile_insert_unless_conflict_on(
@@ -487,13 +588,22 @@ def compile_insert_unless_conflict_on(
 
     ds = {ptr.get_shortname(schema).name: (ptr, field_constrs)
           for ptr in ptrs}
-    select_ir = compile_insert_unless_conflict_select(
+    select_ir, always_check, from_anc = compile_insert_unless_conflict_select(
         stmt, insert_subject, real_typ, constrs=ds, obj_constrs=obj_constrs,
         parser_context=stmt.context, ctx=ctx)
 
     # Compile an else branch
     else_ir = None
     if else_branch:
+        # TODO: We should support this, but there is some semantic and
+        # implementation trickiness.
+        if from_anc:
+            raise errors.UnsupportedFeatureError(
+                'UNLESS CONFLICT can not use ELSE when constraint is from a '
+                'parent type',
+                context=constraint_spec.context,
+            )
+
         # The ELSE needs to be able to reference the subject in an
         # UPDATE, even though that would normally be prohibited.
         ctx.path_scope.factoring_allowlist.add(stmt.subject.path_id)
@@ -506,6 +616,7 @@ def compile_insert_unless_conflict_on(
     return irast.OnConflictClause(
         constraint=irast.ConstraintRef(id=all_constrs[0].id),
         select_ir=select_ir,
+        always_check=always_check,
         else_ir=else_ir
     )
 
diff --git a/edb/ir/ast.py b/edb/ir/ast.py
--- a/edb/ir/ast.py
+++ b/edb/ir/ast.py
@@ -839,6 +839,7 @@ class MutatingStmt(Stmt):
 class OnConflictClause(Base):
     constraint: typing.Optional[ConstraintRef]
     select_ir: Set
+    always_check: bool
     else_ir: typing.Optional[Set]
 
 
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
@@ -728,6 +728,9 @@ def insert_needs_conflict_cte(
     if ctx.dml_stmts:
         return True
 
+    if ir_stmt.on_conflict and ir_stmt.on_conflict.always_check:
+        return True
+
     for shape_el, _ in ir_stmt.subject.shape:
         assert shape_el.rptr is not None
         ptrref = shape_el.rptr.ptrref


Test patch:
diff --git a/tests/schemas/insert.esdl b/tests/schemas/insert.esdl
--- a/tests/schemas/insert.esdl
+++ b/tests/schemas/insert.esdl
@@ -63,6 +63,11 @@ type Person {
         constraint exclusive on (str_lower(__subject__));
     }
 }
+type DerivedPerson extending Person {
+    property sub_key -> str {
+        constraint exclusive;
+    }
+};
 
 type Person2 {
     required single property first -> std::str;
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
@@ -2707,7 +2707,7 @@ async def test_edgeql_insert_unless_conflict_16(self):
 
     async def test_edgeql_insert_unless_conflict_16b(self):
         async with self.assertRaisesRegexTx(
-                edgedb.QueryError,
+                edgedb.UnsupportedFeatureError,
                 "INSERT UNLESS CONFLICT ON does not support volatile "
                 "properties"):
             await self.con.execute('''
@@ -2715,6 +2715,224 @@ async def test_edgeql_insert_unless_conflict_16b(self):
                 UNLESS CONFLICT ON (.name) ELSE (Person)
             ''')
 
+    async def test_edgeql_insert_unless_conflict_17(self):
+        await self.con.execute(r'''
+            FOR x IN {"1", "2", "3", "4"} UNION (
+                INSERT Person { name := x }
+            );
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            FOR x IN {"1", "2", "3", "4"} UNION (
+                INSERT Person { name := x }
+                UNLESS CONFLICT ON (.name)
+                ELSE (UPDATE Person SET { tag := "!" })
+            );
+            ''',
+            [{}, {}],
+            implicit_limit=2,
+        )
+
+        await self.assert_query_result(
+            r'''
+            SELECT Person.tag
+            ''',
+            ["!"] * 4,
+        )
+
+    async def test_edgeql_insert_unless_conflict_18a(self):
+        await self.con.execute(r'''
+            INSERT Person { name := "Phil Emarg" };
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            INSERT DerivedPerson { name := "Phil Emarg" } UNLESS CONFLICT;
+            ''',
+            [],
+        )
+
+        await self.assert_query_result(
+            r'''
+            INSERT DerivedPerson { name := "Phil Emarg" }
+            UNLESS CONFLICT ON (.name);
+            ''',
+            [],
+        )
+
+    async def test_edgeql_insert_unless_conflict_18b(self):
+        await self.con.execute(r'''
+            INSERT DerivedPerson { name := "Phil Emarg" };
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            INSERT Person { name := "Phil Emarg" } UNLESS CONFLICT;
+            ''',
+            [],
+        )
+
+        await self.assert_query_result(
+            r'''
+            INSERT Person { name := "Phil Emarg" }
+            UNLESS CONFLICT ON (.name);
+            ''',
+            [],
+        )
+
+    async def test_edgeql_insert_unless_conflict_19(self):
+        await self.con.execute(r'''
+            INSERT DerivedPerson { name := "Phil Emarg", sub_key := "1" };
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            INSERT DerivedPerson { name := "Madeline Hatch", sub_key := "1" }
+            UNLESS CONFLICT;
+            ''',
+            [],
+        )
+
+    async def test_edgeql_insert_unless_conflict_20a(self):
+        # currently we reject ELSE in these cases
+        with self.assertRaisesRegex(
+            edgedb.errors.UnsupportedFeatureError,
+            "UNLESS CONFLICT can not use ELSE when constraint is from a "
+            "parent type",
+        ):
+            await self.con.execute(r'''
+                INSERT DerivedPerson { name := "Madeline Hatch" }
+                UNLESS CONFLICT ON (.name) ELSE (SELECT DerivedPerson)
+            ''')
+
+    @test.not_implemented("""
+        ELSE isn't implemented in inheritance cases
+    """)
+    async def test_edgeql_insert_unless_conflict_20b(self):
+        await self.con.execute(r'''
+            INSERT Person { name := "1" };
+        ''')
+
+        # This is maybe what we want? Though it would also possibly
+        # be reasonable to require filtering Person explicitly?
+        # (we'll also want a test case with inheriting from two parents
+        await self.assert_query_result(
+            r'''
+            FOR x IN {"1", "2"} UNION (
+                INSERT DerivedPerson { name := x }
+                UNLESS CONFLICT ON (.name)
+                ELSE (UPDATE Person SET { tag := "!" })
+            );
+            ''',
+            [{}],
+        )
+
+        await self.assert_query_result(
+            r'''
+            SELECT Person {name, tag, sub: Person IS DerivedPerson}
+            ORDER BY .name
+            ''',
+            [
+                {'name': "1", 'tag': "!", 'sub': False},
+                {'name': "2", 'tag': None, 'sub': True},
+            ]
+        )
+
+    async def test_edgeql_insert_unless_conflict_21(self):
+        await self.con.execute('''
+            CREATE TYPE Foo {
+                CREATE REQUIRED PROPERTY name -> str {
+                    CREATE CONSTRAINT exclusive;
+                };
+            };
+            CREATE TYPE Bar {
+                CREATE REQUIRED PROPERTY name -> str {
+                    CREATE CONSTRAINT exclusive;
+                };
+            };
+            CREATE TYPE Baz extending Foo, Bar;
+        ''')
+
+        await self.con.execute(r'''
+            INSERT Foo { name := "foo" };
+            INSERT Foo { name := "both" };
+            INSERT Bar { name := "bar" };
+            INSERT Bar { name := "both" };
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            FOR x IN {"foo", "bar", "both", "asdf"} UNION (
+                INSERT Baz { name := x }
+                UNLESS CONFLICT ON (.name)
+            );
+            ''',
+            [{}],
+        )
+
+    async def test_edgeql_insert_unless_conflict_22(self):
+        await self.con.execute('''
+            CREATE TYPE Foo {
+                CREATE REQUIRED PROPERTY foo -> str {
+                    CREATE CONSTRAINT exclusive;
+                };
+            };
+            CREATE TYPE Bar {
+                CREATE REQUIRED PROPERTY bar -> str {
+                    CREATE CONSTRAINT exclusive;
+                };
+            };
+            CREATE TYPE Baz extending Foo, Bar;
+        ''')
+
+        await self.con.execute(r'''
+            INSERT Foo { foo := "foo" };
+            INSERT Bar { bar := "bar" };
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            INSERT Baz { foo := "!", bar := "bar" }
+            UNLESS CONFLICT ON (.bar)
+            ''',
+            [],
+        )
+
+        async with self.assertRaisesRegexTx(
+                edgedb.ConstraintViolationError,
+                "violates exclusivity constraint"):
+            await self.con.execute(
+                r'''
+                INSERT Baz { foo := "!", bar := "bar" }
+                UNLESS CONFLICT ON (.foo)
+                ''',
+            )
+
+        await self.assert_query_result(
+            r'''
+            INSERT Baz { foo := "foo", bar := "!" }
+            UNLESS CONFLICT
+            ''',
+            [],
+        )
+
+        await self.assert_query_result(
+            r'''
+            INSERT Baz { foo := "!", bar := "bar" }
+            UNLESS CONFLICT
+            ''',
+            [],
+        )
+
+        await self.assert_query_result(
+            r'''
+            INSERT Baz { foo := "foo", bar := "bar" }
+            UNLESS CONFLICT
+            ''',
+            [],
+        )
+
     async def test_edgeql_insert_dependent_01(self):
         query = r'''
             SELECT (
@@ -3652,6 +3870,38 @@ async def test_edgeql_insert_nested_volatile_01(self):
             [2]
         )
 
+    @test.xfail("Issue #2845")
+    async def test_edgeql_insert_cross_type_conflict_01(self):
+        query = r'''
+            WITH name := 'Madeline Hatch',
+                 B := (INSERT Person {name := name}),
+                 F := (INSERT DerivedPerson {name := name}),
+            SELECT (B, F);
+        '''
+
+        with self.assertRaisesRegex(edgedb.ConstraintViolationError,
+                                    "violates exclusivity constraint"):
+            await self.con.execute(query)
+
+    @test.xfail("Issue #2845")
+    async def test_edgeql_insert_cross_type_conflict_02(self):
+        # this isn't really an insert test
+        await self.con.execute('''
+            INSERT Person { name := 'Foo' };
+            INSERT DerivedPerson { name := 'Bar' };
+        ''')
+
+        query = r'''
+            WITH name := 'Madeline hatch',
+                 B := (UPDATE Person FILTER .name = 'Bar' SET {name := name}),
+                 F := (UPDATE Person FILTER .name = 'Foo' SET {name := name}),
+            SELECT (B, F);
+        '''
+
+        with self.assertRaisesRegex(edgedb.ConstraintViolationError,
+                                    "violates exclusivity constraint"):
+            await self.con.execute(query)
+
     async def test_edgeql_insert_and_update_01(self):
         # INSERTing something that would violate a constraint while
         # fixing the violation is still supposed to be an error.