0

Ruby's json library defaults to converting Time objects to Strings

require 'json'
Time.at(1000).utc.to_json # => "\"1970-01-01 00:16:40 UTC\"" 

The problem with this is that we lose precision. I'd like to_json to produce a float instead.

I also know there are some workarounds using oj or requiring json/add/time, but both of these add excess data to the output and aren't the most portable.

A straightforward approach is to monkey patch Time, although I'm not fond of doing that, especially to core classes

class Time
  def to_json(*a)
    self.to_f.to_json(*a)
  end
end

Are there any better approaches?

jessebs
  • 517
  • 3
  • 14
  • If you really want a float then `Time#to_f` will return a float for you. or even `BigDecimal.new(Time.at(1000.123456).to_r,24).to_s('24F').to_json` if the yoctosecond precision is really important – engineersmnky Nov 13 '18 at 21:45

2 Answers2

5

A straightforward approach is to monkey patch Time, although I'm not fond of doing that, especially to core classes

There's no JSON format for dates, as far as JSON cares they're just strings. Most languages understand ISO 8601, and that's what Time#to_json produces. So long as Time#to_json continues to produce an ISO 8601 datetime you'll remain backwards compatible.

require 'json'
require 'time'  # for Time#iso8601 and Time.parse

class Time
  def to_json
    return self.iso8601(6).to_json
  end
end

time = Time.at(1000.123456)
puts "Before: #{time.iso8601(6)}"

json_time = Time.at(1000.123456).to_json
puts "As JSON: #{json_time}"

# Demonstrate round tripping.
puts "Round trip: #{Time.parse(JSON.parse(json_time)).iso8601(6)}"
Before: 1969-12-31T16:16:40.123456-08:00
As JSON: "1969-12-31T16:16:40.123456-08:00"
Round trip: 1969-12-31T16:16:40.123456-08:00

If you're not comfortable with monkey patching globally, you can monkey patch in isolation by implementing around.

class Time
  require 'time'
  require 'json'

  def precise_to_json(*args)
    return iso8601(6).to_json(*args)
  end

  alias_method :original_to_json, :to_json
end

module PreciseJson
  def self.around
    # Swap in our precise_to_json as Time#to_json
    Time.class_eval {
      alias_method :to_json, :precise_to_json
    }

    # This block will use Time#precise_to_json as Time#to_json
    yield

  # Always put the original Time#to_json back.
  ensure
    Time.class_eval {
      alias_method :to_json, :original_to_json
    }
  end
end

obj = { 
  time: Time.at(1000.123456),
  string: "Basset Hounds Got Long Ears"
}

puts "Before: #{obj.to_json}"

PreciseJson.around {
  puts "Around: #{obj.to_json}"
}

puts "After: #{obj.to_json}"

begin
  PreciseJson.around {
    raise Exception
  }
rescue Exception
end

puts "After exception: #{obj.to_json}"
Before: {"time":"1969-12-31 16:16:40 -0800","string":"Basset Hounds Got Long Ears"}
Around: {"time":"1969-12-31T16:16:40.123456-08:00","string":"Basset Hounds Got Long Ears"}
After: {"time":"1969-12-31 16:16:40 -0800","string":"Basset Hounds Got Long Ears"}
After exception: {"time":"1969-12-31 16:16:40 -0800","string":"Basset Hounds Got Long Ears"}
Schwern
  • 153,029
  • 25
  • 195
  • 336
  • Thanks. Great point and iso8601 is probably a good way for me to go. That's still modifying the Time class, though. Any thoughts on avoiding that? – jessebs Nov 13 '18 at 20:42
  • @jessebs You need to monkey patch for it to work on Time objects embedded in other objects. You could use [refinements](https://ruby-doc.org/core/doc/syntax/refinements_rdoc.html) but they're lexical in scope and so [don't recurse into `obj.to_json`](https://gist.github.com/schwern/e1f87090b7d5928e75a41a7dbc59e2f3). The alternative is to implement `around` to only monkey patch for a certain block of code. I've updated the answer to demonstrate. – Schwern Nov 13 '18 at 21:28
  • @jessebs it is really not an issue in this case. The `Time` class does not have a specific `to_json` method so it falls back to `Object#to_json` which is just the `to_s` representation as shown [Here](https://github.com/flori/json/blob/master/ext/json/ext/generator/generator.c#L502) so patching it in really has no specific impact although the signature should probably be `to_json(*)` to remain consistent with all other implementations. However you could just use `Time.at(1000.123456).strftime("%Y-%m-%dT%H:%M:%S.%6N%z").to_json` – engineersmnky Nov 13 '18 at 21:31
  • Very interesting. You may wish to make [DateTime#iso8601](http://ruby-doc.org/stdlib-2.5.1/libdoc/date/rdoc/DateTime.html#method-i-iso8601)'s argument equal to `9`, to get full precision on numbers of nanoseconds without inspecting numbers of significant digits. When I execute `Time.now.nsec` I get `7` significant digits (Bash on Ubuntu on Windows 10). You can shorten `return self.iso8601(6).to_json` to `iso8601(6).to_json`. (I'm sure you know that. I'm guessing you probably spend most of your time these days working with a language other than Ruby.) – Cary Swoveland Nov 15 '18 at 22:19
  • @CarySwoveland Thanks for the notes. I spend most of my time in Ruby now. I largely left the particular details of `Time#to_json` to the OP (microseconds seemed safe) and focused on making it work transparently and recursively without a global monkey patch. I do occasionally get bit by `foo` not being equivalent to `self.foo`, possibly variable/method ambiguities, so sometimes I'm conservative. I'm quite happy with the `around` implementation and might generalize it and turn it into a library. – Schwern Nov 15 '18 at 22:27
0

You could save the number of nanoseconds since the Epoch.

require 'time'
require 'json'

t = Time.now
  #=> 2018-11-13 13:23:32 -0800
json = [t.to_i, t.nsec].to_json
  #=> "[1542144212,883611300]"

secs, nsecs = JSON.parse(json)
  #=> [1542144212, 883611300]
r = secs + 10**-9 * nsecs
  #=> (15421442128836113/10000000)
tt = Time.at r
  #=> 2018-11-13 13:23:32 -0800

t == tt
  #=> true

Note (10**-9).class #=> Rational.

Cary Swoveland
  • 106,649
  • 6
  • 63
  • 100
  • 1
    `require 'json/add/time'` then `Time.now.to_json #=> "{\"json_class\":\"Time\",\"s\":1542145917,\"n\":74844000}"` where `s` is seconds and `n` is nanoseconds – engineersmnky Nov 13 '18 at 22:06
  • 1
    It also adds `Time::json_create` so `th= JSON.parse(Time.now.to_json); t= Object.const_get(th.delete("json_class")).json_create(th)` – engineersmnky Nov 13 '18 at 22:36
  • I mentioned wanting to avoid `json/add/time` in the original question. It makes json parsing between languages more difficult – jessebs Nov 15 '18 at 21:28
  • jessebs, I missed that, so rolled back to my original answer. – Cary Swoveland Nov 15 '18 at 21:45