Skip to content

Per-field chrono format wrappers: glz::date_format and glz::epoch_count#2658

Merged
stephenberry merged 6 commits into
mainfrom
chrono-per-field-format
Jun 20, 2026
Merged

Per-field chrono format wrappers: glz::date_format and glz::epoch_count#2658
stephenberry merged 6 commits into
mainfrom
chrono-per-field-format

Conversation

@stephenberry

@stephenberry stephenberry commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Addresses #2640. Decouples the format of a chrono field from its type, so a single field can opt into a custom representation while others keep the type-driven default. (std::chrono::utc_time support from #2640 is tracked separately in #2657.)

What

Two per-field wrappers, applied inside glz::object(...) in a glz::meta<T> specialization:

  • glz::date_format(&T::member, "pattern") — serializes a system_clock time point (or a year_month_day) with a locale-independent strftime-subset pattern instead of ISO 8601.
  • glz::epoch_count<Duration>(&T::member) — serializes a system_clock time point as a numeric Unix timestamp in Duration units (the per-field counterpart to the glz::epoch_time storage wrapper).
struct Event {
   std::chrono::system_clock::time_point start{};
   std::chrono::sys_time<std::chrono::milliseconds> logged{};
};
template <> struct glz::meta<Event> {
   using T = Event;
   static constexpr auto value = glz::object(
      "start",  glz::date_format(&T::start, "%Y-%m-%d %H:%M:%S"),          // "2026-06-18 12:34:56"
      "logged", glz::epoch_count<std::chrono::milliseconds>(&T::logged));  // 1781786096789
};

The member pointer is a value argument (and for date_format so is the pattern), not a non-type template parameter — see Implementation notes for why.

Supported tokens (compile-time validated): %Y %m %d %H %M %S %F %T %%. Unsupported tokens, a missing calendar date, or time tokens on a year_month_day field are hard compile errors.

Scope

  • JSON / text backends only. These are textual wrappers that route through the JSON serializer, so NDJSON and stencil output work too. A field using them under a native-encoding backend (BEVE, CBOR, MsgPack, BSON, TOML) is a compile error (undefined glz::to<Format, …>), not a silent miscoding.
  • %S writes integer seconds; sub-second precision is truncated on write (a %S pattern has nowhere to put a fraction). Use the default ISO 8601 representation for sub-second fidelity.
  • UTC wall-clock, no timezone token.

Guarantees

  • The year is constrained to [0000, 9999] on both read and write, matching the ISO 8601 path.
  • date_format rejects an invalid/default year_month_day on write (constraint_violated), so the writer never emits a string its own reader would reject and an out-of-range month/day cannot slip past write_digits<2>.
  • epoch_count validates both its member type and its Duration unit at compile time, so misuse produces a clear diagnostic rather than a deep template cascade.

Implementation notes

  • Value-carried, not NTTP. The member pointer (and date_format's pattern) are passed as values, so the view type is keyed on the member type alone and the format lives as a runtime std::string_view. Fields that differ only in their format string no longer each instantiate a fresh date_format_t / to / from / closure — on a synthetic struct of N same-typed members with distinct formats this removes ~40% of the wrapper's template instantiations and makes the per-distinct-format compile cost flat. The pattern stays a compile-time literal: a consteval guard in the date_format(...) factory rejects unsupported tokens, a missing date, or time tokens on a year_month_day. (glz::float_format keeps the NTTP form because it relies on std::format's compile-time format checking; date_format's strftime walk is already runtime, so it has no such dependency.)
  • The strftime subset is a small, locale-independent hand-roll in core/chrono.hpp that reuses the existing parse_digits / write_digits primitives, so the [0000, 9999] year and component-range guarantees match the ISO 8601 path.
  • The date_format reader anchors its time-point reconstruction at seconds precision (sys_seconds) before folding in the time-of-day. MSVC's std::chrono::hours/minutes use a 32-bit representation, so the natural sys_days + hours + minutes idiom overflows the intermediate minutes count for far-future years; the seconds anchor keeps the arithmetic in 64 bits and the reconstruction exact across libstdc++, libc++, and MSVC.
  • float_format's compile-time format-string helper was factored out of json/float_format.hpp into core/format_str.hpp (no behavioral change).

Follow-ups (tracked separately)

…_count

Decouple the wire format from the chrono type (issue #2640) with two JSON
field wrappers usable inside glz::object():

  - glz::date_format<&T::m, "%Y-%m-%d %H:%M:%S"> renders a system_clock
    time_point or year_month_day with a hand-rolled, compile-time-validated
    strftime subset (%Y %m %d %H %M %S %F %T %%). UTC wall-clock; %S is integer
    seconds (sub-second truncated by design).
  - glz::epoch_count<&T::m, Duration> renders a system_clock time_point as a
    numeric Unix count, the per-field counterpart to glz::epoch_time.

Both follow the established wrapper precedents (float_format's NTTP format_str,
quoted_t's full custom read+write) and are JSON-only: marked glaze_reflect=false
so binary backends fail to compile rather than silently miscode.

Format string parsing/formatting is hand-rolled (no std::chrono::parse/format)
reusing the existing write_digits/parse_digits primitives, for consistent
behavior across the compiler matrix. utc_time and binary backends are
intentionally out of scope for this MVP.

- Extract format_str NTTP into glaze/core/format_str.hpp (shared with float_format)
- Add strftime-subset helpers to chrono_detail in glaze/core/chrono.hpp
- New header glaze/json/chrono_format.hpp; wired via glaze/chrono.hpp
- Tests in tests/chrono_test and docs in docs/chrono.md
Review-driven guards, tests, and docs for glz::date_format / glz::epoch_count.
No behavioral regressions for valid inputs.

- epoch_count: validate the Duration unit (is_duration) alongside the member
  type via a shared validate_epoch_count() guard, so a non-duration unit gives
  a friendly message instead of a cascade out of `typename Duration::rep`.
- date_format: reject an invalid/default year_month_day on write
  (constraint_violated) so the writer never emits a string its own reader
  rejects, and an out-of-range month/day cannot slip past write_digits<2>.
- date_format: skip the ensure_space reservation on the write_unchecked fast
  path (matches to<JSON, num_t>); correct the buffer-size comments (%F, not %T,
  is the widest token at 5 bytes per source char).
- docs: correct the backend-scope wording (text/JSON-family only; TOML also
  fails to compile; NDJSON/stencil work) and document the epoch_count integer
  input contract.
- tests: compile-time validator assertions plus runtime coverage for year
  boundaries, pre-1970 instants, the %% literal, sub-second read-back, the
  epoch_count cross-precision duration_cast, and the invalid-ymd write guard.
MSVC's std::chrono::hours and minutes use a 32-bit rep, so reconstructing a
far-future instant as `sys_days{ymd} + hours + minutes + seconds` overflows
the intermediate minutes count (days * 24 * 60 exceeds INT_MAX, ~4.2e9 at year
9999) and silently wraps to a bogus instant. libc++/libstdc++ use a 64-bit rep,
so the year-boundary test passed there but failed on the msvc/clang-cl jobs,
emitting 1833-11-15T19:43:59 for 9999-12-31T23:59:59.

Anchor the day at seconds precision (sys_seconds) before folding in the
time-of-day so the whole chain is computed in 64 bits, in both the date_format
reader and the test's input construction.
@stephenberry stephenberry linked an issue Jun 20, 2026 that may be closed by this pull request
date_format and epoch_count now take the member pointer as a value argument:

  glz::date_format(&T::start, "%Y-%m-%d %H:%M:%S")
  glz::epoch_count<std::chrono::milliseconds>(&T::logged)

Previously the member pointer and (for date_format) the format string were
non-type template parameters. Carrying the format as a runtime std::string_view
keys the view type on the member type alone, so fields that differ only in their
format string no longer each instantiate a fresh date_format_t / to / from /
closure. On a synthetic struct of N same-typed members with distinct formats this
removed ~40% of the wrapper's template instantiations and made the per-distinct-
format compile cost ~flat (it tracks a single shared format). date_format's
strftime walk was already runtime, so codegen is unchanged.

The strftime pattern stays a compile-time literal: a consteval guard in the
date_format() factory still rejects unsupported tokens, a missing calendar date,
and time tokens on a year_month_day field. float_format keeps its NTTP form
because it relies on std::format's compile-time format checking.

The reader keeps the seconds-anchored reconstruction (sys_seconds) so far-future
years stay exact under MSVC's 32-bit chrono hours/minutes rep.
The consteval date_format() validator used throw to turn an invalid format
string into a compile error. throw is ill-formed under -fno-exceptions even
inside an immediate function, so the chrono_test build (which compiles with
-fno-exceptions -fno-rtti) failed across the GCC/Clang matrix.

Replace the throws with calls to non-constexpr marker functions on the error
branches: when a branch is constant-evaluated the call makes the immediate
invocation non-constant, producing a hard compile error whose diagnostic names
the marker. Same compile-time rejection of unsupported tokens, a missing date,
and time tokens on a year_month_day field, now without relying on exceptions.
date_format and epoch_count were documented only in chrono.md and were absent
from docs/wrappers.md, the canonical wrapper list where the sibling float_format
lives, so a user browsing wrappers would not find them. Add both there (with the
glaze/chrono.hpp include note and a link to the chrono docs).

Also round out the chrono.md section: add a read/round-trip example for
date_format (previously only the write side was shown) and document the
write-side constraint_violated error for out-of-range years / non-ok dates.
@stephenberry stephenberry merged commit e1d70fa into main Jun 20, 2026
54 checks passed
@stephenberry stephenberry deleted the chrono-per-field-format branch June 20, 2026 18:07
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.

Chrono Improvements

1 participant