Parse dates, do not guess them - #31
Conversation
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>
|
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. The
|
| when Date then [:ok, value] | ||
| when String then [:ok, Date.parse(value)] | ||
| when String | ||
| found = Date._parse(value) |
There was a problem hiding this comment.
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.
| # 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"] |
There was a problem hiding this comment.
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.
…into fix/dates-are-not-guessed
The bug
Coercion is documented as strict:
But
:dateand:datetimehanded strings straight toDate.parse, which fills in what they omit from today's date:"09/2026"2026-09-01"5th"2026-09-05"Sept"2026-09-01"10:30"(:datetime)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:Date._parse— the layer underneathDate.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 behindDate.parse's.A
:datetimemay still omit the time part, which reads as midnight UTC as documented; only the date part must be complete.Date,Time,DateTimeandActiveSupport::TimeWithZoneobjects 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
Date/Time/DateTimeobjects unchanged, and the:datetimedate-complete/time-optional rule:datetime— that one adds a require, this one changes the cast body, and they touch different spec files