Repository: edgedb/edgedb

Issue:
Cardinality Inference on cascading
<!-- Please search existing issues to avoid creating duplicates. -->
- EdgeDB Version: 1.0
- OS Version: macOS Monterey 12.0.1

Steps to Reproduce:

1.I got a problem about cardinality Inference
![image](https://user-images.githubusercontent.com/71876021/156167349-40f3f3eb-ccb0-4f90-a331-5ff5a8cf1949.png)

- The red condition causes cardinality exception;
- The blue condition works




<!-- If the issue is about a query error, please also provide your schema -->
2.  related schema:
```  
create abstract type smartlist { 
    create required property name -> str;
    create property desc -> str; 
  };
  
  create type sml_cf_usage extending smartlist {
    alter property name {
      create constraint exclusive; 
    }; 
  }; 

  create type lease_asset { 
    create property sys_asset_id -> uuid; 
    create required property asset_no -> str { 
      create constraint exclusive; 
    };
    create property area -> decimal { 
      create constraint min_value(0); 
    } ;
    create property _create_time -> cal::local_datetime; 
    create property _modify_time -> cal::local_datetime; 
     
    create link entity -> dim_entity; 
    create link _creator -> user; 
    create link _modifier -> user; 
  };

  create type lease_cf_usage {
    create property lease_sdate -> cal::local_datetime; 
    create property lease_edate -> cal::local_datetime; 
    create property total_amount -> decimal; 
    
    create link cf_usage -> sml_cf_usage; 
    create required link asset -> lease_asset { 
      on target delete delete source; 
    } ;
    
    create constraint exclusive on ((.cf_usage, .asset));
  } ;

  create type lease_cf_payment { 
    create property cf_id -> uuid; 
    create property cf_date -> cal::local_datetime; 
    create property eff_sdate -> cal::local_datetime; 
    create property eff_edate -> cal::local_datetime; 
    create property cf_amount -> decimal; 
    create property cf_amount_calc -> decimal; 
    
    create link cf_type -> sml_cf_type; 
    create link cf_status -> sml_cf_status; 
    create required link lease_cf_usage -> lease_cf_usage { 
      on target delete delete source; 
    } ;
  } ;

```

Cardinality Inference on cascading has some problem.



Discussion:
Taking a look. In the future, please post code snippets in as text, instead of sharing a screenshot, though. A screenshot makes it harder to try out the code.
This is an interesting question. I think that I would categorize it as a "feature request" and not a bug report:

The current rules for cardinality inference is that if you filter on the exclusive constraints of an object, the cardinality will be AtMostOne. This means that as specified, the blue version is expected to work and the red version isn't, since it does not directly filter on the links with the exclusive constraints.

However, I think that it would be *sound* to allow filtering on longer paths, as long as each element of the path has an exclusive constraint on it.

That said, I think it is more important for the cardinality rules to be predictable to reason about than for them to cover every possible case. Extending it to cover longer paths like this is probably still pretty predictable to reason about, though?

To further complicate matters, while playing around with this idea, I discovered a bug in cardinality inference that actually does allow this sort of pattern to be accepted in some cases... including ones in which it is *not* sound.
For example, in our `cards` db, `select User filter .avatar.name = 'Dragon'` gets inferred as having `AtMostOne` because we misinterpret the `.name` and look up the name pointer on `User`, while totally ignoring the `.avatar` part.

So maybe we want to implement this feature along with the fix for that, to avoid breaking code that was depending on it and could be sound?

@elprans thoughts?


Resolving patch:
diff --git a/edb/edgeql/compiler/inference/cardinality.py b/edb/edgeql/compiler/inference/cardinality.py
--- a/edb/edgeql/compiler/inference/cardinality.py
+++ b/edb/edgeql/compiler/inference/cardinality.py
@@ -834,15 +834,13 @@ def _is_ptr_or_self_ref(
 
         return (
             isinstance(srccls, s_objtypes.ObjectType)
-            and ir_set.expr is None
             and (
                 env.set_types[ir_set] == srccls
                 or (
                     (rptr := ir_set.rptr) is not None
-                    and srccls.maybe_get_ptr(
-                        env.schema,
-                        rptr.ptrref.shortname.get_local_name()
-                    ) is not None
+                    and isinstance(rptr.ptrref, irast.PointerRef)
+                    and not rptr.ptrref.is_computable
+                    and _is_ptr_or_self_ref(rptr.source, result_expr, env)
                 )
             )
         )
@@ -853,14 +851,12 @@ def extract_filters(
     filter_set: irast.Set,
     scope_tree: irast.ScopeTreeNode,
     ctx: inference_context.InfCtx,
-) -> Sequence[Tuple[s_pointers.Pointer, irast.Set]]:
+) -> Sequence[Tuple[Sequence[s_pointers.Pointer], irast.Set]]:
 
     env = ctx.env
     schema = env.schema
     scope_tree = inf_utils.get_set_scope(filter_set, scope_tree, ctx=ctx)
 
-    ptr: s_pointers.Pointer
-
     expr = filter_set.expr
     if isinstance(expr, irast.OperatorCall):
         if str(expr.func_shortname) == 'std::=':
@@ -874,41 +870,33 @@ def extract_filters(
             if op_card.is_multi():
                 pass
 
-            elif _is_ptr_or_self_ref(left, result_set, env):
+            elif (
+                (left_matches := _is_ptr_or_self_ref(left, result_set, env))
+                or _is_ptr_or_self_ref(right, result_set, env)
+            ):
+                # If the match was on the right, flip the args
+                if not left_matches:
+                    left, right = right, left
+
                 if infer_cardinality(
                     right, scope_tree=scope_tree, ctx=ctx,
                 ).is_single():
+                    ptrs = []
                     left_stype = env.set_types[left]
                     if left_stype == result_stype:
                         assert isinstance(left_stype, s_objtypes.ObjectType)
                         _ptr = left_stype.getptr(schema, sn.UnqualName('id'))
+                        ptrs.append(_ptr)
                     else:
-                        assert left.rptr is not None
-                        _ptr = env.schema.get(left.rptr.ptrref.name,
-                                              type=s_pointers.Pointer)
-
-                    assert _ptr is not None
-                    ptr = _ptr
+                        while env.set_types[left] != result_stype:
+                            assert left.rptr is not None
+                            _ptr = env.schema.get(left.rptr.ptrref.name,
+                                                  type=s_pointers.Pointer)
+                            ptrs.append(_ptr)
+                            left = left.rptr.source
+                        ptrs.reverse()
 
-                    return [(ptr, right)]
-
-            elif _is_ptr_or_self_ref(right, result_set, env):
-                if infer_cardinality(
-                    left, scope_tree=scope_tree, ctx=ctx,
-                ).is_single():
-                    right_stype = env.set_types[right]
-                    if right_stype == result_stype:
-                        assert isinstance(right_stype, s_objtypes.ObjectType)
-                        _ptr = right_stype.getptr(schema, sn.UnqualName('id'))
-                    else:
-                        assert right.rptr is not None
-                        _ptr = env.schema.get(right.rptr.ptrref.name,
-                                              type=s_pointers.Pointer)
-
-                    assert _ptr is not None
-                    ptr = _ptr
-
-                    return [(ptr, right)]
+                    return [(ptrs, right)]
 
         elif str(expr.func_shortname) == 'std::AND':
             left, right = (a.expr for a in expr.args)
@@ -920,15 +908,33 @@ def extract_filters(
                 result_set, right, scope_tree, ctx
             )
 
-            ptr_filters: List[Tuple[s_pointers.Pointer, irast.Set]] = []
-            ptr_filters.extend(left_filters)
-            ptr_filters.extend(right_filters)
-
-            return ptr_filters
+            return [*left_filters, *right_filters]
 
     return []
 
 
+def _all_have_exclusive(
+    ptrs: Sequence[s_pointers.Pointer],
+    ctx: inference_context.InfCtx,
+) -> bool:
+    return all(
+        bool(ptr.get_exclusive_constraints(ctx.env.schema))
+        for ptr in ptrs
+    )
+
+
+def _track_all_constraint_refs(
+    ptrs: Sequence[s_pointers.Pointer],
+    ctx: inference_context.InfCtx,
+) -> None:
+    for ptr in ptrs:
+        for constr in ptr.get_exclusive_constraints(ctx.env.schema):
+            # We need to track all schema refs, since an expression
+            # in the schema needs to depend on any constraint
+            # that affects its cardinality.
+            ctx.env.add_schema_ref(constr, None)
+
+
 def extract_exclusive_filters(
     result_set: irast.Set,
     filter_set: irast.Set,
@@ -941,22 +947,24 @@ def extract_exclusive_filters(
     results: List[Tuple[Tuple[s_pointers.Pointer, irast.Set], ...]] = []
     if filtered_ptrs:
         schema = ctx.env.schema
+        # Only look at paths where all trailing pointers are exclusive;
+        # that is, if we see `.foo.bar`, `bar` must be exclusive.
+        # If that's the case, then we can look at whether `.foo` is
+        # exclusive or used in an exclusive object constraint.
         filtered_ptrs_map = {
-            ptr.get_nearest_non_derived_parent(schema): expr
-            for ptr, expr in filtered_ptrs
+            ptrs[0].get_nearest_non_derived_parent(schema): (ptrs, expr)
+            for ptrs, expr in filtered_ptrs
+            if _all_have_exclusive(ptrs[1:], ctx)
         }
         ptr_set = set(filtered_ptrs_map)
         # First look at each referenced pointer and see if it has
         # an exclusive constraint.
-        for ptr, expr in filtered_ptrs_map.items():
-            for constr in ptr.get_exclusive_constraints(schema):
+        for ptr, (ptrs, expr) in filtered_ptrs_map.items():
+            if _all_have_exclusive([ptr], ctx):
                 # Bingo, got an equality filter on a pointer with a
                 # unique constraint
                 results.append(((ptr, expr),))
-                # We need to track all schema refs, since an expression
-                # in the schema needs to depend on any constraint
-                # that affects its cardinality.
-                ctx.env.add_schema_ref(constr, None)
+                _track_all_constraint_refs(ptrs, ctx)
 
         # Then look at all the object exclusive constraints
         result_stype = ctx.env.set_types[result_set]
@@ -964,8 +972,11 @@ def extract_exclusive_filters(
             result_stype, ptr_set, ctx.env)
         for constr, obj_exc_ptrs in obj_exclusives.items():
             results.append(
-                tuple((ptr, filtered_ptrs_map[ptr]) for ptr in obj_exc_ptrs))
+                tuple((ptr, filtered_ptrs_map[ptr][1]) for ptr in obj_exc_ptrs)
+            )
             ctx.env.add_schema_ref(constr, None)
+            for ptr in obj_exc_ptrs:
+                _track_all_constraint_refs(filtered_ptrs_map[ptr][0], ctx)
 
     return results
 


Test patch:
diff --git a/tests/schemas/cards_ir_inference.esdl b/tests/schemas/cards_ir_inference.esdl
--- a/tests/schemas/cards_ir_inference.esdl
+++ b/tests/schemas/cards_ir_inference.esdl
@@ -43,6 +43,10 @@ type User extending Named {
     link avatar -> Card {
         property text -> str;
     }
+
+    link unique_avatar -> Card {
+        constraint exclusive;
+    }
 }
 
 type Card extending Named {
diff --git a/tests/test_edgeql_ir_card_inference.py b/tests/test_edgeql_ir_card_inference.py
--- a/tests/test_edgeql_ir_card_inference.py
+++ b/tests/test_edgeql_ir_card_inference.py
@@ -570,6 +570,14 @@ def test_edgeql_ir_card_inference_60(self):
         AT_MOST_ONE
         """
 
+    def test_edgeql_ir_card_inference_60b(self):
+        """
+        SELECT Person
+        FILTER .p = 12 AND .card.name = 'Imp'
+% OK %
+        AT_MOST_ONE
+        """
+
     def test_edgeql_ir_card_inference_61(self):
         """
         SELECT Person FILTER .first = "Phil" OR .last = "Emarg"
@@ -904,6 +912,22 @@ def test_edgeql_ir_card_inference_103(self):
     def test_edgeql_ir_card_inference_104(self):
         """
         SELECT 1 OFFSET 2
+% OK %
+        AT_MOST_ONE
+        """
+
+    def test_edgeql_ir_card_inference_105(self):
+        """
+        select User
+        filter .avatar.name = 'Dragon'
+% OK %
+        MANY
+        """
+
+    def test_edgeql_ir_card_inference_106(self):
+        """
+        select User
+        filter .unique_avatar.name = 'Dragon'
 % OK %
         AT_MOST_ONE
         """