Skip to content

Commit 967637a

Browse files
committed
SNOW-2119489: Add support for interval types in json
1 parent 2f346e4 commit 967637a

File tree

8 files changed

+135
-67
lines changed

8 files changed

+135
-67
lines changed

DESCRIPTION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ https://docs.snowflake.com/
77
Source code is also available at: https://github.com/snowflakedb/snowflake-connector-python
88

99
# Release Notes
10+
- v3.16.0(TBD)
11+
- Added basic json support for Interval types.
12+
1013
- v3.15.1(May 20, 2025)
1114
- Added basic arrow support for Interval types.
1215
- Fix `write_pandas` special characters usage in the location name.

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
@@ -1224,65 +1224,6 @@ def test_fetch_as_numpy_val(conn_cnx):
12241224
assert val[3] == numpy.datetime64("2019-01-02 12:34:56.12345678")
12251225

12261226

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