diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java index fb743c8f1..e809d966d 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/executor/ExcelWriteFillExecutor.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -239,23 +240,10 @@ private void doFill( if (analysisCell.getOnlyOneVariable()) { String variable = analysisCell.getVariableList().get(0); - Object value = null; - if (dataKeySet.contains(variable)) { - value = dataMap.get(variable); - } - ExcelContentProperty excelContentProperty = ClassUtils.declaredExcelContentProperty( - dataMap, - writeContext - .currentWriteHolder() - .excelWriteHeadProperty() - .getHeadClazz(), - variable, - writeContext.currentWriteHolder()); - cellWriteHandlerContext.setExcelContentProperty(excelContentProperty); + Object value = resolveFillProperty(dataMap, dataKeySet, variable, cellWriteHandlerContext); createCell(analysisCell, fillConfig, cellWriteHandlerContext, rowWriteHandlerContext); cellWriteHandlerContext.setOriginalValue(value); - cellWriteHandlerContext.setOriginalFieldClass(FieldUtils.getFieldClass(dataMap, variable, value)); converterAndSet(cellWriteHandlerContext); WriteCellData cellData = cellWriteHandlerContext.getFirstCellData(); @@ -279,21 +267,8 @@ private void doFill( for (String variable : analysisCell.getVariableList()) { cellValueBuild.append(analysisCell.getPrepareDataList().get(index++)); - Object value = null; - if (dataKeySet.contains(variable)) { - value = dataMap.get(variable); - } - ExcelContentProperty excelContentProperty = ClassUtils.declaredExcelContentProperty( - dataMap, - writeContext - .currentWriteHolder() - .excelWriteHeadProperty() - .getHeadClazz(), - variable, - writeContext.currentWriteHolder()); + Object value = resolveFillProperty(dataMap, dataKeySet, variable, cellWriteHandlerContext); cellWriteHandlerContext.setOriginalValue(value); - cellWriteHandlerContext.setOriginalFieldClass(FieldUtils.getFieldClass(dataMap, variable, value)); - cellWriteHandlerContext.setExcelContentProperty(excelContentProperty); cellWriteHandlerContext.setTargetCellDataType(CellDataTypeEnum.STRING); WriteCellData cellData = convert(cellWriteHandlerContext); @@ -339,6 +314,37 @@ private void doFill( } } + private Object resolveFillProperty( + Map dataMap, Set dataKeySet, String variable, CellWriteHandlerContext context) { + Map propertyMap = dataMap; + String fieldName = variable; + // A literal Map key (including a null value) takes precedence over a nested path. + if (!dataKeySet.contains(variable)) { + int separator = fieldName.indexOf('.'); + while (separator >= 0) { + Object parent = propertyMap.get(fieldName.substring(0, separator)); + fieldName = fieldName.substring(separator + 1); + if (parent == null) { + propertyMap = Collections.emptyMap(); + break; + } + propertyMap = parent instanceof Map ? (Map) parent : BeanMapUtils.create(parent); + if (propertyMap.containsKey(fieldName)) { + break; + } + separator = fieldName.indexOf('.'); + } + } + Object value = propertyMap.get(fieldName); + Class headClass = propertyMap == dataMap + ? writeContext.currentWriteHolder().excelWriteHeadProperty().getHeadClazz() + : null; + context.setExcelContentProperty(ClassUtils.declaredExcelContentProperty( + propertyMap, headClass, fieldName, writeContext.currentWriteHolder())); + context.setOriginalFieldClass(FieldUtils.getFieldClass(propertyMap, fieldName, value)); + return value; + } + private Integer getRelativeRowIndex() { Integer relativeRowIndex = relativeRowIndexMap.get(currentUniqueDataFlag); if (relativeRowIndex == null) { diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/fill/NestedObjectFillTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/fill/NestedObjectFillTest.java new file mode 100644 index 000000000..bbe8475e8 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/fill/NestedObjectFillTest.java @@ -0,0 +1,200 @@ +/* + * 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.fill; + +import java.io.File; +import java.io.FileOutputStream; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.annotation.ExcelProperty; +import org.apache.fesod.sheet.annotation.format.DateTimeFormat; +import org.apache.fesod.sheet.converters.Converter; +import org.apache.fesod.sheet.enums.CellDataTypeEnum; +import org.apache.fesod.sheet.enums.WriteDirectionEnum; +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.testkit.Tags; +import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; +import org.apache.fesod.sheet.testkit.enums.ExcelFormat; +import org.apache.fesod.sheet.testkit.params.ExcelFormatSource; +import org.apache.fesod.sheet.testkit.params.FormatScope; +import org.apache.fesod.sheet.write.metadata.fill.FillConfig; +import org.apache.fesod.sheet.write.metadata.fill.FillWrapper; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; + +@Tag(Tags.ROUND_TRIP) +class NestedObjectFillTest extends AbstractExcelTest { + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void fillsNestedBeansAndRetainsLeafAnnotations(ExcelFormat format) throws Exception { + File template = template(format, "{.child.name}", "Value {.child.name}", "{.child.date}"); + File output = createTempFile("nested-beans", format); + FesodSheet.write(output) + .withTemplate(template) + .sheet() + .doFill(Arrays.asList(new Record(new Child("alpha")), new Record(new Child("beta")))); + + try (Workbook workbook = WorkbookFactory.create(output)) { + Sheet sheet = workbook.getSheetAt(0); + Assertions.assertEquals("ALPHA", sheet.getRow(0).getCell(0).getStringCellValue()); + Assertions.assertEquals("Value ALPHA", sheet.getRow(0).getCell(1).getStringCellValue()); + Assertions.assertEquals("BETA", sheet.getRow(1).getCell(0).getStringCellValue()); + Assertions.assertEquals( + "yyyy/MM/dd", sheet.getRow(0).getCell(2).getCellStyle().getDataFormatString()); + } + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void fillsNamedNestedMapsAndKeepsExactKeysFirst(ExcelFormat format) throws Exception { + File template = template(format, "{items.child.name}", "Value {items.child.name}", "{items.child.meta.code}"); + File output = createTempFile("nested-maps", format); + Map row = new HashMap<>(); + Map child = new HashMap<>(); + child.put("name", "nested"); + child.put("meta", Collections.singletonMap("code", 7)); + row.put("child", child); + row.put("child.name", "literal"); + + FesodSheet.write(output) + .withTemplate(template) + .sheet() + .doFill(new FillWrapper("items", Collections.singletonList(row))); + + try (Workbook workbook = WorkbookFactory.create(output)) { + Row filled = workbook.getSheetAt(0).getRow(0); + Assertions.assertEquals("literal", filled.getCell(0).getStringCellValue()); + Assertions.assertEquals("Value literal", filled.getCell(1).getStringCellValue()); + Assertions.assertEquals(7, filled.getCell(2).getNumericCellValue()); + } + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void clearsMissingAndNullNestedProperties(ExcelFormat format) throws Exception { + File template = template(format, "{.child.name}", "Value {.child.name}", "{.child.missing}"); + File output = createTempFile("nested-nulls", format); + Map exactNull = new HashMap<>(); + exactNull.put("child", new Child("must-not-be-used")); + exactNull.put("child.name", null); + FesodSheet.write(output) + .withTemplate(template) + .sheet() + .doFill(Arrays.asList(new Record(null), Collections.emptyMap(), exactNull)); + + try (Workbook workbook = WorkbookFactory.create(output)) { + for (int row = 0; row < 3; row++) { + Row filled = workbook.getSheetAt(0).getRow(row); + Assertions.assertEquals("", filled.getCell(0).getStringCellValue()); + Assertions.assertEquals("Value ", filled.getCell(1).getStringCellValue()); + Assertions.assertEquals("", filled.getCell(2).getStringCellValue()); + } + } + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void fillsMixedMapAndBeanPathsHorizontally(ExcelFormat format) throws Exception { + File template = template(format, "{.child.name}"); + File output = createTempFile("nested-horizontal", format); + FillConfig config = + FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL).build(); + FesodSheet.write(output) + .withTemplate(template) + .sheet() + .doFill( + Arrays.asList( + Collections.singletonMap("child", new Child("alpha")), new Record(new Child("beta"))), + config); + + try (Workbook workbook = WorkbookFactory.create(output)) { + Row filled = workbook.getSheetAt(0).getRow(0); + Assertions.assertEquals("ALPHA", filled.getCell(0).getStringCellValue()); + Assertions.assertEquals("BETA", filled.getCell(1).getStringCellValue()); + } + } + + private File template(ExcelFormat format, String... placeholders) throws Exception { + File file = createTempFile("nested-template", format); + try (Workbook workbook = format == ExcelFormat.XLS ? new HSSFWorkbook() : new XSSFWorkbook(); + FileOutputStream output = new FileOutputStream(file)) { + Row row = workbook.createSheet().createRow(0); + for (int i = 0; i < placeholders.length; i++) { + row.createCell(i).setCellValue(placeholders[i]); + } + workbook.write(output); + } + return file; + } + + @Getter + @AllArgsConstructor + public static class Record { + private final Child child; + } + + @Getter + public static class Child { + @ExcelProperty(converter = UpperCaseConverter.class) + private final String name; + + @DateTimeFormat("yyyy/MM/dd") + private final LocalDate date = LocalDate.of(2026, 10, 20); + + public Child(String name) { + this.name = name; + } + } + + public static class UpperCaseConverter implements Converter { + @Override + public Class supportJavaTypeKey() { + return String.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return CellDataTypeEnum.STRING; + } + + @Override + public WriteCellData convertToExcelData( + String value, ExcelContentProperty property, GlobalConfiguration configuration) { + return new WriteCellData<>(value.toUpperCase(Locale.ROOT)); + } + } +} diff --git a/website/docs/sheet/fill/fill.md b/website/docs/sheet/fill/fill.md index 24ea99aa2..6c41178e5 100644 --- a/website/docs/sheet/fill/fill.md +++ b/website/docs/sheet/fill/fill.md @@ -34,12 +34,21 @@ the cell is filled: | `{name}` | a single variable | `doFill(object)`, `doFill(map)` | | `{.name}` | the `name` property of every item of a list | `doFill(list)`, `fill(list, ...)` | | `{data1.name}` | the same, for the list named `data1` | `fill(new FillWrapper("data1", list), ...)` | +| `{.child.name}` | a nested property of each list item | `doFill(list)` | +| `{data1.child.name}` | a nested property of each item in the list named `data1` | `fill(new FillWrapper("data1", list), ...)` | | `\{name\}` | escaped with `\`, never parsed | nothing | The leading `.` is what makes a cell repeat once per item - downwards by default, or across the columns with `FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL)`. The text before the `.` names which list the items come from, so one template can hold several lists side by side. +After the list prefix, additional dots traverse JavaBean properties or Map entries. For example, +`{.child.name}` reads `item.getChild().getName()`. A missing property or null intermediate value is +filled as empty. A Map entry matching the complete remaining path, such as `"child.name"`, takes +precedence over traversal, including when its value is null. Format annotations and converters on +the final JavaBean field are applied as usual. The first dot still identifies a list: use a +`FillWrapper` for named lists rather than `{child.name}` for a single object. + A cell may mix several placeholders with ordinary text, as in `{name} is {number} years old this year`. A placeholder the fill does not supply is cleared rather than left in the sheet: filling a list against `{name}`, or an object against `{.name}`, empties the cell and keeps only the text diff --git a/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/fill/fill.md b/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/fill/fill.md index d6f8458d7..263323e2c 100644 --- a/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/fill/fill.md +++ b/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/fill/fill.md @@ -33,10 +33,18 @@ title: '填充' | `{name}` | 单个变量 | `doFill(object)`、`doFill(map)` | | `{.name}` | 列表中每一项的 `name` 属性 | `doFill(list)`、`fill(list, ...)` | | `{data1.name}` | 同上,但取自名为 `data1` 的列表 | `fill(new FillWrapper("data1", list), ...)` | +| `{.child.name}` | 列表中每一项的嵌套属性 | `doFill(list)` | +| `{data1.child.name}` | 名为 `data1` 的列表中每一项的嵌套属性 | `fill(new FillWrapper("data1", list), ...)` | | `\{name\}` | 用 `\` 转义,不会被解析 | 不填充 | 开头的 `.` 决定了单元格会按列表的每一项重复:默认向下重复,也可以通过 `FillConfig.builder().direction(WriteDirectionEnum.HORIZONTAL)` 向右重复。`.` 之前的文字指明数据来自哪个列表,因此一个模板中可以并存多个列表。 +列表前缀之后的点号用于逐层读取 JavaBean 属性或 Map 条目。例如,`{.child.name}` 对应 +`item.getChild().getName()`。属性不存在或中间对象为 null 时填充为空。Map 中如果存在完整的 +剩余路径键(如 `"child.name"`),则优先使用该键,包括值为 null 的情况。最终 JavaBean 字段的 +格式注解和转换器仍然生效。第一个点号仍用于区分列表,因此不要用 `{child.name}` 填充单个对象; +命名列表请使用 `FillWrapper`。 + 一个单元格里可以混合多个占位符和普通文本,例如 `{name}今年{number}岁了`。填充时没有提供的占位符会被清空,而不是原样留在表格里:用列表填充 `{name}`,或者用单个对象填充 `{.name}`,都会清空该单元格,只保留周围的文本。 转义只是让大括号不被解析,而 `\` 本身只有在同一个单元格里还存在真实占位符时才会被去掉。如果单元格里没有别的内容,`\{name\}` 会原样写出,包括反斜杠。