Repository: edgedb/edgedb

Issue:
ISE on exclusive constraint
<!-- Please search existing issues to avoid creating duplicates. -->

<!--
For the EdgeDB Version: run `edgedb query 'select sys::get_version_as_str()'` from your project directory (or run `select sys::get_version_as_str();` in the EdgeDB interactive shell).
For the EdgeDB CLI Version: Run `edgedb --version` from anywhere
-->

- EdgeDB Version: 3.0-dev.7343+4d0b071
- EdgeDB CLI Version: 3.0.0-dev.941+730fc35
- OS Version: ubuntu 22.04

Steps to Reproduce:

```bash
$ edgedb migration create
Created dbschema/migrations/00001.edgeql, id: m14qm75zdaubqhctniawlfnnr6vd4weqtqduxuenhpclnxjvzavvba

$ edgedb migration apply
edgedb error: InternalServerError: functions in index expression must be marked IMMUTABLE
  Hint: This is most likely a bug in EdgeDB. Please consider opening an issue ticket at https://github.com/edgedb/edgedb/issues/new?template=bug_report.md
  Server traceback:
      edb.errors.InternalServerError: functions in index expression must be marked IMMUTABLE

```

<!-- If the issue is about a query error, please also provide your schema -->

Schema:
```esdl
module default {
  scalar type Status extending enum<'Active', 'Inactive'>;
  type Foo {
    required property ip -> str;
    required property status -> Status;
    constraint exclusive on ((.ip, <str>.status = 'Active'));
  }
}
```


Discussion:
We fixed some stuff related to this in #4775 but there is more to do apparently.

(I think as a workaround, doing the cast in the other way should work now?)

Resolving patch:
diff --git a/edb/edgeql/compiler/casts.py b/edb/edgeql/compiler/casts.py
--- a/edb/edgeql/compiler/casts.py
+++ b/edb/edgeql/compiler/casts.py
@@ -224,12 +224,20 @@ def compile_cast(
     # Constraints and indexes require an immutable expression, but pg cast is
     # only stable. In this specific case, we use cast wrapper function that
     # is declared to be immutable.
-    if new_stype.is_enum(ctx.env.schema):
+    if orig_stype.is_enum(ctx.env.schema) or new_stype.is_enum(ctx.env.schema):
         objctx = ctx.env.options.schema_object_context
         if objctx in (s_constr.Constraint, s_indexes.Index):
-            return _cast_enum_immutable(
-                ir_expr, orig_stype, new_stype, ctx=ctx
+
+            str_typ = ctx.env.schema.get(
+                sn.QualName("std", "str"),
+                type=s_types.Type,
             )
+            orig_str = orig_stype.issubclass(ctx.env.schema, str_typ)
+            new_str = new_stype.issubclass(ctx.env.schema, str_typ)
+            if orig_str or new_str:
+                return _cast_enum_str_immutable(
+                    ir_expr, orig_stype, new_stype, ctx=ctx
+                )
 
     return _compile_cast(
         ir_expr,
@@ -962,19 +970,33 @@ def _cast_array_literal(
     return setgen.ensure_set(cast_ir, ctx=ctx)
 
 
-def _cast_enum_immutable(
+def _cast_enum_str_immutable(
     ir_expr: Union[irast.Set, irast.Expr],
     orig_stype: s_types.Type,
     new_stype: s_types.Type,
     *,
     ctx: context.ContextLevel,
 ) -> irast.Set:
-    orig_typeref = typegen.type_to_typeref(orig_stype, env=ctx.env)
-    new_typeref = typegen.type_to_typeref(new_stype, env=ctx.env)
+    """
+    Compiles cast between an enum and std::str
+    under the assumption that this expression must be immutable.
+    """
+
+    if new_stype.is_enum(ctx.env.schema):
+        enum_stype = new_stype
+        suffix = "_from_str"
+    else:
+        enum_stype = orig_stype
+        suffix = "_into_str"
 
-    name: s_name.Name = new_stype.get_name(ctx.env.schema)
+    name: s_name.Name = enum_stype.get_name(ctx.env.schema)
     name = cast(s_name.QualName, name)
-    cast_name = s_name.QualName(module=name.module, name=str(new_stype.id))
+    cast_name = s_name.QualName(
+        module=name.module, name=str(enum_stype.id) + suffix
+    )
+
+    orig_typeref = typegen.type_to_typeref(orig_stype, env=ctx.env)
+    new_typeref = typegen.type_to_typeref(new_stype, env=ctx.env)
 
     cast_ir = irast.TypeCast(
         expr=setgen.ensure_set(ir_expr, ctx=ctx),
diff --git a/edb/pgsql/common.py b/edb/pgsql/common.py
--- a/edb/pgsql/common.py
+++ b/edb/pgsql/common.py
@@ -219,7 +219,8 @@ def get_scalar_backend_name(id, module_name, catenate=True, *, aspect=None):
         "domain",
         "sequence",
         "enum",
-        "enum-cast",
+        "enum-cast-into-str",
+        "enum-cast-from-str",
         "source-del-imm-otl-f",
         "source-del-imm-otl-t",
     ):
@@ -227,7 +228,9 @@ def get_scalar_backend_name(id, module_name, catenate=True, *, aspect=None):
             f'unexpected aspect for scalar backend name: {aspect!r}')
     name = s_name.QualName(module=module_name, name=str(id))
 
-    if aspect == "enum-cast":
+    if aspect.startswith("enum-cast-"):
+        suffix = "_into_str" if aspect == "enum-cast-into-str" else "_from_str"
+        name = s_name.QualName(name.module, name.name + suffix)
         return get_cast_backend_name(name, catenate, aspect="function")
 
     return convert_name(name, aspect, catenate)
diff --git a/edb/pgsql/delta.py b/edb/pgsql/delta.py
--- a/edb/pgsql/delta.py
+++ b/edb/pgsql/delta.py
@@ -2397,7 +2397,7 @@ def create_enum(
         # needed for casting within indexes/constraints.
         # (Postgres casts are only stable)
         cast_func_name = common.get_backend_name(
-            schema, scalar, catenate=False, aspect="enum-cast"
+            schema, scalar, catenate=False, aspect="enum-cast-from-str"
         )
         cast_func = dbops.Function(
             name=cast_func_name,
@@ -2407,6 +2407,19 @@ def create_enum(
             text=f"SELECT value::{qt(new_enum_name)}",
         )
         ops.add_command(dbops.CreateFunction(cast_func))
+
+        # Simialry, uncast from enum to str
+        uncast_func_name = common.get_backend_name(
+            schema, scalar, catenate=False, aspect="enum-cast-into-str"
+        )
+        uncast_func = dbops.Function(
+            name=uncast_func_name,
+            args=[("value", ("anyelement",))],
+            volatility="immutable",
+            returns="text",
+            text=f"SELECT value::text",
+        )
+        ops.add_command(dbops.CreateFunction(uncast_func))
         return ops
 
     def _create_begin(
@@ -2842,7 +2855,7 @@ def delete_scalar(
             cond = dbops.EnumExists(old_enum_name)
 
             cast_func_name = common.get_backend_name(
-                orig_schema, scalar, catenate=False, aspect="enum-cast"
+                orig_schema, scalar, False, aspect="enum-cast-from-str"
             )
             cast_func = dbops.DropFunction(
                 name=cast_func_name,
@@ -2851,6 +2864,16 @@ def delete_scalar(
             )
             ops.add_command(cast_func)
 
+            uncast_func_name = common.get_backend_name(
+                orig_schema, scalar, False, aspect="enum-cast-into-str"
+            )
+            uncast_func = dbops.DropFunction(
+                name=uncast_func_name,
+                args=[("value", ("anyelement",))],
+                conditions=[cond],
+            )
+            ops.add_command(uncast_func)
+
             enum = dbops.DropEnum(name=old_enum_name, conditions=[cond])
             ops.add_command(enum)
             return ops


Test patch:
diff --git a/tests/test_edgeql_ddl.py b/tests/test_edgeql_ddl.py
--- a/tests/test_edgeql_ddl.py
+++ b/tests/test_edgeql_ddl.py
@@ -10149,6 +10149,8 @@ async def test_edgeql_ddl_constraint_25(self):
                 create required property status -> Status;
             }
         """)
+
+        # enum casts in constraints
         await self.con.execute("""
             alter type Order {
                 create constraint exclusive on ((Status.open = .status));
@@ -10159,6 +10161,13 @@ async def test_edgeql_ddl_constraint_25(self):
                 create constraint exclusive on ((<Status>'open' = .status));
             };
         """)
+        await self.con.execute("""
+            alter type Order {
+                create constraint exclusive on (('open' = <str>.status));
+            };
+        """)
+
+        # enum casts in indexes
         await self.con.execute("""
             alter type Order {
                 create index on ((Status.open = .status));
@@ -10169,6 +10178,11 @@ async def test_edgeql_ddl_constraint_25(self):
                 create index on ((<Status>'open' = .status));
             };
         """)
+        await self.con.execute("""
+            alter type Order {
+                create index on (('open' = <str>.status));
+            };
+        """)
 
     async def test_edgeql_ddl_constraint_check_01a(self):
         await self.con.execute(r"""