Repository: edgedb/edgedb

Issue:
Trying to assign result of two coalesced select queries to a union type link throws an InvalidLinkTargetError 
<!-- Please search existing issues to avoid creating duplicates. -->

<!--
For the EdgeDB Version: run `edgedb query 'select sys::get_version_as_str()'` from your project directory (or run `select sys::get_version_as_str();` in the EdgeDB interactive shell).
For the EdgeDB CLI Version: Run `edgedb --version` from anywhere
-->

- EdgeDB Version: 2.13+c171f92
- EdgeDB CLI Version: 2.3.1+ef99779
- OS Version: Windows 10 Home build 19045.2846

I'm trying to update a union type link based on whichever of the two types contains the record I'm looking for

<!-- If the issue is about a query error, please also provide your schema -->

Schema:

```
# Vehicles
abstract type Vehicle {
    required property model -> str { constraint exclusive; }
}
type Motorcycle extending Vehicle {
    required property geared -> bool;
}
type Car extending Vehicle {
    required property numOfDoors -> int16;
}
type Bicycle extending Vehicle {
    required property frameMaterial -> str;
}

# Drivers/riders
abstract type Driver {
    required link vehicle -> Vehicle;
}
type CarDriver extending Driver {
    overloaded required link vehicle -> Car;
}
type MotorcycleRider extending Driver {
    overloaded required link vehicle -> Motorcycle;
}
type Cyclist extending Driver {
    overloaded required link vehicle -> Bicycle;

    # Don't want to use the common abstract type Vehicle because this should not include other Bicycles
    link hobbyVehicle -> Car | Motorcycle; 
}
```

Query:
```
with
  hobbyVehicleModel := <str>$hobbyVehicleModel
update Cyclist
filter .vehicle.id = <uuid>$lorryId
set {
  hobbyVehicle := (
    # Try to look in both types to find the model that matches 🔍
    (select Motorcycle filter .model = hobbyVehicleModel) ?? (select Car filter .model = hobbyVehicleModel)
    # Also tried: `(select Vehicle filter .model = vehicleModel and (Vehicle is Car or Vehicle is Motorcycle))`
  )
}
```
The above update statement fails with error
```
InvalidLinkTargetError: invalid target for link 'hobbyVehicle' of object type 'default::Cyclist': 'default::Vehicle' (expecting 'default::Car | default::Motorcycle')
```
Even though as far as I can tell this should work, since the `select` query should return a record of type `Car | Motorcycle`, so why does it think I'm trying to assign the link a value of type `Vehicle`? 
But either way, how would I get this working without needing to run a separate update statement to try to find each of the allowed subtypes separately?


Discussion:
I think this is kind of tricky, and speaks to a common problem in systems that have both an inheritance hierarchy and union types of: when should you infer a union type and when should you infer a common parent?

Possibly the solution is to support type operators in `IS` so you can write `[is Car | Motorcycle]` after?
To me it feels like the natural shape of a query like this should just be `A | B`. I don't want the type system to get too clever and figure out that since these two queries have an ancestor type that that's what the return type should be. Especially since if I were to coalesce two queries on types that have no common ancestor I would expect that expression to just return `A | B`, so changing that behaviour just because the two types have a common ancestor is "surprising."

Similarly, what would the behaviour be if `A` and `B` both inherit from the same _two_ ancestor types via multiple inheritance? The union of those two ancestor types? If it would return a union type anyway why not just return the union of the actual types queried directly? I can't think of any case when I'd want explicit queries on `A` and `B` to ever return a common ancestor instead of just `A | B`. If I wanted the common ancestor I'd just query on that ancestor type directly. 
Actually, I think you are right, and in this context union types are probably better.
(The situation is different in other type systems I've worked on, like mypy for python, where the interaction with things like mutable lists causes trouble.)

We already have UNION and IF produce a union type, we should just do it for coalesce also

Resolving patch:
diff --git a/edb/edgeql/compiler/func.py b/edb/edgeql/compiler/func.py
--- a/edb/edgeql/compiler/func.py
+++ b/edb/edgeql/compiler/func.py
@@ -551,13 +551,15 @@ def compile_operator(
         ctx=ctx,
     )
 
-    if str_oper_name in {'std::UNION', 'std::IF'} and rtype.is_object_type():
-        # Special case for the UNION and IF operators, instead of common
+    if str_oper_name in {
+        'std::UNION', 'std::IF', 'std::??'
+    } and rtype.is_object_type():
+        # Special case for the UNION, IF and ?? operators: instead of common
         # parent type, we return a union type.
-        if str_oper_name == 'std::UNION':
-            larg, rarg = (a.expr for a in final_args)
-        else:
+        if str_oper_name == 'std::IF':
             larg, _, rarg = (a.expr for a in final_args)
+        else:
+            larg, rarg = (a.expr for a in final_args)
 
         left_type = setgen.get_set_type(larg, ctx=ctx)
         right_type = setgen.get_set_type(rarg, ctx=ctx)


Test patch:
diff --git a/tests/test_edgeql_ir_type_inference.py b/tests/test_edgeql_ir_type_inference.py
new file mode 100644
--- /dev/null
+++ b/tests/test_edgeql_ir_type_inference.py
@@ -0,0 +1,122 @@
+#
+# This source file is part of the EdgeDB open source project.
+#
+# Copyright 2012-present MagicStack Inc. and the EdgeDB authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+
+import os.path
+import textwrap
+
+from edb.testbase import lang as tb
+
+from edb.edgeql import compiler
+from edb.edgeql import parser as qlparser
+
+
+class TestEdgeQLTypeInference(tb.BaseEdgeQLCompilerTest):
+    """Unit tests for type inference."""
+
+    SCHEMA = os.path.join(os.path.dirname(__file__), 'schemas',
+                          'cards_ir_inference.esdl')
+
+    def run_test(self, *, source, spec, expected):
+        qltree = qlparser.parse_query(source)
+        ir = compiler.compile_ast_to_ir(
+            qltree,
+            self.schema,
+            options=compiler.CompilerOptions(
+                modaliases={None: 'default'},
+            ),
+        )
+
+        if not expected:
+            return
+
+        # The expected type is either given for the whole query
+        # (by default) or for a specific element of the top-level
+        # shape. In case of the specific element the name of the shape
+        # element must be given followed by ": " and then the
+        # type.
+        exp = textwrap.dedent(expected).strip(' \n').split(': ')
+
+        if len(exp) == 1:
+            field = None
+            expected_type_name = exp[0]
+        elif len(exp) == 2:
+            field = exp[0].strip()
+            expected_type_name = exp[1].strip()
+        else:
+            raise ValueError(
+                f'unrecognized expected specification: {expected!r}')
+
+        if field is not None:
+            shape = ir.expr.expr.result.shape
+            for el, _ in shape:
+                if str(el.path_id.rptr_name()).endswith(field):
+                    typeref = el.typeref
+                    break
+            else:
+                raise AssertionError(f'shape field not found: {field!r}')
+
+        else:
+            typeref = ir.expr.typeref
+
+        typeref = typeref.real_material_type
+
+        self.assertEqual(str(typeref.name_hint), expected_type_name,
+                         'unexpected type:\n' + source)
+
+    def test_edgeql_ir_type_inference_00(self):
+        """
+        SELECT Card { name }
+% OK %
+        default::Card
+        """
+
+    def test_edgeql_ir_type_inference_01(self):
+        """
+        SELECT Card { name }
+% OK %
+        name: std::str
+        """
+
+    def test_edgeql_ir_type_inference_02(self):
+        """
+        SELECT Card UNION User
+% OK %
+        __derived__::(default:Card | default:User)
+        """
+
+    def test_edgeql_ir_type_inference_03(self):
+        """
+        SELECT {Card, User}
+% OK %
+        __derived__::(default:Card | default:User)
+        """
+
+    def test_edgeql_ir_type_inference_04(self):
+        """
+        SELECT Card if true else User
+% OK %
+        __derived__::(default:Card | default:User)
+        """
+
+    def test_edgeql_ir_type_inference_05(self):
+        """
+        SELECT Card ?? User
+% OK %
+        __derived__::(default:Card | default:User)
+        """