Skip to content

Commit 6d2c0c0

Browse files
SNOW-2119489: Add support for interval types in json format (#2336)
1 parent 0f776c5 commit 6d2c0c0

File tree

8 files changed

+133
-67
lines changed

8 files changed

+133
-67
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1616
- Fix OAuth authenticator values.
1717
- Add `unsafe_skip_file_permissions_check` flag to skip file permissions check on cache and config.
1818
- Introduce snowflake_version property to the connection
19+
- Added basic json support for Interval types.
1920

2021
- v3.16.0(July 04,2025)
2122
- Bumped numpy dependency from <2.1.0 to <=2.2.4.

src/snowflake/connector/arrow_context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from .constants import PARAMETER_TIMEZONE
1515
from .converter import _generate_tzinfo_from_tzoffset
16+
from .interval_util import interval_year_month_to_string
1617

1718
if TYPE_CHECKING:
1819
from numpy import datetime64, float64, int64, timedelta64
@@ -164,6 +165,9 @@ def DECFLOAT_to_decimal(self, exponent: int, significand: bytes) -> decimal.Deci
164165
def DECFLOAT_to_numpy_float64(self, exponent: int, significand: bytes) -> float64:
165166
return numpy.float64(self.DECFLOAT_to_decimal(exponent, significand))
166167

168+
def INTERVAL_YEAR_MONTH_to_str(self, months: int) -> str:
169+
return interval_year_month_to_string(months)
170+
167171
def INTERVAL_YEAR_MONTH_to_numpy_timedelta(self, months: int) -> timedelta64:
168172
return numpy.timedelta64(months, "M")
169173

src/snowflake/connector/converter.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .compat import IS_BINARY, IS_NUMERIC
2121
from .errorcode import ER_NOT_SUPPORT_DATA_TYPE
2222
from .errors import ProgrammingError
23+
from .interval_util import interval_year_month_to_string
2324
from .sfbinaryformat import binary_to_python, binary_to_snowflake
2425
from .sfdatetime import sfdatetime_total_seconds_from_timedelta
2526

@@ -355,6 +356,28 @@ def _BOOLEAN_to_python(
355356
) -> Callable:
356357
return lambda value: value in ("1", "TRUE")
357358

359+
def _INTERVAL_YEAR_MONTH_to_python(self, ctx: dict[str, Any]) -> Callable:
360+
return lambda v: interval_year_month_to_string(int(v))
361+
362+
def _INTERVAL_YEAR_MONTH_numpy_to_python(self, ctx: dict[str, Any]) -> Callable:
363+
return lambda v: numpy.timedelta64(int(v), "M")
364+
365+
def _INTERVAL_DAY_TIME_to_python(self, ctx: dict[str, Any]) -> Callable:
366+
# Python timedelta only supports microsecond precision. We receive value in
367+
# nanoseconds.
368+
return lambda v: timedelta(microseconds=int(v) // 1000)
369+
370+
def _INTERVAL_DAY_TIME_numpy_to_python(self, ctx: dict[str, Any]) -> Callable:
371+
# Last 4 bits of the precision are used to store the leading field precision of
372+
# the interval.
373+
lfp = ctx["precision"] & 0x0F
374+
# Numpy timedelta only supports up to 64-bit integers. If the leading field
375+
# precision is higher than 5 we receive 16 byte integer from server. So we need
376+
# to change the unit to milliseconds to fit in 64-bit integer.
377+
if lfp > 5:
378+
return lambda v: numpy.timedelta64(int(v) // 1_000_000, "ms")
379+
return lambda v: numpy.timedelta64(int(v), "ns")
380+
358381
def snowflake_type(self, value: Any) -> str | None:
359382
"""Returns Snowflake data type for the value. This is used for qmark parameter style."""
360383
type_name = value.__class__.__name__.lower()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env python
2+
3+
4+
def interval_year_month_to_string(interval: int) -> str:
5+
"""Convert a year-month interval to a string.
6+
7+
Args:
8+
interval: The year-month interval.
9+
10+
Returns:
11+
The string representation of the interval.
12+
"""
13+
sign = "+" if interval >= 0 else "-"
14+
interval = abs(interval)
15+
years = interval // 12
16+
months = interval % 12
17+
return f"{sign}{years}-{months:02}"

src/snowflake/connector/nanoarrow_cpp/ArrowIterator/IntervalConverter.cpp

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,27 @@ static constexpr char INTERVAL_DT_INT_TO_NUMPY_TIMEDELTA[] =
1616
"INTERVAL_DAY_TIME_int_to_numpy_timedelta";
1717
static constexpr char INTERVAL_DT_INT_TO_TIMEDELTA[] =
1818
"INTERVAL_DAY_TIME_int_to_timedelta";
19+
static constexpr char INTERVAL_YEAR_MONTH_TO_NUMPY_TIMEDELTA[] =
20+
"INTERVAL_YEAR_MONTH_to_numpy_timedelta";
21+
// Python timedelta does not support year-month intervals. Use ANSI SQL
22+
// formatted string instead.
23+
static constexpr char INTERVAL_YEAR_MONTH_TO_STR[] =
24+
"INTERVAL_YEAR_MONTH_to_str";
1925

2026
IntervalYearMonthConverter::IntervalYearMonthConverter(ArrowArrayView* array,
2127
PyObject* context,
2228
bool useNumpy)
23-
: m_array(array), m_context(context), m_useNumpy(useNumpy) {}
29+
: m_array(array), m_context(context) {
30+
m_method = useNumpy ? INTERVAL_YEAR_MONTH_TO_NUMPY_TIMEDELTA
31+
: INTERVAL_YEAR_MONTH_TO_STR;
32+
}
2433

2534
PyObject* IntervalYearMonthConverter::toPyObject(int64_t rowIndex) const {
2635
if (ArrowArrayViewIsNull(m_array, rowIndex)) {
2736
Py_RETURN_NONE;
2837
}
2938
int64_t val = ArrowArrayViewGetIntUnsafe(m_array, rowIndex);
30-
if (m_useNumpy) {
31-
return PyObject_CallMethod(
32-
m_context, "INTERVAL_YEAR_MONTH_to_numpy_timedelta", "L", val);
33-
}
34-
// Python timedelta does not support year-month intervals. Use long instead.
35-
return PyLong_FromLongLong(val);
39+
return PyObject_CallMethod(m_context, m_method, "L", val);
3640
}
3741

3842
IntervalDayTimeConverterInt::IntervalDayTimeConverterInt(ArrowArrayView* array,

src/snowflake/connector/nanoarrow_cpp/ArrowIterator/IntervalConverter.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class IntervalYearMonthConverter : public IColumnConverter {
2020
private:
2121
ArrowArrayView* m_array;
2222
PyObject* m_context;
23-
bool m_useNumpy;
23+
const char* m_method;
2424
};
2525

2626
class IntervalDayTimeConverterInt : public IColumnConverter {

test/integ/test_arrow_result.py

Lines changed: 0 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,65 +1235,6 @@ def test_fetch_as_numpy_val(conn_cnx):
12351235
assert val[3] == numpy.datetime64("2019-01-02 12:34:56.12345678")
12361236

12371237

1238-
@pytest.mark.parametrize("use_numpy", [True, False])
1239-
def test_select_year_month_interval_arrow(conn_cnx, use_numpy):
1240-
cases = ["0-0", "1-2", "-1-3", "999999999-11", "-999999999-11"]
1241-
expected = [0, 14, -15, 11_999_999_999, -11_999_999_999]
1242-
if use_numpy:
1243-
expected = [numpy.timedelta64(e, "M") for e in expected]
1244-
1245-
table = "test_arrow_day_time_interval"
1246-
values = "(" + "),(".join([f"'{c}'" for c in cases]) + ")"
1247-
with conn_cnx(numpy=use_numpy) as conn:
1248-
cursor = conn.cursor()
1249-
cursor.execute("alter session set python_connector_query_result_format='arrow'")
1250-
1251-
cursor.execute("alter session set feature_interval_types=enabled")
1252-
cursor.execute(f"create or replace table {table} (c1 interval year to month)")
1253-
cursor.execute(f"insert into {table} values {values}")
1254-
result = conn.cursor().execute(f"select * from {table}").fetchall()
1255-
result = [r[0] for r in result]
1256-
assert result == expected
1257-
1258-
1259-
@pytest.mark.skip(
1260-
reason="SNOW-1878635: Add support for day-time interval in ArrowStreamWriter"
1261-
)
1262-
@pytest.mark.parametrize("use_numpy", [True, False])
1263-
def test_select_day_time_interval_arrow(conn_cnx, use_numpy):
1264-
cases = [
1265-
"0 0:0:0.0",
1266-
"12 3:4:5.678",
1267-
"-1 2:3:4.567",
1268-
"99999 23:59:59.999999",
1269-
"-99999 23:59:59.999999",
1270-
]
1271-
expected = [
1272-
timedelta(days=0),
1273-
timedelta(days=12, hours=3, minutes=4, seconds=5.678),
1274-
-timedelta(days=1, hours=2, minutes=3, seconds=4.567),
1275-
timedelta(days=99999, hours=23, minutes=59, seconds=59.999999),
1276-
-timedelta(days=99999, hours=23, minutes=59, seconds=59.999999),
1277-
]
1278-
if use_numpy:
1279-
expected = [numpy.timedelta64(e) for e in expected]
1280-
1281-
table = "test_arrow_day_time_interval"
1282-
values = "(" + "),(".join([f"'{c}'" for c in cases]) + ")"
1283-
with conn_cnx(numpy=use_numpy) as conn:
1284-
cursor = conn.cursor()
1285-
cursor.execute("alter session set python_connector_query_result_format='arrow'")
1286-
1287-
cursor.execute("alter session set feature_interval_types=enabled")
1288-
cursor.execute(
1289-
f"create or replace table {table} (c1 interval day(5) to second)"
1290-
)
1291-
cursor.execute(f"insert into {table} values {values}")
1292-
result = conn.cursor().execute(f"select * from {table}").fetchall()
1293-
result = [r[0] for r in result]
1294-
assert result == expected
1295-
1296-
12971238
def get_random_seed():
12981239
random.seed(datetime.now().timestamp())
12991240
return random.randint(0, 10000)

test/integ/test_interval_types.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env python
2+
from __future__ import annotations
3+
4+
from datetime import timedelta
5+
6+
import numpy
7+
import pytest
8+
9+
pytestmark = pytest.mark.skipolddriver # old test driver tests won't run this module
10+
11+
12+
@pytest.mark.parametrize("use_numpy", [True, False])
13+
@pytest.mark.parametrize("result_format", ["json", "arrow"])
14+
def test_select_year_month_interval(conn_cnx, use_numpy, result_format):
15+
cases = ["0-0", "1-2", "-1-3", "999999999-11", "-999999999-11"]
16+
expected = [0, 14, -15, 11_999_999_999, -11_999_999_999]
17+
if use_numpy:
18+
expected = [numpy.timedelta64(e, "M") for e in expected]
19+
else:
20+
expected = ["+0-00", "+1-02", "-1-03", "+999999999-11", "-999999999-11"]
21+
22+
table = "test_arrow_day_time_interval"
23+
values = "(" + "),(".join([f"'{c}'" for c in cases]) + ")"
24+
with conn_cnx(numpy=use_numpy) as conn:
25+
cursor = conn.cursor()
26+
cursor.execute(
27+
f"alter session set python_connector_query_result_format='{result_format}'"
28+
)
29+
30+
cursor.execute("alter session set feature_interval_types=enabled")
31+
cursor.execute(f"create or replace table {table} (c1 interval year to month)")
32+
cursor.execute(f"insert into {table} values {values}")
33+
result = conn.cursor().execute(f"select * from {table}").fetchall()
34+
result = [r[0] for r in result]
35+
assert result == expected
36+
37+
38+
@pytest.mark.skip(
39+
reason="SNOW-1878635: Add support for day-time interval in ArrowStreamWriter"
40+
)
41+
@pytest.mark.parametrize("use_numpy", [True, False])
42+
@pytest.mark.parametrize("result_format", ["json", "arrow"])
43+
def test_select_day_time_interval(conn_cnx, use_numpy, result_format):
44+
cases = [
45+
"0 0:0:0.0",
46+
"12 3:4:5.678",
47+
"-1 2:3:4.567",
48+
"99999 23:59:59.999999",
49+
"-99999 23:59:59.999999",
50+
]
51+
expected = [
52+
timedelta(days=0),
53+
timedelta(days=12, hours=3, minutes=4, seconds=5.678),
54+
-timedelta(days=1, hours=2, minutes=3, seconds=4.567),
55+
timedelta(days=99999, hours=23, minutes=59, seconds=59.999999),
56+
-timedelta(days=99999, hours=23, minutes=59, seconds=59.999999),
57+
]
58+
if use_numpy:
59+
expected = [numpy.timedelta64(e) for e in expected]
60+
61+
table = "test_arrow_day_time_interval"
62+
values = "(" + "),(".join([f"'{c}'" for c in cases]) + ")"
63+
with conn_cnx(numpy=use_numpy) as conn:
64+
cursor = conn.cursor()
65+
cursor.execute(
66+
f"alter session set python_connector_query_result_format='{result_format}'"
67+
)
68+
69+
cursor.execute("alter session set feature_interval_types=enabled")
70+
cursor.execute(
71+
f"create or replace table {table} (c1 interval day(5) to second)"
72+
)
73+
cursor.execute(f"insert into {table} values {values}")
74+
result = conn.cursor().execute(f"select * from {table}").fetchall()
75+
result = [r[0] for r in result]
76+
assert result == expected

0 commit comments

Comments
 (0)