Repository: edgedb/edgedb

Issue:
Adding links to types that have a global view puts schema into wedged state
<!-- Please search existing issues to avoid creating duplicates. -->
- EdgeDB Version: 
EdgeDB CLI 2.0.1+93a7fab
EdgeDB 2.0+710b779
- OS Version: Linux LTS

Can't create migration on provided schema for:
- removing .email index on member's object (member.esdl);
- removing .email exclusive constraint on member's object;

Steps to Reproduce:

1. Create first migration and apply;
2. remove .email index or exclusive constraint from member's object SDL;
3. create migration;

<!-- If the issue is about a query error, please also provide your schema -->
Schema:
[schema](https://github.com/lu-zen/edgedb-migration-creation-bug/)


Discussion:
The problem is actually much worse than reported: this migration gets into a state where almost everything is broken. (Deleting `current_user` and everything that uses it should work.)

A minimal reproducing schema is:
```
module default {
  global current_user_id -> uuid;
  global current_user := (
    select Member filter .id = global current_user_id
  );

  type Foo {
    link owner := .<avatar[is Member];
  };
  type Member {
    link avatar -> Foo {
      constraint exclusive;
      on source delete delete target if orphan;
      on target delete allow;
    }
  }
}
```

It seems that if we create the global before we create the link, we create a constraint on the link in the derived type for the computed global, but not if we do it in the other order, and this causes a mismatch. Ahhhhh.
@lu-zen You should be able to recover from this state by deleting `current_user` (and whatever references it) and recreating it. 
Hey, thanks!
Tried to remove the references then the globals and the constraint you mentioned remains tho.
```
SchemaError: cannot drop object type 'default::current_user' because other objects in the schema depend on it
  Detail: constraint 'std::exclusive' of link 'avatar' of object type 'default::User' depends on default::current_user[
```

Sigh. Try dropping the constraint too, then recreating the constraint, then the rest of the stuff. Sorry about this one---it's a mess.

I'm working on a fix, and we'll probably try to hurry out a 2.1 that includes it, but that doesn't really save you from needing to recover.

Resolving patch:
diff --git a/edb/schema/globals.py b/edb/schema/globals.py
--- a/edb/schema/globals.py
+++ b/edb/schema/globals.py
@@ -131,6 +131,10 @@ def _check_expr(
 
         expression = self.get_attribute_value('expr')
         assert isinstance(expression, s_expr.Expression)
+        # If it's not compiled, don't worry about it. This should just
+        # be a dummy expression.
+        if not expression.irast:
+            return schema
         assert isinstance(expression.irast, irast.Statement)
 
         required, card = expression.irast.cardinality.to_schema_value()
@@ -282,10 +286,12 @@ def get_dummy_expr_field_value(
         field: so.Field[Any],
         value: Any,
     ) -> Optional[s_expr.Expression]:
-        if field.name == 'expr':
-            return None
-        elif field.name == 'default':
-            return None
+        if field.name in ('expr', 'default'):
+            rt = self.scls.get_target(schema)
+            if isinstance(rt, so.DerivableInheritingObject):
+                rt = rt.get_nearest_non_derived_parent(schema)
+            text = f'SELECT assert_exists(<{rt.get_displayname(schema)}>{{}})'
+            return s_expr.Expression(text=text)
         else:
             raise NotImplementedError(f'unhandled field {field.name!r}')
 
diff --git a/edb/schema/inheriting.py b/edb/schema/inheriting.py
--- a/edb/schema/inheriting.py
+++ b/edb/schema/inheriting.py
@@ -307,6 +307,25 @@ def get_no_longer_inherited_ref_layout(
 
         return dropped_refs
 
+    def _fixup_inheritance_refdicts(
+        self,
+        schema: s_schema.Schema,
+        context: sd.CommandContext,
+    ) -> None:
+        # HACK?: Derived object types and pointers are created
+        # with inheritance_refdicts={'pointers'}, and typically
+        # don't get persisted. However, for globals and aliases,
+        # they *do* get persisted, and will be altered if a parent
+        # is modified. Make sure those alters are also executed
+        # with a restricted inheritance_refdicts or else whether
+        # things like constraints are created on derived views
+        # will be ordering dependent.
+        # TODO: Clean this up--maybe make it driven explicitly by
+        # is_derived, always?
+        if self.scls.get_is_derived(schema):
+            context.current().inheritance_refdicts = {'pointers'}
+            context.current().inheritance_merge = True
+
     def _recompute_inheritance(
         self,
         schema: s_schema.Schema,
@@ -336,9 +355,10 @@ def _recompute_inheritance(
 
         deleted_refs = {}
         for refdict in mcls.get_refdicts():
-            schema, deleted = self._reinherit_classref_dict(
-                schema, context, refdict)
-            deleted_refs.update(deleted)
+            if _needs_refdict(refdict, context):
+                schema, deleted = self._reinherit_classref_dict(
+                    schema, context, refdict)
+                deleted_refs.update(deleted)
 
         # Finalize the deletes. We need to linearize them, since they might
         # have dependencies between them.
@@ -717,11 +737,7 @@ def _create_innards(
             mcls = self.get_schema_metaclass()
 
             for refdict in mcls.get_refdicts():
-                inheritance_refdicts = context.inheritance_refdicts
-                if ((inheritance_refdicts is None
-                        or refdict.attr in inheritance_refdicts)
-                        and (context.inheritance_merge is None
-                             or context.inheritance_merge)):
+                if _needs_refdict(refdict, context):
                     cmd.add(self.inherit_classref_dict(
                         schema, context, refdict))
 
@@ -860,6 +876,8 @@ def _alter_begin(
         scls = self.scls
 
         if not context.canonical:
+            self._fixup_inheritance_refdicts(schema, context)
+
             props = self.enumerate_attributes()
             if props:
                 bases = scls.get_bases(schema).objects(schema)
@@ -1048,12 +1066,12 @@ def get_verb(self) -> str:
         # would still usually say 'alter', so just always do that.
         return 'alter'
 
-    def apply(
+    def _alter_finalize(
         self,
         schema: s_schema.Schema,
         context: sd.CommandContext
     ) -> s_schema.Schema:
-        schema = super().apply(schema, context)
+        schema = super()._alter_finalize(schema, context)
 
         if not context.canonical:
             schema = self._recompute_inheritance(schema, context)
@@ -1064,6 +1082,8 @@ def apply(
                             schema, context, sd.AlterObject))
                     assert isinstance(d_alter_cmd, InheritingObjectCommand)
                     with ctx_stack():
+                        d_alter_cmd._fixup_inheritance_refdicts(
+                            schema, context)
                         schema = d_alter_cmd._recompute_inheritance(
                             schema, context)
                     self.add_caused(d_root_cmd)
@@ -1198,3 +1218,11 @@ def _get_bases_for_ast(
         mcls = self.get_schema_metaclass()
         roots = set(mcls.get_root_classes())
         return tuple(b for b in bases if b.name not in roots)
+
+
+def _needs_refdict(refdict: so.RefDict, context: sd.CommandContext) -> bool:
+    inheritance_refdicts = context.inheritance_refdicts
+    return (
+        inheritance_refdicts is None
+        or refdict.attr in inheritance_refdicts
+    ) and (context.inheritance_merge is None or context.inheritance_merge)


Test patch:
diff --git a/tests/test_edgeql_data_migration.py b/tests/test_edgeql_data_migration.py
--- a/tests/test_edgeql_data_migration.py
+++ b/tests/test_edgeql_data_migration.py
@@ -11086,6 +11086,61 @@ async def test_edgeql_migration_access_policy_01(self):
             }
         """)
 
+    async def test_edgeql_migration_globals_01(self):
+        schema = r"""
+            global current_user_id -> uuid;
+            global current_user := (
+              select Member filter .id = global current_user_id
+            );
+
+            type Foo {
+              link owner := .<avatar[is Member];
+            };
+            type Member {
+              link avatar -> Foo {
+                constraint exclusive;
+              }
+            }
+        """
+        # Make sure it doesn't get into a wedged state
+        await self.migrate(schema)
+        await self.migrate(schema)
+
+    async def test_edgeql_migration_globals_02(self):
+        await self.migrate(r"""
+            global current_user_id -> uuid;
+            global current_user := (
+              select Member filter .id = global current_user_id
+            );
+
+            type Foo;
+            type Base {
+              link avatar -> Foo {
+                constraint exclusive;
+              }
+            }
+            type Member;
+        """)
+
+        schema = r"""
+            global current_user_id -> uuid;
+            global current_user := (
+              select Member filter .id = global current_user_id
+            );
+
+            type Foo;
+            type Base {
+              link avatar -> Foo {
+                constraint exclusive;
+              }
+            }
+            type Member extending Base;
+        """
+
+        # Make sure it doesn't get into a wedged state
+        await self.migrate(schema)
+        await self.migrate(schema)
+
     async def test_edgeql_migration_recovery(self):
         await self.con.execute(r"""
             START MIGRATION TO {