Repository: edgedb/edgedb

Issue:
Infer callable volaitlity instead of defaulting to VOLATILE
We are currently defaulting to `VOLATILE` for functions instead of inferring volatility from the function body, which is a problem since we're now placing more restrictions on where volatiles can appear in an expression.


Discussion:

Resolving patch:
diff --git a/edb/schema/delta.py b/edb/schema/delta.py
--- a/edb/schema/delta.py
+++ b/edb/schema/delta.py
@@ -947,7 +947,7 @@ def __init__(
         self.schema_object_ids = schema_object_ids
         self.backend_runtime_params = backend_runtime_params
         self.affected_finalization: \
-            Dict[Command, List[Tuple[DeltaRoot, Command, bool]]] = dict()
+            Dict[Command, List[Tuple[DeltaRoot, Command]]] = dict()
         self.compat_ver = compat_ver
 
     @property
@@ -1153,6 +1153,12 @@ def __call__(
     ) -> CommandContextWrapper[Command_T]:
         return CommandContextWrapper(self, token)
 
+    def compat_ver_is_before(
+        self,
+        ver: Tuple[int, int, verutils.VersionStage, int],
+    ) -> bool:
+        return self.compat_ver is not None and self.compat_ver < ver
+
 
 class DeltaRootContext(CommandContextToken["DeltaRoot"]):
     pass
@@ -1338,6 +1344,7 @@ def _propagate_if_expr_refs(
                       CommandContext, s_expr.Expression],
                      s_expr.Expression]
         ]=None,
+        metadata_only: bool=True,
     ) -> s_schema.Schema:
         scls = self.scls
         expr_refs = s_expr.get_expr_referrers(schema, scls)
@@ -1345,8 +1352,6 @@ def _propagate_if_expr_refs(
         if expr_refs:
             ref_desc = []
             for ref, fns in expr_refs.items():
-                really_apply = False
-
                 from . import functions as s_func
                 from . import indexes as s_indexes
                 from . import pointers as s_pointers
@@ -1379,8 +1384,9 @@ def _propagate_if_expr_refs(
                     # Mark it metadata_only so that if it actually gets
                     # applied, only the metadata is changed but not
                     # the real underlying schema.
-                    cmd_drop.metadata_only = True
-                    cmd_create.metadata_only = True
+                    if metadata_only:
+                        cmd_drop.metadata_only = True
+                        cmd_create.metadata_only = True
 
                     # Compute a dummy value
                     dummy = None
@@ -1418,13 +1424,12 @@ def _propagate_if_expr_refs(
                             with obj_cmd.new_context(schema, context, obj):
                                 value = fixer(
                                     schema, cmd_create, fn, context, value)
-                                really_apply = True
 
                         cmd_drop.set_attribute_value(fn, dummy)
                         cmd_create.set_attribute_value(fn, value)
 
                     context.affected_finalization.setdefault(self, []).append(
-                        (delta_create, cmd_create, really_apply)
+                        (delta_create, cmd_create)
                     )
                     schema = delta_drop.apply(schema, context)
                     continue
@@ -1581,14 +1586,14 @@ def _finalize_affected_refs(
         schema: s_schema.Schema,
         context: CommandContext,
     ) -> s_schema.Schema:
-        for delta, cmd, really_apply in context.affected_finalization.get(
+        for delta, cmd in context.affected_finalization.get(
             self, []
         ):
             self._finalize_affected_refs_specialize(cmd, schema, context)
 
             schema = delta.apply(schema, context)
 
-            if not context.canonical and really_apply and delta:
+            if not context.canonical and delta:
                 # We need to force the attributes to be resolved so
                 # that expressions get compiled *now* under a schema
                 # where they are correct, and not later, when more
@@ -2279,12 +2284,8 @@ def _create_begin(
                 qlclass = mcls.get_ql_class_or_die()
 
             objname = self.classname
-            if (
-                context.compat_ver is not None
-                and (
-                    context.compat_ver
-                    < (1, 0, verutils.VersionStage.ALPHA, 5)
-                )
+            if context.compat_ver_is_before(
+                (1, 0, verutils.VersionStage.ALPHA, 5)
             ):
                 # Pre alpha.5 used to have a different name mangling scheme.
                 objname = sn.compat_name_remangle(str(objname))
diff --git a/edb/schema/functions.py b/edb/schema/functions.py
--- a/edb/schema/functions.py
+++ b/edb/schema/functions.py
@@ -26,6 +26,8 @@
 
 from edb import errors
 
+from edb.common import verutils
+
 from edb.edgeql import ast as qlast
 from edb.edgeql import compiler as qlcompiler
 from edb.edgeql import qltypes as ft
@@ -975,7 +977,7 @@ class AlterCallableObject(
     sd.AlterObject[CallableObjectT],
 ):
 
-    def get_ast(
+    def _get_ast(
         self,
         schema: s_schema.Schema,
         context: sd.CommandContext,
@@ -1274,6 +1276,34 @@ def _get_attribute_value(
             )
         return val
 
+    def canonicalize_attributes(
+        self,
+        schema: s_schema.Schema,
+        context: sd.CommandContext,
+    ) -> s_schema.Schema:
+        schema = super().canonicalize_attributes(schema, context)
+        # When volatility is altered, we need to force a
+        # reconsideration of nativecode if it exists in order to check
+        # it against the new volatility or compute the volatility on a
+        # RESET.  This is kind of unfortunate.
+        if (
+            isinstance(self, sd.AlterObject)
+            and self.has_attribute_value('volatility')
+            and not self.has_attribute_value('nativecode')
+            and (nativecode := self.scls.get_nativecode(schema)) is not None
+        ):
+            self.set_attribute_value(
+                'nativecode',
+                expr.Expression.not_compiled(nativecode)
+            )
+
+        # Resolving 'nativecode' has side effects on has_dml and
+        # volatility, so force it to happen as part of
+        # canonicalization of attributes.
+        super().get_resolved_attribute_value(
+            'nativecode', schema=schema, context=context)
+        return schema
+
     def compile_function(
         self,
         schema: s_schema.Schema,
@@ -1316,6 +1346,41 @@ def compile_function(
             # opportunity to raise exceptions or give warnings.
             self.set_attribute_value('has_dml', True)
 
+        is_alter = isinstance(self, sd.AlterObject)
+
+        spec_volatility = self.get_attribute_value('volatility')
+        if is_alter:
+            cfs = self.scls.get_computed_fields(schema)
+            if (
+                spec_volatility is None
+                and not self.has_attribute_value('volatility')
+                and 'volatility' not in cfs
+            ):
+                spec_volatility = self.scls.get_explicit_field_value(
+                    schema, 'volatility', default=None)
+
+        if spec_volatility is None:
+            self.set_attribute_value('volatility', ir.volatility,
+                                     computed=True)
+
+        # If a volatility is specified, it can be more volatile than the
+        # inferred volatility but not less.
+        if spec_volatility is not None and spec_volatility < ir.volatility:
+            # When restoring from old versions, just ignore the problem
+            # and use the inferred volatility
+            if context.compat_ver_is_before(
+                (1, 0, verutils.VersionStage.ALPHA, 8)
+            ):
+                self.set_attribute_value('volatility', ir.volatility)
+            else:
+                raise errors.InvalidFunctionDefinitionError(
+                    f'volatility mismatch in function declared as '
+                    f'{str(spec_volatility).lower()}',
+                    details=f'Actual volatility is '
+                            f'{str(ir.volatility).lower()}',
+                    context=body.qlast.context,
+                )
+
         return_type = self._get_attribute_value(schema, context, 'return_type')
         if (not ir.stype.issubclass(schema, return_type)
                 and not ir.stype.implicitly_castable_to(return_type, schema)):
@@ -1711,6 +1776,27 @@ class AlterFunction(AlterCallableObject[Function], FunctionCommand):
 
     astnode = qlast.AlterFunction
 
+    def _alter_begin(
+        self,
+        schema: s_schema.Schema,
+        context: sd.CommandContext,
+    ) -> s_schema.Schema:
+        schema = super()._alter_begin(schema, context)
+        scls = self.scls
+
+        # If volatilitiy changed, propagate that to referring exprs
+        if not self.has_attribute_value("volatility"):
+            return schema
+
+        context.altered_targets.add(scls)
+
+        vn = scls.get_verbosename(schema, with_parent=True)
+        schema = self._propagate_if_expr_refs(
+            schema, context, metadata_only=False,
+            action=f'alter the volatility of {vn}')
+
+        return schema
+
     @classmethod
     def _cmd_tree_from_ast(
         cls,


Test patch:
diff --git a/tests/schemas/dump01_default.esdl b/tests/schemas/dump01_default.esdl
--- a/tests/schemas/dump01_default.esdl
+++ b/tests/schemas/dump01_default.esdl
@@ -32,7 +32,7 @@ function user_func_1(x: array<int64>, y: str) -> str {
     using (
         SELECT array_join(<array<str>>x, y)
     );
-    volatility := 'IMMUTABLE';
+    volatility := 'STABLE';
 };
 
 function user_func_2(x: OPTIONAL int64, y: str = 'x') -> SET OF str {
diff --git a/tests/test_edgeql_ddl.py b/tests/test_edgeql_ddl.py
--- a/tests/test_edgeql_ddl.py
+++ b/tests/test_edgeql_ddl.py
@@ -2056,6 +2056,39 @@ async def test_edgeql_ddl_link_target_alter_05(self):
             ALTER TYPE Base1 CREATE LINK foo -> A;
         ''')
 
+    async def test_edgeql_ddl_link_target_alter_06(self):
+        await self.con.execute(r"""
+            CREATE TYPE test::Foo {
+                CREATE PROPERTY foo -> int64;
+                CREATE PROPERTY bar := .foo + .foo;
+            };
+        """)
+
+        await self.con.execute(r"""
+            ALTER TYPE test::Foo {
+                ALTER PROPERTY foo SET TYPE int16;
+            };
+        """)
+
+        await self.assert_query_result(
+            r"""
+                WITH
+                    C := (SELECT schema::ObjectType
+                          FILTER .name = 'test::Foo')
+                SELECT
+                    C.pointers { target: { name } }
+                FILTER
+                    C.pointers.name = 'bar'
+            """,
+            [
+                {
+                    'target': {
+                        'name': 'std::int16'
+                    }
+                },
+            ],
+        )
+
     async def test_edgeql_ddl_prop_target_alter_array_01(self):
         await self.con.execute(r"""
             CREATE TYPE test::Foo {
@@ -3312,6 +3345,227 @@ async def test_edgeql_ddl_function_rename_06(self):
             ["a"],
         )
 
+    async def test_edgeql_ddl_function_volatility_01(self):
+        await self.con.execute('''
+            CREATE FUNCTION test::foo() -> int64 {
+                USING (SELECT 1)
+            }
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function { volatility }
+            FILTER .name = 'test::foo';
+            ''',
+            [{
+                "volatility": "Immutable",
+            }]
+        )
+
+        await self.assert_query_result(
+            '''SELECT (test::foo(), {1,2})''',
+            [[1, 1], [1, 2]]
+        )
+
+    async def test_edgeql_ddl_function_volatility_02(self):
+        await self.con.execute('''
+            CREATE FUNCTION test::foo() -> int64 {
+                USING (SELECT <int64>random())
+            }
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {volatility}
+            FILTER .name = 'test::foo';
+            ''',
+            [{
+                "volatility": "Volatile",
+            }]
+        )
+
+        with self.assertRaisesRegex(
+                edgedb.QueryError,
+                r"can not take cross product of volatile operation"):
+            await self.con.query(
+                '''SELECT (test::foo(), {1,2})'''
+            )
+
+    async def test_edgeql_ddl_function_volatility_03(self):
+        await self.con.execute('''
+            CREATE FUNCTION test::foo() -> int64 {
+                USING (SELECT 1);
+                SET volatility := "volatile";
+            }
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {volatility}
+            FILTER .name = 'test::foo';
+            ''',
+            [{
+                "volatility": "Volatile",
+            }]
+        )
+
+        with self.assertRaisesRegex(
+                edgedb.QueryError,
+                r"can not take cross product of volatile operation"):
+            await self.con.query(
+                '''SELECT (test::foo(), {1,2})'''
+            )
+
+    async def test_edgeql_ddl_function_volatility_04(self):
+        with self.assertRaisesRegex(
+                edgedb.InvalidFunctionDefinitionError,
+                r"(?s)volatility mismatch in function declared as stable"
+                r".*Actual volatility is volatile"):
+
+            await self.con.execute('''
+                CREATE FUNCTION test::foo() -> int64 {
+                    USING (SELECT <int64>random());
+                    SET volatility := "stable";
+                }
+            ''')
+
+    async def test_edgeql_ddl_function_volatility_05(self):
+        with self.assertRaisesRegex(
+                edgedb.InvalidFunctionDefinitionError,
+                r"(?s)volatility mismatch in function declared as immutable"
+                r".*Actual volatility is stable"):
+
+            await self.con.execute('''
+                CREATE FUNCTION test::foo() -> int64 {
+                    USING (SELECT count(Object));
+                    SET volatility := "immutable";
+                }
+            ''')
+
+    async def test_edgeql_ddl_function_volatility_06(self):
+        await self.con.execute('''
+            CREATE FUNCTION test::foo() -> float64 {
+                USING (1);
+            };
+            CREATE FUNCTION test::bar() -> float64 {
+                USING (test::foo());
+            };
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {name, volatility}
+            FILTER .name LIKE 'test::%'
+            ORDER BY .name;
+            ''',
+            [
+                {"name": "test::bar", "volatility": "Immutable"},
+                {"name": "test::foo", "volatility": "Immutable"},
+            ]
+        )
+
+        await self.con.execute('''
+            ALTER FUNCTION test::foo() SET volatility := "stable";
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {name, volatility}
+            FILTER .name LIKE 'test::%'
+            ORDER BY .name;
+            ''',
+            [
+                {"name": "test::bar", "volatility": "Stable"},
+                {"name": "test::foo", "volatility": "Stable"},
+            ]
+        )
+
+        await self.con.execute('''
+            ALTER FUNCTION test::foo() {
+                RESET volatility;
+            }
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {name, volatility}
+            FILTER .name LIKE 'test::%'
+            ORDER BY .name;
+            ''',
+            [
+                {"name": "test::bar", "volatility": "Immutable"},
+                {"name": "test::foo", "volatility": "Immutable"},
+            ]
+        )
+
+        await self.con.execute('''
+            ALTER FUNCTION test::foo() {
+                RESET volatility;
+                USING (random());
+            }
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {name, volatility}
+            FILTER .name LIKE 'test::%'
+            ORDER BY .name;
+            ''',
+            [
+                {"name": "test::bar", "volatility": "Volatile"},
+                {"name": "test::foo", "volatility": "Volatile"},
+            ]
+        )
+
+    async def test_edgeql_ddl_function_volatility_07(self):
+        await self.con.execute('''
+            CREATE FUNCTION test::foo() -> float64 {
+                USING (1);
+            };
+            CREATE FUNCTION test::bar() -> float64 {
+                USING (test::foo());
+            };
+            CREATE FUNCTION test::baz() -> float64 {
+                USING (test::bar());
+            };
+        ''')
+
+        # Test that the alter propagates multiple times
+        await self.con.execute('''
+            ALTER FUNCTION test::foo() SET volatility := "stable";
+        ''')
+
+        await self.assert_query_result(
+            r'''
+            SELECT schema::Function {name, volatility}
+            FILTER .name LIKE 'test::%'
+            ORDER BY .name;
+            ''',
+            [
+                {"name": "test::bar", "volatility": "Stable"},
+                {"name": "test::baz", "volatility": "Stable"},
+                {"name": "test::foo", "volatility": "Stable"},
+            ]
+        )
+
+    async def test_edgeql_ddl_function_volatility_08(self):
+        await self.con.execute('''
+            CREATE FUNCTION test::foo() -> float64 {
+                USING (1);
+            };
+            CREATE FUNCTION test::bar() -> float64 {
+                SET volatility := "stable";
+                USING (test::foo());
+            };
+        ''')
+
+        async with self.assertRaisesRegexTx(
+                edgedb.InvalidFunctionDefinitionError,
+                'volatility mismatch in function declared as stable'):
+            await self.con.execute('''
+                ALTER FUNCTION test::foo() SET volatility := "volatile";
+            ''')
+
     async def test_edgeql_ddl_module_01(self):
         with self.assertRaisesRegex(
                 edgedb.SchemaError,
diff --git a/tests/test_schema.py b/tests/test_schema.py
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -3369,6 +3369,54 @@ def test_schema_migrations_equivalence_function_17(self):
                 )
         """])
 
+    def test_schema_migrations_equivalence_function_18(self):
+        self._assert_migration_equivalence([r"""
+            function a() -> float64 {
+                using (
+                    SELECT random()
+                )
+            }
+        """, r"""
+            function a() -> float64 {
+                volatility := "volatile";
+                using (
+                    SELECT random()
+                )
+            }
+        """])
+
+    def test_schema_migrations_equivalence_function_19(self):
+        self._assert_migration_equivalence([r"""
+            function a() -> float64 {
+                volatility := "volatile";
+                using (
+                    SELECT random()
+                )
+            }
+        """, r"""
+            function a() -> float64 {
+                using (
+                    SELECT random()
+                )
+            }
+        """])
+
+    def test_schema_migrations_equivalence_function_20(self):
+        self._assert_migration_equivalence([r"""
+            function a() -> float64 {
+                volatility := "volatile";
+                using (
+                    SELECT 1.0
+                )
+            }
+        """, r"""
+            function a() -> float64 {
+                using (
+                    SELECT 1.0
+                )
+            }
+        """])
+
     def test_schema_migrations_equivalence_linkprops_03(self):
         self._assert_migration_equivalence([r"""
             type Child;