Skip to content
Merged
8 changes: 6 additions & 2 deletions CodenameOne/src/com/codename1/annotations/db/Temporal.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/// Stores a `java.util.Date` field in a native date or timestamp column instead
/// of the portable default, epoch milliseconds in an integer column.
/// Stores a `java.util.Date`, `java.time.Instant`, `LocalDate` or
/// `LocalDateTime` field in a native date or timestamp column instead of the
/// portable default, a number in an integer column: epoch milliseconds, or
/// epoch days for a `LocalDate`. A `LocalDate` takes `DATE` and the other
/// java.time types take `TIMESTAMP`; a `LocalDateTime` is written as its wall
/// clock, with no zone applied.
///
/// Opt-in, because the two are different columns: an existing integer column
/// keeps its milliseconds and an existing native one needs this annotation.
Expand Down
171 changes: 171 additions & 0 deletions CodenameOne/src/com/codename1/impl/orm/JavaTimeConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Codename One designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Codename One through http://www.codenameone.com/ if you
* need additional information or have any questions.
*/
package com.codename1.impl.orm;

import com.codename1.orm.session.AttributeConverter;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;

/// The storage of `java.time.Instant`, `LocalDate` and `LocalDateTime` fields.
///
/// The defaults follow `java.util.Date`'s: a number in an integer column, which
/// sorts, compares and indexes the same on every engine. An Instant or a
/// LocalDateTime is milliseconds since 1970-01-01T00:00 -- UTC for the Instant,
/// the wall clock itself for the LocalDateTime, which has no zone to convert
/// through -- and a LocalDate is the day count since 1970-01-01. So an Instant
/// reads a column a `long createdAt` or a `Date` wrote, unchanged.
///
/// `@Temporal` opts into a native column instead, through the same UTC ISO text
/// `TemporalConverter` writes: `TIMESTAMP` for an Instant or a LocalDateTime,
/// `DATE` for a LocalDate.
///
/// Precision is the millisecond, as for a Date: nanoseconds below it are dropped
/// toward the earlier instant on write.
///
/// Internal ORM runtime; not an application API.
/// @hidden
@com.codename1.impl.SharedWithBackend
public final class JavaTimeConverter {
private static final long DAY = 86400000L;

private JavaTimeConverter() {
}

/// An Instant as epoch milliseconds, the default.
/// @hidden
public static final class InstantMillis implements AttributeConverter<Instant, Long> {
@Override
public Long toDatabase(Instant value) {
return value == null ? null : Long.valueOf(value.toEpochMilli());
}

@Override
public Instant fromDatabase(Long value) {
return value == null ? null : Instant.ofEpochMilli(value.longValue());
}
}

/// An Instant in a native timestamp column, `@Temporal(TemporalType.TIMESTAMP)`.
/// @hidden
public static final class InstantTimestamp implements AttributeConverter<Instant, String> {
private final TemporalConverter text = new TemporalConverter.Timestamp();

@Override
public String toDatabase(Instant value) {
return value == null ? null : text.toDatabase(new java.util.Date(value.toEpochMilli()));
}

@Override
public Instant fromDatabase(String value) {
return value == null ? null : Instant.ofEpochMilli(TemporalConverter.parse(value));
}
}

/// A LocalDate as the day count since 1970-01-01, the default.
/// @hidden
public static final class LocalDateEpochDay implements AttributeConverter<LocalDate, Long> {
@Override
public Long toDatabase(LocalDate value) {
return value == null ? null : Long.valueOf(value.toEpochDay());
}

@Override
public LocalDate fromDatabase(Long value) {
return value == null ? null : LocalDate.ofEpochDay(value.longValue());
}
}

/// A LocalDate in a native date column, `@Temporal(TemporalType.DATE)`.
/// @hidden
public static final class LocalDateDay implements AttributeConverter<LocalDate, String> {
private final TemporalConverter text = new TemporalConverter.Day();

@Override
public String toDatabase(LocalDate value) {
return value == null ? null : text.toDatabase(new java.util.Date(value.toEpochDay() * DAY));
}

@Override
public LocalDate fromDatabase(String value) {
return value == null ? null : LocalDate.ofEpochDay(floorDiv(TemporalConverter.parse(value), DAY));
}
}

/// A LocalDateTime's wall clock as milliseconds since 1970-01-01T00:00, the
/// default.
/// @hidden
public static final class LocalDateTimeMillis implements AttributeConverter<LocalDateTime, Long> {
@Override
public Long toDatabase(LocalDateTime value) {
return value == null ? null : Long.valueOf(millis(value));
}

@Override
public LocalDateTime fromDatabase(Long value) {
return value == null ? null : wallClock(value.longValue());
}
}

/// A LocalDateTime in a native timestamp column,
/// `@Temporal(TemporalType.TIMESTAMP)`. The wall clock is written as is; the
/// backend's UTC session means no zone ever shifts it.
/// @hidden
public static final class LocalDateTimeTimestamp implements AttributeConverter<LocalDateTime, String> {
private final TemporalConverter text = new TemporalConverter.Timestamp();

@Override
public String toDatabase(LocalDateTime value) {
return value == null ? null : text.toDatabase(new java.util.Date(millis(value)));
}

@Override
public LocalDateTime fromDatabase(String value) {
return value == null ? null : wallClock(TemporalConverter.parse(value));
}
}

/// Milliseconds of a wall clock, refusing what a long cannot hold: a
/// LocalDateTime reaches the year 999999999, and `LocalDateTime.MAX` as a
/// "no bound" value would otherwise wrap to a plausible wrong time. An
/// Instant needs no such check; its toEpochMilli throws by itself.
static long millis(LocalDateTime value) {
long days = value.toLocalDate().toEpochDay();
if (days > Long.MAX_VALUE / DAY - 1 || days < Long.MIN_VALUE / DAY + 1) {
throw new IllegalArgumentException("A LocalDateTime does not fit in epoch milliseconds: " + value);
}
return days * DAY + value.toLocalTime().toNanoOfDay() / 1000000L;
}

static LocalDateTime wallClock(long millis) {
long days = floorDiv(millis, DAY);
return LocalDateTime.of(LocalDate.ofEpochDay(days), LocalTime.ofNanoOfDay((millis - days * DAY) * 1000000L));
}

private static long floorDiv(long value, long divisor) {
long quotient = value / divisor;
return value % divisor != 0 && (value < 0) != (divisor < 0) ? quotient - 1 : quotient;
}
}
27 changes: 25 additions & 2 deletions docs/developer-guide/Annotation-SQLite-ORM.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ write a loaded value into it.
Java type (`String -> TEXT`, `int/long -> INTEGER`,
`float/double -> REAL`, `boolean -> INTEGER`,
`java.util.Date -> INTEGER` (epoch millis),
`Instant`, `LocalDateTime -> INTEGER` (epoch millis),
`LocalDate -> INTEGER` (epoch day),
`byte[] -> BLOB`).
| `@Temporal` | Stores a `java.util.Date` in a native date or time
| `@Temporal` | Stores a date or time field in a native date or time
column instead of epoch milliseconds. See
<<orm-native-dates>>.
| `@Column(nullable=false)`| Adds `NOT NULL` to the column declaration.
Expand Down Expand Up @@ -454,7 +456,28 @@ represent exactly are rejected rather than converted. A value whose text carries
a time-zone offset, such as one read from a PostgreSQL `timestamptz` column,
reads as the instant it names.

`@Temporal` applies to entity fields of type `java.util.Date`. It can't be
`java.time.Instant`, `LocalDate`, and `LocalDateTime` fields map without an
annotation, to the same portable integer columns:

[cols="1,2,2", options="header"]
|===
| Field type | Default column | With `@Temporal`
| `Instant` | epoch milliseconds, the same value a `Date` or a `long` of `System.currentTimeMillis()` holds | `TIMESTAMP`
| `LocalDateTime` | milliseconds since `1970-01-01T00:00` of the wall clock, with no zone applied | `TIMESTAMP`, the wall clock as written
| `LocalDate` | days since `1970-01-01` | `DATE`
|===

Because an `Instant` stores the same number, changing a `long` or `Date` field of
epoch milliseconds to an `Instant` reads the rows already in the table. Pass an
`Instant`, `LocalDate`, or `LocalDateTime` as the query parameter for one of
these fields, not a number. Precision is milliseconds, as for a `Date`; finer
digits are dropped on write. A `LocalDate` takes only `@Temporal(TemporalType.DATE)`
and the other two only `TIMESTAMP`. `LocalTime`, `OffsetDateTime`, and
`ZonedDateTime` aren't mapped; store an `Instant` and keep the zone in its own
field when it matters.

`@Temporal` applies to entity fields of type `java.util.Date`, `Instant`,
`LocalDate`, and `LocalDateTime`. It can't be
combined with `@Convert`, used on an identifier or version, or applied to
element-collection values. A field with `@Temporal` requires `openSession()`.
An explicit `@Column(type)` on one must name a matching date type, such as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from
if (relationship != null) {
// Element collections and relationships keep their own column
// format; a @Temporal there would be silently ignored.
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; }
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; }
if(fieldPath.prefix.length()>0) { ctx.error(cls,"Relationships inside embeddables are not supported: "+fieldPath.path);continue; }
relationship.declaringType=fieldPath.declaringType;relationship.index=ec.relations.size(); ec.relations.add(relationship); continue; }
PersistedField pf = new PersistedField();
Expand Down Expand Up @@ -489,15 +489,41 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from
// parameters all take the path a @Convert field already takes.
Object value=temporal.get("value");
boolean day=value instanceof String[] && "DATE".equals(((String[])value)[1]);
String javaTime=JAVA_TIME_TYPES.get(f.getDescriptor());
if(conversion!=null) ctx.error(cls,"@Temporal cannot be combined with @Convert: "+pf.fieldName);
else if(!"Ljava/util/Date;".equals(f.getDescriptor())) ctx.error(cls,"@Temporal requires a java.util.Date field: "+pf.fieldName);
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);
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);
// A LocalDate has no time to keep and the other two have no
// single day to be: the type already says which column it is.
else if(javaTime!=null && day!="LocalDate".equals(javaTime)) ctx.error(cls,"@Temporal("+(day?"DATE":"TIMESTAMP")+") does not hold a java.time."+javaTime+": "+pf.fieldName);
else {
pf.converter="com.codename1.impl.orm.TemporalConverter."+(day?"Day":"Timestamp");
pf.domainType="java.util.Date";
pf.converter=javaTime==null?"com.codename1.impl.orm.TemporalConverter."+(day?"Day":"Timestamp")
:"com.codename1.impl.orm.JavaTimeConverter."+javaTime+(day?"Day":"Timestamp");
pf.domainType=javaTime==null?"java.util.Date":"java.time."+javaTime;
pf.kind=PropertyTypeKind.scalar("java.lang.String");
pf.temporal=day?1:2;
}
} else if(conversion==null && JAVA_TIME_TYPES.containsKey(f.getDescriptor())) {
// java.time takes Date's portable default, a number in an integer
// column, through a built-in converter: an Instant reads what a
// long or a Date of milliseconds wrote.
String javaTime=JAVA_TIME_TYPES.get(f.getDescriptor());
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; }
else if(pf.version) { ctx.error(cls,"Version fields cannot be java.time values: "+pf.fieldName);continue; }
else {
pf.converter="com.codename1.impl.orm.JavaTimeConverter."+javaTime+("LocalDate".equals(javaTime)?"EpochDay":"Millis");
pf.domainType="java.time."+javaTime;
pf.kind=PropertyTypeKind.scalar("java.lang.Long");
}
}
// A java.time field with no converter by now was refused above; it
// must not go on to be reported again as a relationship.
if(pf.converter==null && JAVA_TIME_TYPES.containsKey(f.getDescriptor())) continue;
// A Property holds its value through generated accessors that know
// only the scalar types; a converter has no hook there.
if(pf.kind.kind==PropertyTypeKind.Kind.PROPERTY && pf.kind.elementBinaryName!=null && pf.kind.elementBinaryName.startsWith("java.time.")) {
ctx.error(cls,"A Property of "+pf.kind.elementBinaryName+" is not supported; declare a plain java.time field instead: "+pf.fieldName);
continue;
}
AnnotatedClass enumClass = pf.kind.kind == PropertyTypeKind.Kind.REFERENCE ? findType(pf.kind.binaryName,ctx) : null;
if (pf.kind.kind == PropertyTypeKind.Kind.REFERENCE && enumClass != null && enumClass.isEnum())
Expand Down Expand Up @@ -1135,6 +1161,15 @@ && lowerOrDigit(name.charAt(i-1)) && lowerOrDigit(name.charAt(i+1)))
return out.toString().toLowerCase(java.util.Locale.ROOT);
}

/// The java.time field types the ORM maps, by descriptor, to the simple name
/// JavaTimeConverter's nested converters start with.
private static final java.util.Map<String,String> JAVA_TIME_TYPES=new java.util.HashMap<String,String>();
static {
JAVA_TIME_TYPES.put("Ljava/time/Instant;","Instant");
JAVA_TIME_TYPES.put("Ljava/time/LocalDate;","LocalDate");
JAVA_TIME_TYPES.put("Ljava/time/LocalDateTime;","LocalDateTime");
}

/// Whether a declared column type holds a @Temporal value of the given kind
/// (1 DATE, 2 TIMESTAMP), at millisecond precision. TEXT holds either: SQLite has no date type and
/// schemas written for it declare their dates as TEXT. Mirrors SessionImpl's
Expand Down
Loading
Loading