Skip to content

Commit d0a6274

Browse files
authored
[PECOBLR-427] Add INTERVAL Type support (#835)
* init changes * update error message * update error message * added unit tests * return only body in toLiteral * edge case * handle edge case * add logging for edge case * correct getColumn() output for interval type * added fake service test * address comment * added a TODO * removed unused constants
1 parent bcee3f8 commit d0a6274

File tree

40 files changed

+1337
-2
lines changed

40 files changed

+1337
-2
lines changed

src/main/java/com/databricks/jdbc/api/impl/DatabricksResultSet.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ public Object getObject(int columnIndex) throws SQLException {
487487
|| columnTypeName.startsWith(STRUCT)) {
488488
return handleComplexDataTypes(obj, columnTypeName);
489489
}
490+
// TODO: Add separate handling for INTERVAL JSON_ARRAY result format.
490491
return ConverterHelper.convertSqlTypeToJavaType(columnType, obj);
491492
}
492493

src/main/java/com/databricks/jdbc/api/impl/converters/ArrowToJavaObjectConverter.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ public static Object convert(
125125
timeZone = Optional.of(((TimeStampMicroTZVector) columnVector).getTimeZone());
126126
}
127127
return convertToTimestamp(object, timeZone);
128+
case INTERVAL:
129+
if (arrowMetadata == null) {
130+
String errorMessage =
131+
String.format("Failed to read INTERVAL %s with null metadata.", object);
132+
LOGGER.error(errorMessage);
133+
throw new DatabricksValidationException(errorMessage);
134+
}
135+
IntervalConverter ic = new IntervalConverter(arrowMetadata);
136+
return ic.toLiteral(object);
128137
case NULL:
129138
return null;
130139
default:
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.databricks.jdbc.api.impl.converters;
2+
3+
import com.databricks.jdbc.log.JdbcLogger;
4+
import com.databricks.jdbc.log.JdbcLoggerFactory;
5+
import java.time.Duration;
6+
import java.time.Period;
7+
import java.util.regex.Matcher;
8+
import java.util.regex.Pattern;
9+
10+
/**
11+
* Converts a java.time.Period or java.time.Duration into the exact ANSI‐style interval literals
12+
* that Databricks prints.
13+
*/
14+
public class IntervalConverter {
15+
16+
private static final JdbcLogger LOGGER = JdbcLoggerFactory.getLogger(IntervalConverter.class);
17+
18+
// Arrow stores day‐time intervals in microseconds. Converting to nanoseconds to align with Thrift
19+
private static final long NANOS_PER_SECOND = 1_000_000_000L;
20+
private static final long NANOS_PER_MINUTE = NANOS_PER_SECOND * 60;
21+
private static final long NANOS_PER_HOUR = NANOS_PER_MINUTE * 60;
22+
private static final long NANOS_PER_DAY = NANOS_PER_HOUR * 24;
23+
24+
private static final Pattern INTERVAL_PATTERN =
25+
Pattern.compile("INTERVAL\\s+(\\w+)(?:\\s+TO\\s+(\\w+))?", Pattern.CASE_INSENSITIVE);
26+
27+
/** The supported fields in the SQL syntax. */
28+
public enum Field {
29+
YEAR,
30+
MONTH,
31+
DAY,
32+
HOUR,
33+
MINUTE,
34+
SECOND
35+
}
36+
37+
private final boolean isYearMonth;
38+
39+
/**
40+
* @param arrowMetadata e.g. "INTERVAL YEAR TO MONTH" or "INTERVAL HOUR TO SECOND"
41+
*/
42+
public IntervalConverter(String arrowMetadata) {
43+
Matcher m = INTERVAL_PATTERN.matcher(arrowMetadata.trim());
44+
if (!m.matches()) {
45+
throw new IllegalArgumentException("Invalid interval metadata: " + arrowMetadata);
46+
}
47+
Field start = Field.valueOf(m.group(1).toUpperCase());
48+
// YEAR or MONTH qualifiers → Period; otherwise → Duration
49+
this.isYearMonth = (start.equals(Field.YEAR) || start.equals(Field.MONTH));
50+
}
51+
52+
/**
53+
* Turn a Period (YEAR/MONTH intervals) or Duration (DAY–TIME intervals) into exactly the string
54+
* Databricks will print.
55+
*/
56+
public String toLiteral(Object obj) {
57+
String body;
58+
if (isYearMonth) {
59+
if (!(obj instanceof Period)) {
60+
throw new IllegalArgumentException("Expected Period, got " + obj.getClass());
61+
}
62+
body = formatYearMonth((Period) obj);
63+
} else {
64+
if (!(obj instanceof Duration)) {
65+
throw new IllegalArgumentException("Expected Duration, got " + obj.getClass());
66+
}
67+
body = formatFullDayTime((Duration) obj);
68+
}
69+
return body;
70+
}
71+
72+
// --- YEAR–MONTH formatting ---
73+
private String formatYearMonth(Period p) {
74+
long totalMonths = p.getYears() * 12L + p.getMonths();
75+
boolean neg = totalMonths < 0;
76+
long absMonths = Math.abs(totalMonths);
77+
long years = absMonths / 12;
78+
long months = absMonths % 12;
79+
// Databricks shows "Y-M" with no zero‐padding
80+
String body = years + "-" + months;
81+
return (neg ? "-" : "") + body;
82+
}
83+
84+
// DAY–TIME always prints all subfields in D HH:MM:SS.NNNNNNNNN
85+
// max day to second supported is 106751 23:47:16.854775. Beyond that Duration Object rolls over
86+
// to LONG_MIN and loses info.
87+
private String formatFullDayTime(Duration d) {
88+
long nanos = d.toNanos();
89+
if (nanos == Long.MIN_VALUE) {
90+
nanos += 1; // -abs(LONG.MAX_VALUE)
91+
}
92+
boolean neg = nanos < 0;
93+
if (neg) nanos = -nanos;
94+
95+
if (nanos == Long.MAX_VALUE) {
96+
LOGGER.warn(
97+
"Duration value at Long.MAX_VALUE detected - interval representation may be incorrect due to overflow");
98+
}
99+
100+
long days = nanos / NANOS_PER_DAY;
101+
nanos %= NANOS_PER_DAY;
102+
long hours = nanos / NANOS_PER_HOUR;
103+
nanos %= NANOS_PER_HOUR;
104+
long minutes = nanos / NANOS_PER_MINUTE;
105+
nanos %= NANOS_PER_MINUTE;
106+
long seconds = nanos / NANOS_PER_SECOND;
107+
long frac = nanos % NANOS_PER_SECOND;
108+
109+
// "%02d" for HH,MM,SS and "%09d" for nanosecond fraction
110+
return String.format(
111+
"%s%d %02d:%02d:%02d.%09d", neg ? "-" : "", days, hours, minutes, seconds, frac);
112+
}
113+
}

src/main/java/com/databricks/jdbc/common/util/DatabricksTypeUtil.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public class DatabricksTypeUtil {
5454
public static final String STRUCT = "STRUCT";
5555
public static final String VARIANT = "VARIANT";
5656
public static final String CHAR = "CHAR";
57+
public static final String INTERVAL = "INTERVAL";
5758
private static final ArrayList<ColumnInfoTypeName> SIGNED_TYPES =
5859
new ArrayList<>(
5960
Arrays.asList(

src/main/java/com/databricks/jdbc/dbclient/impl/common/MetadataResultSetBuilder.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.databricks.jdbc.dbclient.impl.common;
22

33
import static com.databricks.jdbc.common.MetadataResultConstants.*;
4+
import static com.databricks.jdbc.common.util.DatabricksTypeUtil.INTERVAL;
45
import static com.databricks.jdbc.dbclient.impl.common.CommandConstants.*;
56
import static com.databricks.jdbc.dbclient.impl.common.TypeValConstants.*;
67

@@ -322,7 +323,7 @@ int extractPrecision(String typeVal) {
322323
}
323324

324325
int getColumnSize(String typeVal) {
325-
if (typeVal == null || typeVal.isEmpty()) {
326+
if (typeVal == null || typeVal.isEmpty() || typeVal.contains(INTERVAL)) {
326327
return 0;
327328
}
328329
int sizeFromTypeVal = getSizeFromTypeVal(typeVal);
@@ -500,6 +501,9 @@ int getCode(String s) {
500501
case "VARIANT":
501502
return 1111;
502503
}
504+
if (s.startsWith(INTERVAL)) {
505+
return 12;
506+
}
503507
return 0;
504508
}
505509

src/main/java/com/databricks/jdbc/dbclient/impl/sqlexec/ResultConstants.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,26 @@ public class ResultConstants {
455455
Types.TIMESTAMP,
456456
3,
457457
null
458+
},
459+
{
460+
"INTERVAL",
461+
Types.VARCHAR,
462+
40,
463+
"'",
464+
"'",
465+
"Qualifier",
466+
typeNullable,
467+
false,
468+
typeSearchable,
469+
null,
470+
false,
471+
null,
472+
"INTERVAL",
473+
0,
474+
6,
475+
Types.VARCHAR,
476+
null,
477+
null
458478
}
459479
},
460480
StatementType.METADATA);

src/test/java/com/databricks/jdbc/api/impl/converters/ArrowToJavaObjectConverterTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,42 @@ public class ArrowToJavaObjectConverterTest {
3535
this.bufferAllocator = new RootAllocator();
3636
}
3737

38+
@Test
39+
public void testConvert_Interval() throws SQLException {
40+
// 1200 months → "100-0"
41+
IntervalYearVector yv = new IntervalYearVector("iv", bufferAllocator);
42+
yv.allocateNewSafe();
43+
yv.setSafe(0, 1200);
44+
yv.setValueCount(1);
45+
46+
Object out =
47+
ArrowToJavaObjectConverter.convert(
48+
yv, 0, ColumnInfoTypeName.INTERVAL, "INTERVAL YEAR TO MONTH");
49+
assertEquals("100-0", out);
50+
51+
// build a Duration of 200h13m50.3s → -200:13:50.3
52+
IntervalDayVector dv = new IntervalDayVector("dv", bufferAllocator);
53+
dv.allocateNewSafe();
54+
// Arrow’s IntervalDayVector takes (days, milliseconds)
55+
Duration d = Duration.ofHours(200).plusMinutes(13).plusSeconds(50).plusMillis(300);
56+
long days = d.toDays();
57+
int millis = (int) (d.minusDays(days).toMillis());
58+
dv.setSafe(0, (int) days, millis);
59+
dv.setValueCount(1);
60+
61+
out =
62+
ArrowToJavaObjectConverter.convert(
63+
dv, 0, ColumnInfoTypeName.INTERVAL, "INTERVAL HOUR TO SECOND");
64+
assertEquals("8 08:13:50.300000000", out);
65+
66+
// null metadata throws DatabricksValidation Exception
67+
assertThrows(
68+
DatabricksValidationException.class,
69+
() -> {
70+
ArrowToJavaObjectConverter.convert(dv, 0, ColumnInfoTypeName.INTERVAL, null);
71+
});
72+
}
73+
3874
@Test
3975
public void testNullObjectConversion() throws SQLException {
4076
TinyIntVector tinyIntVector = new TinyIntVector("tinyIntVector", this.bufferAllocator);
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package com.databricks.jdbc.api.impl.converters;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import java.time.Duration;
6+
import java.time.Period;
7+
import java.time.temporal.ChronoUnit;
8+
import org.junit.jupiter.api.DisplayName;
9+
import org.junit.jupiter.api.Nested;
10+
import org.junit.jupiter.api.Test;
11+
import org.junit.jupiter.params.ParameterizedTest;
12+
import org.junit.jupiter.params.provider.CsvSource;
13+
14+
@DisplayName("IntervalConverter")
15+
class IntervalConverterTest {
16+
17+
@Nested
18+
@DisplayName("Year–Month intervals")
19+
class YearMonth {
20+
21+
@ParameterizedTest(name = "[{index}] {1} of {0} months ⇒ “{2}”")
22+
@CsvSource({
23+
"1200, INTERVAL YEAR, 100-0",
24+
"1200, INTERVAL MONTH, 100-0",
25+
"1200, INTERVAL YEAR TO MONTH, 100-0",
26+
"14, INTERVAL YEAR TO MONTH, 1-2",
27+
"-14, INTERVAL YEAR TO MONTH, -1-2",
28+
"0, INTERVAL YEAR, 0-0",
29+
"0, INTERVAL MONTH, 0-0"
30+
})
31+
void testNormalization(long totalMonths, String meta, String expected) {
32+
Period p = Period.ofMonths((int) totalMonths);
33+
IntervalConverter ic = new IntervalConverter(meta);
34+
assertEquals(expected, ic.toLiteral(p));
35+
}
36+
37+
@Test
38+
@DisplayName("Huge period still normalizes correctly")
39+
void testHugePeriod() {
40+
Period p = Period.ofYears(37212).plusMonths(11);
41+
IntervalConverter ic = new IntervalConverter("INTERVAL YEAR TO MONTH");
42+
assertEquals("37212-11", ic.toLiteral(p));
43+
}
44+
45+
@Test
46+
@DisplayName("Passing Duration to YEAR qualifier throws")
47+
void testTypeMismatch() {
48+
IntervalConverter ic = new IntervalConverter("INTERVAL YEAR");
49+
assertThrows(IllegalArgumentException.class, () -> ic.toLiteral(Duration.ofHours(5)));
50+
}
51+
}
52+
53+
@Nested
54+
@DisplayName("Day–Time intervals (full D HH:MM:SS.NNNNNNNNN form)")
55+
class DayTime {
56+
57+
@ParameterizedTest(name = "[{index}] {0} ⇒ “{3}”")
58+
@CsvSource({
59+
// desc , metadata , ISO8601 , expected
60+
"zero , INTERVAL DAY TO SECOND, PT0S , 0 00:00:00.000000000",
61+
"only days , INTERVAL DAY, P3D , 3 00:00:00.000000000",
62+
"days+hours , INTERVAL DAY TO HOUR, P2DT5H , 2 05:00:00.000000000",
63+
"days+minutes , INTERVAL DAY TO MINUTE,P1DT2H30M , 1 02:30:00.000000000",
64+
"days+seconds frac , INTERVAL DAY TO SECOND,P0DT0H0M4.005S , 0 00:00:04.005000000",
65+
"only hours , INTERVAL HOUR, PT27H , 1 03:00:00.000000000",
66+
"hour+minute , INTERVAL HOUR TO MINUTE,PT2H5M , 0 02:05:00.000000000",
67+
"hour+second frac , INTERVAL HOUR TO SECOND,PT1H0M0.123S , 0 01:00:00.123000000",
68+
"only minutes , INTERVAL MINUTE, PT125M , 0 02:05:00.000000000",
69+
"minute+second frac , INTERVAL MINUTE TO SECOND,PT1M30.5S , 0 00:01:30.500000000",
70+
"only seconds frac , INTERVAL SECOND, PT45.789S , 0 00:00:45.789000000",
71+
"large nano fraction , INTERVAL SECOND, PT0.000000123S , 0 00:00:00.000000123"
72+
})
73+
void testCanonicalForm(String desc, String meta, String iso, String expected) {
74+
Duration d = Duration.parse(iso);
75+
IntervalConverter ic = new IntervalConverter(meta);
76+
assertEquals(expected, ic.toLiteral(d), () -> desc + " failed");
77+
}
78+
79+
@Test
80+
@DisplayName("Negative duration rollover across days")
81+
void testNegativeAndRollover() {
82+
// -(49h + 10m + 5s + 123ms)
83+
Duration d =
84+
Duration.ofHours(-49)
85+
.plusMinutes(-10)
86+
.plusSeconds(-5)
87+
.plusMillis(-123)
88+
.truncatedTo(ChronoUnit.MILLIS);
89+
IntervalConverter ic = new IntervalConverter("INTERVAL DAY TO SECOND");
90+
// |d| = 177005.123s → 2 days + 4205.123s → 2 days, 1h 10m 5.123s
91+
assertEquals("-2 01:10:05.123000000", ic.toLiteral(d));
92+
}
93+
94+
@Test
95+
@DisplayName("Edge: just under one day")
96+
void testMaxNanosUnderDay() {
97+
long justUnder = Duration.ofDays(1).toNanos() - 1;
98+
Duration d = Duration.ofNanos(justUnder);
99+
IntervalConverter ic = new IntervalConverter("INTERVAL DAY TO SECOND");
100+
assertEquals("0 23:59:59.999999999", ic.toLiteral(d));
101+
}
102+
103+
@Test
104+
@DisplayName("Very large duration formatting")
105+
void testVeryLargeDuration() {
106+
// 1000d + 12h + 34m + 56s + 789ns
107+
Duration d =
108+
Duration.ofDays(1000).plusHours(12).plusMinutes(34).plusSeconds(56).plusNanos(789);
109+
IntervalConverter ic = new IntervalConverter("INTERVAL DAY TO SECOND");
110+
assertEquals("1000 12:34:56.000000789", ic.toLiteral(d));
111+
}
112+
113+
@Test
114+
@DisplayName("LONG_MIN duration handling")
115+
void testLongExtremesDuration() {
116+
// LONG_MIN is -abs(LONG_MAX) + 1
117+
Duration d = Duration.ofNanos(Long.MIN_VALUE);
118+
IntervalConverter ic = new IntervalConverter("INTERVAL DAY TO SECOND");
119+
assertEquals("-106751 23:47:16.854775807", ic.toLiteral(d));
120+
121+
// LONG_MAX
122+
d = Duration.ofNanos(Long.MAX_VALUE);
123+
ic = new IntervalConverter("INTERVAL DAY TO SECOND");
124+
assertEquals("106751 23:47:16.854775807", ic.toLiteral(d));
125+
}
126+
127+
@Test
128+
@DisplayName("Passing Period to DAY qualifier throws")
129+
void testDayMismatch() {
130+
IntervalConverter ic = new IntervalConverter("INTERVAL HOUR TO MINUTE");
131+
assertThrows(IllegalArgumentException.class, () -> ic.toLiteral(Period.ofDays(1)));
132+
}
133+
}
134+
135+
@Nested
136+
@DisplayName("Error conditions")
137+
class Errors {
138+
@Test
139+
@DisplayName("Invalid metadata string")
140+
void testInvalidMetadata() {
141+
assertThrows(IllegalArgumentException.class, () -> new IntervalConverter("NOT AN INTERVAL"));
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)