Repository: edgedb/edgedb

Issue:
Free object selection of update nested inside for loop throws an error

<!--
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.7+81de4dd
- EdgeDB CLI Version: 2.2.6+af56767
- OS Version: Multiple (windows and linux)

Steps to Reproduce:

1. With any existing schema, pick an object type and run the following:
```
select {
  updateData := (assert_distinct((for item in {"itemone", "itemtwo"} union (
    (update ObjectType set {})))))
}
```

The result will be `InternalServerError: missing FROM-clause entry for table "expr-1~2"`.


Discussion:
The bug here is that we are inserting a volatility ref into the CTE we generate for the iterator, to make sure it is evaluated for every object that the shape is over.

Of course, the shape is a literal free object, will only have one element, and so doesn't need to iterate (which is why we allow DML in the shape, when we don't allow it in other select shapes), so it shouldn't need a volatility ref.

Resolving patch:
diff --git a/edb/edgeql/compiler/stmt.py b/edb/edgeql/compiler/stmt.py
--- a/edb/edgeql/compiler/stmt.py
+++ b/edb/edgeql/compiler/stmt.py
@@ -1075,8 +1075,17 @@ def init_stmt(
             (dv := ctx.defining_view) is not None
             and dv.get_expr_type(ctx.env.schema) is s_types.ExprType.Select
             and not (
+                # We allow DML in trivial *top-level* free objects
                 ctx.partial_path_prefix
                 and irutils.is_trivial_free_object(ctx.partial_path_prefix)
+                # Find the enclosing context at the point the free object
+                # was defined.
+                and (outer_ctx := next((
+                    x for x in reversed(ctx._stack.stack)
+                    if isinstance(x, context.ContextLevel)
+                    and x.partial_path_prefix != ctx.partial_path_prefix
+                ), None))
+                and outer_ctx.expr_exposed
             )
         ):
             # This is some shape in a regular query. Although
diff --git a/edb/pgsql/compiler/relgen.py b/edb/pgsql/compiler/relgen.py
--- a/edb/pgsql/compiler/relgen.py
+++ b/edb/pgsql/compiler/relgen.py
@@ -1270,7 +1270,10 @@ def process_set_as_subquery(
         semi_join = False
 
         if ir_source is not None:
-            if ir_source.path_id != ctx.current_insert_path_id:
+            if (
+                ir_source.path_id != ctx.current_insert_path_id
+                and not irutils.is_trivial_free_object(ir_source)
+            ):
                 # This is a computable pointer.  In order to ensure that
                 # the volatile functions in the pointer expression are called
                 # the necessary number of times, we must inject a
@@ -1280,6 +1283,17 @@ def process_set_as_subquery(
                 # If the source is an insert that we are in the middle
                 # of doing, we don't have a volatility ref to add, so
                 # skip it based on the current_insert_path_id check.
+
+                # Note also that we skip this when the source is a
+                # trivial free object reference. A trivial free object
+                # reference is always executed exactly once (if there
+                # is an outer iterator of some kind, we'll pick up
+                # *that* volatility ref) and, unlike other shapes, may
+                # contain DML. We disable the volatility ref for
+                # trival free objects then both as a minor
+                # optimization and to avoid it interfering with DML in
+                # the object (since the volatility ref would not be
+                # visible in DML CTEs).
                 path_id = ir_source.path_id
                 newctx.volatility_ref += (
                     lambda _stmt, xctx: relctx.maybe_get_path_var(


Test patch:
diff --git a/tests/test_edgeql_insert.py b/tests/test_edgeql_insert.py
--- a/tests/test_edgeql_insert.py
+++ b/tests/test_edgeql_insert.py
@@ -5786,6 +5786,21 @@ async def test_edgeql_insert_in_free_object_01(self):
             [{"obj": {'name': "insert simple 02", 'l2': 0}}],
         )
 
+        await self.assert_query_result(
+            r"""
+                select {
+                    objs := (
+                        for name in {'one', 'two'} union (
+                            INSERT InsertTest {
+                                name := name, l2 := 0,
+                            }
+                        )
+                    )
+                }
+            """,
+            [{"objs": [{"id": str}, {"id": str}]}],
+        )
+
     async def test_edgeql_insert_in_free_object_02(self):
         async with self.assertRaisesRegexTx(
                 edgedb.QueryError,
@@ -5814,6 +5829,20 @@ async def test_edgeql_insert_in_free_object_02(self):
                 };
             ''')
 
+        async with self.assertRaisesRegexTx(
+                edgedb.QueryError,
+                "mutations are invalid in a shape's computed expression"):
+            await self.con.query('''
+                with X := {
+                    obj := (
+                        INSERT InsertTest {
+                            name := 'insert simple 01',
+                            l2 := 0,
+                        }
+                     )
+                }, select X;
+            ''')
+
     async def test_edgeql_insert_rebind_with_typenames_01(self):
         await self.assert_query_result(
             '''