diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7b1707b5..ea750f0639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Snowpark Python API Updates +#### New Features + +- Added interval type support for Python UDFs and stored procedures. Use `datetime.timedelta` as the type annotation for day-time interval (`DayTimeIntervalType`) parameters and return values, and `YearMonthInterval` (a type annotation sentinel from `snowflake.snowpark.types`) for year-month interval (`YearMonthIntervalType`) parameters and return values. + ## 1.54.0 (2026-07-29) ### Snowpark Python API updates diff --git a/src/snowflake/snowpark/_internal/analyzer/datatype_mapper.py b/src/snowflake/snowpark/_internal/analyzer/datatype_mapper.py index c824927ec9..65319b5814 100644 --- a/src/snowflake/snowpark/_internal/analyzer/datatype_mapper.py +++ b/src/snowflake/snowpark/_internal/analyzer/datatype_mapper.py @@ -501,6 +501,31 @@ def to_sql( if isinstance(value, str) and isinstance(datatype, DayTimeIntervalType): return f"{str_to_sql_for_day_time_interval(value, datatype)} :: {convert_sp_to_sf_type(datatype)}" + if isinstance(value, timedelta) and isinstance(datatype, DayTimeIntervalType): + # Serialize timedelta as an INTERVAL DAY TO SECOND literal. + # timedelta stores (days, seconds, microseconds) all non-negative after normalization. + sign = "-" if value < timedelta(0) else "+" + abs_val = abs(value) + d = abs_val.days + total_us = abs_val.seconds * 1_000_000 + abs_val.microseconds + h = total_us // 3_600_000_000 + total_us %= 3_600_000_000 + m = total_us // 60_000_000 + total_us %= 60_000_000 + s = total_us // 1_000_000 + us = total_us % 1_000_000 + interval_str = f"{sign}{d} {h:02d}:{m:02d}:{s:02d}.{us:06d}" + return f"INTERVAL '{interval_str}' DAY TO SECOND :: {convert_sp_to_sf_type(datatype)}" + + if isinstance(value, int) and isinstance(datatype, YearMonthIntervalType): + # Serialize int (total months) as an INTERVAL YEAR TO MONTH literal. + sign = "-" if value < 0 else "+" + abs_months = abs(value) + years = abs_months // 12 + months = abs_months % 12 + interval_str = f"{sign}{years}-{months:02d}" + return f"INTERVAL '{interval_str}' YEAR TO MONTH :: {convert_sp_to_sf_type(datatype)}" + raise TypeError(f"Unsupported datatype {datatype}, value {value} by to_sql()") diff --git a/src/snowflake/snowpark/_internal/type_utils.py b/src/snowflake/snowpark/_internal/type_utils.py index c7ca2a2a3e..99ebe16ed2 100644 --- a/src/snowflake/snowpark/_internal/type_utils.py +++ b/src/snowflake/snowpark/_internal/type_utils.py @@ -822,6 +822,10 @@ def python_type_str_to_object( return datetime.time elif tp_str == "datetime": return datetime.datetime + elif tp_str == "timedelta": + return datetime.timedelta + elif tp_str == "YearMonthInterval": + return YearMonthInterval # This check is to handle special case when stored procs are registered using # register_from_file where type hints are read as strings and we don't know if # the DataFrame is a snowflake.snowpark.DataFrame or not. Here, the assumption @@ -851,6 +855,8 @@ def python_type_to_snow_type( if tp is decimal.Decimal: return DecimalType(38, 18), False + elif tp is datetime.timedelta: + return DayTimeIntervalType(), False elif tp in PYTHON_TO_SNOW_TYPE_MAPPINGS: return PYTHON_TO_SNOW_TYPE_MAPPINGS[tp](), False diff --git a/src/snowflake/snowpark/_internal/udf_utils.py b/src/snowflake/snowpark/_internal/udf_utils.py index 7b3b947904..db8c9bed5a 100644 --- a/src/snowflake/snowpark/_internal/udf_utils.py +++ b/src/snowflake/snowpark/_internal/udf_utils.py @@ -2,6 +2,7 @@ # Copyright (c) 2012-2025 Snowflake Computing Inc. All rights reserved. # import collections.abc +import datetime import inspect import io import os @@ -57,7 +58,12 @@ validate_object_name, warning, ) -from snowflake.snowpark.types import DataType, StructField, StructType +from snowflake.snowpark.types import ( + DataType, + DayTimeIntervalType, + StructField, + StructType, +) from snowflake.snowpark.version import VERSION from snowflake.snowpark.context import ( _ANACONDA_SHARED_REPOSITORY, @@ -1728,6 +1734,8 @@ def generate_call_python_sp_sql( sql_args.append(session._analyzer.analyze(arg._expression, {})) elif "system$" in sproc_name.lower(): sql_args.append(to_sql_no_cast(arg, infer_type(arg))) + elif isinstance(arg, datetime.timedelta): + sql_args.append(to_sql(arg, DayTimeIntervalType())) else: sql_args.append(to_sql(arg, infer_type(arg))) return f"CALL {sproc_name}({', '.join(sql_args)})" diff --git a/src/snowflake/snowpark/mock/_functions.py b/src/snowflake/snowpark/mock/_functions.py index 4c73554c32..12e8d23d76 100644 --- a/src/snowflake/snowpark/mock/_functions.py +++ b/src/snowflake/snowpark/mock/_functions.py @@ -42,6 +42,7 @@ BinaryType, BooleanType, DateType, + DayTimeIntervalType, DecimalType, DoubleType, DecFloatType, @@ -54,6 +55,7 @@ TimestampType, TimeType, VariantType, + YearMonthIntervalType, _FractionalType, _IntegralType, _NumericType, @@ -2221,6 +2223,10 @@ def cast_column_to( return mock_to_array(col) if isinstance(target_data_type, VariantType): return mock_to_variant(col) + if isinstance(target_data_type, (DayTimeIntervalType, YearMonthIntervalType)): + res = col.copy() + res.sf_type = target_column_type + return res return None diff --git a/src/snowflake/snowpark/types.py b/src/snowflake/snowpark/types.py index 3d97816615..34a5150800 100644 --- a/src/snowflake/snowpark/types.py +++ b/src/snowflake/snowpark/types.py @@ -244,7 +244,7 @@ class YearMonthIntervalType(_AnsiIntervalType): Notes: YearMonthIntervalType is currently in private preview since 1.38.0. It needs to be enabled by setting parameter `FEATURE_INTERVAL_TYPES` to `ENABLED`. - YearMonthIntervalType is currently not supported in UDFs and Stored Procedures. + Support for YearMonthIntervalType in UDFs and Stored Procedures requires the ``ENABLE_INTERVAL_TYPES_IN_UDF`` account parameter to be enabled. Use :class:`YearMonthInterval` as the Python type annotation; values are passed as the total number of months (``int``). """ YEAR = 0 #: Constant representing the YEAR field for interval start/end positions @@ -305,7 +305,7 @@ class DayTimeIntervalType(_AnsiIntervalType): Notes: DayTimeIntervalType is currently in private preview since 1.38.0. It needs to be enabled by setting parameters `FEATURE_INTERVAL_TYPES` to `ENABLED`. - DayTimeIntervalType is currently not supported in UDFs and Stored Procedures. + Support for DayTimeIntervalType in UDFs and Stored Procedures requires the ``ENABLE_INTERVAL_TYPES_IN_UDF`` account parameter to be enabled. Use :class:`DayTimeInterval` or ``datetime.timedelta`` as the Python type annotation; values are passed as ``datetime.timedelta``. """ DAY = 0 #: Constant representing the DAY field for interval start/end positions diff --git a/tests/integ/conftest.py b/tests/integ/conftest.py index a113b2a3d7..bd94206d21 100644 --- a/tests/integ/conftest.py +++ b/tests/integ/conftest.py @@ -310,9 +310,14 @@ def test_schema(connection, local_testing_mode) -> None: cursor.execute( f"GRANT ALL PRIVILEGES ON SCHEMA {TEST_SCHEMA} TO ROLE PUBLIC" ) - cursor.execute( - f"ALTER SCHEMA SET DEFAULT_PYTHON_ARTIFACT_REPOSITORY = {_DEFAULT_ARTIFACT_REPOSITORY}" - ) + try: + cursor.execute( + f"ALTER SCHEMA SET DEFAULT_PYTHON_ARTIFACT_REPOSITORY = {_DEFAULT_ARTIFACT_REPOSITORY}" + ) + except Exception: + # Some accounts (e.g. temptest) do not support + # DEFAULT_PYTHON_ARTIFACT_REPOSITORY; skip silently. + pass yield cursor.execute(f"DROP SCHEMA IF EXISTS {TEST_SCHEMA}") @@ -490,6 +495,17 @@ def temp_stage(session, resources_path, local_testing_mode): Utils.drop_stage(session, tmp_stage_name) +@pytest.fixture(scope="module") +def interval_udf_enabled(session, local_testing_mode): + if local_testing_mode: + pytest.skip("ENABLE_INTERVAL_TYPES_IN_UDF not supported in local testing") + rows = session.sql( + "SHOW PARAMETERS LIKE 'ENABLE_INTERVAL_TYPES_IN_UDF' IN ACCOUNT" + ).collect() + if not (bool(rows) and rows[0]["value"].upper() == "TRUE"): + pytest.skip("ENABLE_INTERVAL_TYPES_IN_UDF not enabled on this account") + + @pytest.fixture(scope="function", autouse=True) def clear_session_ast_batch_on_validate_ast(session, validate_ast): """ diff --git a/tests/integ/test_stored_procedure.py b/tests/integ/test_stored_procedure.py index 134e97b64a..7333571796 100644 --- a/tests/integ/test_stored_procedure.py +++ b/tests/integ/test_stored_procedure.py @@ -53,11 +53,13 @@ from snowflake.snowpark.row import Row from snowflake.snowpark.types import ( DateType, + DayTimeIntervalType, DoubleType, IntegerType, StringType, StructField, StructType, + YearMonthIntervalType, ) from tests.integ.session_parameters import create_session_for_test from tests.utils import ( @@ -2753,3 +2755,44 @@ def multiply(session_: Session, x: int) -> int: is_permanent=False, ) assert sp(6) == 42 + + +# ── Interval type stored procedures (SNOW-3746506) ─────────────────────────── + + +@pytest.mark.skipif(IS_IN_STORED_PROC, reason="Cannot create session in SP") +def test_sproc_daytime_interval(session, interval_udf_enabled): + """DayTimeIntervalType sproc: timedelta arg/return round-trips correctly.""" + + def double_interval(session: Session, d: datetime.timedelta) -> datetime.timedelta: + return d * 2 + + sp = session.sproc.register( + double_interval, + return_type=DayTimeIntervalType(), + input_types=[DayTimeIntervalType()], + ) + result = sp(datetime.timedelta(days=3)) + assert result == datetime.timedelta(days=6) + + +@pytest.mark.skipif(IS_IN_STORED_PROC, reason="Cannot create session in SP") +def test_sproc_yearmonth_interval(session, interval_udf_enabled): + """YearMonthIntervalType sproc: int (total months) arg/return round-trips correctly.""" + + from snowflake.snowpark.types import YearMonthInterval + + def promote(session: Session, m: YearMonthInterval) -> YearMonthInterval: + return m + 12 # m is int (total months) inside the sproc + + sp = session.sproc.register( + promote, + return_type=YearMonthIntervalType(), + input_types=[YearMonthIntervalType()], + ) + # Call via SQL to pass an explicit INTERVAL literal (infer_type(int) → NUMBER). + result = session.sql(f"CALL {sp.name}(INTERVAL '1-2' YEAR TO MONTH)").collect()[0][ + 0 + ] + # 14 months + 12 = 26 months → '+2-02' + assert result == "+2-02" diff --git a/tests/integ/test_udf.py b/tests/integ/test_udf.py index 916eba7cf2..7acee3c1c4 100644 --- a/tests/integ/test_udf.py +++ b/tests/integ/test_udf.py @@ -63,6 +63,7 @@ BinaryType, BooleanType, DateType, + DayTimeIntervalType, DoubleType, FloatType, Geography, @@ -81,6 +82,8 @@ TimeType, Variant, VariantType, + YearMonthInterval, + YearMonthIntervalType, ) from tests.integ.session_parameters import create_session_for_test from tests.utils import ( @@ -3252,3 +3255,111 @@ def test_turtles() -> str: session._run_query(f"drop function if exists {temp_func_name}(int)") session.sql(f"drop database {temp_database}").collect() + + +# ── Interval type UDFs (SNOW-3746497) ──────────────────────────────────────── + + +def test_udf_daytime_interval_timedelta_annotation(session, interval_udf_enabled): + """timedelta type annotation maps to DayTimeIntervalType; round-trip through a UDF.""" + + def add_day(d: datetime.timedelta) -> datetime.timedelta: + return d + datetime.timedelta(days=1) + + f = session.udf.register(add_day) + assert f._return_type == DayTimeIntervalType() + assert f._input_types == [DayTimeIntervalType()] + + result = session.sql(f"SELECT {f.name}(INTERVAL '5' DAY)").collect() + assert result[0][0] == datetime.timedelta(days=6) + + +def test_udf_daytime_interval_explicit_type(session, interval_udf_enabled): + """Explicit DayTimeIntervalType input/return; multiplication round-trip.""" + + def triple(d: datetime.timedelta) -> datetime.timedelta: + return d * 3 + + f = session.udf.register( + triple, + return_type=DayTimeIntervalType(), + input_types=[DayTimeIntervalType()], + ) + result = session.sql( + f"SELECT {f.name}(INTERVAL '2 12:00:00' DAY TO SECOND)" + ).collect() + assert result[0][0] == datetime.timedelta(days=7, hours=12) + + +def test_udf_daytime_interval_null(session, interval_udf_enabled): + """Null DayTime interval input propagates as None.""" + + def identity(d: datetime.timedelta) -> datetime.timedelta: + return d + + f = session.udf.register(identity) + result = ( + session.sql("SELECT NULL::INTERVAL DAY TO SECOND AS d").select(f("d")).collect() + ) + assert result[0][0] is None + + +def test_udf_daytime_interval_negative(session, interval_udf_enabled): + """Negative DayTime intervals round-trip correctly.""" + + def negate(d: datetime.timedelta) -> datetime.timedelta: + return -d + + f = session.udf.register(negate) + result = session.sql(f"SELECT {f.name}(INTERVAL '3' DAY)").collect() + assert result[0][0] == datetime.timedelta(days=-3) + + +def test_udf_yearmonth_interval(session, interval_udf_enabled): + """YearMonthInterval UDF: value arrives as int (total months) inside handler.""" + # YearMonthInterval is a TypeVar used for annotation only; the coprocessor + # surfaces the value as a plain int (total months) inside the UDF body. + def add_year(m: YearMonthInterval) -> YearMonthInterval: + return m + 12 + + f = session.udf.register( + add_year, + return_type=YearMonthIntervalType(), + input_types=[YearMonthIntervalType()], + ) + # 1yr 2mo (14 total months) + 12 = 26 months → '+2-02' + result = session.sql(f"SELECT {f.name}(INTERVAL '1-2' YEAR TO MONTH)").collect() + assert result[0][0] == "+2-02" + + +def test_udf_yearmonth_interval_null(session, interval_udf_enabled): + """Null YearMonth interval input propagates as None.""" + + def identity(m: YearMonthInterval) -> YearMonthInterval: + return m + + f = session.udf.register( + identity, + return_type=YearMonthIntervalType(), + input_types=[YearMonthIntervalType()], + ) + result = ( + session.sql("SELECT NULL::INTERVAL YEAR TO MONTH AS m").select(f("m")).collect() + ) + assert result[0][0] is None + + +def test_udf_yearmonth_interval_negative(session, interval_udf_enabled): + """YearMonth interval UDF returning a negative value displays with leading '-'.""" + + def negate(m: YearMonthInterval) -> YearMonthInterval: + return -m + + f = session.udf.register( + negate, + return_type=YearMonthIntervalType(), + input_types=[YearMonthIntervalType()], + ) + # negate(1yr 2mo) = -(14 months) → '-1-02' + result = session.sql(f"SELECT {f.name}(INTERVAL '1-2' YEAR TO MONTH)").collect() + assert result[0][0] == "-1-02" diff --git a/tests/mock/test_udf.py b/tests/mock/test_udf.py index fa71daa7b4..6a631470d0 100644 --- a/tests/mock/test_udf.py +++ b/tests/mock/test_udf.py @@ -4,6 +4,7 @@ import os import sys +from datetime import timedelta import pytest @@ -12,7 +13,14 @@ from snowflake.snowpark.mock._udf import MockUDFRegistration from snowflake.snowpark.mock.exceptions import SnowparkLocalTestingException from snowflake.snowpark.session import Session -from snowflake.snowpark.types import IntegerType +from snowflake.snowpark.types import ( + DayTimeIntervalType, + IntegerType, + StructField, + StructType, + YearMonthInterval, + YearMonthIntervalType, +) def test_udf_cleanup_on_err(session): @@ -95,3 +103,44 @@ def test_get_udf_negative(session): def test_get_udf_imports_negative(session): reg = MockUDFRegistration(session) assert reg.get_udf_imports("does_not_exist") == set() + + +def test_udf_daytime_interval_local(session): + """DayTimeIntervalType UDF: timedelta in, timedelta out in local testing mode.""" + + def add_day(d: timedelta) -> timedelta: + return d + timedelta(days=1) + + f = session.udf.register(add_day, strict=True) + assert f._return_type == DayTimeIntervalType() + assert f._input_types == [DayTimeIntervalType()] + + df = session.create_dataframe( + [[timedelta(days=5)], [None]], + schema=StructType([StructField("d", DayTimeIntervalType())]), + ) + result = df.select(f("d")).collect() + assert result[0][0] == timedelta(days=6) + assert result[1][0] is None + + +def test_udf_yearmonth_interval_local(session): + """YearMonthIntervalType UDF: int (total months) in/out in local testing mode.""" + + def add_year(m: YearMonthInterval) -> YearMonthInterval: + return m + 12 + + f = session.udf.register( + add_year, + return_type=YearMonthIntervalType(), + input_types=[YearMonthIntervalType()], + strict=True, + ) + + df = session.create_dataframe( + [[14], [None]], + schema=StructType([StructField("m", YearMonthIntervalType())]), + ) + result = df.select(f("m")).collect() + assert result[0][0] == 26 # 14 + 12 + assert result[1][0] is None diff --git a/tests/unit/test_datatype_mapper.py b/tests/unit/test_datatype_mapper.py index 7ce9a25363..abd9e5748e 100644 --- a/tests/unit/test_datatype_mapper.py +++ b/tests/unit/test_datatype_mapper.py @@ -360,6 +360,57 @@ def test_to_sql(): ) +def test_to_sql_interval_types(): + td = datetime.timedelta + + # DayTimeInterval: positive values + assert ( + to_sql(td(days=5), DayTimeIntervalType()) + == "INTERVAL '+5 00:00:00.000000' DAY TO SECOND :: INTERVAL DAY TO SECOND" + ) + assert ( + to_sql( + td(days=2, hours=3, minutes=4, seconds=5, microseconds=6), + DayTimeIntervalType(), + ) + == "INTERVAL '+2 03:04:05.000006' DAY TO SECOND :: INTERVAL DAY TO SECOND" + ) + assert ( + to_sql(td(seconds=30, microseconds=500000), DayTimeIntervalType()) + == "INTERVAL '+0 00:00:30.500000' DAY TO SECOND :: INTERVAL DAY TO SECOND" + ) + + # DayTimeInterval: zero and negative + assert ( + to_sql(td(0), DayTimeIntervalType()) + == "INTERVAL '+0 00:00:00.000000' DAY TO SECOND :: INTERVAL DAY TO SECOND" + ) + assert ( + to_sql(td(days=-3), DayTimeIntervalType()) + == "INTERVAL '-3 00:00:00.000000' DAY TO SECOND :: INTERVAL DAY TO SECOND" + ) + + # YearMonthInterval: positive values + assert ( + to_sql(14, YearMonthIntervalType()) + == "INTERVAL '+1-02' YEAR TO MONTH :: INTERVAL YEAR TO MONTH" + ) + assert ( + to_sql(6, YearMonthIntervalType()) + == "INTERVAL '+0-06' YEAR TO MONTH :: INTERVAL YEAR TO MONTH" + ) + + # YearMonthInterval: zero and negative + assert ( + to_sql(0, YearMonthIntervalType()) + == "INTERVAL '+0-00' YEAR TO MONTH :: INTERVAL YEAR TO MONTH" + ) + assert ( + to_sql(-18, YearMonthIntervalType()) + == "INTERVAL '-1-06' YEAR TO MONTH :: INTERVAL YEAR TO MONTH" + ) + + def test_to_sql_system_function(): # Test nulls assert to_sql_no_cast(None, NullType()) == "NULL" diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 6f2f8cf0ff..50aa8cbaff 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -8,7 +8,7 @@ import typing from array import array from collections import defaultdict -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time, timedelta, timezone from decimal import Decimal from unittest import mock @@ -509,6 +509,7 @@ def check_type( check_type(DataFrame, StructType(), False, is_return_type_of_sproc=True) check_type(YearMonthInterval, YearMonthIntervalType(), False) check_type(DayTimeInterval, DayTimeIntervalType(), False) + check_type(timedelta, DayTimeIntervalType(), False) # complicated (nested) types check_type( @@ -1135,6 +1136,72 @@ def test_convert_sp_to_sf_type(): convert_sp_to_sf_type(None) +def test_interval_types_in_udf_context(): + """Verify that interval types can be used as UDF/sproc parameter and return types. + + This covers SNOW-3746497 (UDFs) and SNOW-3746506 (sprocs). The actual UDF + execution requires ENABLE_INTERVAL_TYPES_IN_UDF to be enabled on the account; + this test covers the client-side SDK type-mapping layer only. + """ + # datetime.timedelta maps to DayTimeIntervalType (DAY TO SECOND). + dt_snow, dt_nullable = python_type_to_snow_type(timedelta) + assert dt_snow == DayTimeIntervalType() + assert dt_nullable is False + + # The string "timedelta" (as it appears in register_from_file type hints) maps the same way. + dt_str_snow, _ = python_type_to_snow_type("timedelta") + assert dt_str_snow == DayTimeIntervalType() + + # The string "YearMonthInterval" (register_from_file) maps to YearMonthIntervalType. + ym_str_snow, ym_str_nullable = python_type_to_snow_type("YearMonthInterval") + assert ym_str_snow == YearMonthIntervalType() + assert ym_str_nullable is False + + # DayTimeInterval (the Snowpark marker class) also maps to DayTimeIntervalType. + assert python_type_to_snow_type(DayTimeInterval) == (DayTimeIntervalType(), False) + + # YearMonthInterval maps to YearMonthIntervalType. + assert python_type_to_snow_type(YearMonthInterval) == ( + YearMonthIntervalType(), + False, + ) + + # Optional[timedelta] is nullable DayTimeIntervalType. + import typing + + opt_snow, opt_nullable = python_type_to_snow_type(typing.Optional[timedelta]) + assert opt_snow == DayTimeIntervalType() + assert opt_nullable is True + + # convert_sp_to_sf_type produces the correct DDL type strings for RETURNS clauses. + assert convert_sp_to_sf_type(DayTimeIntervalType()) == "INTERVAL DAY TO SECOND" + assert ( + convert_sp_to_sf_type( + DayTimeIntervalType(DayTimeIntervalType.DAY, DayTimeIntervalType.HOUR) + ) + == "INTERVAL DAY TO HOUR" + ) + assert ( + convert_sp_to_sf_type( + DayTimeIntervalType(DayTimeIntervalType.HOUR, DayTimeIntervalType.SECOND) + ) + == "INTERVAL HOUR TO SECOND" + ) + assert ( + convert_sp_to_sf_type(DayTimeIntervalType(DayTimeIntervalType.SECOND)) + == "INTERVAL SECOND" + ) + assert convert_sp_to_sf_type(YearMonthIntervalType()) == "INTERVAL YEAR TO MONTH" + assert ( + convert_sp_to_sf_type(YearMonthIntervalType(YearMonthIntervalType.MONTH)) + == "INTERVAL MONTH" + ) + + # Simulating end-to-end: timedelta annotation -> DDL type string (as used in RETURNS/arg clauses). + timedelta_snow, _ = python_type_to_snow_type(timedelta) + assert convert_sp_to_sf_type(timedelta_snow) == "INTERVAL DAY TO SECOND" + + @pytest.mark.parametrize("use_structured_type_semantics", [True, False]) def test_map_type_as_nested_preserves_value_contains_null( use_structured_type_semantics,