Skip to content

feat: consolidate default date formats behind thread-safe tiered config - #1243

Open
GOODBOY008 wants to merge 4 commits into
apache:mainfrom
GOODBOY008:feat/date-format-defaults
Open

GOODBOY008 wants to merge 4 commits into
apache:mainfrom
GOODBOY008:feat/date-format-defaults

Conversation

@GOODBOY008

Copy link
Copy Markdown
Member

Purpose

The default date patterns were three undocumented public static mutable fields on DateUtils (defaultDateFormat, defaultLocalDateFormat, defaultYearFormat): no setter, no validation, no happens-before between a writer thread and reader threads, and no documentation — the only way to change them was a direct field write. This PR consolidates them behind a documented, thread-safe three-tier cascade:

Priority Source Scope
1 @DateTimeFormat("...") per field (unchanged)
2 .defaultDateFormat(...) / .defaultLocalDateFormat(...) / .defaultYearFormat(...) builder parameters per read/write call (new)
3 DateUtils.setDefaultDateFormat(...) etc. process-wide setters process-wide (new, volatile)
— built-in constants yyyy-MM-dd HH:mm:ss / yyyy-MM-dd / yyyy

Each read/write context snapshots tier 3 once at creation (AbstractHolder, workbook level; sheets inherit), so a single workbook always renders with one consistent pattern and a mid-run global change only affects contexts created afterwards.

Background

  • defaultYearFormat was added in feat: add Character and Year converters (#1017) #1087 (merged after 2.1.0, never shipped in a release), deliberately following the sibling-field pattern — this PR stops the pattern from replicating. Since it is unreleased, it is replaced outright by a private volatile field plus accessors, without a deprecation window.
  • defaultDateFormat/defaultLocalDateFormat have shipped since 2.0.0, so they stay binary/source compatible: volatile + @Deprecated, with setter accessors following the UrlImageConverter.fetchPolicy precedent. Removal targeted at the next major version.
  • feat: add ZonedDateTime converters #1020's review (ZonedDateTime STRING path keeping ISO) shows some types need type-specific defaults, so the resolve*Format helpers are opt-in infrastructure, not a mandate — documented in the new "Default date formats" section as guidance for the [Task] Implement more commonly used converter classes #1017 converter pipeline.

Behavior audit

Scenario Before After
Direct DateUtils.defaultDateFormat = x field write works, but the write may never be visible to other threads and can split an in-flight workbook still works (field kept, now volatile); in-flight workbooks keep their snapshot
DateUtils.format(date, null) direct util call global default global default (unchanged)
Only the process-wide default is set (no annotations/params) global value applied per cell same value (snapshotted at context creation) — output identical
Empty @DateTimeFormat on Date/LocalDateTime/LocalDate write empty string written into the cell style verbatim (only OffsetDateTime normalized it — pre-existing inconsistency, same direction as the #1020 review) falls through to the resolved default
Read-side string date parsing length-based auto-detection (unaffected by the global) unchanged
Year string read / empty-@DateTimeFormat String field reading a numeric date cell process-wide default resolved cascade (annotation > per-call > snapshot)
Performance static field read per conversion instance field read per conversion — equivalent, no benchmark needed

Coordination

Documentation

New "Default date formats" section in website/docs/sheet/write/format.md (+ zh-cn mirror): cascade table, per-call and process-wide usage, snapshot semantics, migration note for the deprecated fields, contributor guidance. Short read-side note in read/format.md and three new rows in help/parameter.md (+ zh-cn mirrors).

Testing

  • DateUtilsDefaultFormatTest (10 unit tests): full precedence matrix for the three pattern kinds, blank-pattern rejection, reset, workbook-level snapshot + sheet inheritance, param-over-global.
  • DefaultDateFormatWriteTest (4 tests): xlsx write via builder params and via global setters (style dataFormatString assertions through ExcelAssertions), mid-write global flip does not affect the running workbook, CSV write uses the per-call default.
  • Two existing tests updated from direct field reads to accessors.
  • Full suite: 1737 tests, 0 failures; spotless:check and javadoc:javadoc clean.

The default date patterns were three undocumented public static mutable
fields on DateUtils with no setter, no validation and no happens-before
between writer and reader threads, read at ~15 scattered call sites.

Resolve them through a documented three-tier cascade instead:

  @DateTimeFormat > per-call builder parameter > process-wide setter
  > built-in constant

- DateUtils: defaultDateFormat/defaultLocalDateFormat become volatile
  and deprecated in favor of get/set accessors (blank patterns are
  rejected, resetDefaultDateFormats() restores builtins);
  defaultYearFormat never shipped (added in apache#1087 after 2.1.0) and is
  replaced by a private volatile field plus accessors outright
- resolveDateTimeFormat/resolveLocalDateFormat/resolveYearFormat give
  converters one shared annotation-over-configuration entry point
  (also normalizing empty @DateTimeFormat values, which Date/
  LocalDateTime/LocalDate converters previously wrote into cell styles
  verbatim)
- GlobalConfiguration carries the per-call patterns; AbstractHolder
  snapshots the process-wide default once per workbook so every cell
  of a workbook uses one consistent pattern and mid-run global changes
  only affect contexts created afterwards
- builder parameters defaultDateFormat/defaultLocalDateFormat/
  defaultYearFormat on both read and write sides
- CsvWorkbook/CsvSheet thread the snapshot into CSV rendering
- document the cascade (en + zh-cn) including a migration note and
  guidance for new converters (apache#1017 pipeline)

Signed-off-by: gongzhongqiang <gongzhongqiang@apache.org>
Copilot AI balanced review requested due to automatic review settings October 20, 2026 05:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

- wrap the disclaimer paragraph of the ASF header in the two new test
  files exactly like tools/spotless/license-header.txt (hawkeye wanted
  to replace the headers)
- repair the zh-cn parameter.md table rows mangled by the previous
  insertion: restore the use1904windowing row's trailing pipe (MD055)
  and drop the duplicated trailing pipe after defaultYearFormat (MD056)

Signed-off-by: gongzhongqiang <gongzhongqiang@apache.org>

@skytin1004 skytin1004 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @GOODBOY008, I think the snapshot invariant should be enforced at the holder boundary, or blank values should be rejected consistently across all three builders. Could you please take a look?


// Date patterns resolve eagerly: a workbook snapshots the process-wide default once so every cell in it uses
// one consistent value, while sheet/table holders inherit the workbook's snapshot.
if (basicParameter.getDefaultDateFormat() == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was checking what happens when the per-call format is empty, for example .defaultDateFormat(""). It gets stored here because only null falls back to the snapshot, but the resolver later treats the empty value as missing and reads the current global default again. That means the workbook does not really keep its original snapshot if the global value changes afterwards.

I think blank values should either be rejected by all three builders or normalized to the parent/global snapshot when the holder is created. I'm leaning toward handling it here because that would also cover metadata created without the builder. Could we fix it in that direction and add a small empty/whitespace case?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @skytin1004 review this pr.

Confirmed — real bug, not just an edge case. AbstractHolder:132 only checks null, so "" is stored as the snapshot; resolveDateTimeFormat:573 then treats it as unset and live-reads the static field, which is exactly the invariant this PR claims to protect (and what write/format.md promises).

Fixed at the holder boundary with StringUtils.isBlank(...) for all three patterns, plus blank rejection in the three builder methods so it fails at the call site instead of silently. Added "" / " " cases — the existing settersRejectBlankPatterns only covered the static setters, which is why this slipped through.

Scope note: whether these fields were ever meant to be supported API is unproven either way — no docs, no tests, no setter, no assignment anywhere — and #1097 on the same area is still open with +0 on every option. That is why this stays a deprecation rather than a removal, and why the cascade shape should be settled in a separate discussion rather than here. The blank fix lands regardless.

@skytin1004 skytin1004 Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks for the update, @GOODBOY008. I pulled the latest head and reran the tests. The holder-level blank handling and the added empty/whitespace coverage resolve my concerns.

A blank per-call pattern was stored into the context snapshot as-is, but
the resolvers read an empty pattern as "not set" and fell back to the
process-wide default. A workbook created with .defaultDateFormat("") was
therefore not really snapshotted: changing the global value afterwards
still changed its output, contradicting the snapshot guarantee that this
change documents and tests.

A whitespace-only pattern took a third path: StringUtils.isEmpty does not
trim, so "   " was accepted as a pattern and only failed later when the
formatter was built, far from the call site.

Normalize at the holder boundary so blank always means "inherit the
parent or process-wide default", and reject empty/whitespace in the three
builder methods so the mistake surfaces at the call site. null keeps its
existing "not set" meaning, consistent with the sibling builder
parameters.
@GOODBOY008
GOODBOY008 requested review from bengbengbalabalabeng, delei, psxjoy and skytin1004 and removed request for skytin1004 October 22, 2026 08:20
@bengbengbalabalabeng

Copy link
Copy Markdown
Contributor

Currently, exposing methods like .defaultDateFormat(), .defaultLocalDateFormat(), and .defaultYearFormat() directly on the facade builder might lead to API bloat once we add support for other types like LocalDateTime, LocalTime, Instant, etc.

Could we consider consolidating these into a unified configuration builder instead? For example:

.dateTimeFormat((DateTimeFormatBuilder) fmt -> fmt
    .pattern(Date.class, "yyyy/MM/dd HH:mm:ss")
    .pattern(LocalDate.class, "yyyy/MM/dd")
)

Under the hood, this could be managed via BasicParameter/GlobalConfiguration -> DateTimeFormatRegistry. To use it, simply resolve the pattern via registry.findPattern(Date.class).

This would be much more extensible for future date/time types. What do you think about this?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants