Skip to content

Commit ea7ec99

Browse files
shai-almogclaude
andauthored
ORM: map java.time Instant, LocalDate and LocalDateTime; samples store Instant (#6094)
* ORM: map java.time Instant, LocalDate and LocalDateTime fields Without an annotation they take Date's portable default, a number in an integer column (epoch millis, or epoch days for a LocalDate), so an Instant reads what a long or a Date of milliseconds wrote. @TeMPOraL opts them into native TIMESTAMP/DATE columns through TemporalConverter's UTC ISO text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Samples: store entity timestamps as java.time.Instant Wayline, Savor, Parley and Skirmish entities hold Instant instead of epoch millisecond longs. The ORM's default Instant mapping is the same BIGINT, so no migration changes; NOT NULL "unset" sentinels are Instant.EPOCH and the wire DTOs keep epoch milliseconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ORM: harden java.time mapping after review Refuse a LocalDateTime past a long of milliseconds instead of wrapping it, report java.time identifier/version/@TeMPOraL misuse once rather than also as a relationship, refuse Property<java.time> with a clear error instead of generated code that fails to compile, and test embedded Instants and max/min aggregates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Initializr: resync sample templates after the Instant conversion Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf: raise the macos-arm64 translator RAM row the nightly fold set too low Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Samples: keep the demos on long timestamps until a release maps java.time The Initializr templates are built against the released framework, whose annotation processor rejects java.time fields. The demo conversion moves to its own pull request, for the release that includes this mapping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf: raise the windows-x64 translator time row the nightly fold set too low Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf: raise the second windows-x64 translator time row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ee86fad commit ea7ec99

6 files changed

Lines changed: 397 additions & 9 deletions

File tree

‎CodenameOne/src/com/codename1/annotations/db/Temporal.java‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@
2727
import java.lang.annotation.RetentionPolicy;
2828
import java.lang.annotation.Target;
2929

30-
/// Stores a `java.util.Date` field in a native date or timestamp column instead
31-
/// of the portable default, epoch milliseconds in an integer column.
30+
/// Stores a `java.util.Date`, `java.time.Instant`, `LocalDate` or
31+
/// `LocalDateTime` field in a native date or timestamp column instead of the
32+
/// portable default, a number in an integer column: epoch milliseconds, or
33+
/// epoch days for a `LocalDate`. A `LocalDate` takes `DATE` and the other
34+
/// java.time types take `TIMESTAMP`; a `LocalDateTime` is written as its wall
35+
/// clock, with no zone applied.
3236
///
3337
/// Opt-in, because the two are different columns: an existing integer column
3438
/// keeps its milliseconds and an existing native one needs this annotation.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
3+
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4+
* This code is free software; you can redistribute it and/or modify it
5+
* under the terms of the GNU General Public License version 2 only, as
6+
* published by the Free Software Foundation. Codename One designates this
7+
* particular file as subject to the "Classpath" exception as provided
8+
* by Oracle in the LICENSE file that accompanied this code.
9+
*
10+
* This code is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13+
* version 2 for more details (a copy is included in the LICENSE file that
14+
* accompanied this code).
15+
*
16+
* You should have received a copy of the GNU General Public License version
17+
* 2 along with this work; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
19+
*
20+
* Please contact Codename One through http://www.codenameone.com/ if you
21+
* need additional information or have any questions.
22+
*/
23+
package com.codename1.impl.orm;
24+
25+
import com.codename1.orm.session.AttributeConverter;
26+
27+
import java.time.Instant;
28+
import java.time.LocalDate;
29+
import java.time.LocalDateTime;
30+
import java.time.LocalTime;
31+
32+
/// The storage of `java.time.Instant`, `LocalDate` and `LocalDateTime` fields.
33+
///
34+
/// The defaults follow `java.util.Date`'s: a number in an integer column, which
35+
/// sorts, compares and indexes the same on every engine. An Instant or a
36+
/// LocalDateTime is milliseconds since 1970-01-01T00:00 -- UTC for the Instant,
37+
/// the wall clock itself for the LocalDateTime, which has no zone to convert
38+
/// through -- and a LocalDate is the day count since 1970-01-01. So an Instant
39+
/// reads a column a `long createdAt` or a `Date` wrote, unchanged.
40+
///
41+
/// `@Temporal` opts into a native column instead, through the same UTC ISO text
42+
/// `TemporalConverter` writes: `TIMESTAMP` for an Instant or a LocalDateTime,
43+
/// `DATE` for a LocalDate.
44+
///
45+
/// Precision is the millisecond, as for a Date: nanoseconds below it are dropped
46+
/// toward the earlier instant on write.
47+
///
48+
/// Internal ORM runtime; not an application API.
49+
/// @hidden
50+
@com.codename1.impl.SharedWithBackend
51+
public final class JavaTimeConverter {
52+
private static final long DAY = 86400000L;
53+
54+
private JavaTimeConverter() {
55+
}
56+
57+
/// An Instant as epoch milliseconds, the default.
58+
/// @hidden
59+
public static final class InstantMillis implements AttributeConverter<Instant, Long> {
60+
@Override
61+
public Long toDatabase(Instant value) {
62+
return value == null ? null : Long.valueOf(value.toEpochMilli());
63+
}
64+
65+
@Override
66+
public Instant fromDatabase(Long value) {
67+
return value == null ? null : Instant.ofEpochMilli(value.longValue());
68+
}
69+
}
70+
71+
/// An Instant in a native timestamp column, `@Temporal(TemporalType.TIMESTAMP)`.
72+
/// @hidden
73+
public static final class InstantTimestamp implements AttributeConverter<Instant, String> {
74+
private final TemporalConverter text = new TemporalConverter.Timestamp();
75+
76+
@Override
77+
public String toDatabase(Instant value) {
78+
return value == null ? null : text.toDatabase(new java.util.Date(value.toEpochMilli()));
79+
}
80+
81+
@Override
82+
public Instant fromDatabase(String value) {
83+
return value == null ? null : Instant.ofEpochMilli(TemporalConverter.parse(value));
84+
}
85+
}
86+
87+
/// A LocalDate as the day count since 1970-01-01, the default.
88+
/// @hidden
89+
public static final class LocalDateEpochDay implements AttributeConverter<LocalDate, Long> {
90+
@Override
91+
public Long toDatabase(LocalDate value) {
92+
return value == null ? null : Long.valueOf(value.toEpochDay());
93+
}
94+
95+
@Override
96+
public LocalDate fromDatabase(Long value) {
97+
return value == null ? null : LocalDate.ofEpochDay(value.longValue());
98+
}
99+
}
100+
101+
/// A LocalDate in a native date column, `@Temporal(TemporalType.DATE)`.
102+
/// @hidden
103+
public static final class LocalDateDay implements AttributeConverter<LocalDate, String> {
104+
private final TemporalConverter text = new TemporalConverter.Day();
105+
106+
@Override
107+
public String toDatabase(LocalDate value) {
108+
return value == null ? null : text.toDatabase(new java.util.Date(value.toEpochDay() * DAY));
109+
}
110+
111+
@Override
112+
public LocalDate fromDatabase(String value) {
113+
return value == null ? null : LocalDate.ofEpochDay(floorDiv(TemporalConverter.parse(value), DAY));
114+
}
115+
}
116+
117+
/// A LocalDateTime's wall clock as milliseconds since 1970-01-01T00:00, the
118+
/// default.
119+
/// @hidden
120+
public static final class LocalDateTimeMillis implements AttributeConverter<LocalDateTime, Long> {
121+
@Override
122+
public Long toDatabase(LocalDateTime value) {
123+
return value == null ? null : Long.valueOf(millis(value));
124+
}
125+
126+
@Override
127+
public LocalDateTime fromDatabase(Long value) {
128+
return value == null ? null : wallClock(value.longValue());
129+
}
130+
}
131+
132+
/// A LocalDateTime in a native timestamp column,
133+
/// `@Temporal(TemporalType.TIMESTAMP)`. The wall clock is written as is; the
134+
/// backend's UTC session means no zone ever shifts it.
135+
/// @hidden
136+
public static final class LocalDateTimeTimestamp implements AttributeConverter<LocalDateTime, String> {
137+
private final TemporalConverter text = new TemporalConverter.Timestamp();
138+
139+
@Override
140+
public String toDatabase(LocalDateTime value) {
141+
return value == null ? null : text.toDatabase(new java.util.Date(millis(value)));
142+
}
143+
144+
@Override
145+
public LocalDateTime fromDatabase(String value) {
146+
return value == null ? null : wallClock(TemporalConverter.parse(value));
147+
}
148+
}
149+
150+
/// Milliseconds of a wall clock, refusing what a long cannot hold: a
151+
/// LocalDateTime reaches the year 999999999, and `LocalDateTime.MAX` as a
152+
/// "no bound" value would otherwise wrap to a plausible wrong time. An
153+
/// Instant needs no such check; its toEpochMilli throws by itself.
154+
static long millis(LocalDateTime value) {
155+
long days = value.toLocalDate().toEpochDay();
156+
if (days > Long.MAX_VALUE / DAY - 1 || days < Long.MIN_VALUE / DAY + 1) {
157+
throw new IllegalArgumentException("A LocalDateTime does not fit in epoch milliseconds: " + value);
158+
}
159+
return days * DAY + value.toLocalTime().toNanoOfDay() / 1000000L;
160+
}
161+
162+
static LocalDateTime wallClock(long millis) {
163+
long days = floorDiv(millis, DAY);
164+
return LocalDateTime.of(LocalDate.ofEpochDay(days), LocalTime.ofNanoOfDay((millis - days * DAY) * 1000000L));
165+
}
166+
167+
private static long floorDiv(long value, long divisor) {
168+
long quotient = value / divisor;
169+
return value % divisor != 0 && (value < 0) != (divisor < 0) ? quotient - 1 : quotient;
170+
}
171+
}

‎docs/developer-guide/Annotation-SQLite-ORM.asciidoc‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@ write a loaded value into it.
5252
Java type (`String -> TEXT`, `int/long -> INTEGER`,
5353
`float/double -> REAL`, `boolean -> INTEGER`,
5454
`java.util.Date -> INTEGER` (epoch millis),
55+
`Instant`, `LocalDateTime -> INTEGER` (epoch millis),
56+
`LocalDate -> INTEGER` (epoch day),
5557
`byte[] -> BLOB`).
56-
| `@Temporal` | Stores a `java.util.Date` in a native date or time
58+
| `@Temporal` | Stores a date or time field in a native date or time
5759
column instead of epoch milliseconds. See
5860
<<orm-native-dates>>.
5961
| `@Column(nullable=false)`| Adds `NOT NULL` to the column declaration.
@@ -454,7 +456,28 @@ represent exactly are rejected rather than converted. A value whose text carries
454456
a time-zone offset, such as one read from a PostgreSQL `timestamptz` column,
455457
reads as the instant it names.
456458

457-
`@Temporal` applies to entity fields of type `java.util.Date`. It can't be
459+
`java.time.Instant`, `LocalDate`, and `LocalDateTime` fields map without an
460+
annotation, to the same portable integer columns:
461+
462+
[cols="1,2,2", options="header"]
463+
|===
464+
| Field type | Default column | With `@Temporal`
465+
| `Instant` | epoch milliseconds, the same value a `Date` or a `long` of `System.currentTimeMillis()` holds | `TIMESTAMP`
466+
| `LocalDateTime` | milliseconds since `1970-01-01T00:00` of the wall clock, with no zone applied | `TIMESTAMP`, the wall clock as written
467+
| `LocalDate` | days since `1970-01-01` | `DATE`
468+
|===
469+
470+
Because an `Instant` stores the same number, changing a `long` or `Date` field of
471+
epoch milliseconds to an `Instant` reads the rows already in the table. Pass an
472+
`Instant`, `LocalDate`, or `LocalDateTime` as the query parameter for one of
473+
these fields, not a number. Precision is milliseconds, as for a `Date`; finer
474+
digits are dropped on write. A `LocalDate` takes only `@Temporal(TemporalType.DATE)`
475+
and the other two only `TIMESTAMP`. `LocalTime`, `OffsetDateTime`, and
476+
`ZonedDateTime` aren't mapped; store an `Instant` and keep the zone in its own
477+
field when it matters.
478+
479+
`@Temporal` applies to entity fields of type `java.util.Date`, `Instant`,
480+
`LocalDate`, and `LocalDateTime`. It can't be
458481
combined with `@Convert`, used on an identifier or version, or applied to
459482
element-collection values. A field with `@Temporal` requires `openSession()`.
460483
An explicit `@Column(type)` on one must name a matching date type, such as

‎maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from
460460
if (relationship != null) {
461461
// Element collections and relationships keep their own column
462462
// format; a @Temporal there would be silently ignored.
463-
if(f.getAnnotation("Lcom/codename1/annotations/db/Temporal;")!=null) { ctx.error(cls,"@Temporal applies to a single java.util.Date field, not a relationship or collection: "+fieldPath.path);continue; }
463+
if(f.getAnnotation("Lcom/codename1/annotations/db/Temporal;")!=null) { ctx.error(cls,"@Temporal applies to a single date or time field, not a relationship or collection: "+fieldPath.path);continue; }
464464
if(fieldPath.prefix.length()>0) { ctx.error(cls,"Relationships inside embeddables are not supported: "+fieldPath.path);continue; }
465465
relationship.declaringType=fieldPath.declaringType;relationship.index=ec.relations.size(); ec.relations.add(relationship); continue; }
466466
PersistedField pf = new PersistedField();
@@ -489,15 +489,41 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from
489489
// parameters all take the path a @Convert field already takes.
490490
Object value=temporal.get("value");
491491
boolean day=value instanceof String[] && "DATE".equals(((String[])value)[1]);
492+
String javaTime=JAVA_TIME_TYPES.get(f.getDescriptor());
492493
if(conversion!=null) ctx.error(cls,"@Temporal cannot be combined with @Convert: "+pf.fieldName);
493-
else if(!"Ljava/util/Date;".equals(f.getDescriptor())) ctx.error(cls,"@Temporal requires a java.util.Date field: "+pf.fieldName);
494+
else if(!"Ljava/util/Date;".equals(f.getDescriptor()) && javaTime==null) ctx.error(cls,"@Temporal requires a java.util.Date, Instant, LocalDate or LocalDateTime field: "+pf.fieldName);
494495
else if(f.getAnnotation(ID_DESC)!=null || pf.version || ec.embeddedId!=null && pf.fieldName.startsWith(ec.embeddedId+".")) ctx.error(cls,"Identifier and version fields cannot be @Temporal: "+pf.fieldName);
496+
// A LocalDate has no time to keep and the other two have no
497+
// single day to be: the type already says which column it is.
498+
else if(javaTime!=null && day!="LocalDate".equals(javaTime)) ctx.error(cls,"@Temporal("+(day?"DATE":"TIMESTAMP")+") does not hold a java.time."+javaTime+": "+pf.fieldName);
495499
else {
496-
pf.converter="com.codename1.impl.orm.TemporalConverter."+(day?"Day":"Timestamp");
497-
pf.domainType="java.util.Date";
500+
pf.converter=javaTime==null?"com.codename1.impl.orm.TemporalConverter."+(day?"Day":"Timestamp")
501+
:"com.codename1.impl.orm.JavaTimeConverter."+javaTime+(day?"Day":"Timestamp");
502+
pf.domainType=javaTime==null?"java.util.Date":"java.time."+javaTime;
498503
pf.kind=PropertyTypeKind.scalar("java.lang.String");
499504
pf.temporal=day?1:2;
500505
}
506+
} else if(conversion==null && JAVA_TIME_TYPES.containsKey(f.getDescriptor())) {
507+
// java.time takes Date's portable default, a number in an integer
508+
// column, through a built-in converter: an Instant reads what a
509+
// long or a Date of milliseconds wrote.
510+
String javaTime=JAVA_TIME_TYPES.get(f.getDescriptor());
511+
if(f.getAnnotation(ID_DESC)!=null || ec.embeddedId!=null && pf.fieldName.startsWith(ec.embeddedId+".")) { ctx.error(cls,"Identifier fields cannot be java.time values: "+pf.fieldName);continue; }
512+
else if(pf.version) { ctx.error(cls,"Version fields cannot be java.time values: "+pf.fieldName);continue; }
513+
else {
514+
pf.converter="com.codename1.impl.orm.JavaTimeConverter."+javaTime+("LocalDate".equals(javaTime)?"EpochDay":"Millis");
515+
pf.domainType="java.time."+javaTime;
516+
pf.kind=PropertyTypeKind.scalar("java.lang.Long");
517+
}
518+
}
519+
// A java.time field with no converter by now was refused above; it
520+
// must not go on to be reported again as a relationship.
521+
if(pf.converter==null && JAVA_TIME_TYPES.containsKey(f.getDescriptor())) continue;
522+
// A Property holds its value through generated accessors that know
523+
// only the scalar types; a converter has no hook there.
524+
if(pf.kind.kind==PropertyTypeKind.Kind.PROPERTY && pf.kind.elementBinaryName!=null && pf.kind.elementBinaryName.startsWith("java.time.")) {
525+
ctx.error(cls,"A Property of "+pf.kind.elementBinaryName+" is not supported; declare a plain java.time field instead: "+pf.fieldName);
526+
continue;
501527
}
502528
AnnotatedClass enumClass = pf.kind.kind == PropertyTypeKind.Kind.REFERENCE ? findType(pf.kind.binaryName,ctx) : null;
503529
if (pf.kind.kind == PropertyTypeKind.Kind.REFERENCE && enumClass != null && enumClass.isEnum())
@@ -1135,6 +1161,15 @@ && lowerOrDigit(name.charAt(i-1)) && lowerOrDigit(name.charAt(i+1)))
11351161
return out.toString().toLowerCase(java.util.Locale.ROOT);
11361162
}
11371163

1164+
/// The java.time field types the ORM maps, by descriptor, to the simple name
1165+
/// JavaTimeConverter's nested converters start with.
1166+
private static final java.util.Map<String,String> JAVA_TIME_TYPES=new java.util.HashMap<String,String>();
1167+
static {
1168+
JAVA_TIME_TYPES.put("Ljava/time/Instant;","Instant");
1169+
JAVA_TIME_TYPES.put("Ljava/time/LocalDate;","LocalDate");
1170+
JAVA_TIME_TYPES.put("Ljava/time/LocalDateTime;","LocalDateTime");
1171+
}
1172+
11381173
/// Whether a declared column type holds a @Temporal value of the given kind
11391174
/// (1 DATE, 2 TIMESTAMP), at millisecond precision. TEXT holds either: SQLite has no date type and
11401175
/// schemas written for it declare their dates as TEXT. Mirrors SessionImpl's

0 commit comments

Comments
 (0)