Repository: edgedb/edgedb

Issue:
Filter on computed link on computed link
### Discussed in https://github.com/edgedb/edgedb/discussions/3498

<div type='discussions-op-text'>

<sup>Originally posted by **tchak** February 15, 2022</sup>
I have the following schema:

```
module default {
  type Version {
    multi link fields -> Field {
      property position -> int32 {
        default := 0;
      }
    }
    multi link ordered_fields := (select .fields order by @position);
    multi link sections := (select .ordered_fields filter .type = 'section');
  }

  type Field {
    required property name -> str;
    required property type -> str;
    multi link versions := .<fields[is Version];
  }
}
```

I am getting the following error on migrate:

```
error: object type 'default::Field' has no link or property 'type'
    ┌─ ./dbschema/default.esdl:174:59
    │
174 │     multi link sections := (select .ordered_fields filter .type = 'section');
    │                                                           ^^^^^ error
```

I don't know if I am doing something wrong or if this is a bug. I have the same issue with a filter on an already filtered computed. I am also pretty sure I have something similar working on single computed links.</div>


Discussion:

Resolving patch:
diff --git a/edb/common/topological.py b/edb/common/topological.py
--- a/edb/common/topological.py
+++ b/edb/common/topological.py
@@ -142,10 +142,14 @@ def visit(
         weak_link: bool = False,
     ) -> None:
         if item in visiting:
+            # Separate the matching item from the rest of the visiting
+            # set for error reporting.
+            vis_list = list(visiting - {item})
+            cycle_item = item if len(vis_list) == 0 else vis_list[-1]
             raise CycleError(
-                f"dependency cycle between {list(visiting)[-1]!r} "
+                f"dependency cycle between {cycle_item!r} "
                 f"and {item!r}",
-                path=list(visiting)[1:],
+                path=vis_list,
                 item=item,
             )
         if item not in visited:
diff --git a/edb/edgeql/declarative.py b/edb/edgeql/declarative.py
--- a/edb/edgeql/declarative.py
+++ b/edb/edgeql/declarative.py
@@ -40,6 +40,7 @@
 from edb.common import parsing
 from edb.common import topological
 from edb.common import english
+from edb.common.ordered import OrderedSet
 
 from edb.edgeql import ast as qlast
 from edb.edgeql import codegen as qlcodegen
@@ -402,6 +403,12 @@ def sdl_to_ddl(
         for decl_ast in declarations:
             trace_dependencies(decl_ast, ctx=tracectx)
 
+    # Before sorting normalize all ordering, to make sure that errors
+    # are consistent.
+    for ddlentry in ddlgraph.values():
+        ddlentry.deps = OrderedSet(sorted(ddlentry.deps))
+        ddlentry.weak_deps = OrderedSet(sorted(ddlentry.weak_deps))
+
     try:
         ordered = topological.sort(ddlgraph, allow_unresolved=False)
     except topological.CycleError as e:
@@ -588,10 +595,13 @@ def _trace_item_layout(
         if isinstance(decl, qlast.CreateConcretePointer):
             assert isinstance(obj, qltracer.Source)
             target: Optional[qltracer.TypeLike]
+            target_expr: Optional[qlast.Expr]
             if isinstance(decl.target, qlast.TypeExpr):
                 target = _resolve_type_expr(decl.target, ctx=ctx)
+                target_expr = None
             else:
                 target = None
+                target_expr = decl.target
 
             pn = s_utils.ast_ref_to_unqualname(decl.name)
 
@@ -604,6 +614,7 @@ def _trace_item_layout(
                 s_name.QualName('__', pn.name),
                 source=obj,
                 target=target,
+                target_expr=target_expr,
             )
             obj.pointers[pn] = ptr
             ptr_name = s_name.QualName(
@@ -1078,6 +1089,14 @@ def _get_pointer_deps(
     if pointer in ctx.objects:
         result.add(pointer)
 
+    # HACK: Add all pointers that have this pointer (link, actually)
+    # as their prefix. As a rule, the assumption is that depending on
+    # a link typically comes as a package of depending on the link's
+    # property.
+    for propname in ctx.objects:
+        if str(propname).startswith(str(pointer) + '@'):
+            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
@@ -176,10 +176,12 @@ def __init__(
         *,
         source: Optional[SourceLike] = None,
         target: Optional[TypeLike] = None,
+        target_expr: Optional[qlast.Expr] = None,
     ) -> None:
         super().__init__(name)
         self.source = source
         self.target = target
+        self.target_expr = target_expr
 
     def is_pointer(self) -> bool:
         return True
@@ -256,6 +258,7 @@ def trace_refs(
         path_prefix=path_prefix,
         modaliases={},
         params=params,
+        visited=set(),
     )
     trace(qltree, ctx=ctx)
     return frozenset(ctx.refs)
@@ -273,6 +276,7 @@ def __init__(
         path_prefix: Optional[sn.QualName],
         modaliases: Dict[Optional[str], str],
         params: Mapping[str, sn.QualName],
+        visited: Set[Union[s_pointers.Pointer, Pointer]],
     ) -> None:
         self.schema = schema
         self.refs: Set[sn.QualName] = set()
@@ -283,6 +287,7 @@ def __init__(
         self.path_prefix = path_prefix
         self.modaliases = modaliases
         self.params = params
+        self.visited = visited
 
     def get_ref_name(self, ref: qlast.BaseObjectRef) -> sn.QualName:
         # We don't actually expect to handle anything other than
@@ -349,6 +354,7 @@ def _fork_context() -> TracerContext:
                 path_prefix=ctx.path_prefix,
                 modaliases=dict(ctx.modaliases),
                 params=ctx.params,
+                visited=ctx.visited,
             )
             nctx.refs = ctx.refs
 
@@ -402,6 +408,7 @@ def result_alias_context(
             path_prefix=ctx.path_prefix,
             modaliases=ctx.modaliases,
             params=ctx.params,
+            visited=ctx.visited,
         )
         # use the same refs set
         nctx.refs = ctx.refs
@@ -653,6 +660,23 @@ def trace_Path(
                             assert isinstance(sname, sn.QualName)
                             ctx.refs.add(qualify_name(sname, step.ptr.name))
                             tip = ptr.get_target(ctx.schema)
+
+                            if tip is None:
+                                if ptr in ctx.visited:
+                                    # Possibly recursive definition, bail out.
+                                    return None
+
+                                # This can only be Pointer that didn't
+                                # infer the target type yet.
+                                assert isinstance(ptr, Pointer)
+                                # We haven't computed the target yet,
+                                # so try computing it now.
+                                ctx.visited.add(ptr)
+                                ptr_target = trace(ptr.target_expr, ctx=ctx)
+                                if isinstance(ptr_target, (Type,
+                                                           s_types.Type)):
+                                    tip = ptr.target = ptr_target
+
                         else:
                             # Can't figure out the new tip, so we bail.
                             return None


Test patch:
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1863,6 +1863,69 @@ def as module default,
             }
         ''', modname='default')
 
+    def test_schema_computed_01(self):
+        # Issue #3499
+        """
+            type Version {
+                multi link fields -> Field {
+                    property position -> int32 {
+                        default := 0;
+                    }
+                }
+                multi link sections :=
+                    (select .ordered_fields filter .type = 'section');
+                multi link ordered_fields :=
+                    (select .fields order by @position);
+            }
+
+            type Field {
+                required property type -> str;
+                multi link versions := .<fields[is Version];
+            }
+        """
+
+    def test_schema_computed_02(self):
+        # Issue #3499
+        """
+            type Version {
+                multi link fields -> Field {
+                    property position -> int32 {
+                        default := 0;
+                    }
+                }
+                multi link ordered_fields :=
+                    (select .fields order by @position);
+                multi link sections :=
+                    (select .ordered_fields filter .type = 'section');
+            }
+
+            type Field {
+                required property type -> str;
+                multi link versions := .<fields[is Version];
+            }
+        """
+
+    def test_schema_computed_03(self):
+        # Issue #3499
+        """
+            type Version {
+                multi link ordered_fields :=
+                    (select .fields order by @position);
+                multi link sections :=
+                    (select .ordered_fields filter .type = 'section');
+                multi link fields -> Field {
+                    property position -> int32 {
+                        default := 0;
+                    }
+                }
+            }
+
+            type Field {
+                required property type -> str;
+                multi link versions := .<fields[is Version];
+            }
+        """
+
 
 class TestGetMigration(tb.BaseSchemaLoadTest):
     """Test migration deparse consistency.