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
1 change: 1 addition & 0 deletions subprojects/groovy-yaml/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -125,12 +128,19 @@ public <T> T parseTextAs(Class<T> type, String yaml) {
*/
public <T> T parseAs(Class<T> 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);
}
Comment thread
paulk-asert marked this conversation as resolved.

/**
* Parse YAML from an input stream into a typed object.
*
Expand Down
30 changes: 28 additions & 2 deletions subprojects/groovy-yaml/src/spec/doc/yaml-userguide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<yaml_typed_temporal>>) for `java.time.LocalDate`, `java.time.LocalTime`,
`java.time.LocalDateTime`, and `java.time.OffsetDateTime` fidelity.
====
Comment thread
paulk-asert marked this conversation as resolved.

=== Typed parsing

`YamlSlurper` can parse YAML directly into typed objects using Jackson databinding.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading