This is an automated email from the ASF dual-hosted git repository.
MikeThomsen pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 269a0de57ba NIFI-16443 Added Ignore Cells Beyond Header property to
ExcelReader (#11783)
269a0de57ba is described below
commit 269a0de57ba23b0fd81780c1c561574143933780
Author: Vincenzo Lombardo <[email protected]>
AuthorDate: Thu Oct 15 20:30:45 2026 +0200
NIFI-16443 Added Ignore Cells Beyond Header property to ExcelReader (#11783)
* NIFI-16443 Added Ignore Cells Beyond Header property to ExcelReader
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../java/org/apache/nifi/excel/ExcelReader.java | 17 +++++-
.../excel/ExcelStartingRowSchemaInference.java | 68 +++++++++++++++++++---
.../additionalDetails.md | 5 ++
.../excel/TestExcelStartingRowSchemaInference.java | 46 ++++++++++++++-
4 files changed, 127 insertions(+), 9 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelReader.java
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelReader.java
index 81c2ae6342c..467e3ddaa77 100644
---
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelReader.java
+++
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelReader.java
@@ -108,6 +108,19 @@ public class ExcelReader extends SchemaRegistryService
implements RecordReaderFa
.addValidator(StandardValidators.NON_BLANK_VALIDATOR)
.build();
+ public static final PropertyDescriptor IGNORE_CELLS_BEYOND_HEADER = new
PropertyDescriptor
+ .Builder().name("Ignore Cells Beyond Header")
+ .description("""
+ Whether to ignore cells located beyond the last column of
the header row, instead of failing schema inference. \
+ Such cells are often blank but formatted. A warning is
logged when ignored cells contain a value. \
+ Only the rows evaluated by the '%s' property are checked.\
+ """.formatted(ROW_EVALUATION_STRATEGY.getName()))
+ .required(true)
+ .allowableValues("true", "false")
+ .defaultValue("false")
+ .dependsOn(SchemaAccessUtils.SCHEMA_ACCESS_STRATEGY,
ExcelStartingRowSchemaInference.USE_STARTING_ROW)
+ .build();
+
public static final PropertyDescriptor REQUIRED_SHEETS = new
PropertyDescriptor
.Builder().name("Required Sheets")
.description("Comma-separated list of Excel document sheet names
whose rows should be extracted from the excel document. If this property" +
@@ -165,6 +178,7 @@ public class ExcelReader extends SchemaRegistryService
implements RecordReaderFa
properties.add(PASSWORD);
properties.add(STARTING_ROW);
properties.add(ROW_EVALUATION_STRATEGY);
+ properties.add(IGNORE_CELLS_BEYOND_HEADER);
properties.add(REQUIRED_SHEETS);
properties.add(DateTimeUtils.DATE_FORMAT);
properties.add(DateTimeUtils.TIME_FORMAT);
@@ -181,8 +195,9 @@ public class ExcelReader extends SchemaRegistryService
implements RecordReaderFa
final int firstRow = context.getProperty(STARTING_ROW)
.evaluateAttributeExpressions()
.asInteger();
+ final boolean ignoreCellsBeyondHeader =
context.getProperty(IGNORE_CELLS_BEYOND_HEADER).asBoolean();
final SchemaInferenceEngine<Row> inference =
- new ExcelStartingRowSchemaInference(rowEvaluationStrategy,
firstRow, createTimeValueInference());
+ new ExcelStartingRowSchemaInference(rowEvaluationStrategy,
firstRow, createTimeValueInference(), ignoreCellsBeyondHeader, getLogger());
return createInferSchemaAccessStrategy(context, inference);
} else if
(SchemaInferenceUtil.INFER_SCHEMA.getValue().equals(allowableValue)) {
final SchemaInferenceEngine<Row> inference = new
ExcelSchemaInference(createTimeValueInference());
diff --git
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelStartingRowSchemaInference.java
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelStartingRowSchemaInference.java
index d951f81ef4e..2170696345e 100644
---
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelStartingRowSchemaInference.java
+++
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/java/org/apache/nifi/excel/ExcelStartingRowSchemaInference.java
@@ -17,6 +17,7 @@
package org.apache.nifi.excel;
import org.apache.nifi.components.AllowableValue;
+import org.apache.nifi.logging.ComponentLog;
import org.apache.nifi.schema.access.SchemaNotFoundException;
import org.apache.nifi.schema.inference.FieldTypeInference;
import org.apache.nifi.schema.inference.RecordSource;
@@ -26,8 +27,10 @@ import org.apache.nifi.serialization.SimpleRecordSchema;
import org.apache.nifi.serialization.record.RecordField;
import org.apache.nifi.serialization.record.RecordSchema;
import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.util.CellReference;
import java.io.IOException;
import java.util.ArrayList;
@@ -35,6 +38,7 @@ import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.OptionalInt;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -50,17 +54,23 @@ public class ExcelStartingRowSchemaInference implements
SchemaInferenceEngine<Ro
private final int firstRow;
private final CellFieldTypeReader cellFieldTypeReader;
private final DataFormatter dataFormatter;
+ private final boolean ignoreCellsBeyondHeader;
+ private final ComponentLog logger;
- public ExcelStartingRowSchemaInference(RowEvaluationStrategy
rowEvaluationStrategy, int firstRow, TimeValueInference timeValueInference) {
+ public ExcelStartingRowSchemaInference(RowEvaluationStrategy
rowEvaluationStrategy, int firstRow, TimeValueInference timeValueInference,
+ boolean ignoreCellsBeyondHeader,
ComponentLog logger) {
this.rowEvaluationStrategy = rowEvaluationStrategy;
this.firstRow = firstRow;
this.cellFieldTypeReader = new
StandardCellFieldTypeReader(timeValueInference);
this.dataFormatter = new DataFormatter();
+ this.ignoreCellsBeyondHeader = ignoreCellsBeyondHeader;
+ this.logger = logger;
}
@Override
public RecordSchema inferSchema(RecordSource<Row> recordSource) throws
IOException {
final Map<String, FieldTypeInference> typeMap = new LinkedHashMap<>();
+ final Map<String, IgnoredCells> ignoredCellsBySheet = new
LinkedHashMap<>();
final int zeroBasedFirstRow = ExcelReader.getZeroBasedIndex(firstRow);
List<String> fieldNames = null;
int index = 0;
@@ -79,16 +89,22 @@ public class ExcelStartingRowSchemaInference implements
SchemaInferenceEngine<Ro
} else {
if (RowEvaluationStrategy.STANDARD == rowEvaluationStrategy) {
if (index <=
RowEvaluationStrategy.NUM_ROWS_TO_DETERMINE_TYPES) {
- inferSchema(row, fieldNames, typeMap);
+ inferSchema(row, fieldNames, typeMap,
ignoredCellsBySheet);
} else {
break;
}
} else {
- inferSchema(row, fieldNames, typeMap);
+ inferSchema(row, fieldNames, typeMap, ignoredCellsBySheet);
}
}
index++;
}
+
+ for (Map.Entry<String, IgnoredCells> entry :
ignoredCellsBySheet.entrySet()) {
+ final IgnoredCells ignoredCells = entry.getValue();
+ logger.warn("Ignored non-empty cells beyond the {} header columns
in {} row(s) of sheet [{}], first at row {} column {}",
+ fieldNames.size(), ignoredCells.rowCount(),
entry.getKey(), ignoredCells.firstRowNumber(), ignoredCells.firstColumnName());
+ }
return createSchema(typeMap);
}
@@ -131,15 +147,24 @@ public class ExcelStartingRowSchemaInference implements
SchemaInferenceEngine<Ro
return renamedDuplicateFieldNames;
}
- private void inferSchema(final Row row, final List<String> fieldNames,
final Map<String, FieldTypeInference> typeMap) throws IOException {
+ private void inferSchema(final Row row, final List<String> fieldNames,
final Map<String, FieldTypeInference> typeMap,
+ final Map<String, IgnoredCells>
ignoredCellsBySheet) throws IOException {
// NOTE: This allows rows to be blank when inferring the schema
if (ExcelUtils.hasCells(row)) {
+ final int cellCount;
if (row.getLastCellNum() > fieldNames.size()) {
- throw new IOException(new
SchemaNotFoundException(String.format("Row %s has %s cells, more than the
expected %s number of field names",
- row.getRowNum(), row.getLastCellNum(),
fieldNames.size())));
+ if (!ignoreCellsBeyondHeader) {
+ throw new IOException(new
SchemaNotFoundException(String.format("Row %s has %s cells, more than the
expected %s number of field names",
+ row.getRowNum(), row.getLastCellNum(),
fieldNames.size())));
+ }
+
+ cellCount = fieldNames.size();
+ trackIgnoredCells(row, fieldNames.size(), ignoredCellsBySheet);
+ } else {
+ cellCount = row.getLastCellNum();
}
- IntStream.range(0, row.getLastCellNum())
+ IntStream.range(0, cellCount)
.forEach(index -> {
final Cell cell = row.getCell(index);
final String fieldName = fieldNames.get(index);
@@ -148,6 +173,32 @@ public class ExcelStartingRowSchemaInference implements
SchemaInferenceEngine<Ro
}
}
+ private void trackIgnoredCells(final Row row, final int headerCellCount,
final Map<String, IgnoredCells> ignoredCellsBySheet) {
+ // NOTE: Cells without content, such as formatted blank cells, are
ignored silently
+ final OptionalInt firstColumnWithContent =
IntStream.range(headerCellCount, row.getLastCellNum())
+ .filter(index -> hasContent(row.getCell(index)))
+ .findFirst();
+
+ if (firstColumnWithContent.isPresent()) {
+ final IgnoredCells ignoredCells = new IgnoredCells(1,
row.getRowNum() + 1,
CellReference.convertNumToColString(firstColumnWithContent.getAsInt()));
+ ignoredCellsBySheet.merge(row.getSheet().getSheetName(),
ignoredCells,
+ (existing, added) -> new IgnoredCells(existing.rowCount()
+ 1, existing.firstRowNumber(), existing.firstColumnName()));
+ }
+ }
+
+ private static boolean hasContent(final Cell cell) {
+ if (cell == null) {
+ return false;
+ }
+
+ final CellType cellType = cell.getCellType() == CellType.FORMULA ?
cell.getCachedFormulaResultType() : cell.getCellType();
+ return switch (cellType) {
+ case STRING -> !cell.getStringCellValue().isBlank();
+ case BLANK, _NONE -> false;
+ default -> true;
+ };
+ }
+
private RecordSchema createSchema(final Map<String, FieldTypeInference>
inferences) throws IOException {
if (inferences.isEmpty()) {
throw new IOException(new SchemaNotFoundException("Failed to infer
schema from empty rows"));
@@ -158,4 +209,7 @@ public class ExcelStartingRowSchemaInference implements
SchemaInferenceEngine<Ro
.collect(Collectors.toList());
return new SimpleRecordSchema(recordFields);
}
+
+ private record IgnoredCells(int rowCount, int firstRowNumber, String
firstColumnName) {
+ }
}
diff --git
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/resources/docs/org.apache.nifi.excel.ExcelReader/additionalDetails.md
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/resources/docs/org.apache.nifi.excel.ExcelReader/additionalDetails.md
index c7c325046d7..eabe5056b2e 100644
---
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/resources/docs/org.apache.nifi.excel.ExcelReader/additionalDetails.md
+++
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/main/resources/docs/org.apache.nifi.excel.ExcelReader/additionalDetails.md
@@ -70,6 +70,11 @@ Schema" strategy, the Reader will determine the schema by
first parsing all data
fields that it has encountered and the type of each field. Once all data has
been parsed, a schema is formed that
encompasses all fields that have been encountered.
+When using the "Use Starting Row" strategy, a row having more cells than the
header row causes schema inference to
+fail. This often happens with spreadsheets edited by hand, where cells to the
right of the data are blank but formatted.
+Setting the "Ignore Cells Beyond Header" property to true ignores the cells
beyond the last header column instead. Blank
+cells are ignored silently, while a warning is logged for each sheet where the
ignored cells contain a value.
+
A common concern when inferring schemas is how to handle the condition of two
values that have different types. For
example, consider a FlowFile with the following two records:
diff --git
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/test/java/org/apache/nifi/excel/TestExcelStartingRowSchemaInference.java
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/test/java/org/apache/nifi/excel/TestExcelStartingRowSchemaInference.java
index 104a4171240..8d6bb8ac5a8 100644
---
a/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/test/java/org/apache/nifi/excel/TestExcelStartingRowSchemaInference.java
+++
b/nifi-extension-bundles/nifi-poi-bundle/nifi-poi-services/src/test/java/org/apache/nifi/excel/TestExcelStartingRowSchemaInference.java
@@ -61,10 +61,16 @@ import static
org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
public class TestExcelStartingRowSchemaInference {
private static final TimeValueInference TIME_VALUE_INFERENCE = new
TimeValueInference("MM/dd/yyyy", "HH:mm:ss.SSS", "yyyy/MM/dd/ HH:mm");
+ private static final Object FORMATTED_BLANK = new Object();
@Mock
private ComponentLog logger;
@@ -133,6 +139,34 @@ public class TestExcelStartingRowSchemaInference {
}
}
+ @ParameterizedTest
+ @EnumSource(RowEvaluationStrategy.class)
+ void testIgnoreCellsBeyondHeaderWithoutContent(RowEvaluationStrategy
rowEvaluationStrategy) throws Exception {
+ final Object[][] singleSheet = {{"ID", "First", "Middle"}, {1,
"Manny", "M", FORMATTED_BLANK, " ", new Formula("\"\"")}, {2, "Moe", "M"}};
+ final ByteArrayOutputStream outputStream = createWorkbook(singleSheet);
+
+ try (final InputStream inputStream = new
ByteArrayInputStream(outputStream.toByteArray())) {
+ final InferSchemaAccessStrategy<?> inferSchemaAccessStrategy =
getInferSchemaAccessStrategy(rowEvaluationStrategy, true);
+ final RecordSchema schema =
inferSchemaAccessStrategy.getSchema(null, inputStream, null);
+ assertEquals(List.of("ID", "First", "Middle"),
schema.getFieldNames());
+ verify(logger, never()).warn(anyString(), any(Object[].class));
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(RowEvaluationStrategy.class)
+ void testIgnoreCellsBeyondHeaderWithContent(RowEvaluationStrategy
rowEvaluationStrategy) throws Exception {
+ final Object[][] singleSheet = {{"ID", "First", "Middle"}, {1,
"Manny", "M"}, {2, "Moe", "M", FORMATTED_BLANK, "Extra"}, {3, "Jack", "J", 4}};
+ final ByteArrayOutputStream outputStream = createWorkbook(singleSheet);
+
+ try (final InputStream inputStream = new
ByteArrayInputStream(outputStream.toByteArray())) {
+ final InferSchemaAccessStrategy<?> inferSchemaAccessStrategy =
getInferSchemaAccessStrategy(rowEvaluationStrategy, true);
+ final RecordSchema schema =
inferSchemaAccessStrategy.getSchema(null, inputStream, null);
+ assertEquals(List.of("ID", "First", "Middle"),
schema.getFieldNames());
+ verify(logger).warn(anyString(), eq(3), eq(2), eq("Sheet 1"),
eq(3), eq("E"));
+ }
+ }
+
@Test
void testWhereTotalRowsLessThanConfiguredInferenceRows() throws Exception {
final Object[][] singleSheet = {{"ID", "First", "Middle"}, {1,
"Manny", "M"}, {2, "Moe", "M"}, {3, "Jack", "J"}};
@@ -315,9 +349,13 @@ public class TestExcelStartingRowSchemaInference {
}
private InferSchemaAccessStrategy<?>
getInferSchemaAccessStrategy(RowEvaluationStrategy rowEvaluationStrategy) {
+ return getInferSchemaAccessStrategy(rowEvaluationStrategy, false);
+ }
+
+ private InferSchemaAccessStrategy<?>
getInferSchemaAccessStrategy(RowEvaluationStrategy rowEvaluationStrategy,
boolean ignoreCellsBeyondHeader) {
return new InferSchemaAccessStrategy<>(
(variables, content) -> new ExcelRecordSource(content,
context, variables, logger),
- new ExcelStartingRowSchemaInference(rowEvaluationStrategy, 1,
TIME_VALUE_INFERENCE), logger);
+ new ExcelStartingRowSchemaInference(rowEvaluationStrategy, 1,
TIME_VALUE_INFERENCE, ignoreCellsBeyondHeader, logger), logger);
}
private static ByteArrayOutputStream createWorkbook(Object[][]...
sheetData) throws IOException {
@@ -346,15 +384,21 @@ public class TestExcelStartingRowSchemaInference {
cell.setCellValue(localDate);
cell.setCellStyle(dayMonthYearCellStyle);
}
+ case Formula formula ->
cell.setCellFormula(formula.expression());
+ case Object blank when blank == FORMATTED_BLANK ->
cell.setCellStyle(dayMonthYearCellStyle);
default -> throw new
IllegalStateException("Unexpected value: " + field);
}
}
}
sheetCount++;
}
+ creationHelper.createFormulaEvaluator().evaluateAll();
workbook.write(outputStream);
}
return outputStream;
}
+
+ private record Formula(String expression) {
+ }
}