Repository: edgedb/edgedb

Issue:
ISE with constraint `errmessage`
<!-- 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 && edgedb migration apply
Created dbschema/migrations/00001.edgeql, id: m1zpd7uqb35mnfakmdohsn4qwc3on427s42ptwfaa6lfznh4fgxs7q
Applied m1zpd7uqb35mnfakmdohsn4qwc3on427s42ptwfaa6lfznh4fgxs7q (00001.edgeql)

$ edgedb
EdgeDB 3.0-dev.7343+4d0b071 (repl 3.0.0-dev.941+730fc35)
Type \help for help, \quit to quit.
edgedb> insert User { email := "" };
edgedb error: InternalServerError: unhandled error while calling interpret_backend_error(); run with EDGEDB_DEBUG_SERVER to debug.
  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:
      RuntimeError: unhandled error while calling interpret_backend_error(); run with EDGEDB_DEBUG_SERVER to debug.

```

<!-- If the issue is about a query error, please also provide your schema -->

Schema:
```esdl
module default {
  type User {
      required property email -> str {
          constraint min_len_value(4) {
              errmessage := '{"fields": ["email"], "message": "Emails must be longer than 4 characters."}';
          };
      };
  }
}
```


Discussion:

Resolving patch:
diff --git a/edb/schema/constraints.py b/edb/schema/constraints.py
--- a/edb/schema/constraints.py
+++ b/edb/schema/constraints.py
@@ -19,6 +19,7 @@
 
 from __future__ import annotations
 from typing import *
+import re
 
 from edb import errors
 from edb.common import verutils
@@ -235,7 +236,7 @@ def format_error_message(
         schema: s_schema.Schema,
         subjtitle: str,
     ) -> str:
-        errmsg = self.get_errmessage(schema)
+        errmsg: Optional[str] = self.get_errmessage(schema)
         args = self.get_args(schema)
         if args:
             args_ql: List[qlast.Base] = [
@@ -264,9 +265,7 @@ def format_error_message(
             args_map = {'__subject__': subjtitle}
 
         assert errmsg is not None
-        formatted = errmsg.format(**args_map)
-
-        return formatted
+        return interpolate_errmessage(errmsg, args_map)
 
     def as_alter_delta(
         self,
@@ -1568,3 +1567,32 @@ def _get_bases_for_ast(
         bases: Tuple[so.ObjectShell[Constraint], ...],
     ) -> Tuple[so.ObjectShell[Constraint], ...]:
         return ()
+
+
+def interpolate_errmessage(message: str, args: Dict[str, str]) -> str:
+    """
+    Converts message template "hello {world}! {nope}{{world}}" and
+    arguments {"world": "Alice", "hell": "Eve"}
+    into "hello Alice! {world}".
+    """
+
+    regex = r"\{\{.*\}\}|\{([A-Za-z_0-9]+)\}"
+
+    formatted = ""
+    last_start = 0
+    for match in re.finditer(regex, message, flags=0):
+        formatted += message[last_start : match.start()]
+        last_start = match.end()
+
+        if match[1] is None:
+            # escape double curly braces
+            formatted += match[0][1:-1]
+        elif match[1] in args:
+            # lookup an arg
+            formatted += args[match[1]]
+        else:
+            # arg not found
+            formatted += match[0]
+
+    formatted += message[last_start:]
+    return formatted
diff --git a/edb/server/main.py b/edb/server/main.py
--- a/edb/server/main.py
+++ b/edb/server/main.py
@@ -46,6 +46,7 @@
 from edb.common import exceptions
 from edb.common import devmode
 from edb.common import signalctl
+from edb.common import debug
 
 from . import args as srvargs
 from . import daemon
@@ -455,6 +456,11 @@ async def run_server(
         f"defaulting to the '{args.default_auth_method}' authentication method"
     )
 
+    if debug.flags.pydebug_listen:
+        import debugpy
+
+        debugpy.listen(38782)
+
     _init_parsers()
 
     pg_cluster_init_by_us = False


Test patch:
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -973,6 +973,24 @@ async def test_constraints_ddl_04(self):
                 };
             """)
 
+        # testing interpolation
+        await self.con.execute(r"""
+            CREATE type ConstraintOnTest4_2 {
+                CREATE required property email -> str {
+                    CREATE constraint min_len_value(4) {
+                        SET errmessage := '{"json": "{nope} {{min}} {min}"}';
+                    };
+                };
+            };
+        """)
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            '{"json": "{nope} {min} 4"}',
+        ):
+            await self.con.execute("""
+                INSERT ConstraintOnTest4_2 { email := '' };
+            """)
+
     async def test_constraints_ddl_05(self):
         # Test that constraint expression returns a boolean.