Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions release-notes/CREDITS
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,8 @@ seonwoo_jung (@seonwooj0810)
* Fixed #6101: `@JsonInclude(NON_EMPTY, content=CUSTOM)` does not omit a Map property
after all entries are filtered
[3.2.2]
* Contributed #6151: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS`
[3.3.0]

@doeiqts
* Reported #6065: `SerializationFeature.APPLY_JSON_INCLUDE_FOR_CONTAINERS` does not fully
Expand Down
2 changes: 2 additions & 0 deletions release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Versions: 3.x (for earlier see VERSION-2.x)
#6142: Invalidate read-only lookup snapshot in `SerializerCache` when a typed
serializer entry is replaced (and not just when added)
(fix by @Dongnyoung)
#6151: Add `DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS`
(contributed by @seonwooj0810)

3.2.2 (not yet released)

Expand Down
36 changes: 36 additions & 0 deletions src/main/java/tools/jackson/databind/cfg/DateTimeFeature.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,42 @@ public enum DateTimeFeature implements DatatypeFeature
*/
ALWAYS_ALLOW_STRINGIFIED_DATE_TIMESTAMPS(false),

/**
* Feature that determines whether sub-second digits are always written when
* serializing {@link java.time.Instant}, {@link java.time.OffsetDateTime},
* {@link java.time.ZonedDateTime} and {@link java.time.LocalDateTime} as
* ISO-8601 Strings using the default format.
*<p>
* When disabled (the default), the JDK-provided ISO formatters are used and
* a zero sub-second value is omitted altogether -- {@code 2017-09-14T04:28:48Z}
* -- which means that output width varies with the value, breaking systems
* that expect fixed-precision timestamps (or that sort timestamps as text).
*<p>
* When enabled, at least 3 (millisecond) sub-second digits are always written,
* zero-padded if necessary -- {@code 2017-09-14T04:28:48.000Z}. Higher precision
* is preserved: up to 9 digits are written, as many as needed to avoid losing
* information (but with no trailing zeroes beyond the 3 digit minimum, so
* {@code 123400000} nanoseconds is written as {@code .1234}).
*<p>
* Only affects the default format: an explicit {@code DateTimeFormatter} or
* a {@link com.fasterxml.jackson.annotation.JsonFormat} pattern takes precedence,
* as does writing values as numeric timestamps.
*<p>
* NOTE: only applies to Date/Time values, and NOT to Date/Time values used as
* {@link java.util.Map} keys: keys are written by separate key serializers that
* are not affected by this setting.
*<p>
* Note, too, that the very extremes of the {@link java.time.Instant} range
* (notably {@link java.time.Instant#MIN} and {@link java.time.Instant#MAX}, which
* fall outside the range of {@link java.time.LocalDate}) cannot be written with
* Date/Time fields at all, and retain default handling regardless of this setting.
*<p>
* Default setting is disabled, for backwards compatibility.
*
* @since 3.3
*/
ALWAYS_WRITE_SUBSECOND_DIGITS(false),

/**
* Feature that determines whether {@link java.time.ZoneId} is normalized
* (via call to {@code java.time.ZoneId#normalized()}) when deserializing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
Expand All @@ -31,6 +32,12 @@ public class InstantSerializer extends InstantSerializerBase<Instant>
{
public static final InstantSerializer INSTANCE = new InstantSerializer();

private final static long SECONDS_PER_DAY = 86400L;

private final static long MIN_EPOCH_DAY = LocalDate.MIN.toEpochDay();

private final static long MAX_EPOCH_DAY = LocalDate.MAX.toEpochDay();

protected InstantSerializer() {
super(Instant.class, Instant::toEpochMilli, Instant::getEpochSecond, Instant::getNano,
// null -> use 'value.toString()', default format
Expand Down Expand Up @@ -62,4 +69,23 @@ protected JSR310FormattedSerializerBase<?> withFeatures(Boolean writeZoneId, Boo
return new InstantSerializer(this, _formatter, _useTimestamp, writeNanoseconds,
this._shape);
}

@Override
protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(Instant value,
DateTimeFormatter defaultFormat) {
// Standard default for `Instant` is `null` (meaning `Instant.toString()`);
// anything else is caller-provided and must be left alone
if (defaultFormat != null) {
return null;
}
// Replacement formatter is Date/Time-field-based, so value must be convertible
// into `LocalDate`; `Instant` range is wider than that (by less than a year on
// both ends, but that includes `Instant.MIN` and `Instant.MAX`). For such
// extreme values retain default `Instant.toString()` handling instead of failing
final long epochDay = Math.floorDiv(value.getEpochSecond(), SECONDS_PER_DAY);
if ((epochDay < MIN_EPOCH_DAY) || (epochDay > MAX_EPOCH_DAY)) {
return null;
}
return SubSecondFormatters.INSTANT;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,53 @@ protected JsonToken serializationShape(SerializationContext ctxt) {
return JsonToken.VALUE_STRING;
}

/**
* Accessor for the default formatter this serializer was constructed with: used
* when no explicit {@link #_formatter} is defined. May be {@code null} (meaning
* default {@code toString()} handling), and may be caller-provided (see
* {@link ZonedDateTimeSerializer#ZonedDateTimeSerializer(DateTimeFormatter)}),
* in which case it must not be overridden by settings like
* {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}.
*
* @since 3.3
*/
protected DateTimeFormatter _defaultFormat() {
return defaultFormat;
}

/**
* Overridden by subclasses to supply a formatter equivalent to the standard
* built-in default that always writes at least millisecond-precision sub-second
* digits, for use with {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}.
*<p>
* Implementations MUST return {@code null} unless given {@code defaultFormat} is
* the standard built-in default of the subclass: some subclasses (notably
* {@link ZonedDateTimeSerializer}) allow caller-provided default formatters, and
* those must not be overridden by the feature. Implementations should also return
* {@code null} for values the replacement cannot express, so that default handling
* is retained instead of failing. Returning {@code null} (which the base
* implementation always does) means the feature has no effect.
*
* @param value Value being serialized
* @param defaultFormat Default formatter that would be used if the feature was
* not enabled (possibly {@code null})
*
* @since 3.3
*/
protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(T value,
DateTimeFormatter defaultFormat) {
return null;
}

protected String formatValue(T value, SerializationContext ctxt)
{
DateTimeFormatter formatter = (_formatter == null) ? defaultFormat :_formatter;
if ((_formatter == null) && ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) {
DateTimeFormatter subSecondFormatter = _alwaysWriteSubSecondDigitsFormatter(value, defaultFormat);
if (subSecondFormatter != null) {
formatter = subSecondFormatter;
}
}
if (formatter != null) {
if (formatter.getZone() == null) { // timezone set if annotated on property
// If the user specified to use the context TimeZone explicitly, and the formatter provided doesn't contain a TZ
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,7 @@ public void serialize(LocalDateTime value, JsonGenerator g, SerializationContext
_serializeAsArrayContents(value, g, ctxt);
g.writeEndArray();
} else {
DateTimeFormatter dtf = _formatter;
if (dtf == null) {
dtf = _defaultFormatter();
}
g.writeString(value.format(dtf));
g.writeString(value.format(_effectiveFormatter(ctxt)));
}
}

Expand All @@ -101,15 +97,34 @@ public void serializeWithType(LocalDateTime value, JsonGenerator g, Serializatio
&& typeIdDef.valueShape == JsonToken.START_ARRAY) {
_serializeAsArrayContents(value, g, ctxt);
} else {
DateTimeFormatter dtf = _formatter;
if (dtf == null) {
dtf = _defaultFormatter();
}
g.writeString(value.format(dtf));
g.writeString(value.format(_effectiveFormatter(ctxt)));
}
typeSer.writeTypeSuffix(g, ctxt, typeIdDef);
}

/**
* Resolves the formatter to use when no numeric-timestamp shape applies: the
* explicit per-property {@code _formatter} if set, else the default from
* {@link #_defaultFormatter()}, or -- if
* {@link DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS} is enabled -- a
* counterpart of the standard default that always writes sub-second digits.
*
* @since 3.3
*/
private DateTimeFormatter _effectiveFormatter(SerializationContext ctxt) {
if (_formatter != null) {
return _formatter;
}
DateTimeFormatter dtf = _defaultFormatter();
// Sub-second replacement is a counterpart of the standard built-in default
// only: a subclass-provided default must not be overridden by the feature
if ((dtf == DateTimeFormatter.ISO_LOCAL_DATE_TIME)
&& ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)) {
return SubSecondFormatters.LOCAL_DATE_TIME;
}
return dtf;
}

private final void _serializeAsArrayContents(LocalDateTime value, JsonGenerator g,
SerializationContext ctxt)
throws JacksonException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,11 @@ protected JSR310FormattedSerializerBase<?> withFeatures(Boolean writeZoneId, Boo
return new OffsetDateTimeSerializer(this, _formatter,
_useTimestamp, writeNanoseconds, _shape);
}

@Override
protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(OffsetDateTime value,
DateTimeFormatter defaultFormat) {
return (defaultFormat == DateTimeFormatter.ISO_OFFSET_DATE_TIME)
? SubSecondFormatters.OFFSET_DATE_TIME : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package tools.jackson.databind.ext.javatime.ser;

import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;

/**
* Container for the ISO-8601 {@link DateTimeFormatter}s used in place of the
* JDK-provided defaults when
* {@link tools.jackson.databind.cfg.DateTimeFeature#ALWAYS_WRITE_SUBSECOND_DIGITS}
* is enabled.
*<p>
* These differ from the JDK counterparts only in the sub-second field: instead of
* omitting it when zero, at least 3 (millisecond) digits are always written, and up
* to 9 when the value carries higher precision (so nothing is truncated).
*
* @since 3.3
*/
final class SubSecondFormatters
{
private SubSecondFormatters() { }

/**
* Date and time down to the seconds field, followed by 3 to 9 sub-second digits:
* the shared prefix of all formatters here.
*/
private static DateTimeFormatterBuilder _localDateTimeBuilder() {
return new DateTimeFormatterBuilder()
.append(DateTimeFormatter.ISO_LOCAL_DATE)
.appendLiteral('T')
.appendValue(ChronoField.HOUR_OF_DAY, 2)
.appendLiteral(':')
.appendValue(ChronoField.MINUTE_OF_HOUR, 2)
.appendLiteral(':')
.appendValue(ChronoField.SECOND_OF_MINUTE, 2)
.appendFraction(ChronoField.NANO_OF_SECOND, 3, 9, true);
}

/**
* Counterpart of {@link DateTimeFormatter#ISO_LOCAL_DATE_TIME}.
*/
static final DateTimeFormatter LOCAL_DATE_TIME = _localDateTimeBuilder()
.toFormatter();

/**
* Counterpart of {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME}.
*/
static final DateTimeFormatter OFFSET_DATE_TIME = _localDateTimeBuilder()
.appendOffsetId()
.toFormatter();

/**
* Counterpart of {@link DateTimeFormatter#ISO_ZONED_DATE_TIME}, that is,
* {@link #OFFSET_DATE_TIME} with the optional {@code [Zone/Id]} suffix.
*/
static final DateTimeFormatter ZONED_DATE_TIME = new DateTimeFormatterBuilder()
.append(OFFSET_DATE_TIME)
.optionalStart()
.appendLiteral('[')
.parseCaseSensitive()
.appendZoneRegionId()
.appendLiteral(']')
.toFormatter();

/**
* Counterpart of {@link DateTimeFormatter#ISO_INSTANT} (and of
* {@link java.time.Instant#toString()}, which is what the default
* {@code Instant} serialization actually uses): UTC-based, so the
* offset is always rendered as {@code Z}.
*/
static final DateTimeFormatter INSTANT = OFFSET_DATE_TIME
.withZone(ZoneOffset.UTC);
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,21 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializationContext
throws JacksonException
{
if (!useTimestamp(ctxt)) {
// [modules-java8#333]: `@JsonFormat` with pattern should override
// `SerializationFeature.WRITE_DATES_WITH_ZONE_ID`
if ((_formatter != null) && (_shape == JsonFormat.Shape.STRING)) {
// [modules-java8#333], [databind#6151]: explicitly configured format should
// override `DateTimeFeature.WRITE_DATES_WITH_ZONE_ID`
if (_hasExplicitFormat()) {
; // use default handling
} else if (shouldWriteWithZoneId(ctxt)) {
// Apply millisecond truncation if enabled
if (ctxt.isEnabled(DateTimeFeature.TRUNCATE_TO_MSECS_ON_WRITE)) {
value = value.truncatedTo(ChronoUnit.MILLIS);
}
// write with zone
g.writeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value));
// write with zone (note: only standard default format gets here, so
// sub-second variant may be used as-is)
DateTimeFormatter formatter = ctxt.isEnabled(DateTimeFeature.ALWAYS_WRITE_SUBSECOND_DIGITS)
? SubSecondFormatters.ZONED_DATE_TIME
: DateTimeFormatter.ISO_ZONED_DATE_TIME;
g.writeString(formatter.format(value));
return;
}
}
Expand All @@ -88,16 +92,35 @@ public void serialize(ZonedDateTime value, JsonGenerator g, SerializationContext
@Override
protected String formatValue(ZonedDateTime value, SerializationContext ctxt) {
String formatted = super.formatValue(value, ctxt);
// [modules-java8#333]: `@JsonFormat` with pattern should override
// `SerializationFeature.WRITE_DATES_WITH_ZONE_ID`
if (_formatter != null && _shape == JsonFormat.Shape.STRING) {
// [modules-java8#333], [databind#6151]: when an explicitly configured format is
// used, Zone Id is only added if specifically requested (via `@JsonFormat`),
// and NOT due to `DateTimeFeature.WRITE_DATES_WITH_ZONE_ID`
if (_hasExplicitFormat()) {
// Why not `if (shouldWriteWithZoneId(provider))` ?
if (Boolean.TRUE.equals(_writeZoneId)) {
formatted += "[" + value.getZone().getId() + "]";
}
}
return formatted;
}
}

/**
* Accessor for checking whether this serializer has an explicitly configured
* format that should be used as-is, taking precedence over
* {@link DateTimeFeature#WRITE_DATES_WITH_ZONE_ID}: either a {@code @JsonFormat}
* pattern (with String shape), or a caller-provided default formatter (see
* {@link #ZonedDateTimeSerializer(DateTimeFormatter)}).
*
* @since 3.3
*/
protected boolean _hasExplicitFormat() {
if ((_formatter != null) && (_shape == JsonFormat.Shape.STRING)) {
return true;
}
DateTimeFormatter df = _defaultFormat();
return (df != null) && (df != DateTimeFormatter.ISO_OFFSET_DATE_TIME);
}

public boolean shouldWriteWithZoneId(SerializationContext ctxt) {
return (_writeZoneId != null)
? _writeZoneId
Expand All @@ -111,4 +134,13 @@ protected JsonToken serializationShape(SerializationContext ctxt) {
}
return super.serializationShape(ctxt);
}

@Override
protected DateTimeFormatter _alwaysWriteSubSecondDigitsFormatter(ZonedDateTime value,
DateTimeFormatter defaultFormat) {
// 10-Aug-2026, tatu: Caller may pass its own default formatter (see
// `ZonedDateTimeSerializer(DateTimeFormatter)`); if so, must not override it
return (defaultFormat == DateTimeFormatter.ISO_OFFSET_DATE_TIME)
? SubSecondFormatters.OFFSET_DATE_TIME : null;
}
}
Loading
Loading