Skip to content

Parse dates, do not guess them - #31

Merged
VSN2015 merged 3 commits into
masterfrom
fix/dates-are-not-guessed
Sep 7, 2026
Merged

Parse dates, do not guess them#31
VSN2015 merged 3 commits into
masterfrom
fix/dates-are-not-guessed

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Contains a behaviour change — flagged prominently below. Same sweep as #30.

The bug

Coercion is documented as strict:

A value the type cannot faithfully represent is a violation, not a guess.
…silently corrupting untrusted input is precisely what a contract must not do.

But :date and :datetime handed strings straight to Date.parse, which fills in what they omit from today's date:

Input Produced Invented
"09/2026" 2026-09-01 the day
"5th" 2026-09-05 the month and year
"Sept" 2026-09-01 the day and year
"10:30" (:datetime) today at 10:30 the entire date

The same request meant different things on different days. A "5th" submitted in September and in October produce different stored dates.

The accepted set wasn't designed, either — it was Date.parse's quirks. "2026-09" and "2026" happen to raise and so were rejected, while "5th" happens to succeed and was accepted.

The fix

A string must name all three of year, month and day. Which format it names them in is still Date.parse's business, so every complete format it understands keeps working:

  on   "2026-09-05"           -> 2026-09-05
  on   "2026/09/05"           -> 2026-09-05
  on   "Sep 5, 2026"          -> 2026-09-05
  on   "5 September 2026"     -> 2026-09-05
  on   "09/2026"              -> REJECTED (invalid_type)
  on   "5th"                  -> REJECTED (invalid_type)
  on   "Sept"                 -> REJECTED (invalid_type)
  at   "2026-09-05T10:30:00Z" -> 2026-09-05 10:30:00 UTC
  at   "2026-09-05"           -> 2026-09-05 00:00:00 UTC
  at   "10:30"                -> REJECTED (invalid_type)

Date._parse — the layer underneath Date.parse — reports which components it actually found rather than the filled-in result, so the check needs no format list of its own and can't fall behind Date.parse's.

A :datetime may still omit the time part, which reads as midnight UTC as documented; only the date part must be complete. Date, Time, DateTime and ActiveSupport::TimeWithZone objects are unaffected.

The behaviour change, stated plainly

An endpoint relying on the fill-in will now reject input it used to accept. I think that's right — the values it produced were not the ones the client meant, and the exported schema's "format": "date" already promised RFC 3339 rather than "5th" — but it is a change, and it's your call whether it wants a minor version rather than a patch.

Verification

  • 205 examples, 0 failures (6 new, written before the fix): complete formats still accepted, incomplete ones rejected with the reason named, previously-rejected input unchanged, Date/Time/DateTime objects unchanged, and the :datetime date-complete/time-optional rule
  • RuboCop clean
  • Independent of Fix :datetime raising NameError without Rails #30 despite both being about :datetime — that one adds a require, this one changes the cast body, and they touch different spec files

Coercion is documented as strict — "a value the type cannot faithfully
represent is a violation, not a guess", and "silently corrupting
untrusted input is precisely what a contract must not do" — but :date
and :datetime handed strings straight to Date.parse, which fills in
what they omit FROM TODAY:

  "09/2026"  -> 2026-09-01   (day invented)
  "5th"      -> 2026-09-05   (month and year invented)
  "Sept"     -> 2026-09-01   (day and year invented)
  "10:30"    -> today 10:30  (the whole date invented, for :datetime)

The same request therefore meant different things on different days.
The accepted set was not designed either, it was Date.parse's quirks:
"2026-09" and "2026" happen to raise and so were rejected, while "5th"
happened to succeed and was accepted.

A string must now name all three of year, month and day. Which FORMAT
it names them in is still Date.parse's business, so every complete
format it understands keeps working — "2026-09-05", "2026/09/05",
"Sep 5, 2026", "5 September 2026". Date._parse, the layer underneath,
reports which components it actually found rather than the filled-in
result, so the check needs no format list of its own.

A :datetime may still omit the TIME part, which reads as midnight UTC
as documented; only the date part must be complete. Date, Time,
DateTime and ActiveSupport::TimeWithZone objects are unaffected.

This is a behaviour change for an endpoint relying on the fill-in, but
the values it produced were not the ones the client meant, and the
exported schema's "format": "date" already promised RFC 3339 rather
than "5th".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The completeness check called Date._parse and the cast then called
Date.parse, which parses the same string again — and parsing is the
expensive half. Every :date cast paid for it:

  cast_date  before: 47.3k i/s (21.12 us/i)
  cast_date  after:  83.6k i/s (11.96 us/i)   1.77x faster

The Date is now built from the components Date._parse already
returned. Date.new applies the same calendar validation, so nothing
changes about what is accepted — a spec pins the two agreeing across
compact and slash formats, a real leap day, and both 2026-02-29 and
2026-02-30.

:datetime still parses twice, deliberately. Rebuilding a Time from
components would have to reimplement DateTime.parse's handling of
offsets, zone names and sub-second precision, and getting that subtly
wrong costs more than the parse does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015

VSN2015 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Follow-up pushed to this branch, before merge rather than after.

Benchmarking the merged state of all the open PRs showed the documented overhead figure had moved — and this change was the largest contributor. complete_date? called Date._parse, and the cast then called Date.parse, which parses the same string a second time. Parsing is the expensive half:

cast_date  before: 47.3k i/s (21.12 μs/i)
cast_date  after:  83.6k i/s (11.96 μs/i)   1.77x faster

The Date is now built from the components Date._parse already returned. Date.new applies the same calendar validation, so nothing changes about what is accepted — a new spec pins the two agreeing across compact (20260905) and slash (2026/09/05) formats, a real leap day (2024-02-29), and both 2026-02-29 and 2026-02-30.

:datetime still parses twice, deliberately: rebuilding a Time from components would have to reimplement DateTime.parse's handling of offsets, zone names and sub-second precision, and getting that subtly wrong costs more than the parse does. That's now stated in a comment so the asymmetry doesn't look accidental.

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review of PR #31: High-value determinism fix for date parsing. Disallowing partial dates prevents Date.parse from silently inventing missing year/month/day values based on the current system date.

Comment thread lib/permittable.rb
when Date then [:ok, value]
when String then [:ok, Date.parse(value)]
when String
found = Date._parse(value)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Using Date._parse(value) and constructing via Date.new(found[:year], found[:mon], found[:mday]) ensures all calendar components were explicitly provided without paying the cost of a double parse.

Comment thread lib/permittable.rb
# Time from components would have to reimplement DateTime.parse's
# handling of offsets, zone names and sub-second precision, and
# getting that subtly wrong costs more than the parse.
complete_date?(Date._parse(value)) ? [:ok, DateTime.parse(value).to_time.utc] : [:error, "invalid_type"]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Applying complete_date? to :datetime inputs appropriately accepts full dates (normalised to midnight UTC) while rejecting floating times like "10:30" that would otherwise anchor to today.

@VSN2015
VSN2015 merged commit 4ee40e2 into master Sep 7, 2026
4 checks passed
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.

1 participant