Skip to content

Commit fd596d4

Browse files
committed
update date time patterns to use MySQL-style
1 parent 0f88cc0 commit fd596d4

File tree

6 files changed

+135
-35
lines changed

6 files changed

+135
-35
lines changed

documentation/functions_date_time.md

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,20 @@ Format `DATE` to `VARCHAR`.
183183
**Inputs:**
184184
- `date_expr` (`DATE`)
185185
- `pattern` (`VARCHAR`)
186-
- Note: Patterns follow Java DateTimeFormatter syntax (not MySQL-style).
186+
- Note: pattern follows [MySQL-style](#supported-mysql-style-datetime-patterns).
187187

188188
**Output:**
189189
- `VARCHAR`
190190

191191
**Example:**
192192
```sql
193-
SELECT DATE_FORMAT('2025-01-10'::DATE, 'yyyy-MM-dd') AS fmt;
193+
-- Simple date formatting
194+
SELECT DATE_FORMAT('2025-01-10'::DATE, '%Y-%m-%d') AS fmt;
194195
-- Result: '2025-01-10'
196+
197+
-- Day of the week (%W)
198+
SELECT DATE_FORMAT('2025-01-10'::DATE, '%W') AS weekday;
199+
-- Result: 'Friday'
195200
```
196201

197202
---
@@ -203,14 +208,19 @@ Parse `VARCHAR` into `DATE`.
203208
**Inputs:**
204209
- `VARCHAR`
205210
- `pattern` (`VARCHAR`)
206-
- Note: Patterns follow Java DateTimeFormatter syntax (not MySQL-style).
211+
- Note: pattern follows [MySQL-style](#supported-mysql-style-datetime-patterns).
207212

208213
**Output:**
209214
- `DATE`
210215

211216
**Example:**
212217
```sql
213-
SELECT DATE_PARSE('2025-01-10','yyyy-MM-dd') AS d;
218+
-- Parse ISO-style date
219+
SELECT DATE_PARSE('2025-01-10','%Y-%m-%d') AS d;
220+
-- Result: 2025-01-10
221+
222+
-- Parse with day of week (%W)
223+
SELECT DATE_PARSE('Friday 2025-01-10','%W %Y-%m-%d') AS d;
214224
-- Result: 2025-01-10
215225
```
216226

@@ -223,15 +233,20 @@ Parse `VARCHAR` into `DATETIME` / `TIMESTAMP`.
223233
**Inputs:**
224234
- `VARCHAR`
225235
- `pattern` (`VARCHAR`)
226-
- Note: Patterns follow Java DateTimeFormatter syntax (not MySQL-style).
236+
- Note: pattern follows [MySQL-style](#supported-mysql-style-datetime-patterns).
227237

228238
**Output:**
229239
- `DATETIME`
230240

231241
**Example:**
232242
```sql
233-
SELECT DATETIME_PARSE('2025-01-10T12:00:00Z','yyyy-MM-dd''T''HH:mm:ssZ') AS dt;
234-
-- Result: 2025-01-10T12:00:00Z
243+
-- Parse full datetime with microseconds (%f)
244+
SELECT DATETIME_PARSE('2025-01-10 12:00:00.123456','%Y-%m-%d %H:%i:%s.%f') AS dt;
245+
-- Result: 2025-01-10T12:00:00.123456Z
246+
247+
-- Parse 12-hour clock with AM/PM (%p)
248+
SELECT DATETIME_PARSE('2025-01-10 01:45:30 PM','%Y-%m-%d %h:%i:%s %p') AS dt;
249+
-- Result: 2025-01-10T13:45:30Z
235250
```
236251

237252
---
@@ -243,14 +258,24 @@ Format `DATETIME` / `TIMESTAMP` to `VARCHAR` with pattern.
243258
**Inputs:**
244259
- `datetime_expr` (`DATETIME` or `TIMESTAMP`)
245260
- `pattern` (`VARCHAR`)
246-
- Note: Patterns follow Java DateTimeFormatter syntax (not MySQL-style).
261+
- Note: pattern follows [MySQL-style](#supported-mysql-style-datetime-patterns).
247262

248263
**Output:**
249264
- `VARCHAR`
250265

251266
**Example:**
252267
```sql
253-
SELECT DATETIME_FORMAT('2025-01-10T12:00:00Z'::TIMESTAMP,'yyyy-MM-dd HH:mm:ss') AS s;
268+
-- Format with seconds and microseconds
269+
SELECT DATETIME_FORMAT('2025-01-10T12:00:00.123456Z'::TIMESTAMP,'%Y-%m-%d %H:%i:%s.%f') AS s;
270+
-- Result: '2025-01-10 12:00:00.123456'
271+
272+
-- Format 12-hour clock with AM/PM
273+
SELECT DATETIME_FORMAT('2025-01-10T13:45:30Z'::TIMESTAMP,'%Y-%m-%d %h:%i:%s %p') AS s;
274+
-- Result: '2025-01-10 01:45:30 PM'
275+
276+
-- Format with full weekday name
277+
SELECT DATETIME_FORMAT('2025-01-10T13:45:30Z'::TIMESTAMP,'%W, %Y-%m-%d') AS s;
278+
-- Result: 'Friday, 2025-01-10'
254279
```
255280

256281
---
@@ -398,4 +423,28 @@ SELECT OFFSET_SECONDS('2025-01-01T12:00:00+02:00'::TIMESTAMP) AS off;
398423
-- Result: 7200
399424
```
400425

426+
---
427+
428+
### Supported MySQL-style Date/Time Patterns
429+
430+
| Pattern | Description | Example Output |
431+
|---------|------------------------------|----------------|
432+
| `%Y` | Year (4 digits) | `2025` |
433+
| `%y` | Year (2 digits) | `25` |
434+
| `%m` | Month (2 digits) | `01` |
435+
| `%c` | Month (1–12) | `1` |
436+
| `%M` | Month name (full) | `January` |
437+
| `%b` | Month name (abbrev) | `Jan` |
438+
| `%d` | Day of month (2 digits) | `10` |
439+
| `%e` | Day of month (1–31) | `9` |
440+
| `%W` | Weekday name (full) | `Friday` |
441+
| `%a` | Weekday name (abbrev) | `Fri` |
442+
| `%H` | Hour (00–23) | `13` |
443+
| `%h` | Hour (01–12) | `01` |
444+
| `%I` | Hour (01–12, synonym for %h) | `01` |
445+
| `%i` | Minutes (00–59) | `45` |
446+
| `%s` | Seconds (00–59) | `30` |
447+
| `%f` | Microseconds (000–999) | `123` |
448+
| `%p` | AM/PM marker | `AM` / `PM` |
449+
401450
[Back to index](./README.md)

es6/sql-bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,7 +1293,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
12931293
| "field": "createdAt",
12941294
| "script": {
12951295
| "lang": "painless",
1296-
| "source": "(def e2 = (def e1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-ddTHH:mm:ssZ').parse(e0, ZonedDateTime::from) : null); e1 != null ? e1.truncatedTo(ChronoUnit.MINUTES) : null); e2 != null ? e2.get(ChronoField.YEAR) : null)"
1296+
| "source": "(def e2 = (def e1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-dd HH:mm:ss.SSS XXX').parse(e0, ZonedDateTime::from) : null); e1 != null ? e1.truncatedTo(ChronoUnit.MINUTES) : null); e2 != null ? e2.get(ChronoField.YEAR) : null)"
12971297
| }
12981298
| }
12991299
| }
@@ -1317,6 +1317,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
13171317
.replaceAll("\\|\\|", " || ")
13181318
.replaceAll(">", " > ")
13191319
.replaceAll(",ZonedDateTime", ", ZonedDateTime")
1320+
.replaceAll("SSSXXX", "SSS XXX")
1321+
.replaceAll("ddHH", "dd HH")
13201322
}
13211323

13221324
it should "handle date_diff function as script field" in {
@@ -1383,7 +1385,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
13831385
| "max": {
13841386
| "script": {
13851387
| "lang": "painless",
1386-
| "source": "(def arg0 = (!doc.containsKey('updatedAt') || doc['updatedAt'].empty ? null : doc['updatedAt'].value); def arg1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-ddTHH:mm:ssZ').parse(e0, ZonedDateTime::from) : null); (arg0 == null || arg1 == null) ? null : ChronoUnit.DAYS.between(arg0, arg1))"
1388+
| "source": "(def arg0 = (!doc.containsKey('updatedAt') || doc['updatedAt'].empty ? null : doc['updatedAt'].value); def arg1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-dd HH:mm:ss.SSS XXX').parse(e0, ZonedDateTime::from) : null); (arg0 == null || arg1 == null) ? null : ChronoUnit.DAYS.between(arg0, arg1))"
13871389
| }
13881390
| }
13891391
| }
@@ -1408,6 +1410,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
14081410
.replaceAll("&&", " && ")
14091411
.replaceAll("\\|\\|", " || ")
14101412
.replaceAll("ZonedDateTime", " ZonedDateTime")
1413+
.replaceAll("SSSXXX", "SSS XXX")
1414+
.replaceAll("ddHH", "dd HH")
14111415
}
14121416

14131417
it should "handle date_add function as script field" in {

sql/bridge/src/test/scala/app/softnetwork/elastic/sql/SQLQuerySpec.scala

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,7 +1288,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
12881288
| "field": "createdAt",
12891289
| "script": {
12901290
| "lang": "painless",
1291-
| "source": "(def e2 = (def e1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-ddTHH:mm:ssZ').parse(e0, ZonedDateTime::from) : null); e1 != null ? e1.truncatedTo(ChronoUnit.MINUTES) : null); e2 != null ? e2.get(ChronoField.YEAR) : null)"
1291+
| "source": "(def e2 = (def e1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-dd HH:mm:ss.SSS XXX').parse(e0, ZonedDateTime::from) : null); e1 != null ? e1.truncatedTo(ChronoUnit.MINUTES) : null); e2 != null ? e2.get(ChronoField.YEAR) : null)"
12921292
| }
12931293
| }
12941294
| }
@@ -1312,6 +1312,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
13121312
.replaceAll("\\|\\|", " || ")
13131313
.replaceAll(">", " > ")
13141314
.replaceAll(",ZonedDateTime", ", ZonedDateTime")
1315+
.replaceAll("SSSXXX", "SSS XXX")
1316+
.replaceAll("ddHH", "dd HH")
13151317
}
13161318

13171319
it should "handle date_diff function as script field" in {
@@ -1378,7 +1380,7 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
13781380
| "max": {
13791381
| "script": {
13801382
| "lang": "painless",
1381-
| "source": "(def arg0 = (!doc.containsKey('updatedAt') || doc['updatedAt'].empty ? null : doc['updatedAt'].value); def arg1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-ddTHH:mm:ssZ').parse(e0, ZonedDateTime::from) : null); (arg0 == null || arg1 == null) ? null : ChronoUnit.DAYS.between(arg0, arg1))"
1383+
| "source": "(def arg0 = (!doc.containsKey('updatedAt') || doc['updatedAt'].empty ? null : doc['updatedAt'].value); def arg1 = (def e0 = (!doc.containsKey('createdAt') || doc['createdAt'].empty ? null : doc['createdAt'].value); e0 != null ? DateTimeFormatter.ofPattern('yyyy-MM-dd HH:mm:ss.SSS XXX').parse(e0, ZonedDateTime::from) : null); (arg0 == null || arg1 == null) ? null : ChronoUnit.DAYS.between(arg0, arg1))"
13821384
| }
13831385
| }
13841386
| }
@@ -1403,6 +1405,8 @@ class SQLQuerySpec extends AnyFlatSpec with Matchers {
14031405
.replaceAll("&&", " && ")
14041406
.replaceAll("\\|\\|", " || ")
14051407
.replaceAll("ZonedDateTime", " ZonedDateTime")
1408+
.replaceAll("SSSXXX", "SSS XXX")
1409+
.replaceAll("ddHH", "dd HH")
14061410
}
14071411

14081412
it should "handle date_add function as script field" in {

sql/src/main/scala/app/softnetwork/elastic/sql/function/time/package.scala

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,52 @@ package object time {
334334
}
335335
}
336336

337+
sealed trait FunctionWithDateTimeFormat {
338+
def format: String
339+
340+
val sqlToJava: Map[String, String] = Map(
341+
"%Y" -> "yyyy",
342+
"%y" -> "yy",
343+
"%m" -> "MM",
344+
"%c" -> "M",
345+
"%d" -> "dd",
346+
"%e" -> "d",
347+
"%H" -> "HH",
348+
"%h" -> "hh",
349+
"%I" -> "hh",
350+
"%i" -> "mm",
351+
"%s" -> "ss",
352+
"%f" -> "SSS", // microseconds
353+
"%p" -> "a",
354+
"%W" -> "EEEE",
355+
"%a" -> "EEE",
356+
"%M" -> "MMMM",
357+
"%b" -> "MMM"
358+
)
359+
360+
def convert(includeTimeZone: Boolean = false): String = {
361+
val basePattern = sqlToJava.foldLeft(format) { case (pattern, (sql, java)) =>
362+
pattern.replace(sql, java)
363+
}
364+
365+
val patternWithTZ =
366+
if (basePattern.contains("Z")) basePattern.replace("Z", "X")
367+
else if (includeTimeZone) s"$basePattern XXX"
368+
else basePattern
369+
370+
patternWithTZ
371+
}
372+
}
373+
337374
case object DateParse extends Expr("DATE_PARSE") with TokenRegex with PainlessScript {
338375
override def painless: String = ".parse"
339376
}
340377

341378
case class DateParse(identifier: Identifier, format: String)
342379
extends DateFunction
343380
with TransformFunction[SQLVarchar, SQLDate]
344-
with FunctionWithIdentifier {
381+
with FunctionWithIdentifier
382+
with FunctionWithDateTimeFormat {
345383
override def fun: Option[PainlessScript] = Some(DateParse)
346384

347385
override def args: List[PainlessScript] = List.empty
@@ -357,9 +395,9 @@ package object time {
357395
override def painless: String = throw new NotImplementedError("Use toPainless instead")
358396
override def toPainless(base: String, idx: Int): String =
359397
if (nullable)
360-
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('$format').parse(e$idx, LocalDate::from) : null)"
398+
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('${convert()}').parse(e$idx, LocalDate::from) : null)"
361399
else
362-
s"DateTimeFormatter.ofPattern('$format').parse($base, LocalDate::from)"
400+
s"DateTimeFormatter.ofPattern('${convert()}').parse($base, LocalDate::from)"
363401
}
364402

365403
case object DateFormat extends Expr("DATE_FORMAT") with TokenRegex with PainlessScript {
@@ -369,7 +407,8 @@ package object time {
369407
case class DateFormat(identifier: Identifier, format: String)
370408
extends DateFunction
371409
with TransformFunction[SQLDate, SQLVarchar]
372-
with FunctionWithIdentifier {
410+
with FunctionWithIdentifier
411+
with FunctionWithDateTimeFormat {
373412
override def fun: Option[PainlessScript] = Some(DateFormat)
374413

375414
override def args: List[PainlessScript] = List.empty
@@ -385,9 +424,9 @@ package object time {
385424
override def painless: String = throw new NotImplementedError("Use toPainless instead")
386425
override def toPainless(base: String, idx: Int): String =
387426
if (nullable)
388-
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('$format').format(e$idx) : null)"
427+
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('${convert()}').format(e$idx) : null)"
389428
else
390-
s"DateTimeFormatter.ofPattern('$format').format($base)"
429+
s"DateTimeFormatter.ofPattern('${convert()}').format($base)"
391430
}
392431

393432
case object DateTimeAdd extends Expr("DATETIME_ADD") with TokenRegex {
@@ -431,7 +470,8 @@ package object time {
431470
case class DateTimeParse(identifier: Identifier, format: String)
432471
extends DateTimeFunction
433472
with TransformFunction[SQLVarchar, SQLDateTime]
434-
with FunctionWithIdentifier {
473+
with FunctionWithIdentifier
474+
with FunctionWithDateTimeFormat {
435475
override def fun: Option[PainlessScript] = Some(DateTimeParse)
436476

437477
override def args: List[PainlessScript] = List.empty
@@ -447,9 +487,9 @@ package object time {
447487
override def painless: String = throw new NotImplementedError("Use toPainless instead")
448488
override def toPainless(base: String, idx: Int): String =
449489
if (nullable)
450-
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('$format').parse(e$idx, ZonedDateTime::from) : null)"
490+
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('${convert(includeTimeZone = true)}').parse(e$idx, ZonedDateTime::from) : null)"
451491
else
452-
s"DateTimeFormatter.ofPattern('$format').parse($base, ZonedDateTime::from)"
492+
s"DateTimeFormatter.ofPattern('${convert(includeTimeZone = true)}').parse($base, ZonedDateTime::from)"
453493
}
454494

455495
case object DateTimeFormat extends Expr("DATETIME_FORMAT") with TokenRegex with PainlessScript {
@@ -459,7 +499,8 @@ package object time {
459499
case class DateTimeFormat(identifier: Identifier, format: String)
460500
extends DateTimeFunction
461501
with TransformFunction[SQLDateTime, SQLVarchar]
462-
with FunctionWithIdentifier {
502+
with FunctionWithIdentifier
503+
with FunctionWithDateTimeFormat {
463504
override def fun: Option[PainlessScript] = Some(DateTimeFormat)
464505

465506
override def args: List[PainlessScript] = List.empty
@@ -475,9 +516,9 @@ package object time {
475516
override def painless: String = throw new NotImplementedError("Use toPainless instead")
476517
override def toPainless(base: String, idx: Int): String =
477518
if (nullable)
478-
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('$format').format(e$idx) : null)"
519+
s"(def e$idx = $base; e$idx != null ? DateTimeFormatter.ofPattern('${convert(includeTimeZone = true)}').format(e$idx) : null)"
479520
else
480-
s"DateTimeFormatter.ofPattern('$format').format($base)"
521+
s"DateTimeFormatter.ofPattern('${convert(includeTimeZone = true)}').format($base)"
481522
}
482523

483524
}

sql/src/main/scala/app/softnetwork/elastic/sql/type/SQLTypeUtils.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ object SQLTypeUtils {
133133
case (SQLTypes.Boolean, SQLTypes.TinyInt) =>
134134
s"(byte)($expr ? 1 : 0)"
135135

136-
// ---- VARCHAR -> TEMPORAL ----
136+
// ---- VARCHAR -> NUMERIC ----
137137
case (SQLTypes.Varchar, SQLTypes.Int) =>
138138
s"Integer.parseInt($expr).intValue()"
139139
case (SQLTypes.Varchar, SQLTypes.BigInt) =>
@@ -147,12 +147,14 @@ object SQLTypeUtils {
147147
case (SQLTypes.Varchar, SQLTypes.TinyInt) =>
148148
s"Byte.parseByte($expr).byteValue()"
149149

150-
// ---- VARCHAR -> DATE ----
150+
// ---- VARCHAR -> TEMPORAL ----
151151
case (SQLTypes.Varchar, SQLTypes.Date) =>
152152
s"LocalDate.parse($expr, DateTimeFormatter.ofPattern('yyyy-MM-dd'))"
153153
case (SQLTypes.Varchar, SQLTypes.Time) =>
154154
s"LocalTime.parse($expr, DateTimeFormatter.ofPattern('HH:mm:ss'))"
155-
case (SQLTypes.Varchar, SQLTypes.DateTime | SQLTypes.Timestamp) =>
155+
case (SQLTypes.Varchar, SQLTypes.DateTime) =>
156+
s"ZonedDateTime.parse($expr, DateTimeFormatter.ISO_DATE_TIME)"
157+
case (SQLTypes.Varchar, SQLTypes.Timestamp) =>
156158
s"ZonedDateTime.parse($expr, DateTimeFormatter.ISO_ZONED_DATE_TIME)"
157159

158160
// ---- IDENTITY ----

0 commit comments

Comments
 (0)