diff --git a/core/src/main/java/org/opensearch/sql/calcite/utils/OpenSearchTypeFactory.java b/core/src/main/java/org/opensearch/sql/calcite/utils/OpenSearchTypeFactory.java index 9c15c1485c1..e176c3576a2 100644 --- a/core/src/main/java/org/opensearch/sql/calcite/utils/OpenSearchTypeFactory.java +++ b/core/src/main/java/org/opensearch/sql/calcite/utils/OpenSearchTypeFactory.java @@ -47,7 +47,9 @@ import org.apache.calcite.sql.type.SqlTypeUtil; import org.checkerframework.checker.nullness.qual.Nullable; import org.opensearch.analytics.schema.BinaryType; +import org.opensearch.analytics.schema.DateOnlyType; import org.opensearch.analytics.schema.IpType; +import org.opensearch.analytics.schema.TimeOnlyType; import org.opensearch.sql.calcite.type.AbstractExprRelDataType; import org.opensearch.sql.calcite.type.ExprBinaryType; import org.opensearch.sql.calcite.type.ExprDateType; @@ -293,6 +295,14 @@ public static ExprType convertAnalyticsEngineRelDataTypeToExprType(RelDataType t if (type instanceof BinaryType) { return BINARY; } + // Sandbox UDT markers for format-classified date columns (Timestamp(ms)-backed wire, + // user-visible label downgraded to date / time). + if (type instanceof DateOnlyType) { + return DATE; + } + if (type instanceof TimeOnlyType) { + return TIME; + } return convertRelDataTypeToExprType(type); } diff --git a/core/src/main/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngine.java b/core/src/main/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngine.java index 733c603a761..6f7012b6864 100644 --- a/core/src/main/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngine.java +++ b/core/src/main/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngine.java @@ -12,6 +12,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import org.apache.calcite.plan.RelOptUtil; import org.apache.calcite.rel.RelNode; import org.apache.calcite.rel.type.RelDataType; @@ -19,7 +20,9 @@ import org.opensearch.analytics.exec.QueryPlanExecutor; import org.opensearch.analytics.exec.profile.ProfiledResult; import org.opensearch.analytics.schema.BinaryType; +import org.opensearch.analytics.schema.DateOnlyType; import org.opensearch.analytics.schema.IpType; +import org.opensearch.analytics.schema.TimeOnlyType; import org.opensearch.common.network.InetAddresses; import org.opensearch.core.action.ActionListener; import org.opensearch.sql.ast.statement.ExplainMode; @@ -47,6 +50,17 @@ */ public class AnalyticsExecutionEngine implements ExecutionEngine { + // TIME-typed columns round-trip through Timestamp and arrive in list elements as + // "1970-01-01[ T]HH:mm:ss[.fraction]"; analytics-engine post-processes scalars but + // list-aggregation elements bypass that path (see list_merge in DataFusion). + private static final Pattern EPOCH_DATE_TIME_PREFIX = + Pattern.compile("^1970-01-01[ T](\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?)$"); + + // DATE-typed columns whose wire is Timestamp(ms) arrive as "YYYY-MM-DD HH:mm:ss"; + // when the column carries a DateOnlyType marker we strip the time suffix. + private static final Pattern DATE_WITH_MIDNIGHT_TIME = + Pattern.compile("^(\\d{4}-\\d{2}-\\d{2})[ T]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?$"); + private final QueryPlanExecutor> planExecutor; public AnalyticsExecutionEngine(QueryPlanExecutor> planExecutor) { @@ -234,9 +248,44 @@ private static ExprValue toExprValue(Object value, RelDataType type) { return ExprValueUtils.stringValue(Base64.getEncoder().encodeToString(bytes)); } } + // span(date-typed) returns Timestamp(ms) wire with midnight time; render as YYYY-MM-DD only. + if (type instanceof DateOnlyType && value instanceof String s) { + var m = DATE_WITH_MIDNIGHT_TIME.matcher(s); + if (m.matches()) { + return ExprValueUtils.stringValue(m.group(1)); + } + } + // span(time-typed) returns Timestamp(ms) wire with 1970-01-01 prefix; render as HH:mm:ss only. + if (type instanceof TimeOnlyType && value instanceof String s) { + var m = EPOCH_DATE_TIME_PREFIX.matcher(s); + if (m.matches()) { + return ExprValueUtils.stringValue(m.group(1)); + } + } + // List elements that look like a sentinel-epoch-prefixed time render as HH:mm:ss only. + if (value instanceof List list) { + return ExprValueUtils.collectionValue(stripEpochDatePrefixInList(list)); + } return ExprValueUtils.fromObjectValue(value); } + /** + * Returns a copy of {@code list} with each "1970-01-01[ T]HH:mm:ss[.fraction]" string replaced by + * the time portion only; non-matching elements pass through unchanged. + */ + private static List stripEpochDatePrefixInList(List list) { + List out = new ArrayList<>(list.size()); + for (Object element : list) { + if (element instanceof String s) { + var m = EPOCH_DATE_TIME_PREFIX.matcher(s); + out.add(m.matches() ? m.group(1) : s); + } else { + out.add(element); + } + } + return out; + } + private Schema buildSchema(List fields) { List columns = new ArrayList<>(); for (RelDataTypeField field : fields) { diff --git a/core/src/test/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngineTest.java b/core/src/test/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngineTest.java index e759b128a45..45aa4dab42f 100644 --- a/core/src/test/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngineTest.java +++ b/core/src/test/java/org/opensearch/sql/executor/analytics/AnalyticsExecutionEngineTest.java @@ -30,7 +30,9 @@ import org.junit.jupiter.api.Test; import org.opensearch.analytics.exec.QueryPlanExecutor; import org.opensearch.analytics.schema.BinaryType; +import org.opensearch.analytics.schema.DateOnlyType; import org.opensearch.analytics.schema.IpType; +import org.opensearch.analytics.schema.TimeOnlyType; import org.opensearch.core.action.ActionListener; import org.opensearch.sql.calcite.CalcitePlanContext; import org.opensearch.sql.calcite.SysLimit; @@ -237,6 +239,72 @@ void executeRelNode_binaryColumnRendersAsBase64() { "byte[] should base64-encode to match OpenSearch binary wire format. " + dump); } + /** + * DateOnlyType column value "YYYY-MM-DD HH:MM:SS" → schema "date" + value stripped to YYYY-MM-DD. + */ + @Test + void executeRelNode_dateOnlyTypeStripsTimeSuffix() { + RelNode relNode = mockRelNodeWithType("d", new DateOnlyType(RelDataTypeSystem.DEFAULT, true)); + Iterable rows = Collections.singletonList(new Object[] {"1984-04-12 00:00:00"}); + stubExecutorWith(relNode, rows); + + QueryResponse response = executeAndCapture(relNode); + String dump = dumpResponse(response); + + assertEquals(ExprCoreType.DATE, response.getSchema().getColumns().get(0).getExprType(), dump); + assertEquals( + "1984-04-12", response.getResults().get(0).tupleValue().get("d").stringValue(), dump); + } + + /** + * TimeOnlyType column value "1970-01-01 HH:MM:SS" → schema "time" + value stripped to HH:MM:SS. + */ + @Test + void executeRelNode_timeOnlyTypeStripsEpochDatePrefix() { + RelNode relNode = mockRelNodeWithType("t", new TimeOnlyType(RelDataTypeSystem.DEFAULT, true)); + Iterable rows = Collections.singletonList(new Object[] {"1970-01-01 09:00:00"}); + stubExecutorWith(relNode, rows); + + QueryResponse response = executeAndCapture(relNode); + String dump = dumpResponse(response); + + assertEquals(ExprCoreType.TIME, response.getSchema().getColumns().get(0).getExprType(), dump); + assertEquals( + "09:00:00", response.getResults().get(0).tupleValue().get("t").stringValue(), dump); + } + + /** TIME-typed list elements arrive as "1970-01-01[ T]HH:mm:ss[.frac]" — strip the prefix. */ + @Test + void executeRelNode_listOfStringStripsEpochDatePrefix() { + SqlTypeFactoryImpl typeFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT); + RelDataType varchar = typeFactory.createSqlType(SqlTypeName.VARCHAR); + RelDataType arrayOfVarchar = typeFactory.createArrayType(varchar, -1); + RelDataType rowType = typeFactory.builder().add("time_list", arrayOfVarchar).build(); + RelNode relNode = mock(RelNode.class); + when(relNode.getRowType()).thenReturn(rowType); + java.util.List input = + Arrays.asList( + "1970-01-01 19:36:22", + "1970-01-01T02:05:25", + "1970-01-01 12:34:56.123456789", + "2020-10-13 13:00:00", + "hello"); + Iterable rows = Collections.singletonList(new Object[] {input}); + stubExecutorWith(relNode, rows); + + QueryResponse response = executeAndCapture(relNode); + String dump = dumpResponse(response); + + java.util.List result = + response.getResults().get(0).tupleValue().get("time_list").collectionValue().stream() + .map(org.opensearch.sql.data.model.ExprValue::stringValue) + .toList(); + assertEquals( + Arrays.asList("19:36:22", "02:05:25", "12:34:56.123456789", "2020-10-13 13:00:00", "hello"), + result, + dump); + } + @Test void executeRelNode_emptyResults() { RelNode relNode = mockRelNode("name", SqlTypeName.VARCHAR);