SQL: Implement FORMAT function (#55454) (#62701)

Implement FORMAT according to the SQL Server spec: https://docs.microsoft.com/en-us/sql/t-sql/functions/format-transact-sql?view=sql-server-ver15#ExampleD by translating to the java.time patterns used in DATETIME_FORMAT.

Closes: #54965

Co-authored-by: Marios Trivyzas <matriv@users.noreply.github.com>
Co-authored-by: Bogdan Pintea <bogdan.pintea@elastic.co>
Co-authored-by: Andrei Stefan <astefan@users.noreply.github.com>
(cherry picked from commit da511f4e033db6e8a6aa2a54b23e906b5e026845)
This commit is contained in:
Marios Trivyzas 2020-09-21 19:22:04 +02:00 committed by GitHub
parent cadd5dc53f
commit 1f612cccbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 686 additions and 93 deletions

View File

@ -579,9 +579,9 @@ include-tagged::{sql-specs}/docs/docs.csv-spec[timeParse2]
[NOTE]
====
If timezone is not specified in the time string expression and the parsing pattern,
If timezone is not specified in the time string expression and the parsing pattern,
the resulting `time` will have the offset of the time zone specified by the user through the
<<sql-rest-fields-timezone,`time_zone`>>/<<jdbc-cfg-timezone,`timezone`>> REST/driver
<<sql-rest-fields-timezone,`time_zone`>>/<<jdbc-cfg-timezone,`timezone`>> REST/driver
parameters at the Unix epoch date (`1970-01-01`) with no conversion applied.
[source, sql]
@ -765,6 +765,59 @@ include-tagged::{sql-specs}/docs/docs.csv-spec[truncateIntervalHour]
include-tagged::{sql-specs}/docs/docs.csv-spec[truncateIntervalDay]
--------------------------------------------------
[[sql-functions-datetime-format]]
==== `FORMAT`
.Synopsis:
[source, sql]
--------------------------------------------------
FORMAT(
date_exp/datetime_exp/time_exp, <1>
string_exp) <2>
--------------------------------------------------
*Input*:
<1> date/datetime/time expression
<2> format pattern
*Output*: string
*Description*: Returns the date/datetime/time as a string using the
https://docs.microsoft.com/en-us/sql/t-sql/functions/format-transact-sql#arguments[format] specified in the 2nd argument. The formatting
pattern used is the one from
https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings[Microsoft SQL Server Format Specification].
If any of the two arguments is `null` or the pattern is an empty string `null` is returned.
[NOTE]
If the 1st argument is of type `time`, then pattern specified by the 2nd argument cannot contain date related units
(e.g. 'dd', 'MM', 'YYYY', etc.). If it contains such units an error is returned.
*Special Cases*
- Format specifier `F` will be working similar to format specifier `f`.
It will return the fractional part of seconds, and the number of digits will be same as of the number of `Fs` provided as input (up to 9 digits).
Result will contain `0` appended in the end to match with number of `F` provided.
e.g.: for a time part `10:20:30.1234` and pattern `HH:mm:ss.FFFFFF`, the output string of the function would be: `10:20:30.123400`.
- Format Specifier `y` will return year-of-era instead of one/two low-order digits.
eg.: For year `2009`, `y` will be returning `2009` instead of `9`. For year `43`, `y` format specifier will return `43`.
- Special characters like `"` , `\` and `%` will be returned as it is without any change. eg.: formatting date `17-sep-2020` with `%M` will return `%9`
[source, sql]
--------------------------------------------------
include-tagged::{sql-specs}/docs/docs.csv-spec[formatDate]
--------------------------------------------------
[source, sql]
--------------------------------------------------
include-tagged::{sql-specs}/docs/docs.csv-spec[formatDateTime]
--------------------------------------------------
[source, sql]
--------------------------------------------------
include-tagged::{sql-specs}/docs/docs.csv-spec[formatTime]
--------------------------------------------------
[[sql-functions-datetime-day]]
==== `DAY_OF_MONTH/DOM/DAY`

View File

@ -58,6 +58,7 @@
** <<sql-functions-datetime-dateparse>>
** <<sql-functions-datetime-datetimeformat>>
** <<sql-functions-datetime-datetimeparse>>
** <<sql-functions-datetime-format>>
** <<sql-functions-datetime-timeparse>>
** <<sql-functions-datetime-part>>
** <<sql-functions-datetime-trunc>>

View File

@ -66,6 +66,7 @@ DAY_OF_YEAR |SCALAR
DOM |SCALAR
DOW |SCALAR
DOY |SCALAR
FORMAT |SCALAR
HOUR |SCALAR
HOUR_OF_DAY |SCALAR
IDOW |SCALAR

View File

@ -1017,6 +1017,120 @@ F | 1997-05-19 00:00:00.000Z
M | 1996-11-05 00:00:00.000Z
;
selectFormat
schema::format_date:s|format_datetime:s|format_time:s
SELECT FORMAT('2020-04-05T11:22:33.123Z'::date, 'dd/MM/YYYY HH:mm:ss.fff') AS format_date,
FORMAT('2020-04-05T11:22:33.123Z'::datetime, 'dd/MM/YYYY HH:mm:ss.ff') AS format_datetime,
FORMAT('11:22:33.123456789Z'::time, 'HH:mm:ss.ff') AS format_time;
format_date | format_datetime | format_time
------------------------+------------------------+----------------
05/04/2020 00:00:00.000 | 05/04/2020 11:22:33.12 | 11:22:33.12
;
selectFormatWithLength
schema::format_datetime:s|length:i
SELECT FORMAT('2020-04-05T11:22:33.123Z'::datetime, 'dd/MM/YYYY HH:mm:ss.ff') AS format_datetime,
LENGTH(FORMAT('2020-04-05T11:22:33.123Z'::datetime, 'dd/MM/YYYY HH:mm:ss.ff')) AS length;
format_datetime | length
------------------------+----------------
05/04/2020 11:22:33.12 | 22
;
selectFormatWithField
schema::birth_date:ts|format_birth_date1:s|format_birth_date2:s|emp_no:i
SELECT birth_date, FORMAT(birth_date, 'MM/dd/YYYY') AS format_birth_date1, FORMAT(birth_date, concat(gender, 'M/dd')) AS format_birth_date2, emp_no
FROM test_emp WHERE gender = 'M' AND emp_no BETWEEN 10037 AND 10052 ORDER BY emp_no;
birth_date | format_birth_date1 | format_birth_date2 | emp_no
-------------------------+--------------------+--------------------+----------
1963-07-22 00:00:00.000Z | 07/22/1963 | 07/22 | 10037
1960-07-20 00:00:00.000Z | 07/20/1960 | 07/20 | 10038
1959-10-01 00:00:00.000Z | 10/01/1959 | 10/01 | 10039
null | null | null | 10043
null | null | null | 10045
null | null | null | 10046
null | null | null | 10047
null | null | null | 10048
1958-05-21 00:00:00.000Z | 05/21/1958 | 05/21 | 10050
1953-07-28 00:00:00.000Z | 07/28/1953 | 07/28 | 10051
1961-02-26 00:00:00.000Z | 02/26/1961 | 02/26 | 10052
;
formatWhere
schema::birth_date:ts|format_birth_date:s|emp_no:i
SELECT birth_date, FORMAT(birth_date, 'MM') AS format_birth_date, emp_no FROM test_emp
WHERE FORMAT(birth_date, 'MM')::integer > 10 ORDER BY emp_no LIMIT 10;
birth_date | format_birth_date | emp_no
-------------------------+-------------------+----------
1959-12-03 00:00:00.000Z | 12 | 10003
1953-11-07 00:00:00.000Z | 11 | 10011
1952-12-24 00:00:00.000Z | 12 | 10020
1963-11-26 00:00:00.000Z | 11 | 10028
1956-12-13 00:00:00.000Z | 12 | 10029
1956-11-14 00:00:00.000Z | 11 | 10033
1962-12-29 00:00:00.000Z | 12 | 10034
1961-11-02 00:00:00.000Z | 11 | 10062
1952-11-13 00:00:00.000Z | 11 | 10066
1962-11-26 00:00:00.000Z | 11 | 10068
;
formatOrderBy
schema::birth_date:ts|format_birth_date:s
SELECT birth_date, FORMAT(birth_date, 'MM/dd/YYYY') AS format_birth_date FROM test_emp ORDER BY 2 DESC NULLS LAST LIMIT 10;
birth_date | format_birth_date
-------------------------+---------------
1962-12-29 00:00:00.000Z | 12/29/1962
1959-12-25 00:00:00.000Z | 12/25/1959
1952-12-24 00:00:00.000Z | 12/24/1952
1960-12-17 00:00:00.000Z | 12/17/1960
1956-12-13 00:00:00.000Z | 12/13/1956
1959-12-03 00:00:00.000Z | 12/03/1959
1957-12-03 00:00:00.000Z | 12/03/1957
1963-11-26 00:00:00.000Z | 11/26/1963
1962-11-26 00:00:00.000Z | 11/26/1962
1962-11-19 00:00:00.000Z | 11/19/1962
;
formatGroupBy
schema::count:l|format_birth_date:s
SELECT count(*) AS count, FORMAT(birth_date, 'MM') AS format_birth_date FROM test_emp GROUP BY format_birth_date ORDER BY 1 DESC, 2 DESC;
count | format_birth_date
-------+---------------
10 | 09
10 | 05
10 | null
9 | 10
9 | 07
8 | 11
8 | 04
8 | 02
7 | 12
7 | 06
6 | 08
6 | 01
2 | 03
;
formatHaving
schema::max:ts|format_birth_date:s
SELECT MAX(birth_date) AS max, FORMAT(birth_date, 'MM') AS format_birth_date FROM test_emp GROUP BY format_birth_date
HAVING FORMAT(MAX(birth_date), 'dd')::integer > 20 ORDER BY 1 DESC;
max | format_birth_date
-------------------------+---------------
1963-11-26 00:00:00.000Z | 11
1963-07-22 00:00:00.000Z | 07
1963-03-21 00:00:00.000Z | 03
1962-12-29 00:00:00.000Z | 12
1961-05-30 00:00:00.000Z | 05
1961-02-26 00:00:00.000Z | 02
;
//
// Aggregate
//

View File

@ -262,6 +262,7 @@ DAY_OF_YEAR |SCALAR
DOM |SCALAR
DOW |SCALAR
DOY |SCALAR
FORMAT |SCALAR
HOUR |SCALAR
HOUR_OF_DAY |SCALAR
IDOW |SCALAR
@ -3088,6 +3089,36 @@ SELECT DATE_TRUNC('days', INTERVAL '19 15:24:19' DAY TO SECONDS) AS day;
// end::truncateIntervalDay
;
formatDate
// tag::formatDate
SELECT FORMAT(CAST('2020-04-05' AS DATE), 'dd/MM/YYYY') AS "date";
date
------------------
05/04/2020
// end::formatDate
;
formatDateTime
// tag::formatDateTime
SELECT FORMAT(CAST('2020-04-05T11:22:33.987654' AS DATETIME), 'dd/MM/YYYY HH:mm:ss.ff') AS "datetime";
datetime
------------------
05/04/2020 11:22:33.98
// end::formatDateTime
;
formatTime
// tag::formatTime
SELECT FORMAT(CAST('11:22:33.987' AS TIME), 'HH mm ss.f') AS "time";
time
------------------
11 22 33.9
// end::formatTime
;
constantDayOfWeek
// tag::dayOfWeek
SELECT DAY_OF_WEEK(CAST('2018-02-19T10:23:27Z' AS TIMESTAMP)) AS day;

View File

@ -43,6 +43,7 @@ import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DayName;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DayOfMonth;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DayOfWeek;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DayOfYear;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.Format;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.HourOfDay;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.IsoDayOfWeek;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.IsoWeekOfYear;
@ -182,6 +183,7 @@ public class SqlFunctionRegistry extends FunctionRegistry {
def(DateTimeFormat.class, DateTimeFormat::new, "DATETIME_FORMAT"),
def(DateTimeParse.class, DateTimeParse::new, "DATETIME_PARSE"),
def(DateTrunc.class, DateTrunc::new, "DATETRUNC", "DATE_TRUNC"),
def(Format.class, Format::new, "FORMAT"),
def(HourOfDay.class, HourOfDay::new, "HOUR_OF_DAY", "HOUR"),
def(IsoDayOfWeek.class, IsoDayOfWeek::new, "ISO_DAY_OF_WEEK", "ISODAYOFWEEK", "ISODOW", "IDOW"),
def(IsoWeekOfYear.class, IsoWeekOfYear::new, "ISO_WEEK_OF_YEAR", "ISOWEEKOFYEAR", "ISOWEEK", "IWOY", "IW"),

View File

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
import org.elasticsearch.xpack.ql.expression.Expression;
import org.elasticsearch.xpack.ql.expression.Expressions;
import org.elasticsearch.xpack.ql.expression.gen.pipeline.Pipe;
import org.elasticsearch.xpack.ql.tree.NodeInfo;
import org.elasticsearch.xpack.ql.tree.NodeInfo.NodeCtor3;
import org.elasticsearch.xpack.ql.tree.Source;
import org.elasticsearch.xpack.ql.type.DataType;
import org.elasticsearch.xpack.ql.type.DataTypes;
import java.time.ZoneId;
import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isString;
import static org.elasticsearch.xpack.sql.expression.SqlTypeResolutions.isDateOrTime;
import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
public abstract class BaseDateTimeFormatFunction extends BinaryDateTimeFunction {
public BaseDateTimeFormatFunction(Source source, Expression timestamp, Expression pattern, ZoneId zoneId) {
super(source, timestamp, pattern, zoneId);
}
@Override
public DataType dataType() {
return DataTypes.KEYWORD;
}
@Override
protected TypeResolution resolveType() {
TypeResolution resolution = isDateOrTime(left(), sourceText(), Expressions.ParamOrdinal.FIRST);
if (resolution.unresolved()) {
return resolution;
}
resolution = isString(right(), sourceText(), Expressions.ParamOrdinal.SECOND);
if (resolution.unresolved()) {
return resolution;
}
return TypeResolution.TYPE_RESOLVED;
}
@Override
protected NodeInfo<? extends Expression> info() {
return NodeInfo.create(this, ctor(), left(), right(), zoneId());
}
@Override
public Object fold() {
return formatter().format(left().fold(), right().fold(), zoneId());
}
@Override
protected Pipe createPipe(Pipe timestamp, Pipe pattern, ZoneId zoneId) {
return new DateTimeFormatPipe(source(), this, timestamp, pattern, zoneId, formatter());
}
protected abstract Formatter formatter();
protected abstract NodeCtor3<Expression, Expression, ZoneId, BaseDateTimeFormatFunction> ctor();
}

View File

@ -6,51 +6,29 @@
package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
import org.elasticsearch.xpack.ql.expression.Expression;
import org.elasticsearch.xpack.ql.expression.Expressions;
import org.elasticsearch.xpack.ql.expression.function.scalar.BinaryScalarFunction;
import org.elasticsearch.xpack.ql.expression.gen.pipeline.Pipe;
import org.elasticsearch.xpack.ql.tree.NodeInfo;
import org.elasticsearch.xpack.ql.tree.Source;
import org.elasticsearch.xpack.ql.type.DataType;
import org.elasticsearch.xpack.ql.type.DataTypes;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
import java.time.ZoneId;
import static org.elasticsearch.xpack.ql.expression.TypeResolutions.isString;
import static org.elasticsearch.xpack.sql.expression.SqlTypeResolutions.isDateOrTime;
import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter.DATE_TIME_FORMAT;
public class DateTimeFormat extends BinaryDateTimeFunction {
public class DateTimeFormat extends BaseDateTimeFormatFunction {
public DateTimeFormat(Source source, Expression timestamp, Expression pattern, ZoneId zoneId) {
super(source, timestamp, pattern, zoneId);
}
@Override
public DataType dataType() {
return DataTypes.KEYWORD;
protected Formatter formatter() {
return DATE_TIME_FORMAT;
}
@Override
protected TypeResolution resolveType() {
TypeResolution resolution = isDateOrTime(left(), sourceText(), Expressions.ParamOrdinal.FIRST);
if (resolution.unresolved()) {
return resolution;
}
resolution = isString(right(), sourceText(), Expressions.ParamOrdinal.SECOND);
if (resolution.unresolved()) {
return resolution;
}
return TypeResolution.TYPE_RESOLVED;
}
@Override
protected BinaryScalarFunction replaceChildren(Expression timestamp, Expression pattern) {
return new DateTimeFormat(source(), timestamp, pattern, zoneId());
}
@Override
protected NodeInfo<? extends Expression> info() {
return NodeInfo.create(this, DateTimeFormat::new, left(), right(), zoneId());
protected NodeInfo.NodeCtor3<Expression, Expression, ZoneId, BaseDateTimeFormatFunction> ctor() {
return DateTimeFormat::new;
}
@Override
@ -59,12 +37,7 @@ public class DateTimeFormat extends BinaryDateTimeFunction {
}
@Override
public Object fold() {
return DateTimeFormatProcessor.process(left().fold(), right().fold(), zoneId());
}
@Override
protected Pipe createPipe(Pipe timestamp, Pipe pattern, ZoneId zoneId) {
return new DateTimeFormatPipe(source(), this, timestamp, pattern, zoneId);
protected BinaryScalarFunction replaceChildren(Expression timestamp, Expression pattern) {
return new DateTimeFormat(source(), timestamp, pattern, zoneId());
}
}

View File

@ -12,25 +12,55 @@ import org.elasticsearch.xpack.ql.tree.NodeInfo;
import org.elasticsearch.xpack.ql.tree.Source;
import java.time.ZoneId;
import java.util.Objects;
import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
public class DateTimeFormatPipe extends BinaryDateTimePipe {
public DateTimeFormatPipe(Source source, Expression expression, Pipe left, Pipe right, ZoneId zoneId) {
private final Formatter formatter;
public DateTimeFormatPipe(Source source, Expression expression, Pipe left, Pipe right, ZoneId zoneId, Formatter formatter) {
super(source, expression, left, right, zoneId);
this.formatter = formatter;
}
@Override
protected NodeInfo<DateTimeFormatPipe> info() {
return NodeInfo.create(this, DateTimeFormatPipe::new, expression(), left(), right(), zoneId());
return NodeInfo.create(this, DateTimeFormatPipe::new, expression(), left(), right(), zoneId(), formatter);
}
@Override
protected DateTimeFormatPipe replaceChildren(Pipe left, Pipe right) {
return new DateTimeFormatPipe(source(), expression(), left, right, zoneId());
return new DateTimeFormatPipe(source(), expression(), left, right, zoneId(), formatter);
}
@Override
protected Processor makeProcessor(Processor left, Processor right, ZoneId zoneId) {
return new DateTimeFormatProcessor(left, right, zoneId);
return new DateTimeFormatProcessor(left, right, zoneId, formatter);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), this.formatter);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
if (!super.equals(o)) {
return false;
}
DateTimeFormatPipe other = (DateTimeFormatPipe) o;
return super.equals(o) && this.formatter == other.formatter;
}
public Formatter formatter() {
return formatter;
}
}

View File

@ -6,6 +6,7 @@
package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.xpack.ql.expression.gen.processor.Processor;
import org.elasticsearch.xpack.sql.SqlIllegalArgumentException;
@ -17,55 +18,90 @@ import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.Locale;
import java.util.Objects;
import static org.elasticsearch.xpack.sql.util.DateUtils.asTimeAtZone;
public class DateTimeFormatProcessor extends BinaryDateTimeProcessor {
public static final String NAME = "dtformat";
private static final String[][] JAVA_TIME_FORMAT_REPLACEMENTS = {
{"tt", "a"},
{"t", "a"},
{"dddd", "eeee"},
{"ddd", "eee"},
{"K", "V"},
{"g", "G"},
{"f", "S"},
{"F", "S"},
{"z", "X"}
};
private final Formatter formatter;
public DateTimeFormatProcessor(Processor source1, Processor source2, ZoneId zoneId) {
public enum Formatter {
FORMAT,
DATE_TIME_FORMAT;
private String getJavaPattern(String pattern) {
if (this == FORMAT) {
for (String[] replacement : JAVA_TIME_FORMAT_REPLACEMENTS) {
pattern = pattern.replace(replacement[0], replacement[1]);
}
}
return pattern;
}
public Object format(Object timestamp, Object pattern, ZoneId zoneId) {
if (timestamp == null || pattern == null) {
return null;
}
String patternString;
if (pattern instanceof String) {
patternString = (String) pattern;
} else {
throw new SqlIllegalArgumentException("A string is required; received [{}]", pattern);
}
if (patternString.isEmpty()) {
return null;
}
if (timestamp instanceof ZonedDateTime == false && timestamp instanceof OffsetTime == false) {
throw new SqlIllegalArgumentException("A date/datetime/time is required; received [{}]", timestamp);
}
TemporalAccessor ta;
if (timestamp instanceof ZonedDateTime) {
ta = ((ZonedDateTime) timestamp).withZoneSameInstant(zoneId);
} else {
ta = asTimeAtZone((OffsetTime) timestamp, zoneId);
}
try {
return DateTimeFormatter.ofPattern(getJavaPattern(patternString), Locale.ROOT).format(ta);
} catch (IllegalArgumentException | DateTimeException e) {
throw new SqlIllegalArgumentException(
"Invalid pattern [{}] is received for formatting date/time [{}]; {}",
pattern,
timestamp,
e.getMessage()
);
}
}
}
public DateTimeFormatProcessor(Processor source1, Processor source2, ZoneId zoneId, Formatter formatter) {
super(source1, source2, zoneId);
this.formatter = formatter;
}
public DateTimeFormatProcessor(StreamInput in) throws IOException {
super(in);
this.formatter = in.readEnum(Formatter.class);
}
/**
* Used in Painless scripting
*/
public static Object process(Object timestamp, Object pattern, ZoneId zoneId) {
if (timestamp == null || pattern == null) {
return null;
}
if (pattern instanceof String == false) {
throw new SqlIllegalArgumentException("A string is required; received [{}]", pattern);
}
if (((String) pattern).isEmpty()) {
return null;
}
if (timestamp instanceof ZonedDateTime == false && timestamp instanceof OffsetTime == false) {
throw new SqlIllegalArgumentException("A date/datetime/time is required; received [{}]", timestamp);
}
TemporalAccessor ta;
if (timestamp instanceof ZonedDateTime) {
ta = ((ZonedDateTime) timestamp).withZoneSameInstant(zoneId);
} else {
ta = asTimeAtZone((OffsetTime) timestamp, zoneId);
}
try {
return DateTimeFormatter.ofPattern((String) pattern, Locale.ROOT).format(ta);
} catch (IllegalArgumentException | DateTimeException e) {
throw new SqlIllegalArgumentException(
"Invalid pattern [{}] is received for formatting date/time [{}]; {}",
pattern,
timestamp,
e.getMessage()
);
}
@Override
protected void doWrite(StreamOutput out) throws IOException {
out.writeEnum(formatter);
}
@Override
@ -75,6 +111,29 @@ public class DateTimeFormatProcessor extends BinaryDateTimeProcessor {
@Override
protected Object doProcess(Object timestamp, Object pattern) {
return process(timestamp, pattern, zoneId());
return this.formatter.format(timestamp, pattern, zoneId());
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), formatter);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
DateTimeFormatProcessor other = (DateTimeFormatProcessor) obj;
return super.equals(other) && Objects.equals(formatter, other.formatter);
}
public Formatter formatter() {
return formatter;
}
}

View File

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
package org.elasticsearch.xpack.sql.expression.function.scalar.datetime;
import org.elasticsearch.xpack.ql.expression.Expression;
import org.elasticsearch.xpack.ql.expression.function.scalar.BinaryScalarFunction;
import org.elasticsearch.xpack.ql.tree.NodeInfo;
import org.elasticsearch.xpack.ql.tree.Source;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
import java.time.ZoneId;
import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter.FORMAT;
public class Format extends BaseDateTimeFormatFunction {
public Format(Source source, Expression timestamp, Expression pattern, ZoneId zoneId) {
super(source, timestamp, pattern, zoneId);
}
@Override
protected Formatter formatter() {
return FORMAT;
}
@Override
protected NodeInfo.NodeCtor3<Expression, Expression, ZoneId, BaseDateTimeFormatFunction> ctor() {
return Format::new;
}
@Override
protected String scriptMethodName() {
return "format";
}
@Override
protected BinaryScalarFunction replaceChildren(Expression timestamp, Expression pattern) {
return new Format(source(), timestamp, pattern, zoneId());
}
}

View File

@ -13,7 +13,7 @@ import org.elasticsearch.xpack.sql.SqlIllegalArgumentException;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateAddProcessor;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateDiffProcessor;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DatePartProcessor;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFunction;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTruncProcessor;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.NamedDateTimeProcessor.NameExtractor;
@ -289,17 +289,21 @@ public class InternalSqlScriptUtils extends InternalQlScriptUtils {
}
public static String dateTimeFormat(Object dateTime, String pattern, String tzId) {
return (String) DateTimeFormatProcessor.process(asDateTime(dateTime), pattern, ZoneId.of(tzId));
return (String) Formatter.DATE_TIME_FORMAT.format(asDateTime(dateTime), pattern, ZoneId.of(tzId));
}
public static Object dateTimeParse(String dateField, String pattern, String tzId) {
return Parser.DATE_TIME.parse(dateField, pattern, ZoneId.of(tzId));
}
public static String format(Object dateTime, String pattern, String tzId) {
return (String) Formatter.FORMAT.format(asDateTime(dateTime), pattern, ZoneId.of(tzId));
}
public static Object timeParse(String dateField, String pattern, String tzId) {
return Parser.TIME.parse(dateField, pattern, ZoneId.of(tzId));
}
public static ZonedDateTime asDateTime(Object dateTime) {
return (ZonedDateTime) asDateTime(dateTime, false);
}

View File

@ -135,6 +135,7 @@ class org.elasticsearch.xpack.sql.expression.function.scalar.whitelist.InternalS
def dateParse(String, String, String)
Integer datePart(String, Object, String)
String dateTimeFormat(Object, String, String)
String format(Object, String, String)
def dateTimeParse(String, String, String)
def timeParse(String, String, String)
IntervalDayTime intervalDayTime(String, String)

View File

@ -353,6 +353,23 @@ public class VerifierErrorMessagesTests extends ESTestCase {
);
}
public void testFormatValidArgs() {
accept("SELECT FORMAT(date, 'HH:mm:ss.fff KK') FROM test");
accept("SELECT FORMAT(date::date, 'MM/dd/YYYY') FROM test");
accept("SELECT FORMAT(date::time, 'HH:mm:ss Z') FROM test");
}
public void testFormatInvalidArgs() {
assertEquals(
"1:8: first argument of [FORMAT(int, keyword)] must be [date, time or datetime], found value [int] type [integer]",
error("SELECT FORMAT(int, keyword) FROM test")
);
assertEquals(
"1:8: second argument of [FORMAT(date, int)] must be [string], found value [int] type [integer]",
error("SELECT FORMAT(date, int) FROM test")
);
}
public void testValidDateTimeFunctionsOnTime() {
accept("SELECT HOUR_OF_DAY(CAST(date AS TIME)) FROM test");
accept("SELECT MINUTE_OF_HOUR(CAST(date AS TIME)) FROM test");

View File

@ -25,12 +25,17 @@ import static org.elasticsearch.xpack.ql.expression.Expressions.pipe;
import static org.elasticsearch.xpack.ql.expression.function.scalar.FunctionTestUtils.randomDatetimeLiteral;
import static org.elasticsearch.xpack.ql.expression.function.scalar.FunctionTestUtils.randomStringLiteral;
import static org.elasticsearch.xpack.ql.tree.SourceTests.randomSource;
import static org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormatPipe, Pipe> {
public static DateTimeFormatPipe randomDateTimeFormatPipe() {
return (DateTimeFormatPipe) new DateTimeFormat(randomSource(), randomDatetimeLiteral(), randomStringLiteral(), randomZone())
.makePipe();
List<Pipe> functions = new ArrayList<>();
functions.add(new DateTimeFormat(randomSource(), randomDatetimeLiteral(), randomStringLiteral(), randomZone())
.makePipe());
functions.add(new Format(randomSource(), randomDatetimeLiteral(), randomStringLiteral(), randomZone())
.makePipe());
return (DateTimeFormatPipe) randomFrom(functions);
}
@Override
@ -49,13 +54,23 @@ public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormat
DateTimeFormatPipe b1 = randomInstance();
Expression newExpression = randomValueOtherThan(b1.expression(), this::randomDateTimeFormatPipeExpression);
DateTimeFormatPipe newB = new DateTimeFormatPipe(b1.source(), newExpression, b1.left(), b1.right(), b1.zoneId());
DateTimeFormatPipe newB = new DateTimeFormatPipe(b1.source(), newExpression, b1.left(), b1.right(), b1.zoneId(), b1.formatter());
assertEquals(newB, b1.transformPropertiesOnly(v -> Objects.equals(v, b1.expression()) ? newExpression : v, Expression.class));
DateTimeFormatPipe b2 = randomInstance();
Source newLoc = randomValueOtherThan(b2.source(), SourceTests::randomSource);
newB = new DateTimeFormatPipe(newLoc, b2.expression(), b2.left(), b2.right(), b2.zoneId());
newB = new DateTimeFormatPipe(newLoc, b2.expression(), b2.left(), b2.right(), b2.zoneId(), b2.formatter());
assertEquals(newB, b2.transformPropertiesOnly(v -> Objects.equals(v, b2.source()) ? newLoc : v, Source.class));
DateTimeFormatPipe b3 = randomInstance();
Formatter newFormatter = randomValueOtherThan(b3.formatter(), () -> randomFrom(Formatter.values()));
newB = new DateTimeFormatPipe(b3.source(), b3.expression(), b3.left(), b3.right(), b3.zoneId(), newFormatter);
assertEquals(newB, b3.transformPropertiesOnly(v -> Objects.equals(v, b3.formatter()) ? newFormatter : v, Formatter.class));
DateTimeFormatPipe b4 = randomInstance();
ZoneId newZI = randomValueOtherThan(b4.zoneId(), ESTestCase::randomZone);
newB = new DateTimeFormatPipe(b4.source(), b4.expression(), b4.left(), b4.right(), newZI, b4.formatter());
assertEquals(newB, b4.transformPropertiesOnly(v -> Objects.equals(v, b4.zoneId()) ? newZI : v, ZoneId.class));
}
@Override
@ -64,7 +79,7 @@ public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormat
Pipe newLeft = pipe(((Expression) randomValueOtherThan(b.left(), FunctionTestUtils::randomDatetimeLiteral)));
Pipe newRight = pipe(((Expression) randomValueOtherThan(b.right(), FunctionTestUtils::randomStringLiteral)));
ZoneId newZoneId = randomValueOtherThan(b.zoneId(), ESTestCase::randomZone);
DateTimeFormatPipe newB = new DateTimeFormatPipe(b.source(), b.expression(), b.left(), b.right(), newZoneId);
DateTimeFormatPipe newB = new DateTimeFormatPipe(b.source(), b.expression(), b.left(), b.right(), newZoneId, b.formatter());
BinaryPipe transformed = newB.replaceChildren(newLeft, b.right());
assertEquals(transformed.left(), newLeft);
@ -94,7 +109,8 @@ public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormat
f.expression(),
pipe(((Expression) randomValueOtherThan(f.left(), FunctionTestUtils::randomDatetimeLiteral))),
f.right(),
randomValueOtherThan(f.zoneId(), ESTestCase::randomZone)
randomValueOtherThan(f.zoneId(), ESTestCase::randomZone),
f.formatter()
)
);
randoms.add(
@ -103,7 +119,8 @@ public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormat
f.expression(),
f.left(),
pipe(((Expression) randomValueOtherThan(f.right(), FunctionTestUtils::randomStringLiteral))),
randomValueOtherThan(f.zoneId(), ESTestCase::randomZone)
randomValueOtherThan(f.zoneId(), ESTestCase::randomZone),
f.formatter()
)
);
randoms.add(
@ -112,7 +129,18 @@ public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormat
f.expression(),
pipe(((Expression) randomValueOtherThan(f.left(), FunctionTestUtils::randomDatetimeLiteral))),
pipe(((Expression) randomValueOtherThan(f.right(), FunctionTestUtils::randomStringLiteral))),
randomValueOtherThan(f.zoneId(), ESTestCase::randomZone)
randomValueOtherThan(f.zoneId(), ESTestCase::randomZone),
f.formatter()
)
);
randoms.add(
f -> new DateTimeFormatPipe(
f.source(),
f.expression(),
f.left(),
f.right(),
f.zoneId(),
randomValueOtherThan(f.formatter(), () -> randomFrom(Formatter.values()))
)
);
@ -121,6 +149,11 @@ public class DateTimeFormatPipeTests extends AbstractNodeTestCase<DateTimeFormat
@Override
protected DateTimeFormatPipe copy(DateTimeFormatPipe instance) {
return new DateTimeFormatPipe(instance.source(), instance.expression(), instance.left(), instance.right(), instance.zoneId());
return new DateTimeFormatPipe(instance.source(),
instance.expression(),
instance.left(),
instance.right(),
instance.zoneId(),
instance.formatter());
}
}

View File

@ -13,10 +13,12 @@ import org.elasticsearch.xpack.ql.expression.gen.processor.ConstantProcessor;
import org.elasticsearch.xpack.ql.tree.Source;
import org.elasticsearch.xpack.sql.AbstractSqlWireSerializingTestCase;
import org.elasticsearch.xpack.sql.SqlIllegalArgumentException;
import org.elasticsearch.xpack.sql.expression.function.scalar.datetime.DateTimeFormatProcessor.Formatter;
import java.time.Instant;
import java.time.OffsetTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import static org.elasticsearch.xpack.ql.expression.Literal.NULL;
import static org.elasticsearch.xpack.ql.expression.function.scalar.FunctionTestUtils.l;
@ -32,7 +34,8 @@ public class DateTimeFormatProcessorTests extends AbstractSqlWireSerializingTest
return new DateTimeFormatProcessor(
new ConstantProcessor(DateTimeTestUtils.nowWithMillisResolution()),
new ConstantProcessor(randomRealisticUnicodeOfLengthBetween(0, 128)),
randomZone()
randomZone(),
randomFrom(Formatter.values())
);
}
@ -60,11 +63,12 @@ public class DateTimeFormatProcessorTests extends AbstractSqlWireSerializingTest
return new DateTimeFormatProcessor(
new ConstantProcessor(DateTimeTestUtils.nowWithMillisResolution()),
new ConstantProcessor(ESTestCase.randomRealisticUnicodeOfLength(128)),
randomValueOtherThan(instance.zoneId(), ESTestCase::randomZone)
randomZone(),
randomValueOtherThan(instance.formatter(), () -> randomFrom(Formatter.values()))
);
}
public void testInvalidInputs() {
public void testDateTimeFormatInvalidInputs() {
SqlIllegalArgumentException siae = expectThrows(
SqlIllegalArgumentException.class,
() -> new DateTimeFormat(Source.EMPTY, l("foo"), randomStringLiteral(), randomZone()).makePipe().asProcessor().process(null)
@ -100,10 +104,50 @@ public class DateTimeFormatProcessorTests extends AbstractSqlWireSerializingTest
);
}
public void testFormatInvalidInputs() {
SqlIllegalArgumentException siae = expectThrows(
SqlIllegalArgumentException.class,
() -> new Format(Source.EMPTY, l("foo"), randomStringLiteral(), randomZone()).makePipe().asProcessor().process(null)
);
assertEquals("A date/datetime/time is required; received [foo]", siae.getMessage());
siae = expectThrows(
SqlIllegalArgumentException.class,
() -> new Format(Source.EMPTY, randomDatetimeLiteral(), l(5), randomZone()).makePipe().asProcessor().process(null)
);
assertEquals("A string is required; received [5]", siae.getMessage());
siae = expectThrows(
SqlIllegalArgumentException.class,
() -> new Format(Source.EMPTY, l(dateTime(2019, 9, 3, 18, 10, 37, 0)), l("invalid"), randomZone()).makePipe()
.asProcessor()
.process(null)
);
assertEquals(
"Invalid pattern [invalid] is received for formatting date/time [2019-09-03T18:10:37Z]; Unknown pattern letter: i",
siae.getMessage()
);
siae = expectThrows(
SqlIllegalArgumentException.class,
() -> new Format(Source.EMPTY, l(time(18, 10, 37, 123000000)), l("MM/dd"), randomZone()).makePipe()
.asProcessor()
.process(null)
);
assertEquals(
"Invalid pattern [MM/dd] is received for formatting date/time [18:10:37.123Z]; Unsupported field: MonthOfYear",
siae.getMessage()
);
}
public void testWithNulls() {
assertNull(new DateTimeFormat(Source.EMPTY, randomDatetimeLiteral(), NULL, randomZone()).makePipe().asProcessor().process(null));
assertNull(new DateTimeFormat(Source.EMPTY, randomDatetimeLiteral(), l(""), randomZone()).makePipe().asProcessor().process(null));
assertNull(new DateTimeFormat(Source.EMPTY, NULL, randomStringLiteral(), randomZone()).makePipe().asProcessor().process(null));
assertNull(new Format(Source.EMPTY, randomDatetimeLiteral(), NULL, randomZone()).makePipe().asProcessor().process(null));
assertNull(new Format(Source.EMPTY, randomDatetimeLiteral(), l(""), randomZone()).makePipe().asProcessor().process(null));
assertNull(new Format(Source.EMPTY, NULL, randomStringLiteral(), randomZone()).makePipe().asProcessor().process(null));
}
public void testFormatting() {
@ -141,5 +185,111 @@ public class DateTimeFormatProcessorTests extends AbstractSqlWireSerializingTest
.asProcessor()
.process(null)
);
zoneId = ZoneId.of("Etc/GMT-10");
dateTime = l(dateTime(2019, 9, 3, 18, 10, 37, 123456789));
assertEquals("AD : 3", new Format(Source.EMPTY, dateTime, l("G : Q"), zoneId).makePipe().asProcessor().process(null));
assertEquals("AD", new Format(Source.EMPTY, dateTime, l("g"), zoneId).makePipe().asProcessor().process(null));
assertEquals(
"2019-09-04",
new Format(Source.EMPTY, dateTime, l("YYYY-MM-dd"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"2019-09-04 Wed",
new Format(Source.EMPTY, dateTime, l("YYYY-MM-dd ddd"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"2019-09-04 Wednesday",
new Format(Source.EMPTY, dateTime, l("YYYY-MM-dd dddd"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"04:10:37.123456",
new Format(Source.EMPTY, dateTime, l("HH:mm:ss.ffffff"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"2019-09-04 04:10:37.12345678",
new Format(Source.EMPTY, dateTime, l("YYYY-MM-dd HH:mm:ss.ffffffff"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"2019-09-04 04:10:37.12345678 AM",
new Format(Source.EMPTY, dateTime, l("YYYY-MM-dd HH:mm:ss.ffffffff tt"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"2019-09-04 04:10:37.12345678 AM",
new Format(Source.EMPTY, dateTime, l("YYYY-MM-dd HH:mm:ss.ffffffff t"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals("+1000", new Format(Source.EMPTY, dateTime, l("Z"), zoneId).makePipe().asProcessor().process(null));
assertEquals("+10", new Format(Source.EMPTY, dateTime, l("z"), zoneId).makePipe().asProcessor().process(null));
assertEquals("Etc/GMT-10", new Format(Source.EMPTY, dateTime, l("VV"), zoneId).makePipe().asProcessor().process(null));
assertEquals("Etc/GMT-10", new Format(Source.EMPTY, dateTime, l("KK"), zoneId).makePipe().asProcessor().process(null));
assertEquals("1", new Format(Source.EMPTY, dateTime, l("F"), zoneId).makePipe().asProcessor().process(null));
assertEquals("12", new Format(Source.EMPTY, dateTime, l("FF"), zoneId).makePipe().asProcessor().process(null));
zoneId = ZoneId.of("America/Sao_Paulo");
assertEquals("-0300", new Format(Source.EMPTY, dateTime, l("Z"), zoneId).makePipe().asProcessor().process(null));
assertEquals("-03", new Format(Source.EMPTY, dateTime, l("z"), zoneId).makePipe().asProcessor().process(null));
assertEquals(
"America/Sao_Paulo",
new Format(Source.EMPTY, dateTime, l("VV"), zoneId).makePipe().asProcessor().process(null)
);
assertEquals(
"07:11:22.1234",
new Format(Source.EMPTY, l(time(10, 11, 22, 123456789), TIME), l("HH:mm:ss.ffff"), zoneId).makePipe()
.asProcessor()
.process(null)
);
assertEquals(
"10:11",
new Format(Source.EMPTY, l(time(10, 11, 22, 123456789), TIME), l("H:m"), ZoneOffset.UTC).makePipe()
.asProcessor()
.process(null)
);
assertEquals(
"21:9",
new Format(Source.EMPTY, l(time(21, 11, 22, 123456789), TIME), l("H:h"), ZoneOffset.UTC).makePipe()
.asProcessor()
.process(null)
);
assertEquals(
"2-02",
new Format(Source.EMPTY, l(time(21, 11, 2, 123456789), TIME), l("s-ss"), ZoneOffset.UTC).makePipe()
.asProcessor()
.process(null)
);
assertEquals("9-09-Sep-September",
new Format(Source.EMPTY, dateTime, l("M-MM-MMM-MMMM"), zoneId).makePipe()
.asProcessor()
.process(null));
assertEquals("arr: 3:10 PM",
new Format(Source.EMPTY, dateTime, l("'arr:' h:m t"), zoneId).makePipe()
.asProcessor()
.process(null))
;
assertEquals("-03/-0300/-03:00",
new Format(Source.EMPTY, dateTime, l("z/zz/zzz"), zoneId).makePipe()
.asProcessor()
.process(null));
assertEquals("3", new Format(Source.EMPTY, dateTime, l("d"), zoneId).makePipe().asProcessor().process(null));
assertEquals("2001-01-2001-02001",
new Format(Source.EMPTY, l(dateTime(2001, 9, 3, 18, 10, 37, 123456789)),
l("y-yy-yyyy-yyyyy"), zoneId).makePipe().asProcessor().process(null));
assertEquals("%9-\"09-\\Sep-September",
new Format(Source.EMPTY, dateTime, l("%M-\"MM-\\MMM-MMMM"), zoneId).makePipe()
.asProcessor()
.process(null));
assertEquals("45-0045",
new Format(Source.EMPTY, l(dateTime(45, 9, 3, 18, 10, 37, 123456789)), l("y-yyyy"), zoneId).makePipe()
.asProcessor()
.process(null));
}
}

View File

@ -560,6 +560,22 @@ public class QueryTranslatorTests extends ESTestCase {
assertEquals("[{v=keyword}, {v=uuuu_MM_dd}, {v=Z}, {v=2018-09-04T00:00:00.000Z}]", sc.script().params().toString());
}
public void testTranslateFormat_WhereClause_Painless() {
LogicalPlan p = plan("SELECT int FROM test WHERE FORMAT(date, 'YYYY_MM_dd') = '2018_09_04'");
assertTrue(p instanceof Project);
assertTrue(p.children().get(0) instanceof Filter);
Expression condition = ((Filter) p.children().get(0)).condition();
assertFalse(condition.foldable());
QueryTranslation translation = QueryTranslator.toQuery(condition, false);
assertNull(translation.aggFilter);
assertTrue(translation.query instanceof ScriptQuery);
ScriptQuery sc = (ScriptQuery) translation.query;
assertEquals("InternalQlScriptUtils.nullSafeFilter(InternalQlScriptUtils.eq(InternalSqlScriptUtils.format(" +
"InternalQlScriptUtils.docValue(doc,params.v0),params.v1,params.v2),params.v3))",
sc.script().toString());
assertEquals("[{v=date}, {v=YYYY_MM_dd}, {v=Z}, {v=2018_09_04}]", sc.script().params().toString());
}
public void testLikeOnInexact() {
LogicalPlan p = plan("SELECT * FROM test WHERE some.string LIKE '%a%'");
assertTrue(p instanceof Project);