Skip to content

Commit f48874f

Browse files
authored
Support the volatility config of function nodes (#1414)
1 parent d5cb1ff commit f48874f

File tree

16 files changed

+420
-15
lines changed

16 files changed

+420
-15
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Add default macros for interpolating the volatility of sql UDFs
3+
time: 2025-10-29T16:42:30.989678-05:00
4+
custom:
5+
Author: QMalcolm
6+
Issue: "1345"

dbt-adapters/src/dbt/include/global_project/macros/materializations/functions/scalar.sql

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
{% macro default__scalar_function_create_replace_signature_sql(target_relation) %}
1515
CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}})
1616
RETURNS {{ model.returns.data_type }}
17+
{{ scalar_function_volatility_sql() }}
1718
AS
1819
{% endmacro %}
1920

@@ -38,3 +39,31 @@
3839
{{ model.compiled_code }}
3940
$$ LANGUAGE SQL
4041
{% endmacro %}
42+
43+
{% macro scalar_function_volatility_sql() %}
44+
{{ return(adapter.dispatch('scalar_function_volatility_sql', 'dbt')()) }}
45+
{% endmacro %}
46+
47+
{% macro default__scalar_function_volatility_sql() %}
48+
{% set volatility = model.config.get('volatility') %}
49+
{% if volatility == 'deterministic' %}
50+
IMMUTABLE
51+
{% elif volatility == 'stable' %}
52+
STABLE
53+
{% elif volatility == 'non-deterministic' %}
54+
VOLATILE
55+
{% elif volatility != none %}
56+
{# This shouldn't happen unless a new volatility is invented #}
57+
{% do unsupported_volatility_warning(volatility) %}
58+
{% endif %}
59+
{# If no volatility is set, don't add anything and let the data warehouse default it #}
60+
{% endmacro %}
61+
62+
{% macro unsupported_volatility_warning(volatility) %}
63+
{{ return(adapter.dispatch('unsupported_volatility_warning', 'dbt')(volatility)) }}
64+
{% endmacro %}
65+
66+
{% macro default__unsupported_volatility_warning(volatility) %}
67+
{% set msg = "Found `" ~ volatility ~ "` volatility specified on function `" ~ model.name ~ "`. This volatility is not supported by " ~ adapter.type() ~ ", and will be ignored" %}
68+
{% do exceptions.warn(msg) %}
69+
{% endmacro %}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Under the Hood
2+
body: Test that bigquery does nothing with volatility
3+
time: 2025-10-29T16:56:03.92939-05:00
4+
custom:
5+
Author: QMalcolm
6+
Issue: "1345"

dbt-bigquery/src/dbt/include/bigquery/macros/materializations/functions/scalar.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{% macro bigquery__scalar_function_create_replace_signature_sql(target_relation) %}
22
CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}})
33
RETURNS {{ model.returns.data_type }}
4+
{{ scalar_function_volatility_sql() }}
45
AS
56
{% endmacro %}
67

@@ -9,3 +10,10 @@
910
{{ model.compiled_code }}
1011
)
1112
{% endmacro %}
13+
14+
{% macro bigquery__scalar_function_volatility_sql() %}
15+
{% set volatility = model.config.get('volatility') %}
16+
{% if volatility != None %}
17+
{% do unsupported_volatility_warning(volatility) %}
18+
{% endif %}
19+
{% endmacro %}

dbt-bigquery/tests/functional/functions/test_udfs.py

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import pytest
2-
from dbt.tests.adapter.functions.test_udfs import UDFsBasic
2+
from dbt.contracts.graph.nodes import FunctionNode
3+
from dbt.contracts.results import RunStatus
4+
from dbt.events.types import JinjaLogWarning
5+
from dbt.tests.util import run_dbt
6+
from dbt.tests.adapter.functions.test_udfs import (
7+
UDFsBasic,
8+
DeterministicUDF,
9+
StableUDF,
10+
NonDeterministicUDF,
11+
)
12+
from dbt_common.events.event_catcher import EventCatcher
313
from tests.functional.functions import files
414

515

@@ -12,4 +22,137 @@ def functions(self):
1222
"price_for_xlarge.yml": files.MY_UDF_YML,
1323
}
1424

15-
pass
25+
26+
class TestBigqueryDeterministicUDFs(DeterministicUDF):
27+
@pytest.fixture(scope="class")
28+
def functions(self):
29+
return {
30+
"price_for_xlarge.sql": files.MY_UDF_SQL,
31+
"price_for_xlarge.yml": files.MY_UDF_YML,
32+
}
33+
34+
def check_function_volatility(self, sql: str):
35+
assert "VOLATILE" not in sql
36+
assert "STABLE" not in sql
37+
assert "IMMUTABLE" not in sql
38+
39+
def test_udfs(self, project, sql_event_catcher):
40+
warning_event_catcher = EventCatcher(JinjaLogWarning)
41+
result = run_dbt(
42+
["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch]
43+
)
44+
45+
assert len(result.results) == 1
46+
node_result = result.results[0]
47+
assert node_result.status == RunStatus.Success
48+
node = node_result.node
49+
assert isinstance(node, FunctionNode)
50+
assert node_result.node.name == "price_for_xlarge"
51+
52+
# Check volatility
53+
assert len(sql_event_catcher.caught_events) == 1
54+
self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql)
55+
56+
# Check that the warning event was caught
57+
assert len(warning_event_catcher.caught_events) == 1
58+
assert (
59+
"Found `deterministic` volatility specified on function `price_for_xlarge`. This volatility is not supported by bigquery, and will be ignored"
60+
in warning_event_catcher.caught_events[0].data.msg
61+
)
62+
63+
# Check that the function can be executed
64+
result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"])
65+
assert len(result.results) == 1
66+
# The result should have an agate table with one row and one column (and thus only one value, which is our inline selection)
67+
select_value = int(result.results[0].agate_table.rows[0].values()[0])
68+
assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200)
69+
70+
71+
class TestBigqueryStableUDFs(StableUDF):
72+
@pytest.fixture(scope="class")
73+
def functions(self):
74+
return {
75+
"price_for_xlarge.sql": files.MY_UDF_SQL,
76+
"price_for_xlarge.yml": files.MY_UDF_YML,
77+
}
78+
79+
def check_function_volatility(self, sql: str):
80+
assert "VOLATILE" not in sql
81+
assert "STABLE" not in sql
82+
assert "IMMUTABLE" not in sql
83+
84+
def test_udfs(self, project, sql_event_catcher):
85+
warning_event_catcher = EventCatcher(JinjaLogWarning)
86+
result = run_dbt(
87+
["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch]
88+
)
89+
90+
assert len(result.results) == 1
91+
node_result = result.results[0]
92+
assert node_result.status == RunStatus.Success
93+
node = node_result.node
94+
assert isinstance(node, FunctionNode)
95+
assert node_result.node.name == "price_for_xlarge"
96+
97+
# Check volatility
98+
assert len(sql_event_catcher.caught_events) == 1
99+
self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql)
100+
101+
# Check that the warning event was caught
102+
assert len(warning_event_catcher.caught_events) == 1
103+
assert (
104+
"Found `stable` volatility specified on function `price_for_xlarge`. This volatility is not supported by bigquery, and will be ignored"
105+
in warning_event_catcher.caught_events[0].data.msg
106+
)
107+
108+
# Check that the function can be executed
109+
result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"])
110+
assert len(result.results) == 1
111+
# The result should have an agate table with one row and one column (and thus only one value, which is our inline selection)
112+
select_value = int(result.results[0].agate_table.rows[0].values()[0])
113+
assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200)
114+
115+
116+
class TestBigqueryNonDeterministicUDFs(NonDeterministicUDF):
117+
@pytest.fixture(scope="class")
118+
def functions(self):
119+
return {
120+
"price_for_xlarge.sql": files.MY_UDF_SQL,
121+
"price_for_xlarge.yml": files.MY_UDF_YML,
122+
}
123+
124+
def check_function_volatility(self, sql: str):
125+
assert "VOLATILE" not in sql
126+
assert "STABLE" not in sql
127+
assert "IMMUTABLE" not in sql
128+
129+
def test_udfs(self, project, sql_event_catcher):
130+
warning_event_catcher = EventCatcher(JinjaLogWarning)
131+
result = run_dbt(
132+
["build", "--debug"], callbacks=[sql_event_catcher.catch, warning_event_catcher.catch]
133+
)
134+
135+
assert len(result.results) == 1
136+
node_result = result.results[0]
137+
assert node_result.status == RunStatus.Success
138+
node = node_result.node
139+
assert isinstance(node, FunctionNode)
140+
assert node_result.node.name == "price_for_xlarge"
141+
142+
# Check volatility
143+
assert len(sql_event_catcher.caught_events) == 1
144+
self.check_function_volatility(sql_event_catcher.caught_events[0].data.sql)
145+
146+
# Check that the warning event was caught
147+
assert len(warning_event_catcher.caught_events) == 1
148+
assert (
149+
"Found `non-deterministic` volatility specified on function `price_for_xlarge`. This volatility is not supported by bigquery, and will be ignored"
150+
in warning_event_catcher.caught_events[0].data.msg
151+
)
152+
153+
# Check that the function can be executed
154+
result = run_dbt(["show", "--inline", "SELECT {{ function('price_for_xlarge') }}(100)"])
155+
assert len(result.results) == 1
156+
# The result should have an agate table with one row and one column (and thus only one value, which is our inline selection)
157+
select_value = int(result.results[0].agate_table.rows[0].values()[0])
158+
assert select_value == 200 # the UDF should return 2x the input value (100 * 2 = 200)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Support volatility setting for sql UDFs
3+
time: 2025-10-29T16:41:09.298749-05:00
4+
custom:
5+
Author: QMalcolm
6+
Issue: "1345"
Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
1-
from dbt.tests.adapter.functions.test_udfs import UDFsBasic
1+
from dbt.tests.adapter.functions.test_udfs import (
2+
UDFsBasic,
3+
DeterministicUDF,
4+
StableUDF,
5+
NonDeterministicUDF,
6+
)
27

38

49
class TestPostgresUDFs(UDFsBasic):
510
pass
11+
12+
13+
class TestPostgresDeterministicUDFs(DeterministicUDF):
14+
pass
15+
16+
17+
class TestPostgresStableUDFs(StableUDF):
18+
pass
19+
20+
21+
class TestPostgresNonDeterministicUDFs(NonDeterministicUDF):
22+
pass
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Features
2+
body: Support volatility setting in sql UDFs
3+
time: 2025-10-29T16:39:48.716884-05:00
4+
custom:
5+
Author: QMalcolm
6+
Issue: "1345"
Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
{% macro redshift__scalar_function_create_replace_signature_sql(target_relation) %}
22
CREATE OR REPLACE FUNCTION {{ target_relation.render() }} ({{ formatted_scalar_function_args_sql()}})
33
RETURNS {{ model.returns.data_type }}
4-
{# TODO: Stop defaulting to VOLATILE once we have a way to set the volatility #}
5-
{# We set a default here because redshift requires a volatility to be set #}
6-
VOLATILE
4+
{{ scalar_function_volatility_sql() }}
75
AS
86
{% endmacro %}
97

@@ -14,3 +12,18 @@
1412
{%- endfor %}
1513
{{ args | join(', ') }}
1614
{% endmacro %}
15+
16+
{% macro redshift__scalar_function_volatility_sql() %}
17+
{% set volatility = model.config.get('volatility') %}
18+
{% if volatility == 'deterministic' %}
19+
IMMUTABLE
20+
{% elif volatility == 'stable' %}
21+
STABLE
22+
{% elif volatility == 'non-deterministic' or volatility == none %}
23+
VOLATILE
24+
{% else %}
25+
{% do unsupported_volatility_warning(volatility) %}
26+
{# We're ignoring the unknown volatility. But redshift requires a volatility to be set, so we default to VOLATILE #}
27+
VOLATILE
28+
{% endif %}
29+
{% endmacro %}
Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
from dbt.artifacts.schemas.results import RunStatus
2-
from dbt.contracts.graph.nodes import FunctionNode
3-
from dbt.tests.util import run_dbt
41
import pytest
52
from dbt.tests.adapter.functions.files import MY_UDF_YML
6-
from dbt.tests.adapter.functions.test_udfs import UDFsBasic
3+
from dbt.tests.adapter.functions.test_udfs import (
4+
UDFsBasic,
5+
DeterministicUDF,
6+
StableUDF,
7+
NonDeterministicUDF,
8+
)
79
from tests.functional.functions.files import MY_UDF_SQL
810

911

@@ -15,3 +17,34 @@ def functions(self):
1517
"price_for_xlarge.sql": MY_UDF_SQL,
1618
"price_for_xlarge.yml": MY_UDF_YML,
1719
}
20+
21+
def check_function_volatility(self, sql: str):
22+
# in redshift, if no volatility is set, we template in VOLATILE
23+
assert "VOLATILE" in sql
24+
25+
26+
class TestRedshiftDeterministicUDFs(DeterministicUDF):
27+
@pytest.fixture(scope="class")
28+
def functions(self):
29+
return {
30+
"price_for_xlarge.sql": MY_UDF_SQL,
31+
"price_for_xlarge.yml": MY_UDF_YML,
32+
}
33+
34+
35+
class TestRedshiftStableUDFs(StableUDF):
36+
@pytest.fixture(scope="class")
37+
def functions(self):
38+
return {
39+
"price_for_xlarge.sql": MY_UDF_SQL,
40+
"price_for_xlarge.yml": MY_UDF_YML,
41+
}
42+
43+
44+
class TestRedshiftNonDeterministicUDFs(NonDeterministicUDF):
45+
@pytest.fixture(scope="class")
46+
def functions(self):
47+
return {
48+
"price_for_xlarge.sql": MY_UDF_SQL,
49+
"price_for_xlarge.yml": MY_UDF_YML,
50+
}

0 commit comments

Comments
 (0)