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-csv/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Comment thread
paulk-asert marked this conversation as resolved.
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions.jackson}"
Comment thread
paulk-asert marked this conversation as resolved.
testImplementation projects.groovyTest
testRuntimeOnly "com.fasterxml.jackson.core:jackson-annotations:${versions.jacksonAnnotations}"
testRuntimeOnly projects.groovyAnt // for JavadocAssertionTests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public class CsvBuilder implements Writable {
* for field escaping.
*/
public CsvBuilder() {
this.mapper = new CsvMapper();
this.mapper = CsvSlurper.mapper();
}

/**
Expand Down Expand Up @@ -89,7 +89,7 @@ public static String toCsv(Collection<? extends Map<String, ?>> data) {
if (data == null || data.isEmpty()) {
return "";
}
CsvMapper csvMapper = new CsvMapper();
CsvMapper csvMapper = CsvSlurper.mapper();
Map<String, ?> first = data.iterator().next();
CsvSchema.Builder schemaBuilder = CsvSchema.builder();
for (String key : first.keySet()) {
Expand All @@ -116,7 +116,7 @@ public static <T> String toCsv(Collection<T> data, Class<T> 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);
Expand Down
16 changes: 15 additions & 1 deletion subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down
25 changes: 25 additions & 0 deletions subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<csv_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.
====

[[csv_csvbuilder]]
== CsvBuilder

Expand Down Expand Up @@ -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]
----
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
paulk-asert marked this conversation as resolved.
}
// 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
}
}
Loading