Skip to content

Commit 0af7429

Browse files
authored
strftime function implementation (#4106)
Signed-off-by: Vamsi Manohar <[email protected]>
1 parent cb51d8e commit 0af7429

File tree

17 files changed

+1519
-19
lines changed

17 files changed

+1519
-19
lines changed

core/src/main/java/org/opensearch/sql/analysis/ExpressionAnalyzer.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import java.util.Collections;
1818
import java.util.List;
1919
import java.util.Optional;
20+
import java.util.Set;
2021
import java.util.stream.Collectors;
2122
import lombok.Getter;
2223
import org.opensearch.sql.analysis.symbol.Namespace;
@@ -53,6 +54,7 @@
5354
import org.opensearch.sql.ast.expression.subquery.ExistsSubquery;
5455
import org.opensearch.sql.ast.expression.subquery.InSubquery;
5556
import org.opensearch.sql.ast.expression.subquery.ScalarSubquery;
57+
import org.opensearch.sql.calcite.utils.CalciteUtils;
5658
import org.opensearch.sql.data.model.ExprValueUtils;
5759
import org.opensearch.sql.data.type.ExprCoreType;
5860
import org.opensearch.sql.data.type.ExprType;
@@ -191,6 +193,7 @@ public Expression visitRelevanceFieldList(RelevanceFieldList node, AnalysisConte
191193
@Override
192194
public Expression visitFunction(Function node, AnalysisContext context) {
193195
FunctionName functionName = FunctionName.of(node.getFuncName());
196+
validateCalciteOnlyFunction(functionName);
194197
List<Expression> arguments =
195198
node.getFuncArgs().stream()
196199
.map(
@@ -208,6 +211,34 @@ public Expression visitFunction(Function node, AnalysisContext context) {
208211
repository.compile(context.getFunctionProperties(), functionName, arguments);
209212
}
210213

214+
/**
215+
* Validates that functions requiring Calcite engine are not used without it.
216+
*
217+
* @param functionName The function name to validate
218+
*/
219+
private void validateCalciteOnlyFunction(FunctionName functionName) {
220+
if (isCalciteOnlyFunction(functionName)) {
221+
throw CalciteUtils.getOnlyForCalciteException(functionName.getFunctionName().toUpperCase());
222+
}
223+
}
224+
225+
/**
226+
* Checks if a function requires Calcite engine to be enabled.
227+
*
228+
* @param functionName The function name to check
229+
* @return true if the function requires Calcite, false otherwise
230+
*/
231+
private boolean isCalciteOnlyFunction(FunctionName functionName) {
232+
// Set of functions that are only supported with Calcite engine
233+
Set<String> calciteOnlyFunctions =
234+
ImmutableSet.of(
235+
BuiltinFunctionName.REGEX_MATCH.getName().getFunctionName(),
236+
BuiltinFunctionName.STRFTIME.getName().getFunctionName());
237+
238+
return calciteOnlyFunctions.stream()
239+
.anyMatch(f -> f.equalsIgnoreCase(functionName.getFunctionName()));
240+
}
241+
211242
@SuppressWarnings("unchecked")
212243
@Override
213244
public Expression visitWindowFunction(WindowFunction node, AnalysisContext context) {
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.opensearch.sql.expression.datetime;
7+
8+
import com.google.common.collect.ImmutableMap;
9+
import java.time.ZonedDateTime;
10+
import java.time.format.DateTimeFormatter;
11+
import java.time.format.TextStyle;
12+
import java.time.temporal.IsoFields;
13+
import java.time.temporal.WeekFields;
14+
import java.util.Locale;
15+
import java.util.Map;
16+
import java.util.regex.Matcher;
17+
import java.util.regex.Pattern;
18+
import org.opensearch.sql.data.model.ExprStringValue;
19+
import org.opensearch.sql.data.model.ExprValue;
20+
21+
/** Utility class for POSIX-style strftime formatting. */
22+
public class StrftimeFormatterUtil {
23+
24+
// Constants
25+
private static final String PERCENT_PLACEHOLDER = "\u0001PERCENT\u0001";
26+
private static final String PERCENT_LITERAL = "%%";
27+
private static final int DEFAULT_NANOSECOND_PRECISION = 9;
28+
private static final int DEFAULT_MILLISECOND_PRECISION = 3;
29+
private static final int MICROSECOND_PRECISION = 6;
30+
private static final long NANOS_PER_SECOND = 1_000_000_000L;
31+
private static final long MILLIS_PER_SECOND = 1000L;
32+
private static final long MAX_UNIX_TIMESTAMP = 32536771199L;
33+
private static final int UNIX_TIMESTAMP_DIGITS = 10;
34+
35+
// Pattern to match %N and %Q with optional precision digit
36+
private static final Pattern SUBSECOND_PATTERN = Pattern.compile("%(\\d)?([NQ])");
37+
38+
@FunctionalInterface
39+
private interface StrftimeFormatHandler {
40+
String format(ZonedDateTime dateTime);
41+
}
42+
43+
private static final Map<String, StrftimeFormatHandler> STRFTIME_HANDLERS = buildHandlers();
44+
45+
private static Map<String, StrftimeFormatHandler> buildHandlers() {
46+
return ImmutableMap.<String, StrftimeFormatHandler>builder()
47+
// Date and time combinations
48+
.put(
49+
"%c",
50+
dt -> dt.format(DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss yyyy", Locale.ROOT)))
51+
.put(
52+
"%+",
53+
dt ->
54+
dt.format(DateTimeFormatter.ofPattern("EEE MMM dd HH:mm:ss zzz yyyy", Locale.ROOT)))
55+
56+
// Time formats
57+
.put("%Ez", StrftimeFormatterUtil::formatTimezoneOffsetMinutes)
58+
.put("%f", dt -> String.format("%06d", dt.getNano() / 1000))
59+
.put("%H", dt -> dt.format(DateTimeFormatter.ofPattern("HH", Locale.ROOT)))
60+
.put("%I", dt -> dt.format(DateTimeFormatter.ofPattern("hh", Locale.ROOT)))
61+
.put("%k", dt -> String.format("%2d", dt.getHour()))
62+
.put("%M", dt -> dt.format(DateTimeFormatter.ofPattern("mm", Locale.ROOT)))
63+
.put("%p", dt -> dt.format(DateTimeFormatter.ofPattern("a", Locale.ROOT)))
64+
.put("%S", dt -> dt.format(DateTimeFormatter.ofPattern("ss", Locale.ROOT)))
65+
.put("%s", dt -> String.valueOf(dt.toEpochSecond()))
66+
.put("%T", dt -> dt.format(DateTimeFormatter.ofPattern("HH:mm:ss", Locale.ROOT)))
67+
.put("%X", dt -> dt.format(DateTimeFormatter.ofPattern("HH:mm:ss", Locale.ROOT)))
68+
69+
// Timezone formats
70+
.put("%Z", dt -> dt.getZone().getDisplayName(TextStyle.SHORT, Locale.ROOT))
71+
.put("%z", dt -> dt.format(DateTimeFormatter.ofPattern("xx", Locale.ROOT)))
72+
.put("%:z", dt -> dt.format(DateTimeFormatter.ofPattern("xxx", Locale.ROOT)))
73+
.put("%::z", dt -> dt.format(DateTimeFormatter.ofPattern("xxx:ss", Locale.ROOT)))
74+
.put("%:::z", dt -> dt.format(DateTimeFormatter.ofPattern("x", Locale.ROOT)))
75+
76+
// Date formats
77+
.put("%F", dt -> dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.ROOT)))
78+
.put("%x", dt -> dt.format(DateTimeFormatter.ofPattern("MM/dd/yyyy", Locale.ROOT)))
79+
80+
// Weekday formats
81+
.put("%A", dt -> dt.format(DateTimeFormatter.ofPattern("EEEE", Locale.ROOT)))
82+
.put("%a", dt -> dt.format(DateTimeFormatter.ofPattern("EEE", Locale.ROOT)))
83+
.put("%w", dt -> String.valueOf(dt.getDayOfWeek().getValue() % 7))
84+
85+
// Day formats
86+
.put("%d", dt -> dt.format(DateTimeFormatter.ofPattern("dd", Locale.ROOT)))
87+
.put("%e", dt -> String.format("%2d", dt.getDayOfMonth()))
88+
.put("%j", dt -> dt.format(DateTimeFormatter.ofPattern("DDD", Locale.ROOT)))
89+
90+
// Week formats
91+
.put("%V", dt -> String.format("%02d", dt.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)))
92+
.put("%U", dt -> String.format("%02d", dt.get(WeekFields.SUNDAY_START.weekOfYear()) - 1))
93+
94+
// Month formats
95+
.put("%b", dt -> dt.format(DateTimeFormatter.ofPattern("MMM", Locale.ROOT)))
96+
.put("%B", dt -> dt.format(DateTimeFormatter.ofPattern("MMMM", Locale.ROOT)))
97+
.put("%m", dt -> dt.format(DateTimeFormatter.ofPattern("MM", Locale.ROOT)))
98+
99+
// Year formats
100+
.put("%C", dt -> String.format("%02d", dt.getYear() / 100))
101+
.put("%g", dt -> String.format("%02d", dt.get(IsoFields.WEEK_BASED_YEAR) % 100))
102+
.put("%G", dt -> String.format("%04d", dt.get(IsoFields.WEEK_BASED_YEAR)))
103+
.put("%y", dt -> dt.format(DateTimeFormatter.ofPattern("yy", Locale.ROOT)))
104+
.put("%Y", dt -> dt.format(DateTimeFormatter.ofPattern("yyyy", Locale.ROOT)))
105+
106+
// Literal percent
107+
.put(PERCENT_LITERAL, dt -> "%")
108+
.build();
109+
}
110+
111+
private StrftimeFormatterUtil() {}
112+
113+
/**
114+
* Format a ZonedDateTime using STRFTIME format specifiers.
115+
*
116+
* @param dateTime The ZonedDateTime to format
117+
* @param formatString The STRFTIME format string
118+
* @return Formatted string as ExprValue, or ExprNullValue if inputs are null
119+
*/
120+
public static ExprValue formatZonedDateTime(ZonedDateTime dateTime, String formatString) {
121+
String result = processFormatString(formatString, dateTime);
122+
return new ExprStringValue(result);
123+
}
124+
125+
/** Process the format string and replace all format specifiers. */
126+
private static String processFormatString(String formatString, ZonedDateTime dateTime) {
127+
// Handle %N and %Q with precision first
128+
String result = handleSubSecondFormats(formatString, dateTime);
129+
130+
// Escape %% by replacing with placeholder
131+
result = result.replace(PERCENT_LITERAL, PERCENT_PLACEHOLDER);
132+
133+
// Replace all other format specifiers
134+
result = replaceFormatSpecifiers(result, dateTime);
135+
136+
// Restore literal percent signs
137+
return result.replace(PERCENT_PLACEHOLDER, "%");
138+
}
139+
140+
/** Replace all format specifiers in the string. */
141+
private static String replaceFormatSpecifiers(String input, ZonedDateTime dateTime) {
142+
String result = input;
143+
for (Map.Entry<String, StrftimeFormatHandler> entry : STRFTIME_HANDLERS.entrySet()) {
144+
String specifier = entry.getKey();
145+
if (result.contains(specifier)) {
146+
String replacement = entry.getValue().format(dateTime);
147+
result = result.replace(specifier, replacement);
148+
}
149+
}
150+
return result;
151+
}
152+
153+
/** Handle %N and %Q subsecond formats with optional precision. */
154+
private static String handleSubSecondFormats(String format, ZonedDateTime dateTime) {
155+
StringBuilder result = new StringBuilder();
156+
Matcher matcher = SUBSECOND_PATTERN.matcher(format);
157+
158+
while (matcher.find()) {
159+
String precisionStr = matcher.group(1);
160+
String type = matcher.group(2);
161+
162+
int precision = parsePrecision(precisionStr, type);
163+
String replacement = formatSubseconds(dateTime, type, precision);
164+
165+
matcher.appendReplacement(result, replacement);
166+
}
167+
matcher.appendTail(result);
168+
169+
return result.toString();
170+
}
171+
172+
/** Parse precision value for subsecond formats. */
173+
private static int parsePrecision(String precisionStr, String type) {
174+
if (precisionStr != null) {
175+
return Integer.parseInt(precisionStr);
176+
}
177+
// Default: %N=9 (nanoseconds), %Q=3 (milliseconds)
178+
return "N".equals(type) ? DEFAULT_NANOSECOND_PRECISION : DEFAULT_MILLISECOND_PRECISION;
179+
}
180+
181+
/** Format subseconds based on type and precision. */
182+
private static String formatSubseconds(ZonedDateTime dateTime, String type, int precision) {
183+
if ("N".equals(type)) {
184+
// %N - subsecond digits (nanoseconds)
185+
return formatNanoseconds(dateTime.getNano(), precision);
186+
} else {
187+
// %Q - subsecond component
188+
return formatQSubseconds(dateTime, precision);
189+
}
190+
}
191+
192+
/** Format nanoseconds with specified precision. */
193+
private static String formatNanoseconds(long nanos, int precision) {
194+
double scaled = (double) nanos / NANOS_PER_SECOND;
195+
long truncated = (long) (scaled * Math.pow(10, precision));
196+
return String.format("%0" + precision + "d", truncated);
197+
}
198+
199+
/** Format Q-type subseconds based on precision. */
200+
private static String formatQSubseconds(ZonedDateTime dateTime, int precision) {
201+
switch (precision) {
202+
case MICROSECOND_PRECISION:
203+
// Microseconds
204+
long micros = dateTime.getNano() / 1000;
205+
return String.format("%06d", micros);
206+
207+
case DEFAULT_NANOSECOND_PRECISION:
208+
// Nanoseconds
209+
return String.format("%09d", dateTime.getNano());
210+
211+
default:
212+
// Default to milliseconds
213+
long millis = dateTime.toInstant().toEpochMilli() % MILLIS_PER_SECOND;
214+
return String.format("%03d", millis);
215+
}
216+
}
217+
218+
/** Format timezone offset in minutes. */
219+
private static String formatTimezoneOffsetMinutes(ZonedDateTime dt) {
220+
int offsetMinutes = dt.getOffset().getTotalSeconds() / 60;
221+
return String.format("%+d", offsetMinutes);
222+
}
223+
224+
/**
225+
* Extract the first 10 digits from a timestamp to get UNIX seconds. This handles timestamps that
226+
* may be in milliseconds or nanoseconds.
227+
*
228+
* @param timestamp The timestamp value
229+
* @return UNIX timestamp in seconds
230+
*/
231+
public static long extractUnixSeconds(double timestamp) {
232+
// Return as-is if within valid Unix timestamp range
233+
if (timestamp >= -MAX_UNIX_TIMESTAMP && timestamp <= MAX_UNIX_TIMESTAMP) {
234+
return (long) timestamp;
235+
}
236+
237+
// For larger absolute values, extract first 10 digits (assumes milliseconds/nanoseconds)
238+
return extractFirstNDigits(timestamp, UNIX_TIMESTAMP_DIGITS);
239+
}
240+
241+
/** Extract the first N digits from a number. */
242+
private static long extractFirstNDigits(double value, int digits) {
243+
boolean isNegative = value < 0;
244+
long absValue = Math.abs((long) value);
245+
String valueStr = String.valueOf(absValue);
246+
247+
long result =
248+
valueStr.length() <= digits ? absValue : Long.parseLong(valueStr.substring(0, digits));
249+
250+
return isNegative ? -result : result;
251+
}
252+
}

core/src/main/java/org/opensearch/sql/expression/function/BuiltinFunctionName.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ public enum BuiltinFunctionName {
125125
UTC_TIME(FunctionName.of("utc_time")),
126126
UTC_TIMESTAMP(FunctionName.of("utc_timestamp")),
127127
UNIX_TIMESTAMP(FunctionName.of("unix_timestamp")),
128+
STRFTIME(FunctionName.of("strftime")),
128129
WEEK(FunctionName.of("week")),
129130
WEEKDAY(FunctionName.of("weekday")),
130131
WEEKOFYEAR(FunctionName.of("weekofyear")),

core/src/main/java/org/opensearch/sql/expression/function/PPLBuiltinOperators.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
import org.opensearch.sql.expression.function.udf.datetime.LastDayFunction;
7777
import org.opensearch.sql.expression.function.udf.datetime.PeriodNameFunction;
7878
import org.opensearch.sql.expression.function.udf.datetime.SecToTimeFunction;
79+
import org.opensearch.sql.expression.function.udf.datetime.StrftimeFunction;
7980
import org.opensearch.sql.expression.function.udf.datetime.SysdateFunction;
8081
import org.opensearch.sql.expression.function.udf.datetime.TimestampAddFunction;
8182
import org.opensearch.sql.expression.function.udf.datetime.TimestampDiffFunction;
@@ -170,6 +171,7 @@ public class PPLBuiltinOperators extends ReflectiveSqlOperatorTable {
170171
public static final SqlOperator WEEKDAY = new WeekdayFunction().toUDF("WEEKDAY");
171172
public static final SqlOperator UNIX_TIMESTAMP =
172173
new UnixTimestampFunction().toUDF("UNIX_TIMESTAMP");
174+
public static final SqlOperator STRFTIME = new StrftimeFunction().toUDF("STRFTIME");
173175
public static final SqlOperator TO_SECONDS = new ToSecondsFunction().toUDF("TO_SECONDS");
174176
public static final SqlOperator ADDTIME =
175177
adaptExprMethodWithPropertiesToUDF(

core/src/main/java/org/opensearch/sql/expression/function/PPLFuncImpTable.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
import static org.opensearch.sql.expression.function.BuiltinFunctionName.STDDEV_POP;
189189
import static org.opensearch.sql.expression.function.BuiltinFunctionName.STDDEV_SAMP;
190190
import static org.opensearch.sql.expression.function.BuiltinFunctionName.STRCMP;
191+
import static org.opensearch.sql.expression.function.BuiltinFunctionName.STRFTIME;
191192
import static org.opensearch.sql.expression.function.BuiltinFunctionName.STR_TO_DATE;
192193
import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBDATE;
193194
import static org.opensearch.sql.expression.function.BuiltinFunctionName.SUBSTR;
@@ -755,6 +756,7 @@ void populate() {
755756
registerOperator(YEARWEEK, PPLBuiltinOperators.YEARWEEK);
756757
registerOperator(WEEKDAY, PPLBuiltinOperators.WEEKDAY);
757758
registerOperator(UNIX_TIMESTAMP, PPLBuiltinOperators.UNIX_TIMESTAMP);
759+
registerOperator(STRFTIME, PPLBuiltinOperators.STRFTIME);
758760
registerOperator(TO_SECONDS, PPLBuiltinOperators.TO_SECONDS);
759761
registerOperator(TO_DAYS, PPLBuiltinOperators.TO_DAYS);
760762
registerOperator(ADDTIME, PPLBuiltinOperators.ADDTIME);

0 commit comments

Comments
 (0)