Skip to content

Commit b6c3cfb

Browse files
committed
datetime stringify uses space, time has no epoch prefix
- ArrowValues: scalar + list temporal cells use space separator; time renders as HH:mm:ss[.frac] (no 1970-01-01 prefix); nanos preserved. - ArrowValues: post-process ISO-T VarChar cells from native CAST -> space. - CastToVarcharRewriter (new): boolean -> varchar emits TRUE/FALSE. - ListAggregateMultiTypeIT: assertions updated for new format. Signed-off-by: Vinay Krishna Pudyodu <vinkrish.neo@gmail.com>
1 parent 389c581 commit b6c3cfb

6 files changed

Lines changed: 354 additions & 83 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.be.datafusion;
10+
11+
import org.apache.calcite.rel.RelHomogeneousShuttle;
12+
import org.apache.calcite.rel.RelNode;
13+
import org.apache.calcite.rel.type.RelDataType;
14+
import org.apache.calcite.rex.RexBuilder;
15+
import org.apache.calcite.rex.RexCall;
16+
import org.apache.calcite.rex.RexNode;
17+
import org.apache.calcite.rex.RexShuttle;
18+
import org.apache.calcite.sql.SqlKind;
19+
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
20+
import org.apache.calcite.sql.type.SqlTypeName;
21+
22+
import java.util.List;
23+
24+
/** Boolean → varchar emits upper-case TRUE/FALSE (PPL contract matches {@code tostring(boolean)}). */
25+
final class CastToVarcharRewriter {
26+
27+
private CastToVarcharRewriter() {}
28+
29+
static RelNode rewrite(RelNode root) {
30+
return root.accept(new RelHomogeneousShuttle() {
31+
@Override
32+
public RelNode visit(RelNode other) {
33+
RelNode visited = super.visit(other);
34+
return visited.accept(new CastShuttle(visited.getCluster().getRexBuilder()));
35+
}
36+
});
37+
}
38+
39+
/** Package-visible for unit tests. */
40+
static RexShuttle newShuttle(RexBuilder rexBuilder) {
41+
return new CastShuttle(rexBuilder);
42+
}
43+
44+
private static final class CastShuttle extends RexShuttle {
45+
46+
private final RexBuilder rexBuilder;
47+
48+
CastShuttle(RexBuilder rexBuilder) {
49+
this.rexBuilder = rexBuilder;
50+
}
51+
52+
@Override
53+
public RexNode visitCall(RexCall call) {
54+
RexCall recursed = (RexCall) super.visitCall(call);
55+
if (!isCastBooleanToVarchar(recursed)) {
56+
return recursed;
57+
}
58+
return rewriteBooleanCast(recursed, recursed.getOperands().get(0));
59+
}
60+
61+
private static boolean isCastBooleanToVarchar(RexCall call) {
62+
if (call.getKind() != SqlKind.CAST && call.getKind() != SqlKind.SAFE_CAST) {
63+
return false;
64+
}
65+
if (call.getOperands().size() != 1) {
66+
return false;
67+
}
68+
SqlTypeName target = call.getType().getSqlTypeName();
69+
if (target != SqlTypeName.VARCHAR && target != SqlTypeName.CHAR) {
70+
return false;
71+
}
72+
return call.getOperands().get(0).getType().getSqlTypeName() == SqlTypeName.BOOLEAN;
73+
}
74+
75+
private RexNode rewriteBooleanCast(RexCall original, RexNode value) {
76+
RelDataType varcharType = original.getType();
77+
RexNode trueLit = rexBuilder.makeLiteral("TRUE");
78+
RexNode falseLit = rexBuilder.makeLiteral("FALSE");
79+
RexNode notValue = rexBuilder.makeCall(SqlStdOperatorTable.NOT, value);
80+
RexNode nullLit = rexBuilder.makeNullLiteral(varcharType);
81+
return rexBuilder.makeCall(varcharType, SqlStdOperatorTable.CASE, List.of(value, trueLit, notValue, falseLit, nullLit));
82+
}
83+
}
84+
}

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DataFusionFragmentConvertor.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,7 @@ private byte[] convertToSubstrait(RelNode fragment) {
458458
preprocessed = PplAggregateCallRewriter.rewrite(preprocessed);
459459
preprocessed = PplWindowCallRewriter.rewrite(preprocessed);
460460
preprocessed = ItemTypeRebuilder.rewrite(preprocessed);
461+
preprocessed = CastToVarcharRewriter.rewrite(preprocessed);
461462
RelRoot root = RelRoot.of(preprocessed, SqlKind.SELECT);
462463
SubstraitRelVisitor visitor = createVisitor(preprocessed);
463464
Rel substraitRel;
@@ -488,6 +489,7 @@ private Rel convertStandalone(RelNode operator) {
488489
preprocessed = PplAggregateCallRewriter.rewrite(preprocessed);
489490
preprocessed = PplWindowCallRewriter.rewrite(preprocessed);
490491
preprocessed = ItemTypeRebuilder.rewrite(preprocessed);
492+
preprocessed = CastToVarcharRewriter.rewrite(preprocessed);
491493
SubstraitRelVisitor visitor = createVisitor(preprocessed);
492494
return visitor.apply(preprocessed);
493495
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* SPDX-License-Identifier: Apache-2.0
3+
*
4+
* The OpenSearch Contributors require contributions made to
5+
* this file be licensed under the Apache-2.0 license or a
6+
* compatible open source license.
7+
*/
8+
9+
package org.opensearch.be.datafusion;
10+
11+
import org.apache.calcite.jdbc.JavaTypeFactoryImpl;
12+
import org.apache.calcite.rel.type.RelDataType;
13+
import org.apache.calcite.rel.type.RelDataTypeFactory;
14+
import org.apache.calcite.rex.RexBuilder;
15+
import org.apache.calcite.rex.RexCall;
16+
import org.apache.calcite.rex.RexLiteral;
17+
import org.apache.calcite.rex.RexNode;
18+
import org.apache.calcite.sql.SqlKind;
19+
import org.apache.calcite.sql.fun.SqlStdOperatorTable;
20+
import org.apache.calcite.sql.type.SqlTypeName;
21+
import org.opensearch.test.OpenSearchTestCase;
22+
23+
public class CastToVarcharRewriterTests extends OpenSearchTestCase {
24+
25+
/** CAST(boolean AS VARCHAR) → CASE WHEN val THEN 'TRUE' WHEN NOT val THEN 'FALSE' END. */
26+
public void testBooleanCastRewritesToCaseUpper() {
27+
Cluster c = newCluster();
28+
RexNode operand = c.inputRef(0, SqlTypeName.BOOLEAN);
29+
RexNode cast = c.castTo(operand, SqlTypeName.VARCHAR);
30+
31+
RexCall out = (RexCall) cast.accept(CastToVarcharRewriter.newShuttle(c.rexBuilder));
32+
33+
assertEquals(SqlKind.CASE, out.getKind());
34+
assertEquals("TRUE", ((RexLiteral) out.getOperands().get(1)).getValueAs(String.class));
35+
assertEquals("FALSE", ((RexLiteral) out.getOperands().get(3)).getValueAs(String.class));
36+
}
37+
38+
/** Temporal casts pass through — handled by ArrowValues post-processing instead. */
39+
public void testTimestampCastPassesThrough() {
40+
Cluster c = newCluster();
41+
RexNode operand = c.inputRef(0, SqlTypeName.TIMESTAMP);
42+
RexNode cast = c.castTo(operand, SqlTypeName.VARCHAR);
43+
44+
RexNode out = cast.accept(CastToVarcharRewriter.newShuttle(c.rexBuilder));
45+
46+
assertEquals(SqlKind.CAST, out.getKind());
47+
}
48+
49+
/** Unrelated casts (INT → VARCHAR) pass through. */
50+
public void testIntegerCastPassesThrough() {
51+
Cluster c = newCluster();
52+
RexNode operand = c.inputRef(0, SqlTypeName.INTEGER);
53+
RexNode cast = c.castTo(operand, SqlTypeName.VARCHAR);
54+
55+
RexNode out = cast.accept(CastToVarcharRewriter.newShuttle(c.rexBuilder));
56+
57+
assertEquals(SqlKind.CAST, out.getKind());
58+
}
59+
60+
/** Non-cast calls pass through. */
61+
public void testNonCastCallPassesThrough() {
62+
Cluster c = newCluster();
63+
RexNode left = c.inputRef(0, SqlTypeName.INTEGER);
64+
RexNode right = c.inputRef(1, SqlTypeName.INTEGER);
65+
RexNode plus = c.rexBuilder.makeCall(SqlStdOperatorTable.PLUS, left, right);
66+
67+
RexNode out = plus.accept(CastToVarcharRewriter.newShuttle(c.rexBuilder));
68+
69+
assertSame(plus, out);
70+
}
71+
72+
private static Cluster newCluster() {
73+
RelDataTypeFactory typeFactory = new JavaTypeFactoryImpl();
74+
RexBuilder rexBuilder = new RexBuilder(typeFactory);
75+
return new Cluster(typeFactory, rexBuilder);
76+
}
77+
78+
private static final class Cluster {
79+
final RelDataTypeFactory typeFactory;
80+
final RexBuilder rexBuilder;
81+
82+
Cluster(RelDataTypeFactory typeFactory, RexBuilder rexBuilder) {
83+
this.typeFactory = typeFactory;
84+
this.rexBuilder = rexBuilder;
85+
}
86+
87+
RexNode inputRef(int index, SqlTypeName type) {
88+
return rexBuilder.makeInputRef(typeFactory.createTypeWithNullability(typeFactory.createSqlType(type), true), index);
89+
}
90+
91+
RexNode castTo(RexNode operand, SqlTypeName target) {
92+
RelDataType targetType = typeFactory.createTypeWithNullability(typeFactory.createSqlType(target), true);
93+
return rexBuilder.makeCast(targetType, operand);
94+
}
95+
}
96+
}

sandbox/plugins/analytics-engine/src/main/java/org/opensearch/analytics/exec/ArrowValues.java

Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.apache.arrow.vector.complex.ListVector;
1414
import org.apache.arrow.vector.complex.MapVector;
1515
import org.apache.arrow.vector.types.pojo.ArrowType;
16+
import org.apache.arrow.vector.types.pojo.Field;
1617
import org.apache.arrow.vector.util.Text;
1718

1819
import java.nio.charset.StandardCharsets;
@@ -21,20 +22,31 @@
2122
import java.time.LocalDateTime;
2223
import java.time.LocalTime;
2324
import java.time.ZoneOffset;
25+
import java.time.format.DateTimeFormatter;
2426
import java.util.ArrayList;
2527
import java.util.LinkedHashMap;
2628
import java.util.List;
29+
import java.util.Locale;
2730
import java.util.Map;
31+
import java.util.regex.Pattern;
2832

2933
/** Reads Arrow vector cells as plain Java values, unwrapping Arrow {@link Text} recursively. */
3034
public final class ArrowValues {
3135

36+
// Space-separator output matches the SQL plugin's ExprTimestampValue.
37+
private static final DateTimeFormatter TIMESTAMP_NO_NANO = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.ROOT);
38+
private static final DateTimeFormatter TIMESTAMP_WITH_NANO = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSSSSS", Locale.ROOT);
39+
private static final DateTimeFormatter TIME_NO_NANO = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.ROOT);
40+
private static final DateTimeFormatter TIME_WITH_NANO = DateTimeFormatter.ofPattern("HH:mm:ss.SSSSSSSSS", Locale.ROOT);
41+
// DataFusion CAST(temporal AS VARCHAR) — date and time joined by 'T', optional fraction.
42+
private static final Pattern ISO_TIMESTAMP_T = Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})T(\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?)$");
43+
3244
private ArrowValues() {}
3345

3446
public static Object toJavaValue(FieldVector vector, int index) {
3547
if (vector.isNull(index)) return null;
3648
if (vector instanceof VarCharVector v) {
37-
return new String(v.get(index), StandardCharsets.UTF_8);
49+
return spaceSeparator(new String(v.get(index), StandardCharsets.UTF_8));
3850
}
3951
// MapVector extends ListVector — must come first.
4052
if (vector instanceof MapVector && vector.getObject(index) instanceof List<?> entries) {
@@ -48,74 +60,103 @@ public static Object toJavaValue(FieldVector vector, int index) {
4860
return map;
4961
}
5062
Object value = vector.getObject(index);
51-
if (vector instanceof ListVector && value instanceof List<?> raw) {
52-
return normalizeList(raw);
63+
if (vector instanceof ListVector lv && value instanceof List<?> raw) {
64+
// child Arrow type drives temporal element formatting
65+
return normalizeList(raw, lv.getDataVector().getField());
5366
}
54-
// Date/Time/Timestamp cells return java.time.* so ExprValueUtils wraps them as
55-
// ExprDateValue / ExprTimeValue / ExprTimestampValue and formats them.
56-
Object temporal = normalizeTemporal(vector.getField().getType(), value);
67+
Object temporal = formatTemporal(vector.getField().getType(), value);
5768
if (temporal != null) {
5869
return temporal;
5970
}
6071
return normalize(value);
6172
}
6273

63-
/** Returns LocalDate / LocalTime / LocalDateTime, or null for non-temporal types. */
64-
private static Object normalizeTemporal(ArrowType type, Object value) {
74+
/** ISO-T temporal → space separator; other strings unchanged. */
75+
private static String spaceSeparator(String s) {
76+
if (s == null) return null;
77+
var m = ISO_TIMESTAMP_T.matcher(s);
78+
return m.matches() ? m.group(1) + " " + m.group(2) : s;
79+
}
80+
81+
private static Object formatTemporal(ArrowType type, Object value) {
6582
if (value == null) return null;
6683
if (type instanceof ArrowType.Date date) {
67-
return toLocalDate(date, value);
84+
return formatDate(date, value);
6885
}
6986
if (type instanceof ArrowType.Time time) {
70-
return toLocalTime(time, value);
87+
return formatTime(time, value);
7188
}
7289
if (type instanceof ArrowType.Timestamp ts) {
73-
return toLocalDateTime(ts, value);
90+
return formatTimestamp(ts, value);
7491
}
7592
return null;
7693
}
7794

78-
private static LocalDate toLocalDate(ArrowType.Date type, Object value) {
79-
if (value instanceof LocalDate ld) return ld;
80-
if (value instanceof LocalDateTime ldt) return ldt.toLocalDate();
81-
long raw = ((Number) value).longValue();
82-
return switch (type.getUnit()) {
83-
case DAY -> LocalDate.ofEpochDay(raw);
84-
case MILLISECOND -> LocalDate.ofEpochDay(Math.floorDiv(raw, 86_400_000L));
85-
};
95+
private static String formatDate(ArrowType.Date type, Object value) {
96+
LocalDate ld;
97+
if (value instanceof LocalDate d) {
98+
ld = d;
99+
} else if (value instanceof LocalDateTime ldt) {
100+
ld = ldt.toLocalDate();
101+
} else {
102+
long raw = ((Number) value).longValue();
103+
ld = switch (type.getUnit()) {
104+
case DAY -> LocalDate.ofEpochDay(raw);
105+
case MILLISECOND -> LocalDate.ofEpochDay(Math.floorDiv(raw, 86_400_000L));
106+
};
107+
}
108+
return ld.format(DateTimeFormatter.ISO_LOCAL_DATE);
86109
}
87110

88-
private static LocalTime toLocalTime(ArrowType.Time type, Object value) {
89-
if (value instanceof LocalTime lt) return lt;
90-
if (value instanceof LocalDateTime ldt) return ldt.toLocalTime();
91-
long raw = ((Number) value).longValue();
92-
long nanoOfDay = switch (type.getUnit()) {
93-
case SECOND -> raw * 1_000_000_000L;
94-
case MILLISECOND -> raw * 1_000_000L;
95-
case MICROSECOND -> raw * 1_000L;
96-
case NANOSECOND -> raw;
97-
};
98-
return LocalTime.ofNanoOfDay(nanoOfDay);
111+
/** Time -> HH:mm:ss[.frac]; never prefixes with the 1970 epoch date. */
112+
private static String formatTime(ArrowType.Time type, Object value) {
113+
LocalTime lt;
114+
if (value instanceof LocalTime t) {
115+
lt = t;
116+
} else if (value instanceof LocalDateTime ldt) {
117+
lt = ldt.toLocalTime();
118+
} else {
119+
long raw = ((Number) value).longValue();
120+
long nanoOfDay = switch (type.getUnit()) {
121+
case SECOND -> raw * 1_000_000_000L;
122+
case MILLISECOND -> raw * 1_000_000L;
123+
case MICROSECOND -> raw * 1_000L;
124+
case NANOSECOND -> raw;
125+
};
126+
lt = LocalTime.ofNanoOfDay(nanoOfDay);
127+
}
128+
return lt.getNano() == 0 ? lt.format(TIME_NO_NANO) : lt.format(TIME_WITH_NANO);
99129
}
100130

101-
private static LocalDateTime toLocalDateTime(ArrowType.Timestamp type, Object value) {
102-
if (value instanceof LocalDateTime ldt) return ldt;
103-
long raw = ((Number) value).longValue();
104-
Instant instant = switch (type.getUnit()) {
105-
case SECOND -> Instant.ofEpochSecond(raw);
106-
case MILLISECOND -> Instant.ofEpochMilli(raw);
107-
case MICROSECOND -> Instant.ofEpochSecond(Math.floorDiv(raw, 1_000_000L), Math.floorMod(raw, 1_000_000L) * 1_000L);
108-
case NANOSECOND -> Instant.ofEpochSecond(Math.floorDiv(raw, 1_000_000_000L), Math.floorMod(raw, 1_000_000_000L));
109-
};
110-
return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
131+
/** Timestamp -> yyyy-MM-dd HH:mm:ss[.frac]. */
132+
private static String formatTimestamp(ArrowType.Timestamp type, Object value) {
133+
LocalDateTime ldt;
134+
if (value instanceof LocalDateTime t) {
135+
ldt = t;
136+
} else if (value instanceof LocalDate ld) {
137+
ldt = ld.atStartOfDay();
138+
} else {
139+
long raw = ((Number) value).longValue();
140+
Instant instant = switch (type.getUnit()) {
141+
case SECOND -> Instant.ofEpochSecond(raw);
142+
case MILLISECOND -> Instant.ofEpochMilli(raw);
143+
case MICROSECOND -> Instant.ofEpochSecond(Math.floorDiv(raw, 1_000_000L), Math.floorMod(raw, 1_000_000L) * 1_000L);
144+
case NANOSECOND -> Instant.ofEpochSecond(Math.floorDiv(raw, 1_000_000_000L), Math.floorMod(raw, 1_000_000_000L));
145+
};
146+
ldt = LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
147+
}
148+
return ldt.getNano() == 0 ? ldt.format(TIMESTAMP_NO_NANO) : ldt.format(TIMESTAMP_WITH_NANO);
111149
}
112150

113151
private static Object normalize(Object value) {
114152
if (value instanceof Text t) {
115-
return t.toString();
153+
return spaceSeparator(t.toString());
154+
}
155+
if (value instanceof String s) {
156+
return spaceSeparator(s);
116157
}
117158
if (value instanceof List<?> list) {
118-
return normalizeList(list);
159+
return normalizeList(list, null);
119160
}
120161
if (value instanceof Map<?, ?> m) {
121162
LinkedHashMap<String, Object> out = new LinkedHashMap<>(m.size());
@@ -128,10 +169,12 @@ private static Object normalize(Object value) {
128169
return value;
129170
}
130171

131-
private static List<Object> normalizeList(List<?> raw) {
172+
private static List<Object> normalizeList(List<?> raw, Field childField) {
173+
ArrowType childType = childField == null ? null : childField.getType();
132174
List<Object> out = new ArrayList<>(raw.size());
133175
for (Object element : raw) {
134-
out.add(normalize(element));
176+
Object formatted = childType == null ? null : formatTemporal(childType, element);
177+
out.add(formatted != null ? formatted : normalize(element));
135178
}
136179
return out;
137180
}

0 commit comments

Comments
 (0)