diff --git a/subprojects/groovy-toml/build.gradle b/subprojects/groovy-toml/build.gradle index 62111433350..cd196125f45 100644 --- a/subprojects/groovy-toml/build.gradle +++ b/subprojects/groovy-toml/build.gradle @@ -23,6 +23,7 @@ plugins { dependencies { api rootProject // TomlBuilder extends GroovyObjectSupport... implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-toml:${versions.jackson}" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions.jackson}" implementation projects.groovyJson testImplementation projects.groovyTest testRuntimeOnly projects.groovyAnt // for JavadocAssertionTests diff --git a/subprojects/groovy-toml/src/main/java/groovy/toml/TomlBuilder.java b/subprojects/groovy-toml/src/main/java/groovy/toml/TomlBuilder.java index 61956594aef..d31127ee610 100644 --- a/subprojects/groovy-toml/src/main/java/groovy/toml/TomlBuilder.java +++ b/subprojects/groovy-toml/src/main/java/groovy/toml/TomlBuilder.java @@ -18,7 +18,6 @@ */ package groovy.toml; -import com.fasterxml.jackson.dataformat.toml.TomlMapper; import groovy.json.JsonBuilder; import groovy.lang.Closure; import groovy.lang.GroovyObjectSupport; @@ -59,7 +58,7 @@ public TomlBuilder() { */ public static String toToml(Object object) { try { - return new TomlMapper().writeValueAsString(object); + return TomlSlurper.mapper().writeValueAsString(object); } catch (IOException e) { throw new TomlRuntimeException(e); } diff --git a/subprojects/groovy-toml/src/main/java/groovy/toml/TomlSlurper.java b/subprojects/groovy-toml/src/main/java/groovy/toml/TomlSlurper.java index a2bdf369e09..34938901687 100644 --- a/subprojects/groovy-toml/src/main/java/groovy/toml/TomlSlurper.java +++ b/subprojects/groovy-toml/src/main/java/groovy/toml/TomlSlurper.java @@ -18,7 +18,10 @@ */ package groovy.toml; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import groovy.json.JsonSlurper; import org.apache.groovy.lang.annotation.Incubating; import org.apache.groovy.toml.util.TomlConverter; @@ -126,12 +129,23 @@ public T parseTextAs(Class type, String toml) { */ public T parseAs(Class type, Reader reader) { try { - return new TomlMapper().readValue(reader, type); + return mapper().readValue(reader, type); } catch (IOException e) { throw new TomlRuntimeException(e); } } + // TomlMapper is thread-safe once configured, so a single shared instance is reused + private static final TomlMapper MAPPER = TomlMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + .build(); + + static TomlMapper mapper() { + return MAPPER; + } + /** * Parse TOML from an input stream into a typed object. * diff --git a/subprojects/groovy-toml/src/spec/doc/toml-userguide.adoc b/subprojects/groovy-toml/src/spec/doc/toml-userguide.adoc index d9dfac06d01..6d3ea01d4d7 100644 --- a/subprojects/groovy-toml/src/spec/doc/toml-userguide.adoc +++ b/subprojects/groovy-toml/src/spec/doc/toml-userguide.adoc @@ -82,14 +82,24 @@ The following table gives an overview of the TOML types and the corresponding Gr |null |`null` -|date -|`java.util.Date` based on the `yyyy-MM-dd'T'HH:mm:ssZ` date format +|offset/local date-time, local date, local time +|`java.lang.String` (see the note below) |=== [NOTE] Whenever a value in TOML is `null`, `TomlSlurper` supplements it with the Groovy `null` value. This is in contrast to other TOML parsers that represent a `null` value with a library-provided singleton object. +[NOTE] +==== +For the untyped `parse`/`parseText` API, TOML date and time values are returned as +`String`. This is a limitation of the underlying Jackson TOML support, which only +surfaces `java.time.*` types when a target type is supplied. Use the typed +`parseAs`/`parseTextAs` API (see <>) for `java.time.LocalDate`, +`java.time.LocalTime`, `java.time.LocalDateTime`, and `java.time.OffsetDateTime` +fidelity. +==== + === Typed parsing `TomlSlurper` can parse TOML directly into typed objects using Jackson databinding. @@ -111,6 +121,23 @@ NOTE: For simple cases, Groovy's `as` coercion also works with the untyped resul The `parseTextAs` method uses Jackson databinding, which supports richer annotation-driven mapping via `@JsonProperty`, `@JsonFormat`, etc. +[[toml_typed_temporal]] +=== Typed date and time values + +When a target type declares `java.time.*` fields, the typed `parseAs`/`parseTextAs` +API and `TomlBuilder.toToml` round-trip TOML temporal values with full fidelity, +including the original zone offset for `OffsetDateTime`: + +[source,groovy] +---- +include::../test/groovy/toml/TomlParserTest.groovy[tags=temporal_class,indent=0] +---- + +[source,groovy] +---- +include::../test/groovy/toml/TomlParserTest.groovy[tags=temporal_typed,indent=0] +---- + === Builders Another way to create TOML from Groovy is to use `TomlBuilder`. The builder provide a diff --git a/subprojects/groovy-toml/src/spec/test/groovy/toml/TomlParserTest.groovy b/subprojects/groovy-toml/src/spec/test/groovy/toml/TomlParserTest.groovy index 4e7c6df9006..ce42f90537f 100644 --- a/subprojects/groovy-toml/src/spec/test/groovy/toml/TomlParserTest.groovy +++ b/subprojects/groovy-toml/src/spec/test/groovy/toml/TomlParserTest.groovy @@ -163,4 +163,61 @@ port = 8080 def config = new TomlSlurper().parseAs(ServerConfig, stream) assert config.port == 3000 } + + // tag::temporal_class[] + static class Event { + java.time.OffsetDateTime created + java.time.LocalDate effective + java.time.LocalTime windowStart + java.time.LocalDateTime updated + String name + } + // end::temporal_class[] + + @Test + void testTemporalTypedRoundTrip() { + // tag::temporal_typed[] + def original = new Event( + created: java.time.OffsetDateTime.parse('1979-05-27T07:32:00-08:00'), + effective: java.time.LocalDate.of(1979, 5, 27), + windowStart: java.time.LocalTime.of(7, 32, 0), + updated: java.time.LocalDateTime.of(1979, 5, 27, 7, 32, 0), + name: 'demo') + + def toml = groovy.toml.TomlBuilder.toToml(original) + def parsed = new TomlSlurper().parseTextAs(Event, toml) + + assert parsed.created == original.created // OffsetDateTime, non-UTC offset preserved + assert parsed.effective == original.effective // LocalDate + assert parsed.windowStart == original.windowStart // LocalTime + assert parsed.updated == original.updated // LocalDateTime + assert parsed.name == original.name + // end::temporal_typed[] + assert parsed.created instanceof java.time.OffsetDateTime + assert parsed.effective instanceof java.time.LocalDate + assert parsed.windowStart instanceof java.time.LocalTime + assert parsed.updated instanceof java.time.LocalDateTime + // the round-tripped offset must remain -08:00, not normalised to Z + assert parsed.created.offset == java.time.ZoneOffset.ofHours(-8) + } + + @Test + void testTemporalUntypedStringPath() { + // KNOWN LIMITATION: the untyped slurp path returns TOML temporal values + // as String, not java.time.* types. jackson-dataformat-toml's Object-mode + // deserialization does not surface temporal types unless a target type is + // supplied. Use the typed parseAs/parseTextAs API for java.time.* fidelity. + // This test pins the current behaviour so a future change here is deliberate. + def parsed = new TomlSlurper().parseText(''' +created = 1979-05-27T07:32:00-08:00 +effective = 1979-05-27 +windowStart = 07:32:00 +''') + assert parsed.created == '1979-05-27T07:32:00-08:00' + assert parsed.effective == '1979-05-27' + assert parsed.windowStart == '07:32:00' + assert parsed.created instanceof String + assert parsed.effective instanceof String + assert parsed.windowStart instanceof String + } }