Skip to content

[Proposal] Evolve Converter Architecture: Decouple Read/Write Responsibilities and Eliminate supportExcelTypeKey Ambiguity #1104

Description

@bengbengbalabalabeng

Motivation & Context

In recent community discussions, issues, and PRs concerning Converter read/write logic, the intention behind supportExcelTypeKey() has been a recurring point of confusion:

"Why is supportExcelTypeKey() return null frequently required in write mode? When does it take effect, and is this considered standard practice?"

Root Cause Analysis

Tracing the evolution from EasyExcel to FastExcel, this confusion stems from historical technical debt accumulated as the framework expanded to support multiple output formats:

  • EasyExcel Era:
    Originally, write converter registration was bound strictly to supportJavaTypeKey, agnostic of the target Excel cell type (since the output format is determined dynamically by the returned WriteCellData). When CSV export support was introduced, AbstractExcelWriteExecutor#doConvert hardcoded if (excelType == CSV) { setTargetCellDataType(STRING); }. This coupled the write pipeline with supportExcelTypeKey for the first time.

  • FastExcel Era:
    As features evolved—such as supporting differential conversions for identical Java types—write-mode registration also began strictly binding supportExcelTypeKey, influenced by the inertia of the earlier CSV hard-coupling. This introduced an unexpected side effect: standard write converters began throwing UnsupportedOperationException during registration. To bypass this exception and keep exports functioning, developers resorted to supportExcelTypeKey() { return null; } as a workaround.

  • Current State:
    Over time, returning null as a workaround was widely copied throughout the ecosystem. This obscured the fundamental asymmetry between read and write models and created substantial confusion regarding the true purpose of supportExcelTypeKey in write scenarios.

Fundamentally, supportExcelTypeKey() holds physical meaning exclusively in read mode, where it serves as an invariant prerequisite and must be explicitly specified (non-null).

Core Goals

  • Clarify that supportExcelTypeKey belongs solely to the read model; remove all implicit dependencies and workarounds from write workflows.
  • Introduce WriteConverter and ReadConverter interfaces, clarifying boundaries and reducing cognitive overhead for custom converters.
  • Eliminate hardcoded CSV type-binding hacks in the executor, making a single write converter naturally compatible across XLSX, XLS, and CSV.
  • Introduce ColumnBinding to address scenarios where identical Java types require distinct formatting across different columns in no-bean modes.
  • Introduce CellDataConverterRegistry to manage hierarchical lifecycle and caching, replacing the scattered raw converterMap.
  • Ensure compatibility with the existing Converter<T> interface, the converter registration entry point registerConverter(...) in the public *Builder, and the use of @ExcelProperty(converter = ...).

Proposed Design

Decouple WriteConverter and ReadConverter

Segregate the monolithic Converter into role-specific interfaces:

  • WriteConverter<T>:
    • supportJavaTypeKey()
    • convertToExcelData(T, ExcelContentProperty, GlobalConfiguration)
    • convertToExcelData(WriteConverterContext<T>)
  • ReadConverter<T> :
    • supportJavaTypeKey()
    • supportExcelTypeKey() (non-null contract)
    • convertToJavaData(ReadCellData<?>, ExcelContentProperty, GlobalConfiguration)
    • convertToJavaData(ReadConverterContext<?>)
  • Aggregate Compatibility Interface (Converter<T>):
    Inherits both interfaces (public interface Converter<T> extends ReadConverter<T>, WriteConverter<T>), ensuring existing code compiles and runs without modification.

Column-Scoped Capability Interface (ColumnBinding)

For no-bean modes (List<List<Object>>) where identical Java types require different formatting across columns (e.g., Column 1: Boolean -> "True/False", Columns 2–3: Boolean -> "Yes/No"):

// Binds strictly to Column 1
public class BooleanString1Converter implements WriteConverter<Boolean>, ColumnBinding {
    @Override
    public Class<?> supportJavaTypeKey() {
        return Boolean.class;
    }

    @Override
    public Integer columnIndex() {
        return 1;
    }

    @Override
    public WriteCellData<?> convertToExcelData(WriteConverterContext<Boolean> context) {
        return new WriteCellData<>(context.getValue().toString());
    }
}

// Binds to Columns 2 and 3
public class BooleanString2Converter implements WriteConverter<Boolean>, ColumnBinding {
    @Override
    public Class<?> supportJavaTypeKey() {
        return Boolean.class;
    }

    @Override
    public Set<Integer> columnIndexes() {
        return new HashSet<>(Arrays.asList(2, 3));
    }

    @Override
    public WriteCellData<?> convertToExcelData(WriteConverterContext<Boolean> context) {
        return new WriteCellData<>(context.getValue() ? "Yes" : "No");
    }
}

Extensibility Note: ColumnBinding specifically targets the common requirement of column-level scoping. If other matching dimensions are needed in the future, a generalized parent interface (such as ConditionalConverter) can be introduced seamlessly, with ColumnBinding acting as a specialized sub-interface.


Unified Converter Registry (CellDataConverterRegistry)

Replaces the Map<ConverterKeyBuild.ConverterKey, Converter<?>> converterMap inside AbstractHolder with a unified registry, CellDataConverterRegistry, providing dedicated registration and lookup methods.

  • Registration Precedence: When types or matching conditions are identical, later-registered converters take precedence over earlier ones (Later-registered > Earlier-registered / Last-in Wins).
  • Resolution Precedence: Custom (Write/Read)Converters + ColumnBinding > Custom (Write/Read)Converters > Framework default Converters.

Eliminate Executor Hardcoding

Clean up DefaultConverterLoader and eliminate the CSV-specific type-override branch in AbstractExcelWriteExecutor#doConvert:

// After refactoring: Clean, unified resolution
WriteConverter<?> writeConverter = writeContext.currentWriteHolder()
    .getConverterRegistry()
    .findWriteConverter(cellWriteHandlerContext.getOriginalFieldClass(), cellWriteHandlerContext.getColumnIndex());

API Evolution & Registration Compatibility

Provide dedicated registration methods on builders while preserving backward compatibility:

// Dedicated registration APIs
public T registerWriteConverter(WriteConverter<?> converter) { ... }
public T registerReadConverter(ReadConverter<?> converter) { ... }

// Legacy registration API (backward-compatible)
@Override
public T registerConverter(Converter<?> converter) {
    // Automatically routes internally to the corresponding read/write registration flow
}

Compatibility & Breaking Changes

Public API Compatibility

  • Existing implementations of Converter<T> behavior remains unchanged.
  • Existing implementations of @ExcelProperty(converter = ...) behavior remains unchanged.
  • Existing implementations of registerConverter(...) behavior remains unchanged.

Internal SPI Breaking Changes

  • Internal State Encapsulation:
    • The raw converterMap previously exposed in internal contexts (e.g., ConfigurationHolder#converterMap and ConverterUtils#convertToJavaType) is encapsulated within CellDataConverterRegistry.
    • Plugins that directly accessed or mutated converterMap via reflection must migrate to the public registry APIs.
  • Preloaded Default Collections:
    • Redundant String write converter mappings preloaded specifically for CSV in DefaultConverterLoader have been cleaned up and separated into dedicated sets.

Are you willing to submit a PR?

  • I'm willing to submit a PR! (Draft version implemented)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions