I have a module that translates localized date/time strings (month names) to English for parsing. For better understanding: DateTime parsing only accounts for the first 3 letters of each month name, so replacing the first 3 letters of a localized month name with its English counterpart works.
module DateTimeStringLocalizer
CASE_PERMUTATION = [0, 1].repeated_permutation(3).to_a.freeze
REPLACEMENTS = I18n.available_locales.index_with do |locale|
different_names = I18n.t("date.abbr_month_names", locale:).zip(Date::ABBR_MONTHNAMES).reject { |local, english| local == english }
regexp = Regexp.new(different_names.map { it.first.downcase }.join("|"), true) # case insensitive
replacement = different_names.each_with_object({}) do |(local, english), hash|
cased_chars = local[..2].chars.map { [it.upcase, it.downcase] }
CASE_PERMUTATION.each { |c0, c1, c2| hash["#{cased_chars[0][c0]}#{cased_chars[1][c1]}#{cased_chars[2][c2]}"] = english }
end
next [regexp, replacement]
end.freeze
def self.localize(str)
return I18n.locale == :en ? str : str.gsub(*REPLACEMENTS.fetch(I18n.locale))
end
end
class SomeModel < ApplicationRecord
# model has column 'datetime_value' with type 'datetime', overriding setter
# to allow for localized month names from inputs
def datetime_value=(val)
super(DateTimeStringLocalizer.localize(val))
end
end
The above works fine enough, however, I would like to define an ActiveRecord::Type that does this automatically. The problem is, in_time_zone is called on the String value from the input before cast is called (as illustrated by the caller output). Is there any way to achieve what I intend, i.e. translate the String to English before any DateTime parsing/conversions are applied (I want to keep the rest of the behavior of ActiveRecord::Type::DateTime including time zone conversion)?
class LocalizedDateTime < ActiveRecord::Type::DateTime
# is called too late...
# when assigning a String to an attribute of this type, the `cast`
# here is called with a `Time` object, so localization cannot be applied
def cast(value)
puts caller, value.inspect
value = DateTimeStringLocalizer.localize(value) if value.is_a?(String)
super
end
end
class SomeModel < ApplicationRecord
attribute :datetime_value, LocalizedDateTime.new
end
m = SomeModel.new(datetime_value: "25 Mei 2026")
m.datetime_value
/rubies/ruby-4.0.1/lib/ruby/4.0.0/delegate.rb:427:in 'cast'
/activerecord-8.1.2/lib/active_record/attribute_methods/time_zone_conversion.rb:24:in 'ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter#cast'
/activemodel-8.1.2/lib/active_model/attribute.rb:207:in 'ActiveModel::Attribute::FromUser#type_cast'
/activemodel-8.1.2/lib/active_model/attribute.rb:43:in 'ActiveModel::Attribute#value'
/activemodel-8.1.2/lib/active_model/attribute_set.rb:51:in 'ActiveModel::AttributeSet#fetch_value'
/activerecord-8.1.2/lib/active_record/attribute_methods/read.rb:39:in 'ActiveRecord::AttributeMethods::Read#_read_attribute'
/activemodel-8.1.2/lib/active_model/attribute_methods.rb:273:in 'SomeModel::GeneratedAttributeMethods#datetime_value'
(my-app):1:in '<top (required)>'
/irb-1.17.0/lib/irb/workspace.rb:110:in 'Kernel#eval'
/irb-1.17.0/lib/irb/workspace.rb:110:in 'IRB::WorkSpace#evaluate'
/irb-1.17.0/lib/irb/context.rb:591:in 'IRB::Context#evaluate_expression'
/irb-1.17.0/lib/irb/context.rb:557:in 'IRB::Context#evaluate'
/irb-1.17.0/lib/irb.rb:217:in 'block (2 levels) in IRB::Irb#eval_input'
/irb-1.17.0/lib/irb.rb:534:in 'IRB::Irb#signal_status'
/irb-1.17.0/lib/irb.rb:209:in 'block in IRB::Irb#eval_input'
/irb-1.17.0/lib/irb.rb:296:in 'block in IRB::Irb#each_top_level_statement'
/irb-1.17.0/lib/irb.rb:293:in 'Kernel#loop'
/irb-1.17.0/lib/irb.rb:293:in 'IRB::Irb#each_top_level_statement'
/irb-1.17.0/lib/irb.rb:208:in 'IRB::Irb#eval_input'
/irb-1.17.0/lib/irb.rb:187:in 'block in IRB::Irb#run'
/irb-1.17.0/lib/irb.rb:186:in 'Kernel#catch'
/irb-1.17.0/lib/irb.rb:186:in 'IRB::Irb#run'
/railties-8.1.2/lib/rails/commands/console/irb_console.rb:113:in 'Rails::Console::IRBConsole#start'
/railties-8.1.2/lib/rails/commands/console/console_command.rb:59:in 'Rails::Console#start'
/railties-8.1.2/lib/rails/commands/console/console_command.rb:8:in 'Rails::Console.start'
/railties-8.1.2/lib/rails/commands/console/console_command.rb:87:in 'Rails::Command::ConsoleCommand#perform'
/thor-1.5.0/lib/thor/command.rb:28:in 'Thor::Command#run'
/thor-1.5.0/lib/thor/invocation.rb:127:in 'Thor::Invocation#invoke_command'
/railties-8.1.2/lib/rails/command/base.rb:176:in 'Rails::Command::Base#invoke_command'
/thor-1.5.0/lib/thor.rb:538:in 'Thor.dispatch'
/railties-8.1.2/lib/rails/command/base.rb:71:in 'Rails::Command::Base.perform'
/railties-8.1.2/lib/rails/command.rb:65:in 'block in Rails::Command.invoke'
/railties-8.1.2/lib/rails/command.rb:143:in 'Rails::Command.with_argv'
/railties-8.1.2/lib/rails/command.rb:63:in 'Rails::Command.invoke'
/railties-8.1.2/lib/rails/commands.rb:18:in '<top (required)>'
/rubies/ruby-4.0.1/lib/ruby/4.0.0/bundled_gems.rb:60:in 'Kernel.require'
/rubies/ruby-4.0.1/lib/ruby/4.0.0/bundled_gems.rb:60:in 'block (2 levels) in Kernel#replace_require'
bin/rails:4:in '<main>'
2026-04-09 00:00:25.000000000 CEST +02:00
# => 2026-04-09 00:00:25.000000000 CEST +02:00