diff --git a/subprojects/groovy-yaml/build.gradle b/subprojects/groovy-yaml/build.gradle index f5f0b2f45cc..885dce47b7e 100644 --- a/subprojects/groovy-yaml/build.gradle +++ b/subprojects/groovy-yaml/build.gradle @@ -24,6 +24,7 @@ dependencies { api rootProject // YamlBuilder extends GroovyObjectSupport... implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${versions.jackson}" implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions.jackson}" implementation projects.groovyJson testImplementation projects.groovyTest testRuntimeOnly "com.fasterxml.jackson.core:jackson-annotations:${versions.jacksonAnnotations}" diff --git a/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlBuilder.java b/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlBuilder.java index a0d2a96d611..cca21b0331b 100644 --- a/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlBuilder.java +++ b/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlBuilder.java @@ -18,7 +18,6 @@ */ package groovy.yaml; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; import groovy.json.JsonBuilder; @@ -59,8 +58,8 @@ public YamlBuilder() { */ public static String toYaml(Object object) { try { - return new ObjectMapper(new YAMLFactory() - .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)) + return YamlSlurper.mapper(new YAMLFactory() + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER)) .writeValueAsString(object); } catch (IOException e) { throw new YamlRuntimeException(e); diff --git a/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlSlurper.java b/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlSlurper.java index 21cfef085d3..47d1f2dc23b 100644 --- a/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlSlurper.java +++ b/subprojects/groovy-yaml/src/main/java/groovy/yaml/YamlSlurper.java @@ -18,8 +18,11 @@ */ package groovy.yaml; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import groovy.json.JsonSlurper; import org.apache.groovy.yaml.util.YamlConverter; @@ -125,12 +128,19 @@ public T parseTextAs(Class type, String yaml) { */ public T parseAs(Class type, Reader reader) { try { - return new ObjectMapper(new YAMLFactory()).readValue(reader, type); + return mapper(new YAMLFactory()).readValue(reader, type); } catch (IOException e) { throw new YamlRuntimeException(e); } } + static ObjectMapper mapper(YAMLFactory factory) { + return new ObjectMapper(factory) + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE); + } + /** * Parse YAML from an input stream into a typed object. * diff --git a/subprojects/groovy-yaml/src/spec/doc/yaml-userguide.adoc b/subprojects/groovy-yaml/src/spec/doc/yaml-userguide.adoc index 6fad94a56ae..2fbd792ca50 100644 --- a/subprojects/groovy-yaml/src/spec/doc/yaml-userguide.adoc +++ b/subprojects/groovy-yaml/src/spec/doc/yaml-userguide.adoc @@ -82,14 +82,23 @@ The following table gives an overview of the YAML types and the corresponding Gr |null |`null` -|date -|`java.util.Date` based on the `yyyy-MM-dd'T'HH:mm:ssZ` date format +|date/time +|`java.lang.String` (see the note below) |=== [NOTE] Whenever a value in YAML is `null`, `YamlSlurper` supplements it with the Groovy `null` value. This is in contrast to other YAML parsers that represent a `null` value with a library-provided singleton object. +[NOTE] +==== +For the untyped `parse`/`parseText` API, YAML date and time values are returned as +`String`. `YamlSlurper` routes the untyped path through a YAML-to-JSON conversion, +and JSON has no native temporal types. Use the typed `parseAs`/`parseTextAs` API +(see <>) for `java.time.LocalDate`, `java.time.LocalTime`, +`java.time.LocalDateTime`, and `java.time.OffsetDateTime` fidelity. +==== + === Typed parsing `YamlSlurper` can parse YAML directly into typed objects using Jackson databinding. @@ -111,6 +120,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. +[[yaml_typed_temporal]] +=== Typed date and time values + +When a target type declares `java.time.*` fields, the typed `parseAs`/`parseTextAs` +API and `YamlBuilder.toYaml` round-trip YAML temporal values with full fidelity, +including the original zone offset for `OffsetDateTime`: + +[source,groovy] +---- +include::../test/groovy/yaml/YamlParserTest.groovy[tags=temporal_class,indent=0] +---- + +[source,groovy] +---- +include::../test/groovy/yaml/YamlParserTest.groovy[tags=temporal_typed,indent=0] +---- + === Builders Another way to create YAML from Groovy is to use `YamlBuilder`. The builder provide a diff --git a/subprojects/groovy-yaml/src/spec/test/groovy/yaml/YamlParserTest.groovy b/subprojects/groovy-yaml/src/spec/test/groovy/yaml/YamlParserTest.groovy index e614dfe281b..47d4009ed72 100644 --- a/subprojects/groovy-yaml/src/spec/test/groovy/yaml/YamlParserTest.groovy +++ b/subprojects/groovy-yaml/src/spec/test/groovy/yaml/YamlParserTest.groovy @@ -179,4 +179,61 @@ version: 8 assert 'java' == yaml[1].language assert 8 == yaml[1].version } + + // 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 yaml = YamlBuilder.toYaml(original) + def parsed = new YamlSlurper().parseTextAs(Event, yaml) + + 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 temporal values as + // String, not java.time.* types. YamlSlurper routes the untyped path + // through a YAML->JSON conversion, and JSON has no native temporal + // types. 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 YamlSlurper().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 + } }