diff --git a/dbt-adapters/.changes/unreleased/Features-20251029-164230.yaml b/dbt-adapters/.changes/unreleased/Features-20251029-164230.yaml new file mode 100644 index 000000000..cdb38ae4a --- /dev/null +++ b/dbt-adapters/.changes/unreleased/Features-20251029-164230.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Add default macros for interpolating the volatility of sql UDFs +time: 2025-10-29T16:42:30.989678-05:00 +custom: + Author: QMalcolm + Issue: "1345" diff --git a/dbt-adapters/src/dbt/include/global_project/macros/materializations/functions/scalar.sql b/dbt-adapters/src/dbt/include/global_project/macros/materializations/functions/scalar.sql index f3aef03fc..86923010e 100644 --- a/dbt-adapters/src/dbt/include/global_project/macros/materializations/functions/scalar.sql +++ b/dbt-adapters/src/dbt/include/global_project/macros/materializations/functions/scalar.sql @@ -14,6 +14,7 @@ {% macro default__scalar_function_create_replace_signature_sql(target_relation) %} CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}}) RETURNS {{ model.returns.data_type }} + {{ scalar_function_volatility_sql() }} AS {% endmacro %} @@ -38,3 +39,31 @@ {{ model.compiled_code }} $$ LANGUAGE SQL {% endmacro %} + +{% macro scalar_function_volatility_sql() %} + {{ return(adapter.dispatch('scalar_function_volatility_sql', 'dbt')()) }} +{% endmacro %} + +{% macro default__scalar_function_volatility_sql() %} + {% set volatility = model.config.get('volatility') %} + {% if volatility == 'deterministic' %} + IMMUTABLE + {% elif volatility == 'stable' %} + STABLE + {% elif volatility == 'non-deterministic' %} + VOLATILE + {% elif volatility != none %} + {# This shouldn't happen unless a new volatility is invented #} + {% do unsupported_volatility_warning(volatility) %} + {% endif %} + {# If no volatility is set, don't add anything and let the data warehouse default it #} +{% endmacro %} + +{% macro unsupported_volatility_warning(volatility) %} + {{ return(adapter.dispatch('unsupported_volatility_warning', 'dbt')(volatility)) }} +{% endmacro %} + +{% macro default__unsupported_volatility_warning(volatility) %} + {% set msg = "Found `" ~ volatility ~ "` volatility specified on function `" ~ model.name ~ "`. This volatility is not supported by " ~ adapter.type() ~ ", and will be ignored" %} + {% do exceptions.warn(msg) %} +{% endmacro %} diff --git a/dbt-bigquery/.changes/unreleased/Under the Hood-20251029-165603.yaml b/dbt-bigquery/.changes/unreleased/Under the Hood-20251029-165603.yaml new file mode 100644 index 000000000..a59c6552c --- /dev/null +++ b/dbt-bigquery/.changes/unreleased/Under the Hood-20251029-165603.yaml @@ -0,0 +1,6 @@ +kind: Under the Hood +body: Test that bigquery does nothing with volatility +time: 2025-10-29T16:56:03.92939-05:00 +custom: + Author: QMalcolm + Issue: "1345" diff --git a/dbt-bigquery/src/dbt/include/bigquery/macros/materializations/functions/scalar.sql b/dbt-bigquery/src/dbt/include/bigquery/macros/materializations/functions/scalar.sql index 587d0c3ba..ac2e43595 100644 --- a/dbt-bigquery/src/dbt/include/bigquery/macros/materializations/functions/scalar.sql +++ b/dbt-bigquery/src/dbt/include/bigquery/macros/materializations/functions/scalar.sql @@ -1,6 +1,7 @@ {% macro bigquery__scalar_function_create_replace_signature_sql(target_relation) %} CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}}) RETURNS {{ model.returns.data_type }} + {{ scalar_function_volatility_sql() }} AS {% endmacro %} @@ -9,3 +10,10 @@ {{ model.compiled_code }} ) {% endmacro %} + +{% macro bigquery__scalar_function_volatility_sql() %} + {% set volatility = model.config.get('volatility') %} + {% if volatility != None %} + {% do unsupported_volatility_warning(volatility) %} + {% endif %} +{% endmacro %} diff --git a/dbt-bigquery/tests/functional/functions/test_udfs.py b/dbt-bigquery/tests/functional/functions/test_udfs.py index 13767c94c..31088e6c1 100644 --- a/dbt-bigquery/tests/functional/functions/test_udfs.py +++ b/dbt-bigquery/tests/functional/functions/test_udfs.py @@ -1,5 +1,15 @@ import pytest -from dbt.tests.adapter.functions.test_udfs import UDFsBasic +from dbt.contracts.graph.nodes import FunctionNode +from dbt.contracts.results import RunStatus +from dbt.events.types import JinjaLogWarning +from dbt.tests.util import run_dbt +from dbt.tests.adapter.functions.test_udfs import ( + UDFsBasic, + DeterministicUDF, + StableUDF, + NonDeterministicUDF, +) +from dbt_common.events.event_catcher import EventCatcher from tests.functional.functions import files @@ -12,4 +22,137 @@ def functions(self): "price_for_xlarge.yml": files.MY_UDF_YML, } - pass + +class TestBigqueryDeterministicUDFs(DeterministicUDF): + @pytest.fixture(scope="class") + def functions(self): + return { + "price_for_xlarge.sql": files.MY_UDF_SQL, + "price_for_xlarge.yml": files.MY_UDF_YML, + } + + def check_function_volatility(self, sql: str): + assert "VOLATILE" not in sql + assert "STABLE" not in sql + assert "IMMUTABLE" not in sql + + def test_udfs(self, project, sql_event_catcher): + warning_event_catcher = EventCatcher(JinjaLogWarning) + result = run_dbt( + ["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch] + ) + + assert len(result.results) == 1 + node_result = result.results[0] + assert node_result.status == RunStatus.Success + node = node_result.node + assert isinstance(node, FunctionNode) + assert node_result.node.name == "price_for_xlarge" + + # Check volatility + assert len(sql_event_catcher.caught_events) == 1 + self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql) + + # Check that the warning event was caught + assert len(warning_event_catcher.caught_events) == 1 + assert ( + "Found `deterministic` volatility specified on function `price_for_xlarge`. This volatility is not supported by bigquery, and will be ignored" + in warning_event_catcher.caught_events[0].data.msg + ) + + # Check that the function can be executed + result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"]) + assert len(result.results) == 1 + # The result should have an agate table with one row and one column (and thus only one value, which is our inline selection) + select_value = int(result.results[0].agate_table.rows[0].values()[0]) + assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200) + + +class TestBigqueryStableUDFs(StableUDF): + @pytest.fixture(scope="class") + def functions(self): + return { + "price_for_xlarge.sql": files.MY_UDF_SQL, + "price_for_xlarge.yml": files.MY_UDF_YML, + } + + def check_function_volatility(self, sql: str): + assert "VOLATILE" not in sql + assert "STABLE" not in sql + assert "IMMUTABLE" not in sql + + def test_udfs(self, project, sql_event_catcher): + warning_event_catcher = EventCatcher(JinjaLogWarning) + result = run_dbt( + ["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch] + ) + + assert len(result.results) == 1 + node_result = result.results[0] + assert node_result.status == RunStatus.Success + node = node_result.node + assert isinstance(node, FunctionNode) + assert node_result.node.name == "price_for_xlarge" + + # Check volatility + assert len(sql_event_catcher.caught_events) == 1 + self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql) + + # Check that the warning event was caught + assert len(warning_event_catcher.caught_events) == 1 + assert ( + "Found `stable` volatility specified on function `price_for_xlarge`. This volatility is not supported by bigquery, and will be ignored" + in warning_event_catcher.caught_events[0].data.msg + ) + + # Check that the function can be executed + result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"]) + assert len(result.results) == 1 + # The result should have an agate table with one row and one column (and thus only one value, which is our inline selection) + select_value = int(result.results[0].agate_table.rows[0].values()[0]) + assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200) + + +class TestBigqueryNonDeterministicUDFs(NonDeterministicUDF): + @pytest.fixture(scope="class") + def functions(self): + return { + "price_for_xlarge.sql": files.MY_UDF_SQL, + "price_for_xlarge.yml": files.MY_UDF_YML, + } + + def check_function_volatility(self, sql: str): + assert "VOLATILE" not in sql + assert "STABLE" not in sql + assert "IMMUTABLE" not in sql + + def test_udfs(self, project, sql_event_catcher): + warning_event_catcher = EventCatcher(JinjaLogWarning) + result = run_dbt( + ["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch] + ) + + assert len(result.results) == 1 + node_result = result.results[0] + assert node_result.status == RunStatus.Success + node = node_result.node + assert isinstance(node, FunctionNode) + assert node_result.node.name == "price_for_xlarge" + + # Check volatility + assert len(sql_event_catcher.caught_events) == 1 + self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql) + + # Check that the warning event was caught + assert len(warning_event_catcher.caught_events) == 1 + assert ( + "Found `non-deterministic` volatility specified on function `price_for_xlarge`. This volatility is not supported by bigquery, and will be ignored" + in warning_event_catcher.caught_events[0].data.msg + ) + + # Check that the function can be executed + result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"]) + assert len(result.results) == 1 + # The result should have an agate table with one row and one column (and thus only one value, which is our inline selection) + select_value = int(result.results[0].agate_table.rows[0].values()[0]) + assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200) diff --git a/dbt-postgres/.changes/unreleased/Features-20251029-164109.yaml b/dbt-postgres/.changes/unreleased/Features-20251029-164109.yaml new file mode 100644 index 000000000..3c742ab9c --- /dev/null +++ b/dbt-postgres/.changes/unreleased/Features-20251029-164109.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Support volatility setting for sql UDFs +time: 2025-10-29T16:41:09.298749-05:00 +custom: + Author: QMalcolm + Issue: "1345" diff --git a/dbt-postgres/tests/functional/functions/test_udfs.py b/dbt-postgres/tests/functional/functions/test_udfs.py index 0d392a860..5c3060a6e 100644 --- a/dbt-postgres/tests/functional/functions/test_udfs.py +++ b/dbt-postgres/tests/functional/functions/test_udfs.py @@ -1,5 +1,22 @@ -from dbt.tests.adapter.functions.test_udfs import UDFsBasic +from dbt.tests.adapter.functions.test_udfs import ( + UDFsBasic, + DeterministicUDF, + StableUDF, + NonDeterministicUDF, +) class TestPostgresUDFs(UDFsBasic): pass + + +class TestPostgresDeterministicUDFs(DeterministicUDF): + pass + + +class TestPostgresStableUDFs(StableUDF): + pass + + +class TestPostgresNonDeterministicUDFs(NonDeterministicUDF): + pass diff --git a/dbt-redshift/.changes/unreleased/Features-20251029-163948.yaml b/dbt-redshift/.changes/unreleased/Features-20251029-163948.yaml new file mode 100644 index 000000000..1d4eb64b4 --- /dev/null +++ b/dbt-redshift/.changes/unreleased/Features-20251029-163948.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Support volatility setting in sql UDFs +time: 2025-10-29T16:39:48.716884-05:00 +custom: + Author: QMalcolm + Issue: "1345" diff --git a/dbt-redshift/src/dbt/include/redshift/macros/materializations/functions/scalar.sql b/dbt-redshift/src/dbt/include/redshift/macros/materializations/functions/scalar.sql index 63dc84a42..14b4259f9 100644 --- a/dbt-redshift/src/dbt/include/redshift/macros/materializations/functions/scalar.sql +++ b/dbt-redshift/src/dbt/include/redshift/macros/materializations/functions/scalar.sql @@ -1,9 +1,7 @@ {% macro redshift__scalar_function_create_replace_signature_sql(target_relation) %} CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}}) RETURNS {{ model.returns.data_type }} - {# TODO: Stop defaulting to VOLATILE once we have a way to set the volatility #} - {# We set a default here because redshift requires a volatility to be set #} - VOLATILE + {{ scalar_function_volatility_sql() }} AS {% endmacro %} @@ -14,3 +12,18 @@ {%- endfor %} {{ args | join(', ') }} {% endmacro %} + +{% macro redshift__scalar_function_volatility_sql() %} + {% set volatility = model.config.get('volatility') %} + {% if volatility == 'deterministic' %} + IMMUTABLE + {% elif volatility == 'stable' %} + STABLE + {% elif volatility == 'non-deterministic' or volatility == none %} + VOLATILE + {% else %} + {% do unsupported_volatility_warning(volatility) %} + {# We're ignoring the unknown volatility. But redshift requires a volatility to be set, so we default to VOLATILE #} + VOLATILE + {% endif %} +{% endmacro %} diff --git a/dbt-redshift/tests/functional/functions/test_udfs.py b/dbt-redshift/tests/functional/functions/test_udfs.py index 948c6449f..669b7faaa 100644 --- a/dbt-redshift/tests/functional/functions/test_udfs.py +++ b/dbt-redshift/tests/functional/functions/test_udfs.py @@ -1,9 +1,11 @@ -from dbt.artifacts.schemas.results import RunStatus -from dbt.contracts.graph.nodes import FunctionNode -from dbt.tests.util import run_dbt import pytest from dbt.tests.adapter.functions.files import MY_UDF_YML -from dbt.tests.adapter.functions.test_udfs import UDFsBasic +from dbt.tests.adapter.functions.test_udfs import ( + UDFsBasic, + DeterministicUDF, + StableUDF, + NonDeterministicUDF, +) from tests.functional.functions.files import MY_UDF_SQL @@ -15,3 +17,34 @@ def functions(self): "price_for_xlarge.sql": MY_UDF_SQL, "price_for_xlarge.yml": MY_UDF_YML, } + + def check_function_volatility(self, sql: str): + # in redshift, if no volatility is set, we template in VOLATILE + assert "VOLATILE" in sql + + +class TestRedshiftDeterministicUDFs(DeterministicUDF): + @pytest.fixture(scope="class") + def functions(self): + return { + "price_for_xlarge.sql": MY_UDF_SQL, + "price_for_xlarge.yml": MY_UDF_YML, + } + + +class TestRedshiftStableUDFs(StableUDF): + @pytest.fixture(scope="class") + def functions(self): + return { + "price_for_xlarge.sql": MY_UDF_SQL, + "price_for_xlarge.yml": MY_UDF_YML, + } + + +class TestRedshiftNonDeterministicUDFs(NonDeterministicUDF): + @pytest.fixture(scope="class") + def functions(self): + return { + "price_for_xlarge.sql": MY_UDF_SQL, + "price_for_xlarge.yml": MY_UDF_YML, + } diff --git a/dbt-snowflake/.changes/unreleased/Features-20251029-164024.yaml b/dbt-snowflake/.changes/unreleased/Features-20251029-164024.yaml new file mode 100644 index 000000000..d8b3b7c3b --- /dev/null +++ b/dbt-snowflake/.changes/unreleased/Features-20251029-164024.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Support volatility setting in sql UDFs +time: 2025-10-29T16:40:24.708639-05:00 +custom: + Author: QMalcolm + Issue: "1345" diff --git a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/functions/scalar.sql b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/functions/scalar.sql index fcfba6cbd..50fbabc43 100644 --- a/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/functions/scalar.sql +++ b/dbt-snowflake/src/dbt/include/snowflake/macros/materializations/functions/scalar.sql @@ -2,6 +2,7 @@ CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}}) RETURNS {{ model.returns.data_type }} LANGUAGE SQL + {{ scalar_function_volatility_sql() }} AS {% endmacro %} @@ -10,3 +11,15 @@ {{ model.compiled_code }} $$ {% endmacro %} + +{% macro snowflake__scalar_function_volatility_sql() %} + {% set volatility = model.config.get('volatility') %} + {% if volatility == 'deterministic' %} + IMMUTABLE + {% elif volatility == 'non-deterministic'%} + VOLATILE + {% elif volatility == 'stable' or volatility != none %} + {% do unsupported_volatility_warning(volatility) %} + {% endif %} + {# If no volatility is set, don't add anything and let the data warehouse default it #} +{% endmacro %} diff --git a/dbt-snowflake/tests/functional/functions/test_udfs.py b/dbt-snowflake/tests/functional/functions/test_udfs.py index 99220a9ac..3c12598b2 100644 --- a/dbt-snowflake/tests/functional/functions/test_udfs.py +++ b/dbt-snowflake/tests/functional/functions/test_udfs.py @@ -1,6 +1,16 @@ import pytest +from dbt.contracts.graph.nodes import FunctionNode +from dbt.contracts.results import RunStatus +from dbt.events.types import JinjaLogWarning from dbt.tests.adapter.functions.files import MY_UDF_YML -from dbt.tests.adapter.functions.test_udfs import UDFsBasic +from dbt.tests.adapter.functions.test_udfs import ( + UDFsBasic, + DeterministicUDF, + StableUDF, + NonDeterministicUDF, +) +from dbt.tests.util import run_dbt +from dbt_common.events.event_catcher import EventCatcher from tests.functional.functions.files import MY_UDF_SQL @@ -13,4 +23,48 @@ def functions(self): "price_for_xlarge.yml": MY_UDF_YML, } + +class TestSnowflakeDeterministicUDFs(DeterministicUDF): + pass + + +class TestSnowflakeStableUDFs(StableUDF): + def check_function_volatility(self, sql: str): + assert "VOLATILE" not in sql + assert "STABLE" not in sql + assert "IMMUTABLE" not in sql + + def test_udfs(self, project, sql_event_catcher): + warning_event_catcher = EventCatcher(JinjaLogWarning) + result = run_dbt( + ["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch] + ) + + assert len(result.results) == 1 + node_result = result.results[0] + assert node_result.status == RunStatus.Success + node = node_result.node + assert isinstance(node, FunctionNode) + assert node_result.node.name == "price_for_xlarge" + + # Check volatility + assert len(sql_event_catcher.caught_events) == 1 + self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql) + + # Check that the warning event was caught + assert len(warning_event_catcher.caught_events) == 1 + assert ( + "Found `stable` volatility specified on function `price_for_xlarge`. This volatility is not supported by snowflake, and will be ignored" + in warning_event_catcher.caught_events[0].data.msg + ) + + # Check that the function can be executed + result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"]) + assert len(result.results) == 1 + # The result should have an agate table with one row and one column (and thus only one value, which is our inline selection) + select_value = int(result.results[0].agate_table.rows[0].values()[0]) + assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200) + + +class TestSnowflakeNonDeterministicUDFs(NonDeterministicUDF): pass diff --git a/dbt-tests-adapter/.changes/unreleased/Features-20251029-164200.yaml b/dbt-tests-adapter/.changes/unreleased/Features-20251029-164200.yaml new file mode 100644 index 000000000..0e7fe7a0c --- /dev/null +++ b/dbt-tests-adapter/.changes/unreleased/Features-20251029-164200.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Add tests for the setting of volatility for sql UDFs +time: 2025-10-29T16:42:00.125305-05:00 +custom: + Author: QMalcolm + Issue: "1345" diff --git a/dbt-tests-adapter/pyproject.toml b/dbt-tests-adapter/pyproject.toml index b42863fb0..7cf0000f4 100644 --- a/dbt-tests-adapter/pyproject.toml +++ b/dbt-tests-adapter/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ + "dbt-common>=1.34.0,<2.0", # TODO: remove `dbt-core` dependency "dbt-core>=1.8.0a1", # `dbt-tests-adapter` will ultimately depend on the packages below diff --git a/dbt-tests-adapter/src/dbt/tests/adapter/functions/test_udfs.py b/dbt-tests-adapter/src/dbt/tests/adapter/functions/test_udfs.py index dbb72fc76..e8197cc84 100644 --- a/dbt-tests-adapter/src/dbt/tests/adapter/functions/test_udfs.py +++ b/dbt-tests-adapter/src/dbt/tests/adapter/functions/test_udfs.py @@ -1,9 +1,12 @@ -from dbt.artifacts.schemas.results import RunStatus -from dbt.contracts.graph.nodes import FunctionNode import pytest +from dbt.adapters.events.types import SQLQuery +from dbt.artifacts.schemas.results import RunStatus +from dbt.contracts.graph.nodes import FunctionNode from dbt.tests.adapter.functions import files from dbt.tests.util import run_dbt +from dbt_common.events.base_types import EventMsg +from dbt_common.events.event_catcher import EventCatcher class UDFsBasic: @@ -15,8 +18,25 @@ def functions(self): "price_for_xlarge.yml": files.MY_UDF_YML, } - def test_udfs(self, project): - result = run_dbt(["build"]) + def is_function_create_event(self, event: EventMsg) -> bool: + return ( + event.data.node_info.node_name == "price_for_xlarge" + and "CREATE OR REPLACE FUNCTION" in event.data.sql + ) + + @pytest.fixture(scope="class") + def sql_event_catcher(self) -> EventCatcher: + return EventCatcher( + event_to_catch=SQLQuery, predicate=lambda event: self.is_function_create_event(event) + ) + + def check_function_volatility(self, sql: str): + assert "VOLATILE" not in sql + assert "STABLE" not in sql + assert "IMMUTABLE" not in sql + + def test_udfs(self, project, sql_event_catcher): + result = run_dbt(["build", "--debug"], callbacks=[sql_event_catcher.catch]) assert len(result.results) == 1 node_result = result.results[0] @@ -25,8 +45,46 @@ def test_udfs(self, project): assert isinstance(node, FunctionNode) assert node_result.node.name == "price_for_xlarge" + # Check volatility + assert len(sql_event_catcher.caught_events) == 1 + self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql) + + # Check that the function can be executed result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"]) assert len(result.results) == 1 # The result should have an agate table with one row and one column (and thus only one value, which is our inline selection) select_value = int(result.results[0].agate_table.rows[0].values()[0]) assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200) + + +class DeterministicUDF(UDFsBasic): + @pytest.fixture(scope="class") + def project_config_update(self): + return { + "functions": {"+volatility": "deterministic"}, + } + + def check_function_volatility(self, sql: str): + assert "IMMUTABLE" in sql + + +class StableUDF(UDFsBasic): + @pytest.fixture(scope="class") + def project_config_update(self): + return { + "functions": {"+volatility": "stable"}, + } + + def check_function_volatility(self, sql: str): + assert "STABLE" in sql + + +class NonDeterministicUDF(UDFsBasic): + @pytest.fixture(scope="class") + def project_config_update(self): + return { + "functions": {"+volatility": "non-deterministic"}, + } + + def check_function_volatility(self, sql: str): + assert "VOLATILE" in sql