Monkey Patch 'at_with_coercion' (activesupport) / Handle milliseconds from epoch

Description

We have a Ruby and Rails application with a web frontend and an API to support web calls from outside the frontend. We use the API in one use case to create and update work orders (resource) from calling an web url and sending the parameters as raw JSON data to our API controller (separate controller with separate route).

One attribute of an work order is ‘due_date’ which is defined in the model as a field of type DateTime (through Mongoid).

While Rails tries to instantiate the new object of a work order in the controller with the given parameters the transformation into the defined database fieldtypes gets done. For DateTime fields this is done internally by calling “at_with_coercion” in rails/activesupport/lib/active_support/core_ext/time/calculations.rb in line 44 (rails/calculations.rb at v5.2.6 · rails/rails (github.com)).

Because the API is called from outside there is an use case to set the ‘due_date’ field with an integer number which contains the amount of milliseconds from epoch time (1970-01-01 01:00:00).

Rails can only handle an integer which contains the amount of seconds from epoch time.

So we tried to monkey patch the internal “at_with_coercion” method but ran into some issues.

Steps to reproduce

Model:


include Mongoid::Document

include Mongoid::Timestamps

field :due_date, type: DateTime

Controller:


def create

@work_order = WorkOrder.new(params.require(:work_order).permit(:name, … , :due_date, … ))

@work_order.save

end

Monkey Patches

lib/core_extensions/time.rb:


class Time

class << self

remove_method :at_without_coercion

remove_method :at_with_coercion

remove_method :at

# Layers additional behavior on Time.at so that ActiveSupport::TimeWithZone and DateTime

# instances can be used when called with a single argument

def at_with_coercion(*args)

return at_without_coercion(*args) if args.size != 1

# Time.at can be called with a time or numerical value

time_or_number = args.first

if time_or_number.is_a?(ActiveSupport::TimeWithZone) || time_or_number.is_a?(DateTime)

at_without_coercion(time_or_number.to_f).getlocal

elsif time_or_number.is_a?(Integer) && time_or_number.to_s.length == 13

time_or_number /= 1000.0

at_without_coercion(time_or_number)

else

at_without_coercion(time_or_number)

end

end

alias at_without_coercion at

alias at at_with_coercion

end

end

config/initializers/monkey_patches.rb:


Dir[Rails.root.join('lib', 'core_extensions', '*.rb')].sort.each do |f|

require f

end

API call:

URL: {{protocol}}://{{subdomain}}.{{url}}/api/v2/work_orders.json

Parameters (raw JSON): {


"work_order":{

"name":"Test workorder",

... ,

"due_date":1669105754783,

...

}

}

Expected behavior

Rails should use our monkey patched method and return the correct formatted Time value.

Actual behavior

If we don’t remove the existing aliases the calls to “at_without_coercion” leads to the internal Rails method alias and we get a StackLevel too deep exception.

If we override the aliases we end up in an infinity loop following a StackLevel to deep exception because the overriden method calls itself multiple times.

System configuration

Rails version: 5.2.6

Ruby version: 2.5.9p229

Mongoid gem version: 7.3.1

Alternatives

If there is no possibility to monkey patch the internal method is there a possibility so Rails can handle an amount of milliseconds from epoch time?

Making an alias for at_with_conversion and then calling the original could cause this to work without the “Stack Level too deep” errors:

alias _original_at_without_coercion at_without_coercion
def at_with_coercion(*args)
  return _original_at_without_coercion(*args) if args.size != 1

  # Time.at can be called with a time or numerical value
  time_or_number = args.first
  if time_or_number.is_a?(ActiveSupport::TimeWithZone) || time_or_number.is_a?(DateTime)
    _original_at_without_coercion(time_or_number.to_f).getlocal
  elsif time_or_number.is_a?(Integer) && time_or_number > 1_000_000_000_000
    time_or_number /= 1000
    _original_at_without_coercion(time_or_number)
  else
    _original_at_without_coercion(time_or_number)
  end
end

(Also had changed the check for a milliseconds integer so it compares against a number instead of string length.)

Interested to hear if this might work for you - possibly you wouldn’t need to alias at or at_with_coercion after this change.

Thank you for your reply!

I could get it working with your suggested code but had to do some changes to get it working. So the working final code is:

class Time
  class << self
    remove_method :at

    # Layers additional behavior on Time.at so that ActiveSupport::TimeWithZone and DateTime
    # instances can be used when called with a single argument

    alias _original_at_without_coercion at_without_coercion
    def at_with_coercion(*args)
      return _original_at_without_coercion(*args) if args.size != 1

      # Time.at can be called with a time or numerical value
      time_or_number = args.first

      if time_or_number.is_a?(ActiveSupport::TimeWithZone) || time_or_number.is_a?(DateTime)
        _original_at_without_coercion(time_or_number.to_f).getlocal
      elsif time_or_number.is_a?(Integer) && time_or_number > 1_000_000_000_000
        time_or_number /= 1000.0
        _original_at_without_coercion(time_or_number)
      else
        _original_at_without_coercion(time_or_number)
      end
    end
    alias at_without_coercion at_with_coercion
    alias at at_with_coercion
  end
end

So I had to go with the removing of the at method to overwrite it with the monkey patch, adopted your suggestion about checking for the integer value instead of string length and had to add the aliases in the bottom and updated the first one to go to at_with_coercion when calling at_with_coercion.

But we are not really happy with this solution because we need to have an eye when updating and upgrading into the source code of Rails and then update our monkey patch also. So what we do now is we create a kind of a middleware (DSL) as a concern and add this in a before_validation callback inside the models we need to care about. Inside the concern we get all database field of type Time, loop over the given values for that database fields inside the new/updated record and make the checks like in the monkey patch code and convert the integers with milliseconds by deviding them through 1000.0 to make them float numbers. After that Rails can do it’s normal stuff and everything works fine.

Thank you for your replay and suggested code!

1 Like

Great to hear that it has worked out – and thanks for sharing the working code!