Skip to content

Fix :datetime raising NameError without Rails - #30

Open
VSN2015 wants to merge 1 commit into
masterfrom
fix/datetime-without-rails
Open

Fix :datetime raising NameError without Rails#30
VSN2015 wants to merge 1 commit into
masterfrom
fix/datetime-without-rails

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

A crash, in the controller-free use the gem advertises. Probably the one to read first of the outstanding fixes.

The bug

$ ruby -e 'require "permittable"; Permittable::Contract.define { required :at, :datetime }.call!(at: "2026-09-05T10:30:00Z")'

lib/permittable.rb:336:in `cast_datetime': uninitialized constant ActiveSupport::TimeWithZone (NameError)

      when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
                        ^^^^^^^^^^^^^^

cast_datetime names ActiveSupport::TimeWithZone unguarded, and nothing in the gem loads it — activesupport doesn't load it by default:

require "permittable"
defined?(ActiveSupport::TimeWithZone)   # => nil

So every :datetime field raised instead of validating, in any host that hadn't loaded ActiveSupport's time extensions — a standalone Contract validating a webhook payload or a job argument, which is exactly the use Permittable::Contract exists for.

A Rails app gets the constant from active_support/time at boot, so this never showed up there.

Why the suite didn't catch it

spec_helper requires active_record, which pulls in every core extension and masks a require the gem itself forgot. The spec added for the 0.5.1 nested-hash bug says so in its own comment:

spec_helper loads ActiveRecord, which pulls in every ActiveSupport core extension and would mask a require the gem itself forgot.

Same warning, same shape of bug, one type it didn't cover.

The fix

One precise require — require "active_support/time_with_zone" — rather than the broader active_support/time, matching how the gem already requires only the core extensions it uses.

The specs

Rather than add another bare-subprocess spec, the existing one now exercises every scalar type instead of only nested hashes, which is what would have caught this:

required :s, :string; required :i, :integer; required :f, :float
required :d, :decimal; required :b, :boolean; required :on, :date
required :at, :datetime

A companion spec pins that a real ActiveSupport::TimeWithZone is still accepted and normalised to UTC, so the fix can't regress into a guard that quietly stops handling zoned times.

Verification

  • 201 examples, 0 failures (2 new; the bare-subprocess one fails with the exact NameError before the fix)
  • RuboCop clean
  • Note: Test the compatibility range instead of asserting it #14's runtime-deps CI job would have caught this class of bug, but its smoke script's contract has no :datetime field. Worth extending it when that PR lands — I'll do it if you want.

@VSN2015

VSN2015 commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Code review — #30 :datetime without Rails

Verdict: mergeable with fixes. The headline bug is real and fixed, and the subprocess spec is genuinely Rails-free. But the require is one level too narrow: TimeWithZone#to_time depends on Time core extensions that active_support/time_with_zone does not load, so a real TimeWithZone value still crashes in a bare host on activesupport 8.1.3.1, the version the lockfile pins and CI runs. The companion spec that claims to pin that case runs in-process where spec_helper has already loaded active_support/time, so it cannot see it.

Verified

  • bundle exec rspec at e9985c6: 201 examples, 0 failures. rubocop: no offenses.
  • Base crash reproduced (bare ruby -Ilib, no bundler, AS 8.1.3.1): Contract.define { required :at, :datetime }.call!(at: "2026-09-05T10:30:00Z")NameError: uninitialized constant ActiveSupport::TimeWithZone. active_support.rb has no autoload for it.
  • Head string cast fixed: {"at"=>2026-09-05 10:30:00 UTC}, class Time, utc? true. Both load orders (permittable first, or active_support/time first) work.
  • Subprocess spec is genuine: the child inherits only bundler's load path; inside it ActiveRecord, Rails, ActionController are all nil and active_support/time is not loaded.
  • Companion spec is masked: under the full spec_helper, active_support/time is already loaded via the integration harness, and the spec's own require "active_support/time" adds 0 features.
  • Bare-process matrix at head, casting ActiveSupport::TimeZone["Asia/Bangkok"].local(2026,9,5,17,30):
    • AS 5.2.8.1, 7.0.10, 7.1.6: OK.
    • AS 7.2.3.2, 8.0.5.1: OK, but every cast prints DEPRECATION WARNING: to_time will always preserve the timezone offset to stderr.
    • AS 8.1.3.1: NoMethodError: undefined method 'sec_fraction' for TZInfo::TimeWithOffset at values/time_zone.rb:552 utc_to_localtime_with_zone.rb:502 to_time ← lib/permittable.rb:341.
  • Candidate fixes verified bare on all six loadable versions: require "active_support/core_ext/time/calculations" (which itself requires time_with_zone) fixes 8.1; so does require "active_support/time".
  • Sibling constants in lib/ are all guarded (ActionController::Parameters, ActiveRecord::ActiveRecordError, Rails::Railtie, I18n), BigDecimal is required at :14. No further bare-host crash found.

Strengths

  • lib/permittable.rb:9-13: the require sits with the gem's other explicit AS requires and the comment states the failure mode and who hits it.
  • A load-time require rather than a per-call defined? guard is the right design: the constant exists regardless of load order, no per-cast check, and a defined? guard would have silently routed a valid TimeWithZone to invalid_type in exactly the hosts this PR is about.
  • spec/contract_spec.rb:173-200: the subprocess spec exercises all seven scalar casts in one run and fails on base with the exact NameError. The "0.25e1" assertion also pins require "bigdecimal".

Important

  1. lib/permittable.rb:13 and :341: a TimeWithZone still crashes in a bare host on AS 8.1.3.1. TimeWithZone#to_timelocaltimeTimeZone#utc_to_local calls Time#sec_fraction, defined in core_ext/time/calculations, which time_with_zone.rb never requires. AS never loads TimeWithZone on its own in practice, so the class is present but not self-sufficient. The CHANGELOG's "a real TimeWithZone is still accepted and normalised to UTC" is not true for the host the entry is about. Fix: require "active_support/core_ext/time/calculations" (or active_support/time). Side effect: Time.zone becomes defined, which every Rails host already has.
  2. spec/contract_spec.rb:201-208: the companion spec cannot detect issue 1. It sits under describe "without Rails loaded" but runs in-process with every time extension present. Move the TimeWithZone case into the subprocess script at :177-190, e.g. required :zoned, :datetime fed a Bangkok zoned time, expecting "2026-09-05 10:30:00 UTC". On head as-is that fails with the sec_fraction error, which is the regression guard the PR body says it wants.

Minor

  • lib/permittable.rb:341: on AS 7.2 and 8.0 every TimeWithZone cast in a bare host writes the to_time deprecation to stderr, because nothing outside Rails sets to_time_preserves_timezone. value.utc yields the identical UTC Time on all six versions with no deprecation, but it returns the TimeWithZone's internal cached Time, so .dup or getutc it first.
  • lib/permittable.rb:341 (pre-existing): value.to_time.utc mutates a caller-supplied Time in place, since Time#to_time returns self and #utc converts the receiver. After call!(at: t), t.utc? is true. value.getutc returns a new object. Worth fixing since this PR's spec asserts "normalised to UTC" against this line.
  • CHANGELOG.md:6 is one ~900-character bullet, half of it describing the spec suite. Users upgrading need one sentence on the symptom and one on the fix. Reword the TimeWithZone claim after fixing the two Important items.
  • Observed while checking the gemspec range bare: AS 5.0.7.2 fails at Contract.define because class_attribute default: is 5.2+; AS 6.0/6.1 fail to load on Ruby 3.2 with the known Logger ordering issue. Both belong to Test the compatibility range instead of asserting it #14.

@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 #30: Essential fix for standalone gem usage. Explicitly requiring active_support/time_with_zone prevents NameError when executing contracts outside Rails.

Comment thread lib/permittable.rb
# cast_datetime names ActiveSupport::TimeWithZone, which activesupport does not
# load by default. A Rails app has it via active_support/time at boot; a
# standalone host (a Contract validating a webhook payload or a job argument)
# has nothing that loads it, and every :datetime cast raised NameError there.

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.

require "active_support/time_with_zone" fixes the uninitialized constant error when Permittable::Contract runs in background workers or scripts without full Rails boot.

Comment thread spec/contract_spec.rb
expect(out).to eq('{"user":{"test_key":1},"address_attributes":{"location":2}}')
end

it "casts every scalar type with only `require \"permittable\"`" do

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.

Running the standalone contract in an isolated Ruby subprocess (Open3.capture3) reliably guards against future dependency leaks in tests.

cast_datetime names ActiveSupport::TimeWithZone unguarded, and nothing
in the gem loads it — activesupport does not load it by default:

  $ ruby -e 'require "permittable"; ...'
  lib/permittable.rb:336:in `cast_datetime':
    uninitialized constant ActiveSupport::TimeWithZone (NameError)

A Rails app gets the constant from active_support/time at boot, which
is exactly why the spec suite never saw this: spec_helper requires
active_record, which pulls in every core extension and masks a require
the gem itself forgot. A standalone Contract — a webhook payload, a job
argument, the controller-free use the gem advertises — loads nothing
that defines it, so every :datetime field raised instead of validating.

This is the same shape as the 0.5.1 nested-hash bug, and the spec added
for that one already warned about the masking in its own comment. So
rather than add a new bare-subprocess spec, that one now exercises
EVERY scalar type instead of only nested hashes, which is what would
have caught this. A companion spec pins that a real TimeWithZone is
still accepted and normalised to UTC, so the fix cannot regress into a
guard that quietly stops handling zoned times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@VSN2015
VSN2015 force-pushed the fix/datetime-without-rails branch from e9985c6 to 55e6e38 Compare September 11, 2026 21:41
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