Repository: edgedb/edgedb

Issue:
Two computed properties with same name and no `property` keyword cause InternalServerError
- EdgeDB Version: 3.0+e7d38e9
- EdgeDB CLI Version: 3.1.3+9fdca62
- OS Version: Windows 11

I'm rewriting part of Chapter 18 in Easy EdgeDB on currencies at the moment and came across a bug that happens when you mistakenly give two computed properties the same name. Here's a simplified example of what I was putting together:

Steps to Reproduce:

1. Create a schema that has two computables with the same name (i.e. a user typo) and no `property` keyword:

```
module default {

  type SimpleNumbers {
    required num1: int64;
    read_num1 := .num1;
    read_num1 := .num1;
  }
}
```

2. `edgedb migration create` to get this error:

```
edgedb error: InternalServerError: <QualName default::SimpleNumbers@read_num1>
  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/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler_pool/worker.py", line 161, in compile
          units, cstate = COMPILER.compile(
                          ^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler/compiler.py", line 755, in compile
          unit_group = compile(ctx=ctx, source=source)
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler/compiler.py", line 1944, in compile
          return _try_compile(ctx=ctx, source=source)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler/compiler.py", line 2012, in _try_compile
          comp, capabilities = _compile_dispatch_ql(
                               ^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler/compiler.py", line 1851, in _compile_dispatch_ql
          query = ddl.compile_dispatch_ql_migration(
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler/ddl.py", line 382, in compile_dispatch_ql_migration
          return _start_migration(ctx, ql, in_script)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/server/compiler/ddl.py", line 463, in _start_migration
          target_schema = s_ddl.apply_sdl(
                          ^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/schema/ddl.py", line 520, in apply_sdl
          ddl_stmts = s_decl.sdl_to_ddl(target_schema, documents)
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/edgeql/declarative.py", line 453, in sdl_to_ddl
          trace_dependencies(decl_ast, ctx=tracectx)
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/functools.py", line 909, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/edgeql/declarative.py", line 1055, in trace_default
          _register_item(node, ctx=ctx)
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/edgeql/declarative.py", line 1189, in _register_item
          trace_dependencies(cmd, ctx=ctx)
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/functools.py", line 909, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/edgeql/declarative.py", line 822, in trace_SetField
          _register_item(node, deps=deps, hard_dep_exprs=exprs, ctx=ctx)
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/edgeql/declarative.py", line 1080, in _register_item
          vn = get_verbosename_from_fqname(fq_name, ctx)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/home/edgedb/.local/share/edgedb/portable/3.0/lib/python3.11/site-packages/edb/edgeql/declarative.py", line 235, in get_verbosename_from_fqname
          traceobj = ctx.objects[fq_name]
                     ~~~~~~~~~~~^^^^^^^^^
      KeyError: <QualName default::SimpleNumbers@read_num1>
```

If at least one of the computed properties has the keyword `property` then the compiler error is a normal one:

```
error: property 'read_num1' of object type 'default::SimpleNumbers' was already declared
  ┌─ c:\rust\anything\dbschema\default.esdl:6:5
  │
6 │     property read_num1 := .num1;
  │     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error

edgedb error: cannot proceed until .esdl files are fixed
```


Discussion:
Yeah, this one is kind of funny: `read_num1 := .num1;` *doesn't* declare a computed property, but tries to set a field named `read_num1`. (Fields are things like `default` and `readonly`).

This gets detected as declaring the same field twice, but the code path for formatting the field name doesn't work

Resolving patch:
diff --git a/edb/edgeql/declarative.py b/edb/edgeql/declarative.py
--- a/edb/edgeql/declarative.py
+++ b/edb/edgeql/declarative.py
@@ -230,7 +230,7 @@ def ensure_pointer_kind(
 
 def get_verbosename_from_fqname(
     fq_name: s_name.QualName,
-    ctx: DepTraceContext,
+    ctx: DepTraceContext | LayoutTraceContext,
 ) -> str:
     traceobj = ctx.objects[fq_name]
     assert traceobj is not None
@@ -246,13 +246,15 @@ def get_verbosename_from_fqname(
     elif isinstance(traceobj, qltracer.ScalarType):
         clsname = 'scalar'
     elif isinstance(traceobj, qltracer.Function):
-        node = ctx.ddlgraph[fq_name].item
-        assert isinstance(node, qlast.FunctionCommand)
-        params = ','.join(
-            qlcodegen.generate_source(param, sdlmode=True)
-            for param in node.params
-        )
-        name = f"{str(fq_name).split('@@', 1)[0]}({params})"
+        name = str(fq_name).split('@@', 1)[0]
+        if isinstance(ctx, DepTraceContext):
+            node = ctx.ddlgraph[fq_name].item
+            assert isinstance(node, qlast.FunctionCommand)
+            params = ','.join(
+                qlcodegen.generate_source(param, sdlmode=True)
+                for param in node.params
+            )
+            name = f"{name}({params})"
     elif isinstance(traceobj, qltracer.Pointer):
         ofobj, name = str(fq_name).split('@', 1)
         ofobj = f" of object type '{ofobj}'"
@@ -273,6 +275,11 @@ def get_verbosename_from_fqname(
         if name == str(s_indexes.DEFAULT_INDEX):
             name = ''
         ofobj = f" of object type '{ofobj}'"
+    elif isinstance(traceobj, qltracer.Field):
+        clsname = 'field'
+        obj, name = fq_name.name.rsplit('@', 1)
+        ofobj = ' of ' + get_verbosename_from_fqname(
+            s_name.QualName(fq_name.module, obj), ctx)
 
     if name:
         return f"{clsname} '{name}'{ofobj}"
@@ -749,6 +756,22 @@ def _trace_item_layout(
             )
             ctx.objects[idx_name] = qltracer.ConcreteIndex(idx_name)
 
+        elif isinstance(decl, qlast.SetField):
+            field_name = s_name.QualName(
+                module=fq_name.module,
+                name=f'{fq_name.name}@{decl.name}',
+            )
+
+            # Trivial fields don't get added to the ddlgraph, which is
+            # where duplication checks are normally done, so do the
+            # check here instead.
+            if field_name in ctx.objects:
+                vn = get_verbosename_from_fqname(field_name, ctx)
+                msg = f'{vn} was already declared'
+                raise errors.InvalidDefinitionError(msg, context=decl.context)
+
+            ctx.objects[field_name] = qltracer.Field(field_name)
+
 
 RECURSION_GUARD: Set[s_name.QualName] = set()
 
@@ -1079,9 +1102,7 @@ def _register_item(
     if fq_name in ctx.ddlgraph:
         vn = get_verbosename_from_fqname(fq_name, ctx)
         msg = f'{vn} was already declared'
-
-        raise errors.InvalidDefinitionError(
-            msg, context=decl.context)
+        raise errors.InvalidDefinitionError(msg, context=decl.context)
 
     if deps:
         deps = set(deps)
@@ -1328,8 +1349,11 @@ def _get_pointer_deps(
     # is is important for properly doing cardinality inference
     # on expressions involving it.
     # PERF: We should avoid actually searching all the objects.
-    for propname in ctx.objects:
-        if str(propname).startswith(str(pointer) + '@'):
+    for propname, prop in ctx.objects.items():
+        if (
+            str(propname).startswith(str(pointer) + '@')
+            and not isinstance(prop, qltracer.Field)
+        ):
             result.add(propname)
 
     return result
diff --git a/edb/edgeql/tracer.py b/edb/edgeql/tracer.py
--- a/edb/edgeql/tracer.py
+++ b/edb/edgeql/tracer.py
@@ -88,6 +88,10 @@ class ConcreteIndex(NamedObject):
     pass
 
 
+class Field(NamedObject):
+    pass
+
+
 class Type(NamedObject):
     def is_scalar(self) -> bool:
         return False


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -837,6 +837,49 @@ def test_schema_module_reserved_01(self):
             }
         """
 
+    @tb.must_fail(
+        errors.InvalidDefinitionError,
+        "field 'default' .*was already declared"
+    )
+    def test_schema_field_dupe_01(self):
+        """
+        type SimpleNumbers {
+            property bar: str;
+            property foo: str {
+                default := '';
+                default := '';
+            }
+        }
+        """
+
+    @tb.must_fail(
+        errors.InvalidDefinitionError,
+        "field 'default' .*was already declared"
+    )
+    def test_schema_field_dupe_02(self):
+        """
+        type SimpleNumbers {
+            property bar: str;
+            property foo: str {
+                default := .bar;
+                default := .bar;
+            }
+        }
+        """
+
+    @tb.must_fail(
+        errors.InvalidDefinitionError,
+        "field 'foo' .*was already declared"
+    )
+    def test_schema_field_dupe_03(self):
+        """
+        type SimpleNumbers {
+            bar: str;
+            foo := .bar ++ "!";
+            foo := .bar ++ "!";
+        }
+        """
+
     @tb.must_fail(
         errors.InvalidFunctionDefinitionError,
         r"cannot create the `test::foo\(VARIADIC bar: "