Skip to content

feat: fixedformat4j-delimited — annotation-driven mapping for CSV, TSV, and pipe-delimited files #122

Description

@jeyben

Summary

Add a new Maven module fixedformat4j-delimited that brings the same annotation-driven, bidirectional, type-safe POJO mapping that fixedformat4j provides for fixed-width files to delimiter-separated formats: CSV, TSV, pipe-delimited, and any single-character delimiter variant.

Prerequisite: #121fixedformat4j-core extraction must be complete before this module can be implemented.

Motivation

No Java library currently combines:

  • Annotation-driven POJO binding (just annotate your getters/fields)
  • Bidirectional support (parse → POJO and POJO → line)
  • Reuse of typed formatters (DateFormatter, BigDecimalFormatter, etc.)
  • Strict startup validation of the mapping configuration

OpenCSV and Apache Commons CSV handle delimiter parsing mechanics but offer no annotation-driven type-mapped POJO binding. Jackson CSV requires a schema object rather than annotations on the domain class. This module fills that gap using the same model consumers already know from fixedformat4j.

Proposed annotation API

@DelimitedRecord

Marks a class as a delimiter-separated record. Analogous to @Record.

@DelimitedRecord(delimiter = '|', quote = '"')
public class TradeRecord { ... }
Attribute Type Default Description
delimiter char ',' Field separator character
quote char '"' Quote character for fields containing the delimiter
hasHeader boolean false Whether the first line is a header row (skipped on load)

@Column — index-based mapping

Placed on getter methods or fields. Analogous to @Field.

@DelimitedRecord(delimiter = ',')
public class TradeRecord {

    @Column(index = 1)
    String getTradeId();

    @Column(index = 2)
    @FixedFormatPattern("yyyyMMdd")
    LocalDate getSettlementDate();

    @Column(index = 3)
    @FixedFormatDecimal(decimals = 2, useDecimalDelimiter = true)
    BigDecimal getAmount();

    @Column(index = 4)
    TradeStatus getStatus();  // enum — LITERAL by default
}
Attribute Type Default Description
index int 1-based column position (required)
formatter Class<? extends FixedFormatter<?>> ByTypeFormatter.class Override the type formatter
nullValue String "" The raw string that represents null on load/export

@ColumnName — header-based mapping (alternative to @Column)

For files with a header row, bind by column name rather than position:

@DelimitedRecord(delimiter = ',', hasHeader = true)
public class TradeRecord {

    @ColumnName("trade_id")
    String getTradeId();

    @ColumnName("settlement_date")
    @FixedFormatPattern("yyyy-MM-dd")
    LocalDate getSettlementDate();
}

Proposed manager API

DelimitedFormatManager manager = DelimitedFormatManagerImpl.create();

// load a single line
TradeRecord record = manager.load(TradeRecord.class, "T001|20260506|12500.00|SETTLED");

// export a single record
String line = manager.export(tradeRecord);
// → "T001|20260506|12500.00|SETTLED"

DelimitedFormatManager is a separate interface from FixedFormatManager — it is not a subtype, and FixedFormatManager is not widened. This respects ISP and keeps both interfaces narrow.

Reused from fixedformat4j-core

All typed formatters carry over unchanged:

  • StringFormatter, IntegerFormatter, LongFormatter, BigDecimalFormatter, BooleanFormatter, DateFormatter, LocalDateFormatter, LocalDateTimeFormatter, EnumFormatter, etc.
  • FixedFormatter<T> interface
  • Supplementary annotations: @FixedFormatPattern, @FixedFormatDecimal, @FixedFormatBoolean, @FixedFormatNumber, @FixedFormatEnum

The only new formatting concern is quote handling — fields that contain the delimiter character must be quoted on export and unquoted on load. This is handled by the delimited layer before field values reach the typed formatters.

IO integration — DelimitedReader

A DelimitedReader mirroring FixedFormatReader:

DelimitedReader reader = DelimitedReader.builder()
    .addMapping(TradeRecord.class)           // single type — no discriminator needed
    .parseErrorStrategy(ParseErrorStrategy.skipAndLog())
    .build();

List<TradeRecord> trades = reader.read(Path.of("trades.csv")).get(TradeRecord.class);

For heterogeneous delimited files (multiple record types in one file), a column-value discriminator replaces LinePattern:

DelimitedReader reader = DelimitedReader.builder()
    .addMapping(HeaderRecord.class, ColumnDiscriminator.column(1, "HDR"))
    .addMapping(DetailRecord.class, ColumnDiscriminator.column(1, "DTL"))
    .build();

Validation at startup

Following the same model as FixedFormatManagerImpl, DelimitedFormatManagerImpl validates on first use per class:

  • Duplicate column indices within a record.
  • @ColumnName used without hasHeader = true on @DelimitedRecord.
  • @ColumnName and @Column mixed on the same record class.
  • Formatter incompatible with the field's declared Java type.
  • nullValue configured on a primitive-typed field.

Design notes

  • Quote handling uses a minimal RFC 4180-compliant parser. Embedded newlines within quoted fields are supported on load but rejected on export with a clear exception (fixed-width assumption: one record per line).
  • ClassMetadataCache pattern is replicated for DelimitedFormatManagerImplClassValue-based, GC-safe, thread-safe.
  • @Column indices are 1-based to align with the existing fixed-width convention.
  • Multi-char delimiters (e.g. ||) are explicitly out of scope — delimiter is a single char.

SOLID check

  • SRP: DelimitedFormatManagerImpl owns delimiter-separated parsing. Quote handling is extracted to a DelimitedLineParser with a single responsibility.
  • OCP: adding a new delimiter format does not modify FixedFormatManager or any existing fixed-width class.
  • LSP: DelimitedFormatManager is a new interface; no existing subtype contracts are affected.
  • ISP: DelimitedFormatManager is narrow (load, export); introspection and IO live in separate types.
  • DIP: DelimitedFormatManagerImpl depends on FixedFormatter<T> from fixedformat4j-core, not on concrete fixed-width classes.

Version classification

Minor (2.x.0) relative to the 2.0.0 major introduced by #121 — a new optional Maven artifact with no changes to existing public surfaces. Existing fixedformat4j consumers are completely unaffected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Module-CoreTouches the main fixedformat4j artifact (annotation, format, format/impl layers)Module-DelimitedTouches the fixedformat4j-delimited artifact (CSV/TSV/pipe-delimited support)Type-Enhancementenhancement

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions