From 9f2782d0e8fe33a567079cda920c759bd53f9831 Mon Sep 17 00:00:00 2001 From: Mikkey-f <178465103+Mikkey-f@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:27:29 +0800 Subject: [PATCH] fix: honor wildcard-key custom converters when writing CSV (#1045) (#1056) --- .../fesod/sheet/converters/Converter.java | 8 +- .../metadata/holder/AbstractWriteHolder.java | 42 +++- .../converter/WildcardConverterWriteTest.java | 220 ++++++++++++++++++ website/docs/sheet/write/converter.md | 25 ++ .../current/sheet/write/converter.md | 25 ++ 5 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/WildcardConverterWriteTest.java diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/Converter.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/Converter.java index affb88631..5c2e772ab 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/Converter.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/converters/Converter.java @@ -46,7 +46,13 @@ default Class supportJavaTypeKey() { } /** - * Back to object enum in excel + * Excel data type this converter supports. + * + *

Returning {@code null} declares the wildcard key {@code (JavaType, null)}: the converter matches + * every target cell data type. The xlsx write path looks converters up with a {@code null} target type, + * and a custom converter declaring {@code null} is additionally registered under the + * {@link CellDataTypeEnum#STRING} key so that it is honored by the CSV write path, which forces the + * lookup key to {@code (JavaType, STRING)}. * * @return Support for {@link CellDataTypeEnum} */ diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/metadata/holder/AbstractWriteHolder.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/metadata/holder/AbstractWriteHolder.java index e658b5d8e..10e9e4a1c 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/metadata/holder/AbstractWriteHolder.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/metadata/holder/AbstractWriteHolder.java @@ -42,6 +42,7 @@ import org.apache.fesod.sheet.converters.Converter; import org.apache.fesod.sheet.converters.ConverterKeyBuild; import org.apache.fesod.sheet.converters.DefaultConverterLoader; +import org.apache.fesod.sheet.enums.CellDataTypeEnum; import org.apache.fesod.sheet.enums.HeadKindEnum; import org.apache.fesod.sheet.enums.HeaderMergeStrategy; import org.apache.fesod.sheet.event.NotRepeatExecutor; @@ -273,11 +274,7 @@ public AbstractWriteHolder(WriteBasicParameter writeBasicParameter, AbstractWrit setConverterMap(new HashMap<>(parentAbstractWriteHolder.getConverterMap())); if (CollectionUtils.isNotEmpty(parentAbstractWriteHolder.getCustomConverterList())) { for (Converter converter : parentAbstractWriteHolder.getCustomConverterList()) { - getConverterMap() - .put( - ConverterKeyBuild.buildKey( - converter.supportJavaTypeKey(), converter.supportExcelTypeKey()), - converter); + putCustomConverter(converter); } } } @@ -285,11 +282,36 @@ public AbstractWriteHolder(WriteBasicParameter writeBasicParameter, AbstractWrit && !writeBasicParameter.getCustomConverterList().isEmpty()) { this.customConverterList = writeBasicParameter.getCustomConverterList(); for (Converter converter : writeBasicParameter.getCustomConverterList()) { - getConverterMap() - .put( - ConverterKeyBuild.buildKey( - converter.supportJavaTypeKey(), converter.supportExcelTypeKey()), - converter); + putCustomConverter(converter); + } + } + } + + /** + * Registers a custom converter under the key it declares. + * + *

A custom converter declaring a {@code null} excel type key is additionally registered under the + * {@link CellDataTypeEnum#STRING} key. The xlsx write path looks converters up with a {@code null} + * target cell data type, while the CSV write path forces the target to {@code STRING}; registering + * only the wildcard key would make the converter silently shadowed by the built-in string converter + * in CSV mode. See issues #1045 and #1056. + * + *

The {@code STRING} registration only takes over the slot when it is still held by the built-in + * default converter, so an explicitly registered converter for the same {@code STRING} key is never + * displaced, regardless of registration order. + */ + private void putCustomConverter(Converter converter) { + Class javaTypeKey = converter.supportJavaTypeKey(); + CellDataTypeEnum excelTypeKey = converter.supportExcelTypeKey(); + getConverterMap().put(ConverterKeyBuild.buildKey(javaTypeKey, excelTypeKey), converter); + if (excelTypeKey == null) { + ConverterKeyBuild.ConverterKey stringKey = ConverterKeyBuild.buildKey(javaTypeKey, CellDataTypeEnum.STRING); + Converter occupant = getConverterMap().get(stringKey); + if (occupant == null + || occupant + == DefaultConverterLoader.loadDefaultWriteConverter() + .get(stringKey)) { + getConverterMap().put(stringKey, converter); } } } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/WildcardConverterWriteTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/WildcardConverterWriteTest.java new file mode 100644 index 000000000..c9a7c1a6e --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converter/WildcardConverterWriteTest.java @@ -0,0 +1,220 @@ +/* + * 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.converter; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.apache.fesod.sheet.ExcelWriter; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.converters.Converter; +import org.apache.fesod.sheet.converters.ConverterKeyBuild; +import org.apache.fesod.sheet.enums.CellDataTypeEnum; +import org.apache.fesod.sheet.metadata.GlobalConfiguration; +import org.apache.fesod.sheet.metadata.data.WriteCellData; +import org.apache.fesod.sheet.metadata.property.ExcelContentProperty; +import org.apache.fesod.sheet.support.ExcelTypeEnum; +import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; +import org.apache.fesod.sheet.write.builder.ExcelWriterSheetBuilder; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Tests for custom converters registered with {@code supportExcelTypeKey() == null} (the wildcard key). + * + *

The wildcard key {@code (JavaType, null)} is only reachable from the xlsx lookup, which uses + * {@code null} as the target cell data type. CSV mode forces the target to {@link CellDataTypeEnum#STRING}, + * so a wildcard-only registration is silently shadowed by the built-in string converter. See issues + * #1045 and #1056. + */ +public class WildcardConverterWriteTest extends AbstractExcelTest { + + @Test + void xlsxWildcardConverterIsApplied() throws Exception { + File file = new File(tempDir, "wildcard-converter.xlsx"); + FesodSheet.write(file) + .excelType(ExcelTypeEnum.XLSX) + .registerConverter(new BooleanYesNoConverter()) + .sheet() + .doWrite(booleans(Boolean.TRUE, Boolean.FALSE)); + + try (Workbook workbook = WorkbookFactory.create(file)) { + Assertions.assertEquals( + "YES", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue()); + Assertions.assertEquals( + "NO", workbook.getSheetAt(0).getRow(1).getCell(0).getStringCellValue()); + } + } + + @Test + void csvWildcardConverterIsApplied() throws Exception { + File file = new File(tempDir, "wildcard-converter.csv"); + FesodSheet.write(file) + .excelType(ExcelTypeEnum.CSV) + .charset(StandardCharsets.UTF_8) + .registerConverter(new BooleanYesNoConverter()) + .sheet() + .doWrite(booleans(Boolean.TRUE, Boolean.FALSE)); + + String csvContent = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + Assertions.assertTrue( + csvContent.contains("YES"), "CSV should apply the custom converter, but was: " + csvContent); + Assertions.assertTrue( + csvContent.contains("NO"), "CSV should apply the custom converter, but was: " + csvContent); + } + + @Test + void csvWithoutCustomConvertersIsUnchanged() throws Exception { + File file = new File(tempDir, "wildcard-converter-default.csv"); + FesodSheet.write(file) + .excelType(ExcelTypeEnum.CSV) + .charset(StandardCharsets.UTF_8) + .sheet() + .doWrite(booleans(Boolean.TRUE, Boolean.FALSE)); + + String csvContent = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + Assertions.assertTrue( + csvContent.contains("true"), "Default CSV output should be unchanged, but was: " + csvContent); + Assertions.assertTrue( + csvContent.contains("false"), "Default CSV output should be unchanged, but was: " + csvContent); + } + + @Test + void csvExplicitStringKeyConverterIsUnaffected() throws Exception { + File file = new File(tempDir, "wildcard-converter-explicit.csv"); + FesodSheet.write(file) + .excelType(ExcelTypeEnum.CSV) + .charset(StandardCharsets.UTF_8) + .registerConverter(new BooleanExplicitStringConverter()) + .sheet() + .doWrite(booleans(Boolean.TRUE)); + + String csvContent = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + Assertions.assertTrue( + csvContent.contains("explicit-yes"), + "Explicit STRING-key converter should win, but was: " + csvContent); + } + + @Test + void csvExplicitStringKeyRegisteredBeforeWildcardStillWins() throws Exception { + File file = new File(tempDir, "wildcard-converter-explicit-first.csv"); + FesodSheet.write(file) + .excelType(ExcelTypeEnum.CSV) + .charset(StandardCharsets.UTF_8) + .registerConverter(new BooleanExplicitStringConverter()) + .registerConverter(new BooleanYesNoConverter()) + .sheet() + .doWrite(booleans(Boolean.TRUE, Boolean.FALSE)); + + String csvContent = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + Assertions.assertTrue( + csvContent.contains("explicit-yes"), + "Explicit STRING-key converter registered before the wildcard should still win, but was: " + + csvContent); + } + + @Test + void wildcardConverterIsRegisteredUnderBothKeysInWorkbookAndSheetHolders() throws Exception { + File file = new File(tempDir, "wildcard-converter-map.csv"); + BooleanYesNoConverter converter = new BooleanYesNoConverter(); + try (ExcelWriter excelWriter = FesodSheet.write(file) + .excelType(ExcelTypeEnum.CSV) + .registerConverter(converter) + .build()) { + excelWriter.write( + booleans(Boolean.TRUE), + new ExcelWriterSheetBuilder().sheetNo(0).build()); + Map> workbookMap = + excelWriter.writeContext().writeWorkbookHolder().converterMap(); + Map> sheetMap = + excelWriter.writeContext().writeSheetHolder().converterMap(); + + ConverterKeyBuild.ConverterKey wildcardKey = ConverterKeyBuild.buildKey(Boolean.class, null); + ConverterKeyBuild.ConverterKey stringKey = + ConverterKeyBuild.buildKey(Boolean.class, CellDataTypeEnum.STRING); + for (Map> converterMap : + Arrays.asList(workbookMap, sheetMap)) { + Assertions.assertSame(converter, converterMap.get(wildcardKey)); + Assertions.assertSame(converter, converterMap.get(stringKey)); + } + } + } + + private static List> booleans(Boolean... values) { + List> rows = new java.util.ArrayList<>(); + for (Boolean value : values) { + rows.add(Collections.singletonList(value)); + } + return rows; + } + + /** Custom converter returning {@code null} from {@code supportExcelTypeKey()} (the wildcard key). */ + public static class BooleanYesNoConverter implements Converter { + @Override + public Class supportJavaTypeKey() { + return Boolean.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return null; + } + + @Override + public WriteCellData convertToExcelData( + Boolean value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + if (Boolean.TRUE.equals(value)) { + return new WriteCellData<>("YES"); + } + if (Boolean.FALSE.equals(value)) { + return new WriteCellData<>("NO"); + } + return new WriteCellData<>(""); + } + } + + /** Custom converter explicitly declaring the {@link CellDataTypeEnum#STRING} key. */ + public static class BooleanExplicitStringConverter implements Converter { + @Override + public Class supportJavaTypeKey() { + return Boolean.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return CellDataTypeEnum.STRING; + } + + @Override + public WriteCellData convertToExcelData( + Boolean value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { + if (Boolean.TRUE.equals(value)) { + return new WriteCellData<>("explicit-yes"); + } + return new WriteCellData<>("explicit-no"); + } + } +} diff --git a/website/docs/sheet/write/converter.md b/website/docs/sheet/write/converter.md index 3199ec587..e9e5df211 100644 --- a/website/docs/sheet/write/converter.md +++ b/website/docs/sheet/write/converter.md @@ -113,6 +113,31 @@ public void globalConverterWrite() { } ``` +### Wildcard Key + +Returning `null` from `supportExcelTypeKey()` declares the wildcard key `(JavaType, null)`, which matches every target cell data type: + +```java +public class BooleanYesNoConverter implements Converter { + @Override + public Class supportJavaTypeKey() { + return Boolean.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return null; // wildcard key: (Boolean, null), matches every target cell data type + } + + @Override + public WriteCellData convertToExcelData(WriteConverterContext context) { + return new WriteCellData<>(Boolean.TRUE.equals(context.getValue()) ? "YES" : "NO"); + } +} +``` + +Wildcard converters are registered under both `(JavaType, null)` and `(JavaType, STRING)`, so a single registration applies to both write formats: xlsx looks converters up with the `(JavaType, null)` key, while CSV forces the lookup key to `(JavaType, STRING)`. Return an explicit `CellDataTypeEnum` if you only want the converter to apply to a single target cell data type. + --- ## Converter Resolution Priority diff --git a/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/write/converter.md b/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/write/converter.md index 666d8eb74..bb91bee72 100644 --- a/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/write/converter.md +++ b/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/write/converter.md @@ -113,6 +113,31 @@ public void globalConverterWrite() { } ``` +### 通配键 + +`supportExcelTypeKey()` 返回 `null` 表示声明通配键 `(JavaType, null)`,匹配所有目标单元格数据类型: + +```java +public class BooleanYesNoConverter implements Converter { + @Override + public Class supportJavaTypeKey() { + return Boolean.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return null; // 通配键: (Boolean, null), 匹配所有目标单元格数据类型 + } + + @Override + public WriteCellData convertToExcelData(WriteConverterContext context) { + return new WriteCellData<>(Boolean.TRUE.equals(context.getValue()) ? "YES" : "NO"); + } +} +``` + +通配转换器会同时注册到 `(JavaType, null)` 和 `(JavaType, STRING)` 两个键下,因此一份注册即可同时作用于两种写入格式:xlsx 以 `(JavaType, null)` 作为查找键,CSV 则强制使用 `(JavaType, STRING)` 查找键。若希望转换器仅作用于单一目标单元格数据类型,请返回明确的 `CellDataTypeEnum`。 + --- ## 转换器解析优先级