From f69c7396d9334d16c2b6a1614d5f32fb8a250d1c Mon Sep 17 00:00:00 2001 From: Luigi Dell'Aquila Date: Wed, 18 May 2022 12:03:00 +0200 Subject: [PATCH] SQL: Fix FORMAT function to better comply with Microsoft SQL Server specification (#86225) --- .editorconfig | 3 + docs/changelog/86225.yaml | 6 + .../sql/functions/date-time.asciidoc | 10 +- .../server/src/main/resources/date.csv-spec | 43 ++++++ .../src/main/resources/datetime.csv-spec | 12 +- .../src/main/resources/docs/docs.csv-spec | 4 +- .../datetime/DateTimeFormatProcessor.java | 132 ++++++++++++++++- .../DateTimeFormatProcessorTests.java | 134 +++++++++++++++--- 8 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 docs/changelog/86225.yaml diff --git a/.editorconfig b/.editorconfig index 92111b6c074f9..cf4f74744d2b4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -225,3 +225,6 @@ indent_size = 2 [*.{xsd,xml}] indent_size = 4 + +[*.{csv,sql}-spec] +trim_trailing_whitespace = false diff --git a/docs/changelog/86225.yaml b/docs/changelog/86225.yaml new file mode 100644 index 0000000000000..6b8975a04f960 --- /dev/null +++ b/docs/changelog/86225.yaml @@ -0,0 +1,6 @@ +pr: 86225 +summary: Fix FORMAT function to comply with Microsoft SQL Server specification +area: SQL +type: bug +issues: + - 66560 diff --git a/docs/reference/sql/functions/date-time.asciidoc b/docs/reference/sql/functions/date-time.asciidoc index 425b0f7bf1020..247d785874a03 100644 --- a/docs/reference/sql/functions/date-time.asciidoc +++ b/docs/reference/sql/functions/date-time.asciidoc @@ -454,7 +454,7 @@ include-tagged::{sql-specs}/docs/docs.csv-spec[dateParse1] [NOTE] ==== -The resulting `date` will have the time zone specified by the user through the +The resulting `date` will have the time zone specified by the user through the <>/<> REST/driver parameters with no conversion applied. @@ -810,7 +810,7 @@ SQL Server Format Specification]. [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. + +(e.g. 'dd', 'MM', 'yyyy', etc.). If it contains such units an error is returned. + 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. @@ -862,9 +862,9 @@ Patterns for Date/Time Formatting]. If the 1st argument is of type `time`, then the 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. + The result of the patterns `TZ` and `tz` (time zone abbreviations) in some cases differ from the results returned by the `TO_CHAR` -in PostgreSQL. The reason is that the time zone abbreviations specified by the JDK are different from the ones specified by PostgreSQL. -This function might show an actual time zone abbreviation instead of the generic `LMT` or empty string or offset returned by the PostgreSQL -implementation. The summer/daylight markers might also differ between the two implementations (e.g. will show `HT` instead of `HST` +in PostgreSQL. The reason is that the time zone abbreviations specified by the JDK are different from the ones specified by PostgreSQL. +This function might show an actual time zone abbreviation instead of the generic `LMT` or empty string or offset returned by the PostgreSQL +implementation. The summer/daylight markers might also differ between the two implementations (e.g. will show `HT` instead of `HST` for Hawaii). + The `FX`, `TM`, `SP` pattern modifiers are not supported and will show up as `FX`, `TM`, `SP` literals in the output. diff --git a/x-pack/plugin/sql/qa/server/src/main/resources/date.csv-spec b/x-pack/plugin/sql/qa/server/src/main/resources/date.csv-spec index 438a90ecef426..72d72871ac976 100644 --- a/x-pack/plugin/sql/qa/server/src/main/resources/date.csv-spec +++ b/x-pack/plugin/sql/qa/server/src/main/resources/date.csv-spec @@ -404,3 +404,46 @@ SELECT emp_no FROM test_emp WHERE DATE_ADD('day', 1, hire_date) = '2021-02-03||- 10044 10085 ; + + + +// format + +formatNormalPattern +SELECT FORMAT(birth_date, 'dd/MM/yyyy') as x FROM test_emp ORDER BY emp_no LIMIT 1; + x +---------- +02/09/1953 +; + +formatWithDoubleQuoteEscaping +SELECT FORMAT(birth_date, '"yyyy" yyyy') as x FROM test_emp ORDER BY emp_no LIMIT 1; + + x +------ +yyyy 1953 +; + +formatSingleQuote +SELECT FORMAT(birth_date, '"''" yyyy') as x FROM test_emp ORDER BY emp_no LIMIT 1; + + x +------ +' 1953 +; + +formatQuotesAndAllowedCharacters +SELECT FORMAT(birth_date, 'abc ''yyy'' yyyy') as x FROM test_emp ORDER BY emp_no LIMIT 1; + + x +------ +abc yyy 1953 +; + +formatQuotesComplexString +SELECT FORMAT(birth_date, '\t\hi\s i\s \t\h\e \y\ear yyyy an\d \t\h\e \mon\t\h MM') as x FROM test_emp ORDER BY emp_no LIMIT 1; + + x +------------------------------------ +this is the year 1953 and the month 09 +; diff --git a/x-pack/plugin/sql/qa/server/src/main/resources/datetime.csv-spec b/x-pack/plugin/sql/qa/server/src/main/resources/datetime.csv-spec index 3cab25feaf272..864d9f47db6ed 100644 --- a/x-pack/plugin/sql/qa/server/src/main/resources/datetime.csv-spec +++ b/x-pack/plugin/sql/qa/server/src/main/resources/datetime.csv-spec @@ -1173,8 +1173,8 @@ 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, +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 @@ -1184,8 +1184,8 @@ FORMAT('11:22:33.123456789Z'::time, 'HH:mm:ss.ff') AS format_time; 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; +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 ------------------------+---------------- @@ -1194,7 +1194,7 @@ LENGTH(FORMAT('2020-04-05T11:22:33.123Z'::datetime, 'dd/MM/YYYY HH:mm:ss.ff')) A 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 +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 @@ -1233,7 +1233,7 @@ WHERE FORMAT(birth_date, 'MM')::integer > 10 ORDER BY emp_no LIMIT 10; 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; +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 -------------------------+--------------- diff --git a/x-pack/plugin/sql/qa/server/src/main/resources/docs/docs.csv-spec b/x-pack/plugin/sql/qa/server/src/main/resources/docs/docs.csv-spec index a95345455d374..ae9ccbb2c8d28 100644 --- a/x-pack/plugin/sql/qa/server/src/main/resources/docs/docs.csv-spec +++ b/x-pack/plugin/sql/qa/server/src/main/resources/docs/docs.csv-spec @@ -3190,7 +3190,7 @@ SELECT DATE_TRUNC('days', INTERVAL '19 15:24:19' DAY TO SECONDS) AS day; formatDate // tag::formatDate -SELECT FORMAT(CAST('2020-04-05' AS DATE), 'dd/MM/YYYY') AS "date"; +SELECT FORMAT(CAST('2020-04-05' AS DATE), 'dd/MM/yyyy') AS "date"; date ------------------ @@ -3200,7 +3200,7 @@ SELECT FORMAT(CAST('2020-04-05' AS DATE), 'dd/MM/YYYY') AS "date"; formatDateTime // tag::formatDateTime -SELECT FORMAT(CAST('2020-04-05T11:22:33.987654' AS DATETIME), 'dd/MM/YYYY HH:mm:ss.ff') AS "datetime"; +SELECT FORMAT(CAST('2020-04-05T11:22:33.987654' AS DATETIME), 'dd/MM/yyyy HH:mm:ss.ff') AS "datetime"; datetime ------------------ diff --git a/x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessor.java b/x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessor.java index 689b109af4901..570c813d07dbc 100644 --- a/x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessor.java +++ b/x-pack/plugin/sql/src/main/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessor.java @@ -20,6 +20,7 @@ import java.time.temporal.TemporalAccessor; import java.util.Locale; import java.util.Objects; +import java.util.Set; import java.util.function.Function; import static org.elasticsearch.xpack.sql.util.DateUtils.asTimeAtZone; @@ -27,7 +28,42 @@ public class DateTimeFormatProcessor extends BinaryDateTimeProcessor { public static final String NAME = "dtformat"; - private static final String[][] JAVA_TIME_FORMAT_REPLACEMENTS = { + + /** + * these characters have a meaning in MS date patterns. + * If a character is not in this set, then it's still allowed in MS FORMAT patters + * but not in Java, so it has to be translated or quoted + */ + private static final Set MS_DATETIME_PATTERN_CHARS = Set.of( + 'd', + 'f', + 'F', + 'g', + 'h', + 'H', + 'K', + 'm', + 'M', + 's', + 't', + 'y', + 'z', + ':', + '/', + ' ', + '-' + ); + + /** + * characters that start a quoting block in MS patterns + */ + private static final Set MS_QUOTING_CHARS = Set.of('\\', '\'', '"'); + + /** + * list of MS datetime patterns with the corresponding translation in Java DateTimeFormat + * (patterns that are the same in Java and in MS are not listed here) + */ + private static final String[][] MS_TO_JAVA_PATTERNS = { { "tt", "a" }, { "t", "a" }, { "dddd", "eeee" }, @@ -47,10 +83,7 @@ protected Function formatterFor(String pattern) { if (pattern.isEmpty()) { return null; } - for (String[] replacement : JAVA_TIME_FORMAT_REPLACEMENTS) { - pattern = pattern.replace(replacement[0], replacement[1]); - } - final String javaPattern = pattern; + final String javaPattern = msToJavaPattern(pattern); return DateTimeFormatter.ofPattern(javaPattern, Locale.ROOT)::format; } }, @@ -67,6 +100,95 @@ protected Function formatterFor(String pattern) { } }; + protected static String msToJavaPattern(String pattern) { + StringBuilder result = new StringBuilder(pattern.length()); + StringBuilder partialQuotedString = new StringBuilder(); + + boolean originalCharacterQuoted = false; + boolean lastTargetCharacterQuoted = false; + char quotingChar = '\\'; + + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (originalCharacterQuoted) { + if (quotingChar == '\\') { + // in the original pattern, this is a single quoted character, add it to the partial string + // that will be quoted in Java + originalCharacterQuoted = false; + lastTargetCharacterQuoted = true; + partialQuotedString.append(c); + } else if (c == quotingChar) { + // the original pattern is closing the quoting, + // do nothing for now, next character could open a new quoting block + originalCharacterQuoted = false; + } else { + // any character that is not a quoting char is just added to the partial quoting string + // because there could be more characters to quote after that + partialQuotedString.append(c); + } + } else { + boolean characterProcessed = false; + // the original pattern is not quoting + if (MS_QUOTING_CHARS.contains(c)) { + // next character(s) is quoted, start a quoted block on the target + originalCharacterQuoted = true; + lastTargetCharacterQuoted = true; + quotingChar = c; + characterProcessed = true; + } else { + // manage patterns that are different from MS to Java and have to be translated + for (String[] item : MS_TO_JAVA_PATTERNS) { + int fragmentLength = item[0].length(); + if (i + fragmentLength <= pattern.length() && item[0].equals(pattern.substring(i, i + fragmentLength))) { + if (lastTargetCharacterQuoted) { + // now origin is not quoting for sure and the next block is a valid datetime pattern, + // that has to be translated and written as is (not quoted). + // Before doing this, let's flush the previously quoted string + // and quote it properly with Java syntax + lastTargetCharacterQuoted = false; + quoteAndAppend(result, partialQuotedString); + partialQuotedString = new StringBuilder(); + } + // and then translate the pattern + result.append(item[1]); + characterProcessed = true; + i += (fragmentLength - 1); // fast-forward, because the replaced pattern could be longer than one character + break; + } + } + } + if (characterProcessed == false) { + if (MS_DATETIME_PATTERN_CHARS.contains(c) == false) { + // this character is allowed in MS, but not in Java, so it has to be quoted in the result + lastTargetCharacterQuoted = true; + partialQuotedString.append(c); + } else { + // any other character is a valid datetime pattern in both Java and MS + if (lastTargetCharacterQuoted) { + // flush the quoted string first, if any + lastTargetCharacterQuoted = false; + quoteAndAppend(result, partialQuotedString); + partialQuotedString = new StringBuilder(); + } + // and then add the character itself, as it is + result.append(c); + } + } + } + } + // if the original pattern ended with a quoted block, flush it to the result and quote it in Java + if (lastTargetCharacterQuoted) { + quoteAndAppend(result, partialQuotedString); + } + return result.toString(); + } + + private static void quoteAndAppend(StringBuilder mainBuffer, StringBuilder fragmentToQuote) { + mainBuffer.append("'"); + mainBuffer.append(fragmentToQuote.toString().replaceAll("'", "''")); + mainBuffer.append("'"); + } + protected abstract Function formatterFor(String pattern); public Object format(Object timestamp, Object pattern, ZoneId zoneId) { diff --git a/x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessorTests.java b/x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessorTests.java index d84ff1395e2fc..2c83f666d6f37 100644 --- a/x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessorTests.java +++ b/x-pack/plugin/sql/src/test/java/org/elasticsearch/xpack/sql/expression/function/scalar/datetime/DateTimeFormatProcessorTests.java @@ -118,17 +118,6 @@ public void testFormatInvalidInputs() { ); 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) @@ -188,16 +177,17 @@ public void testFormatting() { 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("G : Q", 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", new Format(Source.EMPTY, dateTime, l("yyyy-MM-dd"), zoneId).makePipe().asProcessor().process(null)); + assertEquals("YYYY-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) + 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) + new Format(Source.EMPTY, dateTime, l("yyyy-MM-dd dddd"), zoneId).makePipe().asProcessor().process(null) ); assertEquals( "04:10:37.123456", @@ -205,28 +195,27 @@ public void testFormatting() { ); 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) + 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) + 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) + 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("Z", 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("K"), 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("Z", 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("VV", new Format(Source.EMPTY, dateTime, l("VV"), zoneId).makePipe().asProcessor().process(null)); assertEquals( "07:11:22.1234", @@ -266,7 +255,7 @@ public void testFormatting() { assertEquals( "%9-\"09-\\Sep-September", - new Format(Source.EMPTY, dateTime, l("%M-\"MM-\\MMM-MMMM"), zoneId).makePipe().asProcessor().process(null) + new Format(Source.EMPTY, dateTime, l("%M-\\\"MM-\\\\MMM-MMMM"), zoneId).makePipe().asProcessor().process(null) ); assertEquals( @@ -276,4 +265,103 @@ public void testFormatting() { .process(null) ); } + + public void testQuoting() { + ZoneId zoneId = ZoneId.of("Etc/GMT-10"); + Literal dateTime = l(dateTime(2019, 9, 3, 18, 10, 37, 123456789)); + + assertEquals( + "this is the year 2019 and the month 09", + new Format(Source.EMPTY, dateTime, l("\\t\\hi\\s i\\s \\t\\h\\e \\y\\ear yyyy an\\d \\t\\h\\e \\mon\\t\\h MM"), zoneId) + .makePipe() + .asProcessor() + .process(null) + ); + assertEquals( + "this is the year 2019 and the month 09", + new Format(Source.EMPTY, dateTime, l("'this is the year' yyyy 'and the month' MM"), zoneId).makePipe() + .asProcessor() + .process(null) + ); + + assertEquals( + "this is the year 2019 and the month 09", + new Format(Source.EMPTY, dateTime, l("\"this is the year\" yyyy \"and the month\" MM"), zoneId).makePipe() + .asProcessor() + .process(null) + ); + + assertEquals( + "yxyzdm 09", + new Format(Source.EMPTY, dateTime, l("\\y'xyz'\"dm\" MM"), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals( + "this \" is a double quote", + new Format(Source.EMPTY, dateTime, l("'this \" is a double quote'"), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals( + "this ' is a single quote", + new Format(Source.EMPTY, dateTime, l("\"this ' is a single quote\""), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals( + "' also this is a single quote", + new Format(Source.EMPTY, dateTime, l("\"' also this is a single quote\""), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals( + "and this too '", + new Format(Source.EMPTY, dateTime, l("\"and this too '\""), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals("''", new Format(Source.EMPTY, dateTime, l("\"''\""), zoneId).makePipe().asProcessor().process(null)); + assertEquals("\\", new Format(Source.EMPTY, dateTime, l("\"\\\""), zoneId).makePipe().asProcessor().process(null)); + } + + public void testAllowedCharactersIn() { + ZoneId zoneId = ZoneId.of("Etc/GMT-10"); + Literal dateTime = l(dateTime(2019, 9, 3, 18, 10, 37, 123456789)); + + assertEquals("DGTYZ", new Format(Source.EMPTY, dateTime, l("DGTYZ"), zoneId).makePipe().asProcessor().process(null)); + + assertEquals( + "DGTYZ 4ADAM2019+10", + new Format(Source.EMPTY, dateTime, l("DGTYZ dgtyz"), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals( + "abceijklnopqruwxABCDEGIJLNOPQRSTUVWXYZ", + new Format(Source.EMPTY, dateTime, l("abceijklnopqruwxABCDEGIJLNOPQRSTUVWXYZ"), zoneId).makePipe().asProcessor().process(null) + ); + + assertEquals( + ";.,?{}[]()!@#$%^&*", + new Format(Source.EMPTY, dateTime, l(";.,?{}[]()!@#$%^&*"), zoneId).makePipe().asProcessor().process(null) + ); + } + + public void testMsToJavaPattern() { + assertEquals("", DateTimeFormatProcessor.Formatter.msToJavaPattern("")); + assertEquals( + "dd/mm/yyyy hh:mm:ssss S S G h H v a y X", + DateTimeFormatProcessor.Formatter.msToJavaPattern("dd/mm/yyyy hh:mm:ssss f F g h H K t y z") + ); + assertEquals( + "'abceijklnopqruwxABCDEGIJLNOPQRSTUVWXYZ'", + DateTimeFormatProcessor.Formatter.msToJavaPattern("abceijklnopqruwxABCDEGIJLNOPQRSTUVWXYZ") + ); + assertEquals("a", DateTimeFormatProcessor.Formatter.msToJavaPattern("t")); + assertEquals("a", DateTimeFormatProcessor.Formatter.msToJavaPattern("tt")); + assertEquals("eee", DateTimeFormatProcessor.Formatter.msToJavaPattern("ddd")); + assertEquals("eeee", DateTimeFormatProcessor.Formatter.msToJavaPattern("dddd")); + assertEquals("vGSSX", DateTimeFormatProcessor.Formatter.msToJavaPattern("KgfFz")); + assertEquals("'foo'", DateTimeFormatProcessor.Formatter.msToJavaPattern("\"foo\"")); + assertEquals("'foo'", DateTimeFormatProcessor.Formatter.msToJavaPattern("'foo'")); + assertEquals("'foo'", DateTimeFormatProcessor.Formatter.msToJavaPattern("\\f\\o\\o")); + assertEquals("'foo'", DateTimeFormatProcessor.Formatter.msToJavaPattern("\\f\"oo\"")); + assertEquals("'foobar'", DateTimeFormatProcessor.Formatter.msToJavaPattern("'foo'\"bar\"")); + assertEquals("'abce' 'abce'", DateTimeFormatProcessor.Formatter.msToJavaPattern("abce abce")); + } }