Repository: edgedb/edgedb

Issue:
`InternalServerError` when creating migration with module name containing backtick
- EdgeDB Version: EdgeDB 3.0-dev.7232+local
- EdgeDB CLI Version: 2.3.0

Steps to Reproduce:

1. Add a module with a name containing a backtick to schema file, eg:
```
  module default {
    module nested {
      type Test {}
    }
  
+   module `back``ticked` {
+     type Test {}
+   }
  }
```
2. Run `edgedb migration create`:
```
$ edgedb migration create
edgedb error: InternalServerError: 
  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:
      Traceback (most recent call last):
        File "/home/james/dev/edgedb/edb/server/compiler_pool/worker_proc.py", line 55, in worker
          res = meth(*args)
        File "/home/james/dev/edgedb/edb/server/compiler_pool/worker.py", line 186, in compile_in_tx
          units, cstate = COMPILER.compile_in_tx(cstate, *args, **kwargs)
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 782, in compile_in_tx
          return compile(ctx=ctx, source=source), ctx.state
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1742, in compile
          return _try_compile(ctx=ctx, source=original)
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1813, in _try_compile
          comp, capabilities = _compile_dispatch_ql(
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1667, in _compile_dispatch_ql
          query = ddl.compile_dispatch_ql_migration(
        File "/home/james/dev/edgedb/edb/server/compiler/ddl.py", line 386, in compile_dispatch_ql_migration
          return _describe_current_migration(ctx, ql)
        File "/home/james/dev/edgedb/edb/server/compiler/ddl.py", line 605, in _describe_current_migration
          guided_diff = s_ddl.delta_schemas(
        File "/home/james/dev/edgedb/edb/schema/ddl.py", line 369, in delta_schemas
          assert mod is not None
      AssertionError
```


Discussion:
This looks like it's probably related:
```
nested> describe schema as sdl;
edgedb error: InternalServerError:
  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:
      Traceback (most recent call last):
        File "/home/james/dev/edgedb/edb/server/compiler_pool/worker_proc.py", line 55, in worker
          res = meth(*args)
        File "/home/james/dev/edgedb/edb/server/compiler_pool/worker.py", line 161, in compile
          units, cstate = COMPILER.compile(
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 725, in compile
          unit_group = compile(ctx=ctx, source=source)
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1745, in compile
          return _try_compile(ctx=ctx, source=source)
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1813, in _try_compile
          comp, capabilities = _compile_dispatch_ql(
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1719, in _compile_dispatch_ql
          query = _compile_ql_query(ctx, ql, script_info=script_info)
        File "/home/james/dev/edgedb/edb/server/compiler/compiler.py", line 1310, in _compile_ql_query
          ir = qlcompiler.compile_ast_to_ir(
        File "/home/james/dev/edgedb/edb/edgeql/compiler/__init__.py", line 179, in wrapper
          return func(*args, **kwargs)
        File "/home/james/dev/edgedb/edb/edgeql/compiler/__init__.py", line 237, in compile_ast_to_ir
          ir_set = dispatch_mod.compile(tree, ctx=ctx)
        File "/usr/lib/python3.10/functools.py", line 889, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
        File "/home/james/dev/edgedb/edb/edgeql/compiler/stmt.py", line 781, in compile_DescribeStmt
          text = s_ddl.sdl_text_from_schema(
        File "/home/james/dev/edgedb/edb/schema/ddl.py", line 880, in sdl_text_from_schema
          diff = delta_schemas(
        File "/home/james/dev/edgedb/edb/schema/ddl.py", line 238, in delta_schemas
          assert mod is not None
      AssertionError
```

Edit: Using this schema from the nested modules discussion:
```
module default {
  type Foo {
    link foo -> Foo;
    link bar -> default::nested1::Bar;
    link baz -> default::nested1::nested_twice::Baz;
    link hello -> default::nested2::Hello;
    link world -> top_level::World;
  };

  module nested1 {
    type Bar {
      link foo -> default::Foo;
      link bar -> Bar;
      link baz -> default::nested1::nested_twice::Baz;
      link hello -> default::nested2::Hello;
      link world -> top_level::World;
    }

    module nested_twice {
      type Baz {
        link foo -> default::Foo;
        link bar -> default::nested1::Bar;
        link baz -> Baz;
        link hello -> default::nested2::Hello;
        link world -> top_level::World;
      }
    }
  }
  module nested2 {
    type Hello {
      link foo -> default::Foo;
      link bar -> default::nested1::Bar;
      link baz -> default::nested1::nested_twice::Baz;
      link hello -> Hello;
      link world -> top_level::World;
    }
  }
}
module top_level {
  type World {
    link foo -> default::Foo;
    link bar -> default::nested1::Bar;
    link baz -> default::nested1::nested_twice::Baz;
    link hello -> default::nested2::Hello;
    link world -> World;
  }
}
```

Resolving patch:
diff --git a/edb/edgeql/codegen.py b/edb/edgeql/codegen.py
--- a/edb/edgeql/codegen.py
+++ b/edb/edgeql/codegen.py
@@ -32,7 +32,6 @@
 from . import qltypes
 
 
-_module_name_re = re.compile(r'^(?!=\d)\w+(\.(?!=\d)\w+)*$')
 _BYTES_ESCAPE_RE = re.compile(b'[\\\'\x00-\x1f\x7e-\xff]')
 _NON_PRINTABLE_RE = re.compile(
     r'[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F\n]')
@@ -57,25 +56,15 @@ def _bytes_escape(match: Match[bytes]) -> bytes:
         return b'\\x%02x' % char[0]
 
 
-def any_ident_to_str(ident: str) -> str:
-    if _module_name_re.match(ident):
-        return ident
-    else:
-        return ident_to_str(ident)
-
-
-def ident_to_str(ident: str, allow_num: bool=False) -> str:
-    return edgeql_quote.quote_ident(ident, allow_num=allow_num)
-
-
 def param_to_str(ident: str) -> str:
     return '$' + edgeql_quote.quote_ident(
         ident, allow_reserved=True, allow_num=True)
 
 
-def module_to_str(module: str) -> str:
+def ident_to_str(ident: str, allow_num: bool=False) -> str:
     return '::'.join([
-        any_ident_to_str(part) for part in module.split('::')
+        edgeql_quote.quote_ident(part, allow_num=allow_num)
+        for part in ident.split('::')
     ])
 
 
@@ -421,7 +410,7 @@ def visit_ModuleAliasDecl(self, node: qlast.ModuleAliasDecl) -> None:
             self.write(ident_to_str(node.alias))
             self._write_keywords(' AS ')
         self._write_keywords('MODULE ')
-        self.write(module_to_str(node.module))
+        self.write(ident_to_str(node.module))
 
     def visit_SortExpr(self, node: qlast.SortExpr) -> None:
         self.visit(node.path)
@@ -916,7 +905,7 @@ def _visit_CreateObject(
         render_commands: bool = True,
         unqualified: bool = False,
         named: bool = True,
-        group_by_system_comment: bool = False
+        group_by_system_comment: bool = False,
     ) -> None:
         self._visit_aliases(node)
         if self.sdlmode:
@@ -951,7 +940,7 @@ def _visit_AlterObject(
         unqualified: bool = False,
         named: bool = True,
         ignored_cmds: Optional[AbstractSet[qlast.DDLOperation]] = None,
-        group_by_system_comment: bool = False
+        group_by_system_comment: bool = False,
     ) -> None:
         self._visit_aliases(node)
         if self.sdlmode:
@@ -986,7 +975,7 @@ def _visit_DropObject(
         *object_keywords: str,
         unqualified: bool = False,
         after_name: Optional[Callable[[], None]] = None,
-        named: bool = True
+        named: bool = True,
     ) -> None:
         self._visit_aliases(node)
         self._write_keywords('DROP', *object_keywords)
@@ -2328,10 +2317,10 @@ def visit_SessionSetAliasDecl(
             self._write_keywords(' ALIAS ')
             self.write(ident_to_str(node.alias))
             self._write_keywords(' AS MODULE ')
-            self.write(node.module)
+            self.write(ident_to_str(node.module))
         else:
             self._write_keywords(' MODULE ')
-            self.write(node.module)
+            self.write(ident_to_str(node.module))
 
     def visit_SessionResetAllAliases(
         self,
diff --git a/edb/schema/modules.py b/edb/schema/modules.py
--- a/edb/schema/modules.py
+++ b/edb/schema/modules.py
@@ -41,6 +41,9 @@ class Module(
     qlkind=qltypes.SchemaObjectClass.MODULE,
     data_safe=False,
 ):
+    # N.B: Modules are not "qualified" objects, even though they can
+    # be nested (because they might *not* be nested) and we arrange
+    # for their names to always be represented with an UnqualName.
     pass
 
 
diff --git a/edb/schema/reflection/reader.py b/edb/schema/reflection/reader.py
--- a/edb/schema/reflection/reader.py
+++ b/edb/schema/reflection/reader.py
@@ -194,7 +194,10 @@ def parse_into(
                         elif issubclass(ftype, s_obj.Object):
                             val = val.id
                         elif issubclass(ftype, s_name.Name):
-                            val = s_name.name_from_string(val)
+                            if isinstance(obj, s_obj.QualifiedObject):
+                                val = s_name.name_from_string(val)
+                            else:
+                                val = s_name.UnqualName(val)
                         else:
                             val = ftype(val)
 


Test patch:
diff --git a/tests/schemas/dump_v3_default.esdl b/tests/schemas/dump_v3_default.esdl
--- a/tests/schemas/dump_v3_default.esdl
+++ b/tests/schemas/dump_v3_default.esdl
@@ -15,3 +15,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
+
+module nested { type T }
+module `back``ticked` { type T }
diff --git a/tests/test_dump_v3.py b/tests/test_dump_v3.py
--- a/tests/test_dump_v3.py
+++ b/tests/test_dump_v3.py
@@ -33,8 +33,17 @@ async def ensure_schema_data_integrity(self):
             await tx.rollback()
 
     async def _ensure_schema_data_integrity(self):
-        # Nothing yet
-        pass
+        await self.assert_query_result(
+            r'''
+            SELECT _ := schema::Module.name
+            FILTER _ LIKE 'default%'
+            ''',
+            {'default', 'default::nested', 'default::back`ticked'},
+        )
+
+        # We don't bother to validate these but we need them to work
+        await self.con.query('describe schema as sdl')
+        await self.con.query('describe schema as ddl')
 
 
 class TestDumpV3(tb.StableDumpTestCase, DumpTestCaseMixin):
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
@@ -11315,6 +11315,16 @@ async def test_edgeql_migration_lift_01(self):
             type Bar extending Foo;
         """)
 
+    async def test_edgeql_migration_nested_backticks_01(self):
+        await self.migrate(r"""
+            module nested { type Test };
+        """)
+
+        await self.migrate(r"""
+            module nested { type Test };
+            module `back``ticked` { type Test };
+        """)
+
 
 class TestEdgeQLDataMigrationNonisolated(EdgeQLDataMigrationTestCase):
     TRANSACTION_ISOLATION = False
diff --git a/tests/test_edgeql_syntax.py b/tests/test_edgeql_syntax.py
--- a/tests/test_edgeql_syntax.py
+++ b/tests/test_edgeql_syntax.py
@@ -2466,6 +2466,17 @@ def test_edgeql_syntax_with_01(self):
             spam,
             ham := baz
         } FILTER (foo = 'special');
+
+% OK %
+
+        WITH
+            extra AS MODULE `lib.extra`,
+            foo := Bar.foo,
+            baz := (SELECT extra::Foo.baz)
+        SELECT Bar {
+            spam,
+            ham := baz
+        } FILTER (foo = 'special');
         """
 
     @tb.must_fail(errors.EdgeQLSyntaxError, line=5, col=9)
@@ -2496,15 +2507,17 @@ def test_edgeql_syntax_with_06(self):
         WITH MODULE abstract SELECT Foo;
         WITH MODULE all SELECT Foo;
         WITH MODULE all.abstract.bar SELECT Foo;
+
+% OK %
+
+        WITH MODULE abstract SELECT Foo;
+        WITH MODULE all SELECT Foo;
+        WITH MODULE `all.abstract.bar` SELECT Foo;
         """
 
     def test_edgeql_syntax_with_07(self):
         """
         WITH MODULE `all.abstract.bar` SELECT Foo;
-
-% OK %
-
-        WITH MODULE all.abstract.bar SELECT Foo;
         """
 
     def test_edgeql_syntax_with_08(self):