diff --git a/subprojects/groovy-csv/build.gradle b/subprojects/groovy-csv/build.gradle index 57fdccd28a3..5f663f82bd8 100644 --- a/subprojects/groovy-csv/build.gradle +++ b/subprojects/groovy-csv/build.gradle @@ -24,6 +24,7 @@ dependencies { api rootProject implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-csv:${versions.jackson}" implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions.jackson}" testImplementation projects.groovyTest testRuntimeOnly "com.fasterxml.jackson.core:jackson-annotations:${versions.jacksonAnnotations}" testRuntimeOnly projects.groovyAnt // for JavadocAssertionTests diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java index fd3b448c7ce..517cc8161fd 100644 --- a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java +++ b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java @@ -53,7 +53,7 @@ public class CsvBuilder implements Writable { * for field escaping. */ public CsvBuilder() { - this.mapper = new CsvMapper(); + this.mapper = CsvSlurper.mapper(); } /** @@ -89,7 +89,7 @@ public static String toCsv(Collection> data) { if (data == null || data.isEmpty()) { return ""; } - CsvMapper csvMapper = new CsvMapper(); + CsvMapper csvMapper = CsvSlurper.mapper(); Map first = data.iterator().next(); CsvSchema.Builder schemaBuilder = CsvSchema.builder(); for (String key : first.keySet()) { @@ -116,7 +116,7 @@ public static String toCsv(Collection data, Class type) { if (data == null || data.isEmpty()) { return ""; } - CsvMapper csvMapper = new CsvMapper(); + CsvMapper csvMapper = CsvSlurper.mapper(); CsvSchema schema = csvMapper.schemaFor(type).withHeader(); try { return csvMapper.writer(schema).writeValueAsString(data); diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java index 25a2b623649..85770d2d3c6 100644 --- a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java +++ b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java @@ -18,9 +18,12 @@ */ package groovy.csv; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.dataformat.csv.CsvMapper; import com.fasterxml.jackson.dataformat.csv.CsvSchema; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.groovy.lang.annotation.Incubating; import java.io.File; @@ -58,7 +61,18 @@ public class CsvSlurper { * and treats the first row as headers. */ public CsvSlurper() { - this.mapper = new CsvMapper(); + this.mapper = mapper(); + } + + // CsvMapper is thread-safe once configured, so a single shared instance is reused + private static final CsvMapper MAPPER = CsvMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE) + .build(); + + static CsvMapper mapper() { + return MAPPER; } /** diff --git a/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc b/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc index 4f361f6379d..862ee8c13e7 100644 --- a/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc +++ b/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc @@ -78,6 +78,15 @@ include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_parsing,indent=0] include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_parsing_usage,indent=0] ---- +[NOTE] +==== +CSV has no native types — every cell is text, so the untyped `parse`/`parseText` +API always returns `String` values, including for date/time-looking columns. Use +the typed `parseAs` API with `java.time.*` fields (see <>) for +`java.time.LocalDate`, `java.time.LocalTime`, `java.time.LocalDateTime`, and +`java.time.OffsetDateTime` fidelity. +==== + [[csv_csvbuilder]] == CsvBuilder @@ -112,3 +121,19 @@ CSV written by `CsvBuilder` can be read back with `CsvSlurper`: ---- include::../test/groovy/csv/CsvBuilderTest.groovy[tags=round_trip,indent=0] ---- + +[[csv_typed_temporal]] +=== Typed date and time values + +When a target type declares `java.time.*` fields, `CsvBuilder.toCsv` and the typed +`CsvSlurper.parseAs` API round-trip temporal values with full fidelity: + +[source,groovy] +---- +include::../test/groovy/csv/CsvBuilderTest.groovy[tags=temporal_class,indent=0] +---- + +[source,groovy] +---- +include::../test/groovy/csv/CsvBuilderTest.groovy[tags=temporal_typed,indent=0] +---- diff --git a/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvBuilderTest.groovy b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvBuilderTest.groovy index 9247517049d..0f5aa4910fb 100644 --- a/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvBuilderTest.groovy +++ b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvBuilderTest.groovy @@ -106,4 +106,55 @@ class CsvBuilderTest { assert parsed[0].name == 'Widget' assert parsed[0].price == 9.99 } + + // tag::temporal_class[] + static class Event { + java.time.LocalDate day + java.time.LocalTime windowStart + java.time.LocalDateTime updated + java.time.OffsetDateTime created + String label + } + // end::temporal_class[] + + @Test + void testTemporalTypedRoundTrip() { + // tag::temporal_typed[] + def original = [new Event( + day: 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), + created: java.time.OffsetDateTime.parse('1979-05-27T07:32:00-08:00'), + label: 'first')] + + def csv = CsvBuilder.toCsv(original, Event) + def parsed = new CsvSlurper().parseAs(Event, csv) + + assert parsed[0].day == original[0].day // LocalDate + assert parsed[0].windowStart == original[0].windowStart // LocalTime + assert parsed[0].updated == original[0].updated // LocalDateTime + assert parsed[0].created == original[0].created // OffsetDateTime, non-UTC offset preserved + assert parsed[0].label == original[0].label + // end::temporal_typed[] + assert parsed[0].day instanceof java.time.LocalDate + assert parsed[0].windowStart instanceof java.time.LocalTime + assert parsed[0].updated instanceof java.time.LocalDateTime + assert parsed[0].created instanceof java.time.OffsetDateTime + // the round-tripped offset must remain -08:00, not normalised to Z + assert parsed[0].created.offset == java.time.ZoneOffset.ofHours(-8) + } + + @Test + void testTemporalUntypedStringPath() { + // KNOWN LIMITATION: CSV has no native types — every cell is text. The + // untyped slurp path therefore always returns String values, including + // for date/time-looking columns. Use the typed parseAs API with + // java.time.* fields for temporal fidelity. This test pins the current + // behaviour so a future change here is deliberate. + def parsed = new CsvSlurper().parseText('day,windowStart\n1979-05-27,07:32:00') + assert parsed[0].day == '1979-05-27' + assert parsed[0].windowStart == '07:32:00' + assert parsed[0].day instanceof String + assert parsed[0].windowStart instanceof String + } }