Repository: edgedb/edgedb

Issue:
Can we improve `type or view does not exist` message?
Here is the query:
```
SELECT User { id } FILTER email = <str>$email
```
And the error is:
```
edgedb.errors.InvalidReferenceError: object type or view 'email' does not exist
```

My points are:
1. It would be quite common error (as it closely matches SQL `WHERE email =`)
2. It's hard to understand what `object type or view` means in this context.

The best fix would be to look through all the names and make a suggestion:
```
edgedb.errors.InvalidReferenceError: object type or view 'email' does not exist. Try `User.email`?
```

If it's hard to implement we can use some heuristics (like 99% that lowercase name is a field). Or at least add an explanation `You can't reference field directly use 'Object.field'`.


Discussion:
Yes, this can be improved.  FYI, you don't have to use `Object.field`, `.field` (with a leading dot) is enough.

Resolving patch:
diff --git a/edb/edgeql/compiler/schemactx.py b/edb/edgeql/compiler/schemactx.py
--- a/edb/edgeql/compiler/schemactx.py
+++ b/edb/edgeql/compiler/schemactx.py
@@ -27,6 +27,7 @@
 from edb import errors
 
 from edb.common import parsing
+from edb.ir import typeutils
 
 from edb.schema import links as s_links
 from edb.schema import name as sn
@@ -95,6 +96,7 @@ def get_schema_object(
             modaliases=ctx.modaliases,
             schema=ctx.env.schema,
             item_type=item_type,
+            pointer_parent=_get_partial_path_prefix_type(ctx),
             condition=condition,
             context=srcctx,
         )
@@ -113,6 +115,19 @@ def get_schema_object(
         return stype
 
 
+def _get_partial_path_prefix_type(
+    ctx: context.ContextLevel,
+) -> Optional[s_types.Type]:
+    if ctx is None:
+        return None
+    ppp = ctx.partial_path_prefix
+    if ppp is None or ppp.typeref is None:
+        return None
+
+    _, type = typeutils.ir_typeref_to_type(ctx.env.schema, ppp.typeref)
+    return type
+
+
 def _get_type_variant(
     name: sn.Name,
     ctx: context.ContextLevel,
diff --git a/edb/edgeql/compiler/setgen.py b/edb/edgeql/compiler/setgen.py
--- a/edb/edgeql/compiler/setgen.py
+++ b/edb/edgeql/compiler/setgen.py
@@ -756,13 +756,12 @@ def resolve_ptr(
     err = errors.InvalidReferenceError(msg, context=source_context)
 
     if direction is s_pointers.PointerDirection.Outbound:
-        near_enpoint_pointers = near_endpoint.get_pointers(ctx.env.schema)
         s_utils.enrich_schema_lookup_error(
             err,
             s_name.UnqualName(pointer_name),
             modaliases=ctx.modaliases,
             item_type=s_pointers.Pointer,
-            collection=near_enpoint_pointers.objects(ctx.env.schema),
+            pointer_parent=near_endpoint,
             schema=ctx.env.schema,
         )
 
diff --git a/edb/schema/utils.py b/edb/schema/utils.py
--- a/edb/schema/utils.py
+++ b/edb/schema/utils.py
@@ -769,48 +769,49 @@ def find_item_suggestions(
     schema: s_schema.Schema,
     *,
     item_type: Optional[so.ObjectMeta] = None,
-    limit: int = 3,
-    collection: Optional[Iterable[so.Object]] = None,
     condition: Optional[Callable[[so.Object], bool]] = None,
-) -> List[so.Object]:
+) -> Iterable[Tuple[so.Object, str]]:
     from . import functions as s_func
+    from . import properties as s_prop
+    from . import links as s_link
     from . import modules as s_mod
 
-    local_name = name.name
     orig_modname = name.module if isinstance(name, sn.QualName) else None
     modname = modaliases.get(orig_modname, orig_modname)
 
     suggestions: List[so.Object] = []
 
-    if collection is not None:
-        suggestions.extend(collection)
-    else:
-        if modname:
-            module = schema.get_global(s_mod.Module, modname, None)
-            if module:
-                suggestions.extend(
-                    schema.get_objects(
-                        included_modules=[sn.UnqualName(modname)],
-                    ),
-                )
-
-        if not orig_modname:
+    if modname:
+        module = schema.get_global(s_mod.Module, modname, None)
+        if module:
             suggestions.extend(
                 schema.get_objects(
-                    included_modules=[sn.UnqualName('std')],
+                    included_modules=[sn.UnqualName(modname)],
                 ),
             )
 
+    if not orig_modname:
+        suggestions.extend(
+            schema.get_objects(
+                included_modules=[sn.UnqualName("std")],
+            ),
+        )
+
     filters = []
 
-    if item_type is not None:
-        it = item_type
-        filters.append(lambda s: isinstance(s, it))
+    # links and properties are suggested by find_fields_suggestions
+    filters.append(
+        lambda s: not isinstance(s, s_prop.Property)
+        and not isinstance(s, s_link.Link)
+    )
 
     if condition is not None:
         filters.append(condition)
 
-    if not item_type:
+    if item_type is not None:
+        it = item_type
+        filters.append(lambda s: isinstance(s, it))
+    else:
         # When schema class is not specified, only suggest generic objects.
         filters.append(lambda s: not sn.is_fullname(str(s.get_name(schema))))
         filters.append(lambda s: not isinstance(s, s_func.CallableObject))
@@ -818,30 +819,79 @@ def find_item_suggestions(
     # Never suggest object fragments.
     filters.append(lambda s: not isinstance(s, so.ObjectFragment))
 
-    filtered_suggestions = filter(lambda s: all(f(s) for f in filters),
-                                  suggestions)
+    filtered = filter(lambda s: all(f(s) for f in filters), suggestions)
+
+    # Add display names
+    cur_module_name = modaliases.get(None)
+
+    def get_display_name(suggestion: so.Object) -> str:
+        if isinstance(suggestion, so.QualifiedObject):
+            mod = suggestion.get_name(schema).module
+            if mod == "std" or mod == cur_module_name:
+                return get_nq_name(schema, suggestion)
+
+        return suggestion.get_displayname(schema)
+
+    return ((s, get_display_name(s)) for s in filtered)
+
+
+def find_pointer_suggestions(
+    schema: s_schema.Schema,
+    item_type: Optional[so.ObjectMeta],
+    parent: Optional[so.Object],
+) -> Iterable[Tuple[so.Object, str]]:
+    from . import pointers as s_pointers
+
+    """
+    Suggests pointers (properties or links) from parent object type.
+    If pointer type is not expected, use .name notation.
+    """
+    from . import sources as s_sources
+
+    if not isinstance(parent, s_sources.Source):
+        return ()
+
+    pointers_with_names = parent.get_pointers(schema).items(schema)
+    pointers = (pointer for _, pointer in pointers_with_names)
+
+    suggestions = ((s, s.get_displayname(schema)) for s in pointers)
+
+    if item_type is not s_pointers.Pointer:
+        # Prefix with .
+        suggestions = ((s, "." + n) for s, n in suggestions)
+
+    return suggestions
+
+
+def pick_closest_suggestions(
+    name: sn.Name,
+    schema: s_schema.Schema,
+    suggestions: Iterable[Tuple[so.Object, str]],
+    limit: int,
+) -> List[Tuple[so.Object, str]]:
+    local_name = name.name
 
     # Compute Levenshtein distance for each suggestion.
-    with_distance: List[Tuple[so.Object, int]] = [
-        (s, levenshtein.distance(local_name, get_nq_name(schema, s)))
-        for s in filtered_suggestions
+    with_distance: List[Tuple[so.Object, str, int]] = [
+        (s, name, levenshtein.distance(local_name, get_nq_name(schema, s)))
+        for s, name in suggestions
     ]
 
     # Filter out suggestions that are too dissimilar.
     max_distance = 3
-    closest = list(filter(lambda s: s[1] < max_distance, with_distance))
+    closest = list(filter(lambda s: s[2] < max_distance, with_distance))
 
     # Sort by proximity, then by whether the suggestion is contains
     # the source string at the beginning, then by suggestion name.
     closest.sort(
         key=lambda s: (
-            s[1],
+            s[2],
             not get_nq_name(schema, s[0]).startswith(local_name),
-            s[0].get_displayname(schema)
+            s[1],
         )
     )
 
-    return [s[0] for s in closest[:limit]]
+    return [(s[0], s[1]) for s in closest[:limit]]
 
 
 def enrich_schema_lookup_error(
@@ -852,39 +902,28 @@ def enrich_schema_lookup_error(
     *,
     item_type: Optional[so.ObjectMeta] = None,
     suggestion_limit: int = 3,
-    name_template: Optional[str] = None,
-    collection: Optional[Iterable[so.Object]] = None,
     condition: Optional[Callable[[so.Object], bool]] = None,
-    context: Any = None,
+    context: Optional[parsing.ParserContext] = None,
+    pointer_parent: Optional[so.Object] = None,
 ) -> None:
 
-    suggestions = find_item_suggestions(
-        item_name, modaliases, schema,
-        item_type=item_type, limit=suggestion_limit,
-        collection=collection, condition=condition)
+    all_suggestions = itertools.chain(
+        find_item_suggestions(
+            item_name,
+            modaliases,
+            schema,
+            item_type=item_type,
+            condition=condition,
+        ),
+        find_pointer_suggestions(schema, item_type, pointer_parent),
+    )
+
+    suggestions = pick_closest_suggestions(
+        item_name, schema, all_suggestions, suggestion_limit
+    )
 
     if suggestions:
-        names: List[str] = []
-        cur_module_name = modaliases.get(None)
-
-        for suggestion in suggestions:
-            if (
-                isinstance(suggestion, so.QualifiedObject)
-                and (
-                    suggestion.get_name(schema).module == 'std'
-                    or suggestion.get_name(schema).module == cur_module_name
-                )
-            ):
-                names.append(get_nq_name(schema, suggestion))
-            else:
-                names.append(str(suggestion.get_displayname(schema)))
-
-        if name_template is not None:
-            # Use a set() for names as there might be duplicates.
-            # E.g. "to_datetime" function has multiple variants, and
-            # we don't want to diplay "did you mean one of these:
-            # to_datetime, to_datetime, to_datetime?"
-            names = list({name_template.format(name=name) for name in names})
+        names = [name for _, name in suggestions]
 
         if len(names) > 1:
             hint = f'did you mean one of these: {", ".join(names)}?'


Test patch:
diff --git a/tests/test_edgeql_select.py b/tests/test_edgeql_select.py
--- a/tests/test_edgeql_select.py
+++ b/tests/test_edgeql_select.py
@@ -6006,6 +6006,39 @@ async def test_edgeql_select_bad_reference_02(self):
                 SELECT User.nam;
             """)
 
+    async def test_edgeql_select_bad_reference_03(self):
+        with self.assertRaisesRegex(
+                edgedb.QueryError,
+                r"object type or alias 'default::number' does not exist",
+                _hint="did you mean '.number'?"):
+
+            await self.con.query("""
+                select Issue filter number = '4418';
+            """)
+
+    async def test_edgeql_select_bad_reference_04(self):
+        with self.assertRaisesRegex(
+                edgedb.QueryError,
+                r"object type or alias 'default::referrnce' does not exist",
+                _hint="did you mean '.references'?"):
+
+            await self.con.query("""
+                select Issue filter referrnce = '#4418';
+            """)
+
+    async def test_edgeql_select_bad_reference_05(self):
+
+        with self.assertRaisesRegex(
+            edgedb.QueryError,
+            "object type 'default::Issue' has no link or property 'referrnce'",
+            _hint="did you mean 'references'?",
+        ):
+            await self.con.query(
+                """
+            select Issue filter .referrnce = '#4418';
+            """
+            )
+
     async def test_edgeql_select_precedence_01(self):
         with self.assertRaisesRegex(
                 edgedb.QueryError, r'index indirection cannot.*int64.*'):