Enhance ActiveRecord::Type::DateTime to handle localized date strings

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

I found a somewhat convoluted solution to this. The preprocessing before the actual type cast is due to hook_attribute_type, which decorates the attribute. This is absent for type :date, so no convoluted solution is needed for that type. It might be possible to implement this by simply not inheriting from ActiveRecord::Type::DateTime, but there may be other conversions or normalizations that would go missing.

# converts localized date time string to english for parsing
module LocalizedTimeStringConverter
  GSUB_ARGS = lambda {
    case_permutation = [0, 1].repeated_permutation(3).to_a
    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
  }.call.freeze

  def self.to_english(string)
    return I18n.locale == :en ? string : string.gsub(*GSUB_ARGS.fetch(I18n.locale))
  end
end
# no decorator is applied here, so using normal #cast suffices
class LocalizedDate < ActiveRecord::Type::Date
  def cast(value)
    value = LocalizedTimeStringConverter.to_english(value) if value.is_a?(String)
    super
  end
end
# just define new class without any logic, is auto-decorated since it inherits
class LocalizedDateTime < ActiveRecord::Type::DateTime; end
# conversion logic for LocalizedDateTime is here, use #hook_attribute_type to use decorator
module LocalizedTimeZoneConversion
  class LocalizedTimeZoneConverter < ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter
    def cast(value)
      value = LocalizedTimeStringConverter.to_english(value) if value.is_a?(String)
      super
    end
  end

  extend ActiveSupport::Concern

  # hook into attribute type to convert to english and to time
  module ClassMethods
    private
    def hook_attribute_type(name, cast_type)
      # checking class instead of #type feels somewhat icky, but is required to keep original behavior of types `:datetime` and `:time`
      cast_type = LocalizedTimeZoneConverter.new(cast_type) if cast_type.is_a?(LocalizedDateTime)
      super
    end
  end
end
# inject custom type casting and types into ActiveRecord
ActiveSupport.on_load(:active_record) do
  include LocalizedTimeZoneConversion

  ActiveRecord::Type.register :localized_date, LocalizedDate
  ActiveRecord::Type.register :localized_datetime, LocalizedDateTime
end