Cast in: members with the field's type, and refuse a String in:, at class load - #65
Conversation
… class load `in: %i[draft published]` on a :string field compared the cast String against Symbols and rejected every request, while the exported enum advertised "draft"/"published"; `in: %w[1 2 3]` on :integer did the same. List members are now cast by the field's own type at class load and stored cast, so the runtime, the JSON Schema enum and the RSpec matcher's `within` read one list. A member no cast accepts fails at boot. `in:` only had to answer include?, which let a String through as a substring test (`in: "free pro"` accepted "e"). It must now be a Range or a non-Hash Enumerable. A Range stays as written, but is refused when its endpoints cannot be compared with the field's values. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
Review follow-ups on the class-load in: check: - A Hash in: (the Rails enum idiom, in: Post.statuses) is read as its keys, cast like any list, so check_column_types' enum rule sees names. - An object that only answers include? is kept as given: not cast, and exported as x-permittable-custom-validation instead of an enum. - Lists, Enumerator::Lazy included, are forced to an Array at class load, so casting never runs per request. - A Time/DateTime member of a :date field is read as its date. - A nil member is dropped on a nullable field. - A String-authored :date/:datetime member is exported as written, so the enum never lists a value the server refuses. - The matcher shares the contract's list predicate (Coercion.in_list). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Addressed the review in 00fffce, after merging master (which includes #57):
The CHANGELOG's list of newly failing declarations is now exact. 🤖 Generated with Claude Code |
Regression-pass follow-ups on the class-load in: check: - Only Array, Set, Hash (keys) and Enumerator are lists to cast. Any other object answering include?, Enumerable or not, is used as given, so an app's case-insensitive allowlist or DB-backed registry keeps its own include? and is never enumerated at class load. - A Hash's keys (and a Set) are stored as a frozen Set: O(1) membership. The matcher compares lists as sets accordingly. - A Time/DateTime member of a :date field is read as its UTC date only at exactly midnight UTC, the one instant that ever equalled a date; any other fails at class load. - An exported Time keeps its fractional-second digits (up to nine), so every exported :datetime member is one the server accepts. - CHANGELOG: the class-load snapshot of an Array is listed as a change, and the newly-failing declarations are described precisely. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Regression pass addressed in cc1ee81:
669 examples, 0 failures. RuboCop is clean. Pushed as a fast-forward. 🤖 Generated with Claude Code |
case/when dispatch on the collection's own class used === (is_a? for a Class), so a subclass overriding include? for its own matching logic (a case-insensitive allowlist, a fuzzy Set, a registry) matched its ancestor's branch and had its override silently discarded, read for its raw keys/elements instead — inverting which values it accepts, with no error at class load. Only a plain Array, Set, Hash, or Enumerator (an include? that is not overridden) is now read as a list. ActiveSupport::HashWithIndifferentAccess is kept as one anyway, since its own override just canonicalises the argument before the same key lookup and it is what a Rails enum's own reader (Post.statuses) actually returns. Also tightens the README/CHANGELOG wording that implied cover? duck- typing on a non-Range in:, which resolve_in! never did. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolves CHANGELOG/README conflicts (kept both sides' entries; master's CHANGELOG entries first) and merges apply_in!'s numeric-bound-safe rework (#60) with this branch's opaque/in_published in: handling. Also reconciles an interaction the merge surfaced: assert_comparable_range! (added on this branch, to catch a wrong-TYPED Range like a String range on an :integer) treated a NaN endpoint the same way, since NaN's <=> always returns nil — which would have raised at class load for `in: Float::NAN..`, a Range #60's own new export logic explicitly allows to load (and simply omits from the schema, since it's never `finite?`). A NaN endpoint is not evidence of a wrong-typed bound, so it is left alone here exactly as an infinite endpoint already is. # Conflicts: # CHANGELOG.md # README.md # lib/permittable/json_schema.rb # spec/json_schema_spec.rb Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Final regression addressed in 77890ec: A Hash/Array/Set subclass overriding
New specs: a Hash, an Array and a Set subclass each overriding Merging master's #60 also surfaced one interaction worth a small extra fix: this branch's 775 examples, 0 failures, on current master (#57/#59/#60/#61/#64/#66 all included). RuboCop clean. Fast-forward push. Also fixed the flagged doc-precision issue: README/CHANGELOG said 🤖 Generated with Claude Code |
CHANGELOG only: keep master's entries ahead of this PR's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Master had moved to include #59-#61, #64 and #66 since this branch's last merge, plus #60 and #65 which both touch json_schema.rb's :decimal/:datetime export and lib/permittable/rspec.rb's `in:`/`default:` matcher chains — real conflicts, not just additive. lib/permittable.rb: kept this branch's permittable_transform seam (AuthoredValues needs it to walk a default without running transform: on it) — same guard (`violations.length == before`) as master's inline form. lib/permittable/json_schema.rb (4 hunks): combined rather than picked a side. - apply_in!'s enum export now uses BOTH #65's `field[:in_published] || allowed` (keeps a :date/:datetime member's authored String form) AND this branch's `decimal: :number` (keeps a :decimal member numeric and consistent with its own default/example export). - kept #60's `apply_normalize!` method (unrelated to this branch) ahead of json_value, whose signature this branch changes to `decimal: :string`. - json_value's Hash/BigDecimal/Time cases: kept this branch's `decimal:` threading for Hash/BigDecimal, and #65's `exact_iso8601` for Time (fixes sub-second Time :in members exporting as whole seconds — this branch's plain `.utc.iso8601` would have regressed that). - kept both decimal_json (this branch) and exact_iso8601 (#65) methods; each is used from the merged json_value body above. Verified with a probe: a :decimal in: list now exports numerically AND agrees with its own numeric default, and a :datetime in: list with sub-second members still exports the authored string via in_published — both at once, which neither branch alone tested. lib/permittable/rspec.rb (2 hunks) and spec/matchers_spec.rb: purely additive — #65's `cast_in`/`same_in?`/`within` alongside this branch's `default_mismatch`/`cast_default`. Combined by keeping both. README.md: combined the `default:` row (this branch, the transform: exception) with the `validate:` row (master, the array-skip-on-failed- validate note) — same table, different rows each PR had touched. Fixed along the way (found while merging, not part of either PR): - spec/permittable_spec.rb: a #61 perf spec asserted `valid_encoding?` is called on the caller's OWN string object, but this branch's permittable_own copies a request's String before Coercion.cast ever sees it (to avoid aliasing params) — so the original object is never touched, though the copy is (correctly) scanned exactly once. Rewrote the spec to count via a shared counter that survives `dup`, so it asserts the real invariant (one scan overall) rather than one specific object's identity. - spec/matchers_spec.rb: my own conflict resolution (both PRs inserted a new `it` block at the same point in the file) left one block's closing `end` missing — a syntax error that silently dropped all 42 examples in this file from the suite total without failing the run. Fixed; the suite total is now 819 (was silently 777). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The bugs
1.
in:members were compared as authored, against a cast value. The runtime casts the request value first, then asksin:whether it includes that value. So within: %i[draft published]on a:stringfield,"draft"was compared with:draft, and every request gotinclusion. Meanwhile the exported JSON Schema, which stringifies Symbols, published"enum": ["draft", "published"]: the docs listed the exact values the server refused.in: %w[1 2 3]on an:integerrejected every value in the same way.assert_satisfiable!didn't catch either one.2.
in:only had to answerinclude?. AStringanswers it with a substring test, soin: "free pro"accepted"e","fr"and"ee p"as plans.The fix
in:is aRange, a list, or any other object that answersinclude?. Only aString, and anything that is neither aRangenor answersinclude?, is refused.Coercion.in_listdecides what counts as a list: only a plainArray,Set,HashorEnumerator— never a subclass overridinginclude?.case allowed; when Hash ...dispatches with===, which for a Class isis_a?, so a subclass first matched its ancestor's branch; a Hash/Array/Set subclass overridinginclude?for its own matching logic (a case-insensitive allowlist, a fuzzySet, a registry) had that override silently discarded, read for its raw keys/elements instead — inverting which values it actually accepted, with no error at class load. Only an unoverriddeninclude?now counts as a list.ActiveSupport::HashWithIndifferentAccessis the one exception kept anyway: its own override just canonicalises the argument before the same key lookup, and it's whatPost.statusesactually returns. A Hash's keys and aSetare stored as a frozenSet, so membership stays O(1). AnEnumerator(Lazyincluded, which doesn't overrideinclude?) is forced to an Array once at class load.Coercion.castwith the field's own type, the same cast a request value gets. The differences:Time/DateTimeon a:datefield is read as its UTC date only when it's exactly midnight UTC, the one instant ActiveSupport ever found equal to a date. Any other instant raises;normalize:isn't applied;nilmember is dropped on anullable:field.include?, Enumerable or not, is stored exactly as given. That covers an app's case-insensitive allowlist or a DB-backed registry: it is never enumerated at class load and never replaced by an exact-match copy. It isn't cast, and it's exported asx-permittable-custom-validationrather than as anenumit can't list.PLANS << "gold"isn't seen, where it used to be; an app that needs a live list passes its owninclude?object. The CHANGELOG lists this as a change.Coercion.in_listandCoercion.cast_in_membersare the only functions behind the builder and the matcher'swithinchain, so the two can't drift apart.The decision I'd most like reviewed: Ranges are checked, not cast
Casting list members is straightforward. Casting Range endpoints isn't, because it changes what the bound means:
0..Float::INFINITYon a:floatand1.5..3on an:integerare real bounds whose endpoints no cast accepts. Casting them would turn working contracts into boot failures.:decimal,0..100would get BigDecimal endpoints, andjson_valueexports those as the string"0.0", whereminimumneeds a number.So a Range is kept exactly as written. What makes a Range reject every request is an endpoint that can't be compared with the cast value, and that's what gets refused. A probe value of the field's type is compared the way
cover?itself compares (begin <=> value, thenvalue <=> end). Whatever the host's own<=>allows is therefore allowed too: ActiveSupport letsDatecompare withTime, so a Date range bounding a:datetimestill loads, and a spec pins that down.Exported members are ones the server accepts
A
:date/:datetimemember written as a String is exported as written, and aTime/DateTime/TimeWithZonemember keeps as many fractional-second digits as it has (up to nine; this is injson_value, so an exported:datetimedefault:/example:gets it too). Re-encoding the castTimeprinted whole seconds, so"2026-09-05T10:00:00.25Z"was published as"…10:00:00Z", a value the server refuses. The String went through the same cast a request does, so the server accepts it by construction. A spec sends every exported member back through the contract, and a conformance case covers the fractional-seconds instant. The authored forms live infield[:in_published], which is set only where they differ from the cast members. Matching still uses the cast values.Agreement with #57's enum rule
check_column_typesreads the storedin:. It therefore seesin: Post.statusesas the enum's names, andin: %i[pending shipped]as Strings, and accepts both. A spec pins this in the enum context.Also
in:isn't inARRAY_OPTS, so arrays reject it today. This PR keeps that and does not add element-levelin:.Setstays aSet(for an author who picked it for O(1)include?), and duplicates created by the cast (["1", 1, "01"]on:integer) are removed, because JSON Schemaenumitems should be unique.within(...)casts its argument with the same function, sowithin(%i[draft published])can repeat the declaration as written, andwithin([1, 2, 3])names what the runtime actually holds. The comparison uses the cast values, but a failure message shows the argument as the spec wrote it.enumnow uses the field's own encoding.in: [1.5]on:decimalexports"1.5", the same precision-safe string itsdefault:already exports.in: [1, 2]on:floatexports1.0, 2.0.This round: a Hash/Array/Set subclass's own
include?was silently discardedA regression from the round before this one.
Coercion.in_listdispatched withcase allowed; when Hash ..., which matches with===—is_a?for a Class — so a Hash/Array/Set subclass overridinginclude?(a case-insensitive allowlist, a fuzzySet, a registry with its own matching logic entirely) matched its ancestor's branch anyway, and was read for its raw keys/elements instead of using its override:Fixed by checking the object's own
include?, not just its ancestry:Set/Arrayneedinstance_of?, andHashneedsinstance_of?(Hash) || instance_of?(HashWithIndifferentAccess)(the one subclass override that's semantically the same lookup).Enumeratorneeds itsinclude?'s method owner to still beEnumerable, sinceEnumerator::Lazy— which must keep working — never overrides it, but a hand-rolledEnumeratorsubclass that does now stays opaque too. New specs cover a Hash, an Array and a Set subclass each overridinginclude?, and confirm the override runs and nothing is cast.Merging in master's #60 (numeric JSON-number bounds) surfaced one more interaction worth naming: this branch's own
assert_comparable_range!(added two rounds ago, to catch a wrong-typed Range like a String range on an:integer) treated aNaNendpoint the same way<=>always returnsnilfor anything againstNaN. That would have raised at class load forin: Float::NAN.., a Range #60's own new export logic explicitly allows to load and simply omits from the schema (neverfinite?). ANaNendpoint isn't evidence of a wrong-typed bound, so it's now left alone exactly as an infinite endpoint already was — a spec pinsin: Float::NAN..(:float) andin: ..BigDecimal("NaN")(:decimal) loading without error.Compatibility
The cast only loosens what a request is judged against. No request that was accepted before is refused now. The declarations below used to boot and now fail at class load, because something in each could never match. A list's other, valid members did match before (
in: [1, 2, "three"]was partly working), so only the Range case rejected everything:Stringin:, which matched substringsnilon a field that isn'tnullable:and a non-midnight-UTCTimeon a:datefieldin: [nil]on anullable:field, which is empty once the nil is droppedRangewhose endpoints the field's values can't be compared withVerification
include?, confirming the override runs and nothing is cast or enumerated, and a NaN-endpoint Range loading without error.🤖 Generated with Claude Code