Repository: edgedb/edgedb

Issue:
Support casting from `uuid` to object types
We want to support doing things like `<User><uuid>$0` (and probably just `<User>$0`, with the same meaning?), and have it mean something like
`assert_exists((select detached User filter .id = <uuid>$0))`
(probably with a better error message passed to assert_exists).

This will make it a lot easier to convert ids into objects.

Offline we've discussed a bunch of alternative syntaxes and decided that just supporting casting was the least bad. If anyone wants I guess we can argue more here now.


Discussion:
> and probably just `<User>$0`, with the same meaning?

Yes, we want this ^

Resolving patch:
diff --git a/edb/edgeql/compiler/casts.py b/edb/edgeql/compiler/casts.py
--- a/edb/edgeql/compiler/casts.py
+++ b/edb/edgeql/compiler/casts.py
@@ -35,6 +35,7 @@
 from edb.schema import functions as s_func
 from edb.schema import name as sn
 from edb.schema import types as s_types
+from edb.schema import utils as s_utils
 
 from edb.edgeql import ast as qlast
 from edb.edgeql import qltypes as ft
@@ -69,7 +70,7 @@ def compile_cast(
             ctx=ctx,
             srcctx=ir_expr.context)
 
-    elif irutils.is_untyped_empty_array_expr(ir_expr):
+    if irutils.is_untyped_empty_array_expr(ir_expr):
         # Ditto for empty arrays.
         new_typeref = typegen.type_to_typeref(new_stype, ctx.env)
         return setgen.ensure_set(
@@ -81,7 +82,7 @@ def compile_cast(
     if (orig_stype == new_stype and
             cardinality_mod is not qlast.CardinalityModifier.Required):
         return ir_set
-    elif orig_stype.is_object_type() and new_stype.is_object_type():
+    if orig_stype.is_object_type() and new_stype.is_object_type():
         # Object types cannot be cast between themselves,
         # as cast is a _constructor_ operation, and the only
         # valid way to construct an object is to INSERT it.
@@ -92,6 +93,13 @@ def compile_cast(
             f'`...[IS {new_stype.get_displayname(ctx.env.schema)}]` instead',
             context=srcctx)
 
+    uuid_t = ctx.env.get_track_schema_type(sn.QualName('std', 'uuid'))
+    if (
+        orig_stype.issubclass(ctx.env.schema, uuid_t)
+        and new_stype.is_object_type()
+    ):
+        return _find_object_by_id(ir_expr, new_stype, ctx=ctx)
+
     json_t = ctx.env.get_track_schema_type(
         sn.QualName('std', 'json'))
 
@@ -99,27 +107,23 @@ def compile_cast(
         return _cast_array_literal(
             ir_set, orig_stype, new_stype, srcctx=srcctx, ctx=ctx)
 
-    elif orig_stype.is_tuple(ctx.env.schema):
+    if orig_stype.is_tuple(ctx.env.schema):
         return _cast_tuple(
             ir_set, orig_stype, new_stype, srcctx=srcctx, ctx=ctx)
 
-    elif (
-        orig_stype.is_array()
-        and not s_types.is_type_compatible(
-            orig_stype, new_stype, schema=ctx.env.schema)
+    if orig_stype.is_array() and not s_types.is_type_compatible(
+        orig_stype, new_stype, schema=ctx.env.schema
     ):
         return _cast_array(
             ir_set, orig_stype, new_stype, srcctx=srcctx, ctx=ctx)
 
-    elif (
-        orig_stype.is_range()
-        and not s_types.is_type_compatible(
-            orig_stype, new_stype, schema=ctx.env.schema)
+    if orig_stype.is_range() and not s_types.is_type_compatible(
+        orig_stype, new_stype, schema=ctx.env.schema
     ):
         return _cast_range(
             ir_set, orig_stype, new_stype, srcctx=srcctx, ctx=ctx)
 
-    elif orig_stype.issubclass(ctx.env.schema, new_stype):
+    if orig_stype.issubclass(ctx.env.schema, new_stype):
         # The new type is a supertype of the old type,
         # and is always a wider domain, so we simply reassign
         # the stype.
@@ -127,61 +131,96 @@ def compile_cast(
             ir_set, orig_stype, new_stype,
             cardinality_mod=cardinality_mod, ctx=ctx)
 
-    elif new_stype.issubclass(ctx.env.schema, orig_stype):
+    if new_stype.issubclass(ctx.env.schema, orig_stype):
         # The new type is a subtype, so may potentially have
         # a more restrictive domain, generate a cast call.
         return _inheritance_cast_to_ir(
             ir_set, orig_stype, new_stype,
             cardinality_mod=cardinality_mod, ctx=ctx)
 
+    if (
+        new_stype.issubclass(ctx.env.schema, json_t)
+        and ir_set.path_id.is_objtype_path()
+    ):
+        # JSON casts of objects are special: we want the full shape
+        # and not just an identity.
+        with ctx.new() as subctx:
+            subctx.implicit_id_in_shapes = False
+            subctx.implicit_tid_in_shapes = False
+            subctx.implicit_tname_in_shapes = False
+            viewgen.late_compile_view_shapes(ir_set, ctx=subctx)
     else:
-        if (new_stype.issubclass(ctx.env.schema, json_t) and
-                ir_set.path_id.is_objtype_path()):
-            # JSON casts of objects are special: we want the full shape
-            # and not just an identity.
-            with ctx.new() as subctx:
-                subctx.implicit_id_in_shapes = False
-                subctx.implicit_tid_in_shapes = False
-                subctx.implicit_tname_in_shapes = False
-                viewgen.late_compile_view_shapes(ir_set, ctx=subctx)
-        elif (orig_stype.issubclass(ctx.env.schema, json_t)
-              and new_stype.is_enum(ctx.env.schema)):
+        if orig_stype.issubclass(ctx.env.schema, json_t) and new_stype.is_enum(
+            ctx.env.schema
+        ):
             # Casts from json to enums need some special handling
             # here, where we have access to the enum type. Just turn
             # it into json->str and str->enum.
-            str_typ = ctx.env.get_track_schema_type(
-                sn.QualName('std', 'str'))
+            str_typ = ctx.env.get_track_schema_type(sn.QualName('std', 'str'))
             str_ir = compile_cast(ir_expr, str_typ, srcctx=srcctx, ctx=ctx)
-            return compile_cast(str_ir, new_stype,
-                                cardinality_mod=cardinality_mod,
-                                srcctx=srcctx, ctx=ctx)
-        elif (orig_stype.issubclass(ctx.env.schema, json_t)
-              and isinstance(new_stype, s_types.Array)
-              and not new_stype.get_subtypes(ctx.env.schema)[0].issubclass(
-                  ctx.env.schema, json_t)):
+            return compile_cast(
+                str_ir,
+                new_stype,
+                cardinality_mod=cardinality_mod,
+                srcctx=srcctx,
+                ctx=ctx,
+            )
+
+        if (
+            orig_stype.issubclass(ctx.env.schema, json_t)
+            and isinstance(new_stype, s_types.Array)
+            and not new_stype.get_subtypes(ctx.env.schema)[0].issubclass(
+                ctx.env.schema, json_t
+            )
+        ):
             # Turn casts from json->array<T> into json->array<json>
             # and array<json>->array<T>.
             ctx.env.schema, json_array_typ = s_types.Array.from_subtypes(
-                ctx.env.schema, [json_t])
+                ctx.env.schema, [json_t]
+            )
             json_array_ir = compile_cast(
-                ir_expr, json_array_typ, cardinality_mod=cardinality_mod,
-                srcctx=srcctx, ctx=ctx)
+                ir_expr,
+                json_array_typ,
+                cardinality_mod=cardinality_mod,
+                srcctx=srcctx,
+                ctx=ctx,
+            )
             return compile_cast(
-                json_array_ir, new_stype, srcctx=srcctx, ctx=ctx)
-        elif (orig_stype.issubclass(ctx.env.schema, json_t)
-              and isinstance(new_stype, s_types.Tuple)):
+                json_array_ir, new_stype, srcctx=srcctx, ctx=ctx
+            )
+
+        if orig_stype.issubclass(ctx.env.schema, json_t) and isinstance(
+            new_stype, s_types.Tuple
+        ):
             return _cast_json_to_tuple(
-                ir_set, orig_stype, new_stype, cardinality_mod,
-                srcctx=srcctx, ctx=ctx)
-        elif (orig_stype.issubclass(ctx.env.schema, json_t)
-              and isinstance(new_stype, s_types.Range)):
+                ir_set,
+                orig_stype,
+                new_stype,
+                cardinality_mod,
+                srcctx=srcctx,
+                ctx=ctx,
+            )
+
+        if orig_stype.issubclass(ctx.env.schema, json_t) and isinstance(
+            new_stype, s_types.Range
+        ):
             return _cast_json_to_range(
-                ir_set, orig_stype, new_stype, cardinality_mod,
-                srcctx=srcctx, ctx=ctx)
+                ir_set,
+                orig_stype,
+                new_stype,
+                cardinality_mod,
+                srcctx=srcctx,
+                ctx=ctx,
+            )
 
-        return _compile_cast(
-            ir_expr, orig_stype, new_stype, cardinality_mod=cardinality_mod,
-            srcctx=srcctx, ctx=ctx)
+    return _compile_cast(
+        ir_expr,
+        orig_stype,
+        new_stype,
+        cardinality_mod=cardinality_mod,
+        srcctx=srcctx,
+        ctx=ctx,
+    )
 
 
 def _compile_cast(
@@ -888,3 +927,66 @@ def _cast_array_literal(
         )
 
     return setgen.ensure_set(cast_ir, ctx=ctx)
+
+
+def _find_object_by_id(
+    ir_expr: Union[irast.Set, irast.Expr],
+    new_stype: s_types.Type,
+    *,
+    ctx: context.ContextLevel,
+) -> irast.Set:
+    with ctx.new() as subctx:
+        subctx.anchors = subctx.anchors.copy()
+
+        ir_set = setgen.ensure_set(ir_expr, ctx=subctx)
+        uuid_anchor = subctx.create_anchor(ir_set, name='a')
+
+        object_name = s_utils.name_to_ast_ref(
+            new_stype.get_name(ctx.env.schema)
+        )
+
+        select_id = qlast.SelectQuery(
+            result=qlast.DetachedExpr(expr=qlast.Path(steps=[object_name])),
+            where=qlast.BinOp(
+                left=qlast.Path(
+                    steps=[
+                        qlast.Ptr(
+                            ptr=qlast.ObjectRef(name='id'), direction='>'
+                        )
+                    ],
+                    partial=True,
+                ),
+                op='=',
+                right=qlast.Path(steps=[qlast.ObjectRef(name='_id')]),
+            ),
+        )
+
+        error_message = qlast.BinOp(
+            left=qlast.StringConstant(
+                value=(
+                    repr(new_stype.get_displayname(ctx.env.schema))
+                    + ' with id \''
+                )
+            ),
+            op='++',
+            right=qlast.BinOp(
+                left=qlast.TypeCast(
+                    expr=qlast.Path(steps=[qlast.ObjectRef(name='_id')]),
+                    type=qlast.TypeName(maintype=qlast.ObjectRef(name='str')),
+                ),
+                op='++',
+                right=qlast.StringConstant(value='\' does not exist'),
+            ),
+        )
+
+        exists_ql = qlast.FunctionCall(
+            func='assert_exists',
+            args=[select_id],
+            kwargs={'message': error_message},
+        )
+
+        for_query = qlast.ForQuery(
+            iterator=uuid_anchor, iterator_alias='_id', result=exists_ql
+        )
+
+        return dispatch.compile(for_query, ctx=subctx)
diff --git a/edb/edgeql/compiler/stmtctx.py b/edb/edgeql/compiler/stmtctx.py
--- a/edb/edgeql/compiler/stmtctx.py
+++ b/edb/edgeql/compiler/stmtctx.py
@@ -750,6 +750,22 @@ def preprocess_script(
         with ctx.new() as mctx:
             mctx.modaliases = modaliases
             target_stype = typegen.ql_typeexpr_to_type(cast.type, ctx=mctx)
+
+        # for ObjectType parameters, we inject intermediate cast to uuid,
+        # so parameter is uuid and then cast to ObjectType
+        if target_stype.is_object_type():
+            uuid_cast = qlast.TypeCast(
+                type=qlast.TypeName(maintype=qlast.ObjectRef(name='uuid')),
+                expr=cast.expr,
+                cardinality_mod=cast.cardinality_mod,
+            )
+            cast.expr = uuid_cast
+            cast = cast.expr
+
+            with ctx.new() as mctx:
+                mctx.modaliases = modaliases
+                target_stype = typegen.ql_typeexpr_to_type(cast.type, ctx=mctx)
+
         target_typeref = typegen.type_to_typeref(target_stype, env=ctx.env)
         required = cast.cardinality_mod != qlast.CardinalityModifier.Optional
         params[name] = irast.Param(


Test patch:
diff --git a/tests/schemas/casts.esdl b/tests/schemas/casts.esdl
--- a/tests/schemas/casts.esdl
+++ b/tests/schemas/casts.esdl
@@ -76,3 +76,7 @@ type ScalarTest {
     property p_decimal -> decimal;
     property p_json -> json;
 }
+
+type Person {
+    property name -> str;
+}
\ No newline at end of file
diff --git a/tests/schemas/casts_setup.edgeql b/tests/schemas/casts_setup.edgeql
--- a/tests/schemas/casts_setup.edgeql
+++ b/tests/schemas/casts_setup.edgeql
@@ -51,3 +51,11 @@ INSERT JSONTest {
     j_bigint := <json>123456789123456789123456789n,
     j_decimal := <json>123456789123456789123456789.123456789123456789123456789n
 };
+
+INSERT Person {
+    name := 'tom',
+};
+
+INSERT Person {
+    name := 'kelly',
+};
\ No newline at end of file
diff --git a/tests/test_edgeql_casts.py b/tests/test_edgeql_casts.py
--- a/tests/test_edgeql_casts.py
+++ b/tests/test_edgeql_casts.py
@@ -2631,3 +2631,68 @@ async def test_edgeql_casts_all_null(self):
                 msg=f'{cast.from_type.name} to {cast.to_type.name}',
                 json_only=json_only,
             )
+
+    async def test_edgeql_casts_uuid_to_object(self):
+        persons = await self.con.query('select Person { id }')
+
+        dummy_uuid = '1' * 32
+
+        res = await self.con.query('select <Person><uuid>$0', persons[0].id)
+        self.assertEqual(len(res), 1)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.CardinalityViolationError, r'with id .* does not exist'
+        ):
+            await self.con.query('select <Person><uuid>$0', dummy_uuid)
+
+        await self.assert_query_result(
+            '''
+            select (<Person>{<uuid>$0, <uuid>$1}) { name }
+            order by .name;
+            ''',
+            [{'name': 'kelly'}, {'name': 'tom'}],
+            json_only=True,
+            variables=(persons[0].id, persons[1].id),
+        )
+
+        async with self.assertRaisesRegexTx(
+            edgedb.CardinalityViolationError, r'with id .* does not exist'
+        ):
+            await self.con.query(
+                '''
+                select (<Person>{<uuid>$0, <uuid>$1}) { name }
+                order by .name;
+                ''', persons[0].id, dummy_uuid
+            )
+
+        res = await self.con.query(
+            'select <Person><optional uuid>$0', persons[0].id
+        )
+        self.assertEqual(len(res), 1)
+
+        res = await self.con.query(
+            'select <optional Person><optional uuid>$0', None
+        )
+        self.assertEqual(len(res), 0)
+
+        res = await self.con.query('select <optional Person><optional uuid>{}')
+        self.assertEqual(len(res), 0)
+
+        res = await self.con.query('select <Person><optional uuid>$0', None)
+        self.assertEqual(len(res), 0)
+
+        res = await self.con.query('select <Person>$0', persons[0].id)
+        self.assertEqual(len(res), 1)
+
+        res = await self.con.query('select <optional Person>$0', None)
+        self.assertEqual(len(res), 0)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.CardinalityViolationError, r'with id .* does not exist'
+        ):
+            await self.con.query('select <optional Person>$0', dummy_uuid)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.CardinalityViolationError, r'with id .* does not exist'
+        ):
+            await self.con.query('select <Person>$0', dummy_uuid)