Repository: edgedb/edgedb

Issue:
Migrations have problems with computed links changing cardinality.
<!-- Please search existing issues to avoid creating duplicates. -->
- EdgeDB Version: RC1
- OS Version: linux

See #3002, tests `test_schema_migrations_equivalence_constraint_06`, `test_schema_migrations_equivalence_constraint_07`, `test_schema_migrations_equivalence_constraint_08`.

```
type Cell {
    link right -> Cell;
    link left := .<right[IS Cell];
}
```

The setup is that a many-to-one link (`right`) gets an exclusive constraint to become one-to-one, the corresponding computed link (`left`) based on the backlink should become one-to-one as well. This fails in a variety of ways depending on the particular migration path. All of the individual schemas appear to have correct cardinality inference when taken separately, though.




Discussion:
Adding an extra step with `link left := assert_single(.<right[IS Cell])` helps as a workaround for this issue. The final migration can then remove the `assert_single()` wrappers because they are unnecessary in the end.

There might be more complex cardinality inference issues where such a workaround could  be necessary. Although I cannot think of a good migration example right now, but this still may be a useful note on when to use `assert_single`.

Resolving patch:
diff --git a/edb/schema/ordering.py b/edb/schema/ordering.py
--- a/edb/schema/ordering.py
+++ b/edb/schema/ordering.py
@@ -32,6 +32,7 @@
 from . import name as sn
 from . import objects as so
 from . import pointers as s_pointers
+from . import constraints as s_constraints
 from . import referencing
 from . import types as s_types
 
@@ -539,6 +540,68 @@ def write_dep_matrix(
                 else:
                     item.deps.add((dependency_tag, dependency))
 
+    def write_ref_deps(
+        ref: so.Object,
+        obj: so.Object,
+        this_name_str: str,
+    ) -> None:
+        ref_name = ref.get_name(new_schema)
+        if ref_name in renames_r:
+            ref_name = renames_r[ref_name]
+        ref_name_str = str(ref_name)
+
+        if ((isinstance(ref, referencing.ReferencedObject)
+                and ref.get_referrer(new_schema) == obj)
+                or (isinstance(obj, referencing.ReferencedObject)
+                    and obj.get_referrer(new_schema) == ref)):
+            # Mostly ignore refs generated by refdict backref, but
+            # make create/alter depend on renames of the backref.
+            # This makes sure that a rename is done before the innards are
+            # modified. DDL doesn't actually require this but some of the
+            # internals for producing the DDL do (since otherwise we can
+            # generate references to the renamed type in our delta before
+            # it is renamed).
+            if tag in ('create', 'alter'):
+                deps.add(('rename', ref_name_str))
+
+            return
+
+        write_dep_matrix(
+            dependent=ref_name_str,
+            dependent_tags=('create', 'alter', 'rebase'),
+            dependency=this_name_str,
+            dependency_tags=('create', 'alter', 'rename'),
+        )
+
+        item = get_deps(('rename', ref_name_str))
+        item.deps.add(('create', this_name_str))
+        item.deps.add(('alter', this_name_str))
+        item.deps.add(('rename', this_name_str))
+
+        if isinstance(ref, s_pointers.Pointer):
+            # The current item is a type referred to by
+            # a link or property in another type.  Set the referring
+            # type and its descendants as weak dependents of the current
+            # item to reduce the number of unnecessary ALTERs in the
+            # final delta, especially ones that might result in SET TYPE
+            # commands being generated.
+            ref_src = ref.get_source(new_schema)
+            if isinstance(ref_src, s_pointers.Pointer):
+                ref_src_src = ref_src.get_source(new_schema)
+                if ref_src_src is not None:
+                    ref_src = ref_src_src
+            if ref_src is not None:
+                for desc in ref_src.descendants(new_schema) | {ref_src}:
+                    desc_name = str(desc.get_name(new_schema))
+
+                    write_dep_matrix(
+                        dependent=desc_name,
+                        dependent_tags=('create', 'alter'),
+                        dependency=this_name_str,
+                        dependency_tags=('create', 'alter', 'rename'),
+                        as_weak=True,
+                    )
+
     deps: ordered.OrderedSet[Tuple[str, str]] = ordered.OrderedSet()
     graph_key: str
     implicit_ancestors: List[sn.Name] = []
@@ -719,62 +782,7 @@ def write_dep_matrix(
 
         refs = _get_referrers(new_schema, obj, strongrefs)
         for ref in refs:
-            ref_name = ref.get_name(new_schema)
-            if ref_name in renames_r:
-                ref_name = renames_r[ref_name]
-            ref_name_str = str(ref_name)
-
-            if ((isinstance(ref, referencing.ReferencedObject)
-                    and ref.get_referrer(new_schema) == obj)
-                    or (isinstance(obj, referencing.ReferencedObject)
-                        and obj.get_referrer(new_schema) == ref)):
-                # Mostly ignore refs generated by refdict backref, but
-                # make create/alter depend on renames of the backref.
-                # This makes sure that a rename is done before the innards are
-                # modified. DDL doesn't actually require this but some of the
-                # internals for producing the DDL do (since otherwise we can
-                # generate references to the renamed type in our delta before
-                # it is renamed).
-                if tag in ('create', 'alter'):
-                    deps.add(('rename', ref_name_str))
-
-                continue
-
-            write_dep_matrix(
-                dependent=ref_name_str,
-                dependent_tags=('create', 'alter', 'rebase'),
-                dependency=this_name_str,
-                dependency_tags=('create', 'alter', 'rename'),
-            )
-
-            item = get_deps(('rename', ref_name_str))
-            item.deps.add(('create', this_name_str))
-            item.deps.add(('alter', this_name_str))
-            item.deps.add(('rename', this_name_str))
-
-            if isinstance(ref, s_pointers.Pointer):
-                # The current item is a type referred to by
-                # a link or property in another type.  Set the referring
-                # type and its descendants as weak dependents of the current
-                # item to reduce the number of unnecessary ALTERs in the
-                # final delta, especially ones that might result in SET TYPE
-                # commands being generated.
-                ref_src = ref.get_source(new_schema)
-                if isinstance(ref_src, s_pointers.Pointer):
-                    ref_src_src = ref_src.get_source(new_schema)
-                    if ref_src_src is not None:
-                        ref_src = ref_src_src
-                if ref_src is not None:
-                    for desc in ref_src.descendants(new_schema) | {ref_src}:
-                        desc_name = str(desc.get_name(new_schema))
-
-                        write_dep_matrix(
-                            dependent=desc_name,
-                            dependent_tags=('create', 'alter'),
-                            dependency=this_name_str,
-                            dependency_tags=('create', 'alter', 'rename'),
-                            as_weak=True,
-                        )
+            write_ref_deps(ref, obj, this_name_str)
 
         if tag in ('create', 'alter'):
             # In a delete/create cycle, deletion must obviously
@@ -812,6 +820,17 @@ def write_dep_matrix(
                 else:
                     deps.add(('rebase', ref_name_str))
 
+                # Addition and removal of constraints can cause
+                # changes to the cardinality of expressions that refer
+                # to them. Add the appropriate dependencies in.
+                if (
+                    isinstance(obj, s_constraints.Constraint)
+                    and isinstance(referrer, s_pointers.Pointer)
+                ):
+                    refs = _get_referrers(new_schema, referrer, strongrefs)
+                    for ref in refs:
+                        write_ref_deps(ref, referrer, this_name_str)
+
                 if isinstance(obj, referencing.ReferencedInheritingObject):
                     implicit_ancestors = [
                         b.get_name(new_schema)
diff --git a/edb/schema/pointers.py b/edb/schema/pointers.py
--- a/edb/schema/pointers.py
+++ b/edb/schema/pointers.py
@@ -1779,10 +1779,16 @@ def _alter_begin(
     ) -> s_schema.Schema:
         schema = super()._alter_begin(schema, context)
 
-        if self.get_attribute_value('expr') is not None:
+        if (
+            self.get_attribute_value('expr') is not None
+            or bool(self.get_subcommands(type=constraints.ConstraintCommand))
+        ):
             # If the expression gets changed, we need to propagate
             # this change to other expressions referring to this one,
             # in case there are any cycles caused by this change.
+            #
+            # Also, if constraints are modified, that can affect
+            # cardinality of other expressions using backlinks.
             schema = self._propagate_if_expr_refs(
                 schema,
                 context,
@@ -2378,6 +2384,10 @@ def _needs_conv_expr(
             and not self.is_attribute_inherited('cardinality')
             and not ptr_op.maybe_get_object_aux_data('from_alias')
             and self.conv_expr is None
+            and not (
+                ptr_op.get_attribute_value('expr')
+                or ptr_op.get_orig_attribute_value('expr')
+            )
             and not (
                 ptr_op.get_attribute_value('declared_overloaded')
                 or isinstance(src_op, sd.CreateObject)


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -5438,22 +5438,6 @@ def test_schema_migrations_equivalence_constraint_05(self):
             type Bar extending Foo;
         """])
 
-    @test.xfail('''
-        AssertionError: unexpected difference in schema produced by
-        incremental migration on step 2:
-
-        <DeltaRoot canonical=True at 0x7fd924ff24f0> (
-            <AlterObjectType classname=default::Cell....> (
-                <AlterLink canonical=True,
-                           classname=default::__|left@default|Cell ...> (
-                    <AlterLinkUpperCardinality ...> (
-                        cardinality = <SchemaCardinality.Many: 'Many'> |
-                                      <SchemaCardinality.One: 'One'> # computed
-                    )
-                )
-            )
-        )
-    ''')
     def test_schema_migrations_equivalence_constraint_06(self):
         self._assert_migration_equivalence([r"""
             type Cell {
@@ -5472,11 +5456,6 @@ def test_schema_migrations_equivalence_constraint_06(self):
             }
         """])
 
-    @test.xfail('''
-        SchemaDefinitionError: possibly more than one element returned
-        by an expression for the computed link 'left' of object type
-        'default::Cell' explicitly declared as 'single'
-    ''')
     def test_schema_migrations_equivalence_constraint_07(self):
         self._assert_migration_equivalence([r"""
             type Cell {
@@ -5495,10 +5474,6 @@ def test_schema_migrations_equivalence_constraint_07(self):
             }
         """])
 
-    @test.xfail('''
-        SchemaError: cannot automatically convert link 'left' of
-        object type 'default::Cell' to 'single' cardinality
-    ''')
     def test_schema_migrations_equivalence_constraint_08(self):
         self._assert_migration_equivalence([r"""
             type Cell {
@@ -5523,6 +5498,40 @@ def test_schema_migrations_equivalence_constraint_08(self):
             }
         """])
 
+    def test_schema_migrations_equivalence_constraint_09(self):
+        self._assert_migration_equivalence([r"""
+            type Cell {
+                link right -> Cell {
+                    # Add the constraint to make it 1-1
+                    constraint exclusive;
+                }
+                # Explicitly single link
+                single link left := .<right[IS Cell];
+            }
+        """, r"""
+            type Cell {
+                link right -> Cell;
+                # Explicitly multi link
+                multi link left := .<right[IS Cell];
+            }
+        """])
+
+    def test_schema_migrations_equivalence_constraint_10(self):
+        self._assert_migration_equivalence([r"""
+            type Cell {
+                link right -> Cell {
+                    # Add the constraint to make it 1-1
+                    constraint exclusive;
+                }
+                link left := .<right[IS Cell];
+            }
+        """, r"""
+            type Cell {
+                link right -> Cell;
+                link left := .<right[IS Cell];
+            }
+        """])
+
     # NOTE: array<str>, array<int16>, array<json> already exist in std
     # schema, so it's better to use array<float32> or some other
     # non-typical scalars in tests as a way of testing a collection
@@ -7984,8 +7993,8 @@ def test_schema_describe_schema_01(self):
             };
             CREATE ABSTRACT LINK test::f {
                 CREATE PROPERTY p -> test::int_t {
-                    CREATE ANNOTATION test::anno := 'annotated link property';
                     CREATE CONSTRAINT std::max_value(10);
+                    CREATE ANNOTATION test::anno := 'annotated link property';
                 };
             };
             CREATE TYPE test::Foo;