diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/AbstractParameterBuilder.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/AbstractParameterBuilder.java index e2966ea5d..a71cff0e6 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/AbstractParameterBuilder.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/AbstractParameterBuilder.java @@ -29,6 +29,7 @@ import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.function.Consumer; import org.apache.fesod.common.util.ListUtils; import org.apache.fesod.sheet.converters.Converter; import org.apache.fesod.sheet.enums.CacheLocationEnum; @@ -40,8 +41,9 @@ */ public abstract class AbstractParameterBuilder { /** - * You can only choose one of the {@link #head(List)} and {@link #head(Class)} + * You can only choose one of the ({@link #head(List)} or {@link #head(Consumer)}) and {@link #head(Class)} * + * @see #head(Consumer) * @param head * @return */ @@ -50,6 +52,18 @@ public T head(List> head) { return self(); } + /** + * You can only choose one of the ({@link #head(List)} or {@link #head(Consumer)}) and {@link #head(Class)} + * + * @see #head(List) + * @param headBuilderConsumer + * @return + */ + public T head(Consumer headBuilderConsumer) { + parameter().setHead(DefaultHeadBuilder.define(headBuilderConsumer)); + return self(); + } + /** * Ensures and returns a mutable head list. * @@ -68,7 +82,7 @@ private List> toMutableListIfNecessary(List> head) { } /** - * You can only choose one of the {@link #head(List)} and {@link #head(Class)} + * You can only choose one of the ({@link #head(List)} or {@link #head(Consumer)}) and {@link #head(Class)} * * @param clazz * @return diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/DefaultHeadBuilder.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/DefaultHeadBuilder.java new file mode 100644 index 000000000..dec891cf2 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/DefaultHeadBuilder.java @@ -0,0 +1,133 @@ +/* + * 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 org.apache.fesod.sheet.metadata; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import org.apache.commons.lang3.Validate; + +/** + * Standard implementation of {@code HeadBuilder}. + */ +class DefaultHeadBuilder implements HeadBuilder { + + private final List> columns; + private final List prefixes; + + DefaultHeadBuilder() { + this(new ArrayList<>(), new ArrayList<>()); + } + + DefaultHeadBuilder(List> columns, List prefixes) { + this.columns = columns; + this.prefixes = prefixes; + } + + static List> define(Consumer headBuilderConsumer) { + Validate.notNull(headBuilderConsumer, "headBuilderConsumer must not be null"); + + DefaultHeadBuilder builder = new DefaultHeadBuilder(); + headBuilderConsumer.accept(builder); + return builder.toHead(); + } + + /** + * Define a single column with a fixed head names (containing at least one column). + * + * @param headName the first header name (must not be {@code null}) + * @param subHeadNames optional sublevel header names + * @return this for builder chains + */ + @Override + public HeadBuilder column(String headName, String... subHeadNames) { + Validate.notNull(headName, "header name must not be null"); + + int initialCapacity = prefixes.size() + ((subHeadNames == null) ? 0 : subHeadNames.length) + 1; + List current = new ArrayList<>(initialCapacity); + current.addAll(prefixes); + current.add(headName); + + if (subHeadNames != null) { + Validate.noNullElements(subHeadNames, "sub-header names must not contain null elements"); + Collections.addAll(current, subHeadNames); + } + columns.add(current); + return this; + } + + /** + * Define a single column with a repeating header name at multiple levels. + * + * @param headName the header name to repeat (must not be {@code null}) + * @param repeat the number of times to repeat the name + * @return this for builder chains + */ + @Override + public HeadBuilder column(String headName, int repeat) { + Validate.notNull(headName, "header name must not be null"); + Validate.isTrue(repeat > 0, "header repeat must be greater than 0"); + + int initialCapacity = prefixes.size() + repeat; + List current = new ArrayList<>(initialCapacity); + current.addAll(prefixes); + + for (int i = 0; i < repeat; i++) { + current.add(headName); + } + + columns.add(current); + return this; + } + + /** + * Declare multiple shared parent header names to group sub-headers. + * + * @param parentHeadNames the shared parent header names (must not be {@code null} or empty) + * @param subHeadBuilderConsumer the consumer to define sub-headers + * @return this for builder chains + */ + @Override + public HeadBuilder columns(List parentHeadNames, Consumer subHeadBuilderConsumer) { + Validate.notEmpty(parentHeadNames, "parent header names must not be null or empty"); + Validate.noNullElements(parentHeadNames, "parent header names must not contain null elements"); + Validate.notNull(subHeadBuilderConsumer, "subHeadBuilderConsumer must not be null"); + + int previousSize = this.prefixes.size(); + this.prefixes.addAll(parentHeadNames); + + subHeadBuilderConsumer.accept(this); + + while (this.prefixes.size() > previousSize) { + this.prefixes.remove(this.prefixes.size() - 1); + } + return this; + } + + /** + * Return the heads. + * + * @return the list of head + */ + List> toHead() { + return columns; + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/HeadBuilder.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/HeadBuilder.java new file mode 100644 index 000000000..39b3984a2 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/metadata/HeadBuilder.java @@ -0,0 +1,134 @@ +/* + * 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 org.apache.fesod.sheet.metadata; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import org.apache.commons.lang3.Validate; + +/** + * A builder for configuring sheet headers in No-Bean mode. + * + *

Example - complex headers:

+ *
+ * {@code FesodSheet.write(pathname)
+ *           .head(builder -> builder
+ *              .column("ID", 2)
+ *              .columns("User Info", sub -> sub.column("Name").column("Age"))
+ *              .column("Others", "Remark")
+ *           )
+ * // Equivalent to:
+ * List> head = new ArrayList<>();
+ * head.add(new ArrayList<>(Arrays.asList("ID", "ID")));
+ * head.add(new ArrayList<>(Arrays.asList("User Info", "Name")));
+ * head.add(new ArrayList<>(Arrays.asList("User Info", "Age")));
+ * head.add(new ArrayList<>(Arrays.asList("Others", "Remark")));
+ *
+ * FesodSheet.write(pathname)
+ *           .head(head)}
+ * 
+ * + *

Example - single-level headers:

+ *
+ * {@code FesodSheet.write(pathname)
+ *           .head(HeadBuilder.forSimple("ID", "Name", "Age", "Remark"))
+ * // Equivalent to:
+ * List> head = new ArrayList<>();
+ * head.add(new ArrayList<>(Arrays.asList("ID")));
+ * head.add(new ArrayList<>(Arrays.asList("Name")));
+ * head.add(new ArrayList<>(Arrays.asList("Age")));
+ * head.add(new ArrayList<>(Arrays.asList("Remark")));
+ *
+ * FesodSheet.write(pathname)
+ *     .head(head)}
+ * 
+ * + * @see AbstractParameterBuilder#head(Consumer) + */ +public interface HeadBuilder { + + /** + * Define a single column with a fixed head names (containing at least one column). + * + * @param headName the first header name (must not be {@code null}) + * @param subHeadNames optional sublevel header names + * @return this for builder chains + */ + HeadBuilder column(String headName, String... subHeadNames); + + /** + * Define a single column with a repeating header name at multiple levels. + * + * @param headName the header name to repeat (must not be {@code null}) + * @param repeat the number of times to repeat the name + * @return this for builder chains + */ + HeadBuilder column(String headName, int repeat); + + /** + * Declare a shared parent header name to group sub-headers. + * + * @param parentHeadName the shared parent header name (must not be {@code null}) + * @param subHeadBuilderConsumer the consumer to define sub-headers + * @return this for builder chains + */ + default HeadBuilder columns(String parentHeadName, Consumer subHeadBuilderConsumer) { + return columns(Collections.singletonList(parentHeadName), subHeadBuilderConsumer); + } + + /** + * Declare multiple shared parent header names to group sub-headers. + * + * @param parentHeadNames the shared parent header names (must not be {@code null} or empty) + * @param subHeadBuilderConsumer the consumer to define sub-headers + * @return this for builder chains + */ + HeadBuilder columns(List parentHeadNames, Consumer subHeadBuilderConsumer); + + /** + * Build a simple single-level header list (containing at least one column). + * + * @param headName the first header name (must not be {@code null}) + * @param otherHeadNames optional other header names + * @return a simple single-level header list + */ + static List> forSimple(String headName, String... otherHeadNames) { + Validate.notNull(headName, "header name must not be null"); + + int initialCapacity = ((otherHeadNames == null) ? 0 : otherHeadNames.length) + 1; + List> result = new ArrayList<>(initialCapacity); + + List firstHead = new ArrayList<>(1); + firstHead.add(headName); + result.add(firstHead); + + if (otherHeadNames != null) { + Validate.noNullElements(otherHeadNames, "other header names must not contain null elements"); + for (String otherHeadName : otherHeadNames) { + List otherHead = new ArrayList<>(1); + otherHead.add(otherHeadName); + result.add(otherHead); + } + } + return result; + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/metadata/HeadBuilderTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/metadata/HeadBuilderTest.java new file mode 100644 index 000000000..d0d15590e --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/metadata/HeadBuilderTest.java @@ -0,0 +1,219 @@ +/* + * 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 org.apache.fesod.sheet.metadata; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import org.apache.fesod.sheet.testkit.Tags; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DefaultHeadBuilder} and {@link HeadBuilder}. + */ +@Tag(Tags.UNIT) +class HeadBuilderTest { + + static Consumer withEmpty() { + return b -> {}; + } + + @Test + void forSimple_singleName() { + List> head = HeadBuilder.forSimple("ID"); + + Assertions.assertEquals(1, head.size()); + Assertions.assertEquals(Arrays.asList("ID"), head.get(0)); + } + + @Test + void forSimple_multipleNames() { + List> head = HeadBuilder.forSimple("ID", "Name", "Age"); + + Assertions.assertEquals(3, head.size()); + Assertions.assertEquals(Arrays.asList("ID"), head.get(0)); + Assertions.assertEquals(Arrays.asList("Name"), head.get(1)); + Assertions.assertEquals(Arrays.asList("Age"), head.get(2)); + } + + @Test + void forSimple_nullFirstNameThrows() { + Assertions.assertThrows(NullPointerException.class, () -> HeadBuilder.forSimple(null, "Name")); + } + + @Test + void forSimple_nullOtherNameThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> HeadBuilder.forSimple("ID", "Name", null)); + } + + @Test + void define_nullConsumerThrows() { + Assertions.assertThrows(NullPointerException.class, () -> DefaultHeadBuilder.define(null)); + } + + @Test + void define_emptyConsumerReturnsEmptyHead() { + List> head = DefaultHeadBuilder.define(withEmpty()); + + Assertions.assertNotNull(head); + Assertions.assertTrue(head.isEmpty()); + } + + @Test + void column_singleName() { + List> head = DefaultHeadBuilder.define(b -> b.column("ID")); + + Assertions.assertEquals(1, head.size()); + Assertions.assertEquals(Arrays.asList("ID"), head.get(0)); + } + + @Test + void column_withSubHeadNames() { + List> head = DefaultHeadBuilder.define(b -> b.column("A", "B", "C")); + + Assertions.assertEquals(1, head.size()); + Assertions.assertEquals(Arrays.asList("A", "B", "C"), head.get(0)); + } + + @Test + void column_nullHeadNameThrows() { + Assertions.assertThrows(NullPointerException.class, () -> DefaultHeadBuilder.define(b -> b.column(null, "B"))); + } + + @Test + void column_nullSubHeadNameThrows() { + Assertions.assertThrows( + IllegalArgumentException.class, () -> DefaultHeadBuilder.define(b -> b.column("A", "B", null))); + } + + @Test + void column_repeatOnce() { + List> head = DefaultHeadBuilder.define(b -> b.column("X", 1)); + + Assertions.assertEquals(Arrays.asList("X"), head.get(0)); + } + + @Test + void column_repeatMultiple() { + List> head = DefaultHeadBuilder.define(b -> b.column("X", 3)); + + Assertions.assertEquals(1, head.size()); + Assertions.assertEquals(Arrays.asList("X", "X", "X"), head.get(0)); + } + + @Test + void column_repeatZeroThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> DefaultHeadBuilder.define(b -> b.column("X", 0))); + } + + @Test + void column_repeatNegativeThrows() { + Assertions.assertThrows( + IllegalArgumentException.class, () -> DefaultHeadBuilder.define(b -> b.column("X", -2))); + } + + @Test + void column_repeat_nullHeadNameThrows() { + Assertions.assertThrows(NullPointerException.class, () -> DefaultHeadBuilder.define(b -> b.column(null, 2))); + } + + @Test + void columns_singleParentAppliesPrefixToSubColumns() { + List> head = DefaultHeadBuilder.define( + b -> b.columns("User Info", sub -> sub.column("Name").column("Age"))); + + Assertions.assertEquals(2, head.size()); + Assertions.assertEquals(Arrays.asList("User Info", "Name"), head.get(0)); + Assertions.assertEquals(Arrays.asList("User Info", "Age"), head.get(1)); + } + + @Test + void columns_multipleParentNamesStackAsPrefix() { + List> head = DefaultHeadBuilder.define( + b -> b.columns(Arrays.asList("P", "Q"), sub -> sub.column("A").column("B"))); + + Assertions.assertEquals(2, head.size()); + Assertions.assertEquals(Arrays.asList("P", "Q", "A"), head.get(0)); + Assertions.assertEquals(Arrays.asList("P", "Q", "B"), head.get(1)); + } + + @Test + void columns_prefixesAreRestoredAfterBlock() { + List> head = DefaultHeadBuilder.define( + b -> b.columns("P", sub -> sub.column("A")).column("B")); + + Assertions.assertEquals(2, head.size()); + Assertions.assertEquals(Arrays.asList("P", "A"), head.get(0)); + Assertions.assertEquals(Arrays.asList("B"), head.get(1)); + } + + @Test + void columns_nestedParentsConcatenate() { + List> head = DefaultHeadBuilder.define( + b -> b.columns("Outer", outer -> outer.columns("Inner", inner -> inner.column("Leaf")))); + + Assertions.assertEquals(1, head.size()); + Assertions.assertEquals(Arrays.asList("Outer", "Inner", "Leaf"), head.get(0)); + } + + @Test + void columns_emptyParentListIsNotAllowed() { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> DefaultHeadBuilder.define(b -> b.columns(Collections.emptyList(), sub -> sub.column("A")))); + } + + @Test + void columns_nullParentNamesThrows() { + Assertions.assertThrows( + NullPointerException.class, + () -> DefaultHeadBuilder.define(b -> b.columns((List) null, withEmpty()))); + } + + @Test + void columns_nullElementInParentNamesThrows() { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> DefaultHeadBuilder.define(b -> b.columns(Arrays.asList("P", null), withEmpty()))); + } + + @Test + void columns_nullConsumerThrows() { + Assertions.assertThrows( + NullPointerException.class, + () -> DefaultHeadBuilder.define(b -> b.columns(Arrays.asList("P"), (Consumer) null))); + } + + @Test + void complexExampleFromJavadoc() { + List> head = DefaultHeadBuilder.define(b -> b.column("ID", 2) + .columns("User Info", sub -> sub.column("Name").column("Age")) + .column("Others", "Remark")); + + Assertions.assertEquals(4, head.size()); + Assertions.assertEquals(Arrays.asList("ID", "ID"), head.get(0)); + Assertions.assertEquals(Arrays.asList("User Info", "Name"), head.get(1)); + Assertions.assertEquals(Arrays.asList("User Info", "Age"), head.get(2)); + Assertions.assertEquals(Arrays.asList("Others", "Remark"), head.get(3)); + } +}