Repository: edgedb/edgedb

Issue:
@target and @source are pretty broken
query:

```
SELECT schema::ObjectType {
  name,
  is_abstract,
  bases: {
    name,
  } ORDER BY @index ASC,
  pointers: {
    cardinality,
    required,
    name,
    target: {
      name,
    },
    kind := 'link' IF @target IS schema::Link ELSE 'property'
  },
} FILTER NOT .is_compound_type;
```


Discussion:
What is the correct behavior here? Rejecting the query because `target` is not a link attribute?
`@target` is a property present on all links.  `Foo.link@target` is equivalent to `Foo.link` in an expression, and it's probably easiest to make it equivalent in `compile_path` to avoid having to deal with a link property path that is not a scalar.

There are a couple of places where `@target` is useful: 1) in the example above as an abbreviated path to "self" in a shape; 2) in constraints on links, e.g `constraint exclusive on ((@property, @target))`.
Ah, ok. And `SELECT Foo.link@target` ought to deduplicate, just like `SELECT Foo.link` does, I assume?
(I ask because my first attempt on this was to directly patch the crashing code to do an isinstance, and then we excitingly wind up with non-deduplicated results)
Hm, though actually maybe not deduplicating makes more sense, since it is operating on the *link*...
> Hm, though actually maybe not deduplicating makes more sense, since it is operating on the _link_...

Exactly. See comment in `attach_path` in `scopetree.py`
Elvis and I discussed this offline and the feeling is that `Foo.link@target` *should* be deduplicated--that all paths to objects should.

Making `@target` get deduplicated properly was a simple matter of handling it in `compile_path`. Working on `@source` now.
> Elvis and I discussed this offline and the feeling is that Foo.link@target should be deduplicated--that all paths to objects should.

+1.
Currently just using `@target` in a path causes problems:
```
expraliases> select User.awards@target;
ERROR: InternalServerError: 'PointerRef' object has no attribute 'is_scalar'
  Hint: This is most likely a bug in EdgeDB. Please consider opening an issue ticket at https://github.com/edgedb/edgedb/issues/new?template=bug_report.md
  Server traceback:
      Traceback (most recent call last):
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/__init__.py", line 100, in compile_ir_to_sql_tree
          qtree = dispatch.compile(ir_expr, ctx=ctx)
        File "/usr/lib/python3.9/functools.py", line 877, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/expr.py", line 60, in compile_Set
          _compile_set_impl(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/expr.py", line 114, in _compile_set_impl
          _compile_set(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/expr.py", line 589, in _compile_set
          relgen.get_set_rvar(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 160, in get_set_rvar
          return _process_toplevel_query(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 268, in _process_toplevel_query
          rvars = _get_set_rvar(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 302, in _get_set_rvar
          rvars = process_set_as_subquery(ir_set, stmt, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 1253, in process_set_as_subquery
          dispatch.visit(ir_set.expr, ctx=newctx)
        File "/usr/lib/python3.9/functools.py", line 877, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/dispatch.py", line 44, in visit
          compile(ir, ctx=ctx)
        File "/usr/lib/python3.9/functools.py", line 877, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/stmt.py", line 98, in compile_SelectStmt
          outvar = clauses.compile_output(stmt.result, ctx=ictx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/clauses.py", line 230, in compile_output
          dispatch.visit(ir_set, ctx=newctx)
        File "/usr/lib/python3.9/functools.py", line 877, in wrapper
          return dispatch(args[0].__class__)(*args, **kw)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/expr.py", line 85, in visit_Set
          _compile_set_impl(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/expr.py", line 114, in _compile_set_impl
          _compile_set(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/expr.py", line 589, in _compile_set
          relgen.get_set_rvar(ir_set, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 214, in get_set_rvar
          rvars = _get_set_rvar(ir_set, ctx=subctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 396, in _get_set_rvar
          rvars = process_set_as_path(ir_set, stmt, ctx=ctx)
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/relgen.py", line 950, in process_set_as_path
          ptr_info = pg_types.get_ptrref_storage_info(
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/types.py", line 432, in get_ptrref_storage_info
          return _get_ptrref_storage_info(
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/types.py", line 484, in _get_ptrref_storage_info
          if irtyputils.is_scalar(source):
        File "/home/victor/dev/magicstack/edgedb/edb/ir/typeutils.py", line 55, in is_scalar
          return typeref.is_scalar
      AttributeError: 'PointerRef' object has no attribute 'is_scalar'
      The above exception was the direct cause of the following exception:
      Traceback (most recent call last):
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler_pool/worker.py", line 311, in worker
          res = meth(*args)
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler_pool/worker.py", line 180, in compile
          units, cstate = COMPILER.compile(
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler/compiler.py", line 2011, in compile
          units = self._compile(ctx=ctx, source=source)
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler/compiler.py", line 1588, in _compile
          return self._try_compile(ctx=ctx, source=source)
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler/compiler.py", line 1641, in _try_compile
          comp, capabilities = self._compile_dispatch_ql(ctx, stmt)
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler/compiler.py", line 1562, in _compile_dispatch_ql
          query = self._compile_ql_query(ctx, ql)
        File "/home/victor/dev/magicstack/edgedb/edb/server/compiler/compiler.py", line 599, in _compile_ql_query
          sql_text, argmap = pg_compiler.compile_ir_to_sql(
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/__init__.py", line 122, in compile_ir_to_sql
          qtree = compile_ir_to_sql_tree(
        File "/home/victor/dev/magicstack/edgedb/edb/pgsql/compiler/__init__.py", line 107, in compile_ir_to_sql_tree
          raise errors.InternalServerError(*args) from e
      edb.errors.InternalServerError: 'PointerRef' object has no attribute 'is_scalar'
```

Resolving patch:
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
@@ -405,6 +405,20 @@ def compile_path(expr: qlast.Path, *, ctx: context.ContextLevel) -> irast.Set:
                         context=ptr_expr.context,
                     )
 
+                # The backend can't really handle @source/@target
+                # outside of the singleton mode compiler, and they
+                # aren't really particularly useful outside that
+                # anyway, so disallow them.
+                if (
+                    ptr_expr.name in ('source', 'target')
+                    and ctx.env.options.schema_object_context
+                    not in (s_constr.Constraint, s_indexes.Index)
+                ):
+                    raise errors.QueryError(
+                        f'@{ptr_expr.name} may only be used in index and '
+                        'constraint definitions',
+                        context=step.context)
+
                 if isinstance(path_tip.rptr.ptrref,
                               irast.TypeIntersectionPointerRef):
                     ind_prefix, ptrs = typegen.collapse_type_intersection_rptr(
diff --git a/edb/pgsql/schemamech.py b/edb/pgsql/schemamech.py
--- a/edb/pgsql/schemamech.py
+++ b/edb/pgsql/schemamech.py
@@ -325,11 +325,7 @@ def compile_constraint(
         assert isinstance(sub, (s_types.Type, s_pointers.Pointer))
         origin_subject: s_types.Type | s_pointers.Pointer = sub
 
-        origin_path_prefix_anchor = (
-            qlast.Subject().name
-            if isinstance(origin_subject, s_types.Type)
-            else None
-        )
+        origin_path_prefix_anchor = qlast.Subject().name
         singletons = frozenset({(origin_subject, is_optional)})
 
         origin_options = qlcompiler.CompilerOptions(
diff --git a/edb/schema/constraints.py b/edb/schema/constraints.py
--- a/edb/schema/constraints.py
+++ b/edb/schema/constraints.py
@@ -853,13 +853,14 @@ def _populate_concrete_constraint_attrs(
                 )
                 refs |= ir_utils.get_longest_paths(final_except_expr.irast)
 
-            has_multi = False
+            has_any_multi = has_non_subject_multi = False
             for ref in refs:
                 assert subject_obj
                 while ref.rptr:
                     rptr = ref.rptr
+
                     if rptr.dir_cardinality.is_multi():
-                        has_multi = True
+                        has_any_multi = True
 
                     # We don't need to look further than the subject,
                     # which is always valid. (And which is a singleton
@@ -871,6 +872,9 @@ def _populate_concrete_constraint_attrs(
                     ):
                         break
 
+                    if rptr.dir_cardinality.is_multi():
+                        has_non_subject_multi = True
+
                     if (not isinstance(rptr.ptrref,
                                        ir_ast.TupleIndirectionPointerRef)
                             and rptr.ptrref.source_ptr is None
@@ -890,14 +894,14 @@ def _populate_concrete_constraint_attrs(
 
                     ref = rptr.source
 
-            if has_multi and len(refs) > 1:
+            if has_non_subject_multi and len(refs) > 1:
                 raise errors.InvalidConstraintDefinitionError(
                     "cannot reference multiple links or properties in a "
                     "constraint where at least one link or property is MULTI",
                     context=sourcectx
                 )
 
-            if has_multi and ir_utils.contains_set_of_op(
+            if has_any_multi and ir_utils.contains_set_of_op(
                     final_subjectexpr.irast):
                 raise errors.InvalidConstraintDefinitionError(
                     "cannot use aggregate functions or operators "


Test patch:
diff --git a/tests/schemas/constraints.esdl b/tests/schemas/constraints.esdl
--- a/tests/schemas/constraints.esdl
+++ b/tests/schemas/constraints.esdl
@@ -141,6 +141,19 @@ type UniqueName {
         constraint exclusive on ((__subject__@source, __subject__@lang));
         constraint exclusive on (__subject__@prop1);
     }
+
+    multi link translated_labels extending translated_label -> Label {
+        constraint exclusive on ((@source, @lang));
+        constraint exclusive on (__subject__@prop1);
+    }
+
+    link translated_label_tgt extending translated_label -> Label {
+        constraint exclusive on ((__subject__@target, __subject__@lang));
+    }
+
+    multi link translated_labels_tgt extending translated_label -> Label {
+        constraint exclusive on ((@target, @lang));
+    }
 }
 
 type UniqueNameInherited extending UniqueName {
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -459,6 +459,190 @@ async def test_constraints_objects(self):
                                   FILTER .text = "obj_test" LIMIT 1) };
                 """)
 
+    async def test_constraints_endpoint_constraint_01(self):
+        # testing (@source, @lang) on a single link
+        # This constraint is pointless and can never fail
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label := ((insert Label) { @lang := 'xxx' })
+            };
+        """)
+
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label := ((insert Label) { @lang := 'xxx' })
+            };
+        """)
+
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label := ((select Label limit 1) { @lang := 'yyy' })
+            };
+        """)
+
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label := ((select Label limit 1) { @lang := 'xxx' })
+            };
+        """)
+
+    async def test_constraints_endpoint_constraint_02(self):
+        # testing (@source, @lang) on a single link
+        # This constraint is pointless and can never fail
+        await self.con.execute("""
+            insert UniqueName {
+                translated_labels := ((insert Label { text := "x" }) {
+                 @lang := 'x' })
+            };
+        """)
+
+        # Should be fine
+        await self.con.execute("""
+            insert UniqueName {
+                translated_labels := ((insert Label { text := "y"  }) {
+                  @lang := 'x' })
+            };
+        """)
+
+        await self.con.execute("""
+            insert UniqueName {
+                translated_labels := (Label { @lang := .text })
+            };
+        """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueName {
+                    translated_labels := (Label { @lang := 'x' })
+                };
+            """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueNameInherited {
+                    translated_labels := (Label { @lang := 'x' })
+                };
+            """)
+
+    async def test_constraints_endpoint_constraint_03(self):
+        # testing (@target, @lang) on a single link
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label_tgt := ((insert Label { text := "x" }) {
+                 @lang := 'x' })
+            };
+        """)
+
+        # Same @lang different @target
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label_tgt := ((insert Label { text := "y" }) {
+                  @lang := 'x' })
+            };
+        """)
+
+        # Same @target different @lang
+        await self.con.execute("""
+            insert UniqueName {
+                translated_label_tgt := (
+                  select Label { @lang := 'y' } filter .text = 'x' limit 1)
+            };
+        """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueName {
+                    translated_label_tgt := (
+                      select Label { @lang := 'x' } filter .text = 'x' limit 1)
+                };
+            """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueNameInherited {
+                    translated_label_tgt := (
+                      select Label { @lang := 'x' } filter .text = 'x' limit 1)
+                };
+            """)
+
+    async def test_constraints_endpoint_constraint_04(self):
+        # testing (@target, @lang) on a multi link
+        await self.con.execute("""
+            insert UniqueName {
+                translated_labels_tgt := ((insert Label { text := "x" }) {
+                 @lang := 'x' })
+            };
+        """)
+
+        # Same @lang different @target
+        await self.con.execute("""
+            insert UniqueName {
+                translated_labels_tgt := ((insert Label { text := "y" }) {
+                  @lang := 'x' })
+            };
+        """)
+
+        # Same @target different @lang
+        await self.con.execute("""
+            insert UniqueName {
+                translated_labels_tgt := (
+                  select Label { @lang := 'y' } filter .text = 'x')
+            };
+        """)
+
+        await self.con.execute("""
+                insert UniqueNameInherited {
+                    translated_labels_tgt := (
+                      select Label { @lang := 'x!' })
+                };
+        """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueName {
+                    translated_labels_tgt := (
+                      select Label { @lang := 'x' } filter .text = 'x')
+                };
+            """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueNameInherited {
+                    translated_labels_tgt := (
+                      select Label { @lang := 'x' } filter .text = 'x')
+                };
+            """)
+
+        async with self.assertRaisesRegexTx(
+            edgedb.ConstraintViolationError,
+            'violates exclusivity constraint'
+        ):
+            await self.con.execute("""
+                insert UniqueNameInherited {
+                    translated_labels_tgt := (
+                      select Label { @lang := .text ++ '!' }
+                      filter .text = 'x')
+                };
+            """)
+
 
 class TestConstraintsSchemaMigration(tb.QueryTestCase):
 
diff --git a/tests/test_edgeql_linkprops.py b/tests/test_edgeql_linkprops.py
--- a/tests/test_edgeql_linkprops.py
+++ b/tests/test_edgeql_linkprops.py
@@ -1575,3 +1575,30 @@ async def test_edgeql_pure_computed_linkprops_01(self):
             ''',
             [{}],
         )
+
+    async def test_edgeql_props_target_06(self):
+        # This should not work
+        with self.assertRaisesRegex(
+            edgedb.QueryError,
+            r"@target may only be used in index and constraint definitions"
+        ):
+            await self.con.query(
+                r'''
+                SELECT schema::ObjectType {
+                  name,
+                  is_abstract,
+                  bases: {
+                    name,
+                  } ORDER BY @index ASC,
+                  pointers: {
+                    cardinality,
+                    required,
+                    name,
+                    target: {
+                      name,
+                    },
+                    kind := 'link' IF @target IS schema::Link ELSE 'property'
+                  },
+                } FILTER NOT .is_compound_type;
+                '''
+            )
diff --git a/tests/test_pg_dump.py b/tests/test_pg_dump.py
--- a/tests/test_pg_dump.py
+++ b/tests/test_pg_dump.py
@@ -113,12 +113,12 @@ async def test_pgdump02_dump_restore_05(self):
                 id,
                 num,
                 _single_link := .single_link {
-                    source := @source.id,
+                    source := E.id,
                     lp0 := @lp0,
                     target := .id,
                 },
                 _multi_link := .multi_link {
-                    source := @source.id,
+                    source := E.id,
                     lp1 := @lp1,
                     target := .id,
                 },
@@ -328,7 +328,7 @@ async def test_pgdump03_dump_restore_04(self):
                 id,
                 `Ł🤞`,
                 `_Ł💯` := .`Ł💯` {
-                    source := @source.id,
+                    source := Łukasz.id,
                     `🙀🚀🚀🚀🙀` := @`🙀🚀🚀🚀🙀`,
                     `🙀مرحبا🙀` := @`🙀مرحبا🙀`,
                     target := .id,