Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
<!-- CHANGELOG.md -->

## Unreleased

### Fixed
- **Every `:datetime` cast raised `NameError` in a host that had not loaded ActiveSupport's time extensions.** A standalone `Permittable::Contract` — validating a webhook payload or a job argument — got `uninitialized constant ActiveSupport::TimeWithZone` instead of a validated param, because activesupport does not load that class by default and the gem never asked for it. A Rails app gets it via `active_support/time` at boot, which is why the spec suite (`require "active_record"`) masked it, the same shape as the 0.5.1 nested-hash bug. Fixed by requiring `active_support/core_ext/time/calculations`, which loads `TimeWithZone` **and** the `Time` extensions it needs: the class alone is not self-sufficient, and converting a real zoned time calls `Time#sec_fraction`, so requiring only `time_with_zone` would have traded `NameError` for `NoMethodError` on activesupport 8.1. The bare-subprocess spec now casts every scalar type, including a real `TimeWithZone`, in a process with no Rails.

### Changed
- **A `Time` passed to a `:datetime` field is no longer converted in place.** Normalising to UTC went through `value.to_time.utc`; `Time#to_time` returns `self` and `Time#utc` converts its **receiver**, so validating a request quietly rewrote the caller's own object — after `call!(at: t)`, `t` had become UTC. The cast now returns a new instance and leaves the argument alone. An `ActiveSupport::TimeWithZone` is likewise no longer handed back by way of the UTC instance it caches internally.

## 0.6.0 (2026-09-08)
<!-- title: nullable fields, :json, and strict dates -->

Expand Down
19 changes: 18 additions & 1 deletion lib/permittable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
require "active_support/core_ext/class/attribute"
require "active_support/core_ext/string/inflections"
require "active_support/core_ext/string/filters"
# 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.

#
# The Time core extensions come with it, and are not optional: TimeWithZone is
# present but not self-sufficient. Converting one goes through
# TimeZone#utc_to_local, which calls Time#sec_fraction — defined in
# core_ext/time/calculations, which time_with_zone.rb does not itself require.
# Without this line a real TimeWithZone raises NoMethodError in a bare host on
# activesupport 8.1, having merely traded one crash for another.
require "active_support/core_ext/time/calculations"
require "bigdecimal"
require "date"
require "time"
Expand Down Expand Up @@ -402,7 +414,12 @@ def complete_date?(found)
def cast_datetime(value)
case value
# DateTime is listed here, ahead of Date, because it subclasses Date.
when ActiveSupport::TimeWithZone, Time, DateTime then [:ok, value.to_time.utc]
# `getutc` rather than `utc`: `Time#utc` converts the RECEIVER, and
# `Time#to_time` returns self, so `value.to_time.utc` silently rewrote
# the caller's own object. A TimeWithZone's `getutc` hands back the
# instance it caches internally, so that one is duped.
when ActiveSupport::TimeWithZone then [:ok, value.getutc.dup]
when Time, DateTime then [:ok, value.to_time.getutc]
when Date then [:ok, Time.utc(value.year, value.month, value.day)]
when String
# Same rule as :date — the DATE part must be named in full, or it is
Expand Down
57 changes: 57 additions & 0 deletions spec/contract_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,5 +178,62 @@
expect(status).to be_success, err
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.

# :datetime named ActiveSupport::TimeWithZone unguarded, and nothing in
# the gem loaded it — so a host that had not loaded ActiveSupport's time
# extensions got NameError instead of a validated param.
script = <<~RUBY
require "json"
require "permittable"
contract = Permittable::Contract.define do
required :s, :string
required :i, :integer
required :f, :float
required :d, :decimal
required :b, :boolean
required :on, :date
required :at, :datetime
required :zoned, :datetime
end
# A REAL TimeWithZone, cast here rather than in-process: the constant
# existing is not enough, it also needs the Time core extensions, and
# an in-process example cannot see that because spec_helper has
# already loaded them.
require "active_support/time_with_zone"
zoned = ActiveSupport::TimeZone["Asia/Bangkok"].local(2026, 9, 5, 17, 30)
out = contract.call!(s: "x", i: "1", f: "1.5", d: "2.50", b: "true",
on: "2026-09-05", at: "2026-09-05T10:30:00Z", zoned: zoned)
print JSON.generate(out.transform_values(&:to_s))
RUBY
lib = File.expand_path("../lib", __dir__)
out, err, status = Open3.capture3(RbConfig.ruby, "-I", lib, "-e", script)
expect(status).to be_success, err
expect(JSON.parse(out)).to eq(
"s" => "x", "i" => "1", "f" => "1.5", "d" => "0.25e1", "b" => "true",
"on" => "2026-09-05", "at" => "2026-09-05 10:30:00 UTC",
"zoned" => "2026-09-05 10:30:00 UTC"
)
end

it "does not rewrite the caller's own Time while normalising it to UTC" do
moment = Time.new(2026, 9, 5, 17, 30, 0, "+07:00")
result = described_class.define { required :at, :datetime }.call!(at: moment)
expect(result[:at]).to eq(Time.utc(2026, 9, 5, 10, 30))
expect(result[:at].utc?).to be(true)
# `Time#utc` converts its receiver, and `Time#to_time` returns self.
expect(moment.utc?).to be(false)
expect(moment.utc_offset).to eq(7 * 3600)
end

it "accepts an ActiveSupport::TimeWithZone for a :datetime without touching its cached UTC instance" do
require "active_support/time"
zone = ActiveSupport::TimeZone["Asia/Bangkok"]
moment = zone.local(2026, 9, 5, 17, 30)
result = described_class.define { required :at, :datetime }.call!(at: moment)
expect(result[:at]).to eq(Time.utc(2026, 9, 5, 10, 30))
expect(result[:at].utc?).to be(true)
expect(result[:at]).not_to be(moment.utc)
end
end
end