From 4f6c808fb20ed9d0e8522b3354f8309b0fbc149a Mon Sep 17 00:00:00 2001 From: Paul King Date: Sun, 12 Apr 2026 06:30:22 +1000 Subject: [PATCH] GROOVY-11923: Provide a groovy-csv optional module --- settings.gradle | 1 + subprojects/groovy-csv/build.gradle | 35 +++ .../src/main/java/groovy/csv/CsvBuilder.java | 160 ++++++++++++ .../java/groovy/csv/CsvRuntimeException.java | 44 ++++ .../src/main/java/groovy/csv/CsvSlurper.java | 245 ++++++++++++++++++ .../main/java/groovy/csv/package-info.java | 23 ++ .../src/spec/doc/csv-userguide.adoc | 112 ++++++++ .../test/groovy/csv/CsvBuilderTest.groovy | 101 ++++++++ .../test/groovy/csv/CsvSlurperTest.groovy | 106 ++++++++ 9 files changed, 827 insertions(+) create mode 100644 subprojects/groovy-csv/build.gradle create mode 100644 subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java create mode 100644 subprojects/groovy-csv/src/main/java/groovy/csv/CsvRuntimeException.java create mode 100644 subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java create mode 100644 subprojects/groovy-csv/src/main/java/groovy/csv/package-info.java create mode 100644 subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc create mode 100644 subprojects/groovy-csv/src/spec/test/groovy/csv/CsvBuilderTest.groovy create mode 100644 subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy diff --git a/settings.gradle b/settings.gradle index 52d38fb5800..51a38c2ea58 100644 --- a/settings.gradle +++ b/settings.gradle @@ -78,6 +78,7 @@ def subprojects = [ 'groovy-test-junit5', 'groovy-test-junit6', 'groovy-testng', + 'groovy-csv', 'groovy-toml', 'groovy-typecheckers', 'groovy-xml', diff --git a/subprojects/groovy-csv/build.gradle b/subprojects/groovy-csv/build.gradle new file mode 100644 index 00000000000..57fdccd28a3 --- /dev/null +++ b/subprojects/groovy-csv/build.gradle @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +plugins { + id 'org.apache.groovy-library' +} + +dependencies { + api rootProject + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-csv:${versions.jackson}" + implementation "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" + testImplementation projects.groovyTest + testRuntimeOnly "com.fasterxml.jackson.core:jackson-annotations:${versions.jacksonAnnotations}" + testRuntimeOnly projects.groovyAnt // for JavadocAssertionTests +} + +groovyLibrary { + optionalModule() + withoutBinaryCompatibilityChecks() +} diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java new file mode 100644 index 00000000000..f11e67f2d55 --- /dev/null +++ b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvBuilder.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.csv; + +import com.fasterxml.jackson.dataformat.csv.CsvMapper; +import com.fasterxml.jackson.dataformat.csv.CsvSchema; +import groovy.lang.Writable; +import org.apache.groovy.lang.annotation.Incubating; + +import java.io.IOException; +import java.io.Writer; +import java.util.Collection; +import java.util.Map; + +/** + * Builds CSV output from collections of maps or typed objects. + *

+ * Example with maps: + *


+ * def data = [[name: 'Alice', age: 30], [name: 'Bob', age: 25]]
+ * def csv = groovy.csv.CsvBuilder.toCsv(data)
+ * assert csv.contains('name,age')
+ * assert csv.contains('Alice,30')
+ * 
+ * + * @since 6.0.0 + */ +@Incubating +public class CsvBuilder implements Writable { + private final CsvMapper mapper; + private char separator = ','; + private char quoteChar = '"'; + private String content; + + public CsvBuilder() { + this.mapper = new CsvMapper(); + } + + /** + * Set the column separator character (default: comma). + * + * @param separator the separator character + * @return this builder for chaining + */ + public CsvBuilder setSeparator(char separator) { + this.separator = separator; + return this; + } + + /** + * Set the quote character (default: double-quote). + * + * @param quoteChar the quote character + * @return this builder for chaining + */ + public CsvBuilder setQuoteChar(char quoteChar) { + this.quoteChar = quoteChar; + return this; + } + + /** + * Convert a collection of maps to CSV. + * The keys of the first map are used as column headers. + * + * @param data the collection of maps + * @return the CSV string + */ + public static String toCsv(Collection> data) { + if (data == null || data.isEmpty()) { + return ""; + } + CsvMapper csvMapper = new CsvMapper(); + Map first = data.iterator().next(); + CsvSchema.Builder schemaBuilder = CsvSchema.builder(); + for (String key : first.keySet()) { + schemaBuilder.addColumn(key); + } + CsvSchema schema = schemaBuilder.build().withHeader(); + try { + return csvMapper.writer(schema).writeValueAsString(data); + } catch (IOException e) { + throw new CsvRuntimeException(e); + } + } + + /** + * Convert a collection of typed objects to CSV using Jackson databinding. + * Supports {@code @JsonProperty} and {@code @JsonFormat} annotations. + * + * @param data the collection of objects + * @param type the object type (used to derive the schema) + * @param the object type + * @return the CSV string + */ + public static String toCsv(Collection data, Class type) { + if (data == null || data.isEmpty()) { + return ""; + } + CsvMapper csvMapper = new CsvMapper(); + CsvSchema schema = csvMapper.schemaFor(type).withHeader(); + try { + return csvMapper.writer(schema).writeValueAsString(data); + } catch (IOException e) { + throw new CsvRuntimeException(e); + } + } + + /** + * Build CSV from a collection of maps. + * + * @param data the collection of maps + * @return this builder + */ + public CsvBuilder call(Collection> data) { + if (data == null || data.isEmpty()) { + this.content = ""; + return this; + } + Map first = data.iterator().next(); + CsvSchema.Builder schemaBuilder = CsvSchema.builder() + .setColumnSeparator(separator) + .setQuoteChar(quoteChar); + for (String key : first.keySet()) { + schemaBuilder.addColumn(key); + } + CsvSchema schema = schemaBuilder.build().withHeader(); + try { + this.content = mapper.writer(schema).writeValueAsString(data); + } catch (IOException e) { + throw new CsvRuntimeException(e); + } + return this; + } + + @Override + public String toString() { + return content != null ? content : ""; + } + + @Override + public Writer writeTo(Writer out) throws IOException { + return out.append(toString()); + } +} diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvRuntimeException.java b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvRuntimeException.java new file mode 100644 index 00000000000..c105074c71f --- /dev/null +++ b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvRuntimeException.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.csv; + +import groovy.lang.GroovyRuntimeException; +import org.apache.groovy.lang.annotation.Incubating; + +/** + * Represents runtime exception occurred when parsing or building CSV + * + * @since 6.0.0 + */ +@Incubating +public class CsvRuntimeException extends GroovyRuntimeException { + private static final long serialVersionUID = 2809672072790437945L; + + public CsvRuntimeException(String msg) { + super(msg); + } + + public CsvRuntimeException(Throwable cause) { + super(cause); + } + + public CsvRuntimeException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java new file mode 100644 index 00000000000..d85536289ad --- /dev/null +++ b/subprojects/groovy-csv/src/main/java/groovy/csv/CsvSlurper.java @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.csv; + +import com.fasterxml.jackson.databind.MappingIterator; +import com.fasterxml.jackson.dataformat.csv.CsvMapper; +import com.fasterxml.jackson.dataformat.csv.CsvSchema; +import org.apache.groovy.lang.annotation.Incubating; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * Represents a CSV parser. + *

+ * Usage: + *


+ * def csv = new groovy.csv.CsvSlurper().parseText('name,age\nAlice,30\nBob,25')
+ * assert csv[0].name == 'Alice'
+ * assert csv[1].age == '25'
+ * 
+ * + * @since 6.0.0 + */ +@Incubating +public class CsvSlurper { + private final CsvMapper mapper; + private char separator = ','; + private char quoteChar = '"'; + private boolean useHeader = true; + + public CsvSlurper() { + this.mapper = new CsvMapper(); + } + + /** + * Set the column separator character (default: comma). + * + * @param separator the separator character + * @return this slurper for chaining + */ + public CsvSlurper setSeparator(char separator) { + this.separator = separator; + return this; + } + + /** + * Set the quote character (default: double-quote). + * + * @param quoteChar the quote character + * @return this slurper for chaining + */ + public CsvSlurper setQuoteChar(char quoteChar) { + this.quoteChar = quoteChar; + return this; + } + + /** + * Set whether the first row is a header row (default: true). + * + * @param useHeader true to treat the first row as headers + * @return this slurper for chaining + */ + public CsvSlurper setUseHeader(boolean useHeader) { + this.useHeader = useHeader; + return this; + } + + /** + * Parse the content of the specified CSV text. + * + * @param csv the CSV text + * @return a list of maps (one per row), keyed by column headers + */ + public List> parseText(String csv) { + if (csv == null || csv.isBlank()) { + return List.of(); + } + return parse(new StringReader(csv)); + } + + /** + * Parse CSV from a reader. + * When {@code useHeader} is true (the default), each row is returned as a map keyed + * by column headers from the first row. When {@code useHeader} is false, maps are + * keyed by auto-generated column names. + * + * @param reader the reader of CSV + * @return a list of maps (one per row) + */ + public List> parse(Reader reader) { + try { + CsvSchema schema = buildSchema(); + MappingIterator> it = mapper + .readerFor(Map.class) + .with(schema) + .readValues(reader); + List> result = it.readAll(); + // Jackson may return a single empty map for header-only input + if (result.size() == 1 && result.get(0).isEmpty()) { + result.clear(); + } + return result; + } catch (IOException e) { + throw new CsvRuntimeException(e); + } + } + + /** + * Parse CSV from an input stream. The caller is responsible for closing the stream. + * + * @param stream the input stream of CSV + * @return a list of maps (one per row) + */ + public List> parse(InputStream stream) { + return parse(new InputStreamReader(stream)); + } + + /** + * Parse CSV from a file. + * + * @param file the CSV file + * @return a list of maps (one per row), keyed by column headers + */ + public List> parse(File file) throws IOException { + return parse(file.toPath()); + } + + /** + * Parse CSV from a path. + * + * @param path the path to the CSV file + * @return a list of maps (one per row), keyed by column headers + */ + public List> parse(Path path) throws IOException { + try (InputStream stream = Files.newInputStream(path)) { + return parse(new InputStreamReader(stream)); + } + } + + /** + * Parse CSV into typed objects using Jackson databinding. + * Supports {@code @JsonProperty} and {@code @JsonFormat} annotations for + * column mapping and type conversion. + * + * @param type the target type + * @param csv the CSV text + * @param the target type + * @return a list of typed objects + */ + public List parseAs(Class type, String csv) { + return parseAs(type, new StringReader(csv)); + } + + /** + * Parse CSV from a reader into typed objects. + * + * @param type the target type + * @param reader the reader of CSV + * @param the target type + * @return a list of typed objects + */ + public List parseAs(Class type, Reader reader) { + try { + // Use empty schema with header — Jackson matches columns by name + // rather than by position, allowing CSV column order to differ from field order + CsvSchema schema = CsvSchema.emptySchema(); + if (useHeader) { + schema = schema.withHeader(); + } + schema = schema.rebuild() + .setColumnSeparator(separator) + .setQuoteChar(quoteChar) + .build(); + MappingIterator it = mapper + .readerFor(type) + .with(schema) + .readValues(reader); + return it.readAll(); + } catch (IOException e) { + throw new CsvRuntimeException(e); + } + } + + /** + * Parse CSV from a file into typed objects. + * + * @param type the target type + * @param file the CSV file + * @param the target type + * @return a list of typed objects + */ + public List parseAs(Class type, File file) throws IOException { + return parseAs(type, file.toPath()); + } + + /** + * Parse CSV from a path into typed objects. + * + * @param type the target type + * @param path the path to the CSV file + * @param the target type + * @return a list of typed objects + */ + public List parseAs(Class type, Path path) throws IOException { + try (InputStream stream = Files.newInputStream(path)) { + return parseAs(type, new InputStreamReader(stream)); + } + } + + private CsvSchema buildSchema() { + CsvSchema.Builder builder = CsvSchema.builder() + .setColumnSeparator(separator) + .setQuoteChar(quoteChar); + CsvSchema schema = builder.build(); + if (useHeader) { + schema = schema.withHeader(); + } + return schema; + } +} diff --git a/subprojects/groovy-csv/src/main/java/groovy/csv/package-info.java b/subprojects/groovy-csv/src/main/java/groovy/csv/package-info.java new file mode 100644 index 00000000000..af071c8fe60 --- /dev/null +++ b/subprojects/groovy-csv/src/main/java/groovy/csv/package-info.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Classes for parsing and building CSV. + */ +package groovy.csv; diff --git a/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc b/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc new file mode 100644 index 00000000000..853970c7012 --- /dev/null +++ b/subprojects/groovy-csv/src/spec/doc/csv-userguide.adoc @@ -0,0 +1,112 @@ +////////////////////////////////////////// + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +////////////////////////////////////////// + += Processing CSV + +Groovy has an optional `groovy-csv` module which provides support for reading and writing +https://datatracker.ietf.org/doc/html/rfc4180[CSV (RFC 4180)] data. The classes are found +in the `groovy.csv` package. + +[[csv_csvslurper]] +== CsvSlurper + +`CsvSlurper` parses CSV text into a list of maps, where each row becomes a map keyed +by the column headers from the first row. Values are returned as strings. + +[source,groovy] +---- +include::../test/groovy/csv/CsvSlurperTest.groovy[tags=parse_text,indent=0] +---- + +Rows support dynamic property access using the header names: + +[source,groovy] +---- +include::../test/groovy/csv/CsvSlurperTest.groovy[tags=property_access,indent=0] +---- + +=== Configuration + +The separator character and quote character can be customised: + +[source,groovy] +---- +include::../test/groovy/csv/CsvSlurperTest.groovy[tags=custom_separator,indent=0] +---- + +Quoted fields follow RFC 4180 — fields containing the separator, newlines, or the quote +character are enclosed in quotes, with embedded quotes doubled: + +[source,groovy] +---- +include::../test/groovy/csv/CsvSlurperTest.groovy[tags=quoted_fields,indent=0] +---- + +=== Typed parsing + +`CsvSlurper` can parse CSV directly into typed objects using Jackson databinding. +Standard Jackson annotations such as `@JsonProperty` and `@JsonFormat` are supported +for column name mapping and type conversion: + +[source,groovy] +---- +include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_parsing,indent=0] +---- + +[source,groovy] +---- +include::../test/groovy/csv/CsvSlurperTest.groovy[tags=typed_parsing_usage,indent=0] +---- + +[[csv_csvbuilder]] +== CsvBuilder + +`CsvBuilder` converts collections of maps or typed objects to CSV. The keys of the first +map are used as column headers. + +[source,groovy] +---- +include::../test/groovy/csv/CsvBuilderTest.groovy[tags=to_csv_maps,indent=0] +---- + +=== Typed writing + +`CsvBuilder` can also write typed objects. Jackson annotations are supported for +column naming and formatting: + +[source,groovy] +---- +include::../test/groovy/csv/CsvBuilderTest.groovy[tags=typed_writing,indent=0] +---- + +[source,groovy] +---- +include::../test/groovy/csv/CsvBuilderTest.groovy[tags=typed_writing_usage,indent=0] +---- + +=== Round-trip + +CSV written by `CsvBuilder` can be read back with `CsvSlurper`: + +[source,groovy] +---- +include::../test/groovy/csv/CsvBuilderTest.groovy[tags=round_trip,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 new file mode 100644 index 00000000000..989bd6afcf2 --- /dev/null +++ b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvBuilderTest.groovy @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.csv + +import groovy.test.GroovyTestCase + +class CsvBuilderTest extends GroovyTestCase { + + void testToCsvFromMaps() { + // tag::to_csv_maps[] + def data = [ + [name: 'Alice', age: 30], + [name: 'Bob', age: 25] + ] + def csv = CsvBuilder.toCsv(data) + assert csv.contains('name,age') + assert csv.contains('Alice,30') + assert csv.contains('Bob,25') + // end::to_csv_maps[] + } + + void testToCsvEmpty() { + assert CsvBuilder.toCsv([]) == '' + assert CsvBuilder.toCsv(null) == '' + } + + void testToCsvQuotesSpecialChars() { + def data = [[name: 'Alice, Jr.', note: 'said "hi"']] + def csv = CsvBuilder.toCsv(data) + assert csv.contains('"Alice, Jr."') + assert csv.contains('"said ""hi"""') + } + + void testBuilderInstance() { + def builder = new CsvBuilder() + builder.call([[name: 'Alice', age: 30], [name: 'Bob', age: 25]]) + def csv = builder.toString() + assert csv.contains('name,age') + assert csv.contains('Alice,30') + } + + void testWritable() { + def builder = new CsvBuilder() + builder.call([[x: 1, y: 2]]) + def out = new StringWriter() + out << builder + assert out.toString().contains('x,y') + } + + void testRoundTrip() { + // tag::round_trip[] + def original = [[name: 'Alice', age: '30'], [name: 'Bob', age: '25']] + def csv = CsvBuilder.toCsv(original) + def parsed = new CsvSlurper().parseText(csv) + assert parsed[0].name == 'Alice' + assert parsed[1].age == '25' + // end::round_trip[] + } + + // tag::typed_writing[] + static class Product { + String name + BigDecimal price + } + // end::typed_writing[] + + void testToCsvFromTypedObjects() { + // tag::typed_writing_usage[] + def products = [new Product(name: 'Widget', price: 9.99), + new Product(name: 'Gadget', price: 24.50)] + def csv = CsvBuilder.toCsv(products, Product) + assert csv.contains('name,price') + assert csv.contains('Widget,9.99') + assert csv.contains('Gadget,24.5') + // end::typed_writing_usage[] + } + + void testTypedRoundTrip() { + def products = [new Product(name: 'Widget', price: 9.99)] + def csv = CsvBuilder.toCsv(products, Product) + def parsed = new CsvSlurper().parseAs(Product, csv) + assert parsed[0].name == 'Widget' + assert parsed[0].price == 9.99 + } +} diff --git a/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy new file mode 100644 index 00000000000..9a19dabcb77 --- /dev/null +++ b/subprojects/groovy-csv/src/spec/test/groovy/csv/CsvSlurperTest.groovy @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.csv + +import groovy.test.GroovyTestCase + +class CsvSlurperTest extends GroovyTestCase { + + void testParseText() { + // tag::parse_text[] + def csv = new CsvSlurper().parseText('name,age\nAlice,30\nBob,25') + assert csv.size() == 2 + assert csv[0].name == 'Alice' + assert csv[0].age == '30' + assert csv[1].name == 'Bob' + // end::parse_text[] + } + + void testPropertyAccess() { + // tag::property_access[] + def csv = new CsvSlurper().parseText('''\ + name,city,country + Alice,London,UK + Bob,Paris,France'''.stripIndent()) + assert csv[0].city == 'London' + assert csv[1].country == 'France' + // end::property_access[] + } + + void testCustomSeparator() { + // tag::custom_separator[] + def csv = new CsvSlurper().setSeparator((char) '\t').parseText('name\tage\nAlice\t30') + assert csv[0].name == 'Alice' + assert csv[0].age == '30' + // end::custom_separator[] + } + + void testQuotedFields() { + // tag::quoted_fields[] + def csv = new CsvSlurper().parseText('name,note\nAlice,"hello, world"\nBob,"say ""hi"""') + assert csv[0].note == 'hello, world' + assert csv[1].note == 'say "hi"' + // end::quoted_fields[] + } + + void testEmptyInput() { + def csv = new CsvSlurper().parseText('') + assert csv.isEmpty() + } + + void testSingleRow() { + def csv = new CsvSlurper().parseText('name,age\nAlice,30') + assert csv.size() == 1 + assert csv[0].name == 'Alice' + } + + void testSemicolonSeparator() { + def csv = new CsvSlurper().setSeparator((char) ';').parseText('name;age\nAlice;30') + assert csv[0].name == 'Alice' + } + + void testParseFromReader() { + def reader = new StringReader('name,age\nAlice,30') + def csv = new CsvSlurper().parse(reader) + assert csv[0].name == 'Alice' + } + + // tag::typed_parsing[] + static class Sale { + String customer + BigDecimal amount + } + // end::typed_parsing[] + + void testTypedParsing() { + // tag::typed_parsing_usage[] + def sales = new CsvSlurper().parseAs(Sale, 'customer,amount\nAcme,1500.00\nGlobex,250.50') + assert sales.size() == 2 + assert sales[0].customer == 'Acme' + assert sales[0].amount == 1500.00 + assert sales[1].customer == 'Globex' + // end::typed_parsing_usage[] + } + + void testTypedParsingMultipleFields() { + def items = new CsvSlurper().parseAs(Sale, 'customer,amount\nAlice,99.99') + assert items[0] instanceof Sale + assert items[0].amount instanceof BigDecimal + } +}