diff --git a/README.md b/README.md index 1e9e5115..da2e4383 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,34 @@ development: database: "app-dev" ``` +### JSON numbers + +Spanner checks that JSON numbers round trip without losing decimal precision. +The adapter formats finite floats with a pure Ruby port of nlohmann/json 3.11.3's +Grisu2 algorithm before encoding JSON documents. The formatter requires no native +extension or C++ library. This applies to `JSON` and `ARRAY` attributes, +query parameters, and mutation writes, without changing `Float#to_s` or the +application's JSON encoder. The `json` gem can be upgraded within its supported +2.x range. + +The port preserves the exact decimal digits chosen by nlohmann/json, including +negative zero and subnormal values. Ruby's normal float formatting can choose +different digits even when the result represents the same binary float. + +Requiring `activerecord-spanner-adapter` also applies the formatter to JSON Hash +values sent through the Google Spanner client, including raw mutations. Native +`FLOAT64` and `STRUCT` values keep their normal client encoding. Objects with +custom `to_column_value` or `to_grpc_value_and_type` hooks retain control over +their own conversion. + +Pre-encoded JSON strings pass through unchanged. For a direct client call with +a JSON array or scalar, encode the whole document explicitly: + +```ruby +payload = ActiveRecordSpannerAdapter::JsonEncoder.new.encode([1.240425]) +client.execute "SELECT @payload", params: { payload: payload }, types: { payload: :JSON } +``` + ## Examples To get started with Rails, read the tutorial under [examples/rails/README.md](examples/rails/README.md). diff --git a/acceptance/cases/type/json_test.rb b/acceptance/cases/type/json_test.rb index ad96b7aa..08ba4e24 100644 --- a/acceptance/cases/type/json_test.rb +++ b/acceptance/cases/type/json_test.rb @@ -27,6 +27,18 @@ def test_set_json record.reload assert_equal expected_hash, record.details end + + def test_round_trip_json_floats + expected = { "amount" => 1.240425, "nested" => [0.0023529, 1.0e-7] } + record = TestTypeModel.create! details: expected + + assert_equal expected, record.reload.details + + TestTypeModel.transaction do + record.update! details: { "amount" => 0.0023529 } + end + assert_equal({ "amount" => 0.0023529 }, record.reload.details) + end end end -end \ No newline at end of file +end diff --git a/activerecord-spanner-adapter.gemspec b/activerecord-spanner-adapter.gemspec index fde66c22..dc892c1b 100644 --- a/activerecord-spanner-adapter.gemspec +++ b/activerecord-spanner-adapter.gemspec @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.add_dependency "google-cloud-spanner", "~> 2.25" spec.add_dependency "google-cloud-spanner-v1", "~> 1.7" spec.add_runtime_dependency "activerecord", [">= 7.1", "< 9"] + spec.add_dependency "json", ">= 2.10", "< 3" spec.add_development_dependency "appraisal", "~> 2.5" spec.add_development_dependency "autotest-suffix", "~> 1.1" diff --git a/lib/active_record/connection_adapters/spanner_adapter.rb b/lib/active_record/connection_adapters/spanner_adapter.rb index 87c6ef9e..e559140a 100644 --- a/lib/active_record/connection_adapters/spanner_adapter.rb +++ b/lib/active_record/connection_adapters/spanner_adapter.rb @@ -18,6 +18,8 @@ require "active_record/connection_adapters/spanner/quoting" require "active_record/type/spanner/array" require "active_record/type/spanner/bytes" +require "active_record/type/spanner/json" +require "activerecord_spanner_adapter/json_conversion" require "active_record/type/spanner/spanner_active_record_converter" require "active_record/type/spanner/time" require "active_record/type/spanner/uuid" @@ -254,7 +256,7 @@ def initialize_type_map m = type_map m.register_type "INT64", Type::Integer.new(limit: 8) register_class_with_limit m, %r{^STRING}i, Type::String m.register_type "TIMESTAMP", ActiveRecord::Type::Spanner::Time.new - m.register_type "JSON", ActiveRecord::Type::Json.new + m.register_type "JSON", ActiveRecord::Type::Spanner::Json.new m.register_type "UUID", ActiveRecord::Type::Spanner::Uuid.new register_array_types m @@ -270,7 +272,7 @@ def register_array_types m m.register_type %r{^ARRAY}i, Type::Spanner::Array.new(Type::Integer.new(limit: 8)) m.register_type %r{^ARRAY}i, Type::Spanner::Array.new(Type::String.new) m.register_type %r{^ARRAY}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Time.new) - m.register_type %r{^ARRAY}i, Type::Spanner::Array.new(ActiveRecord::Type::Json.new) + m.register_type %r{^ARRAY}i, Type::Spanner::Array.new(Type::Spanner::Json.new) m.register_type %r{^ARRAY}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Uuid.new) end diff --git a/lib/active_record/type/spanner/json.rb b/lib/active_record/type/spanner/json.rb new file mode 100644 index 00000000..dd9d8e1b --- /dev/null +++ b/lib/active_record/type/spanner/json.rb @@ -0,0 +1,19 @@ +require "active_record/type/json" +require "activerecord_spanner_adapter/json_encoder" + +module ActiveRecord + module Type + module Spanner + class Json < Type::Json + def serialize value + return if value.nil? + + options = { escape: false } if ActiveSupport::JSON::Encoding.respond_to? :encode_without_escape + ActiveRecordSpannerAdapter::JsonEncoder.new(options).encode value + end + end + end + end +end + +ActiveRecord::Type.register :json, ActiveRecord::Type::Spanner::Json, adapter: :spanner diff --git a/lib/activerecord-spanner-adapter.rb b/lib/activerecord-spanner-adapter.rb index 9e92fcd1..2fb5f878 100644 --- a/lib/activerecord-spanner-adapter.rb +++ b/lib/activerecord-spanner-adapter.rb @@ -5,6 +5,7 @@ # https://opensource.org/licenses/MIT. require "activerecord_spanner_adapter/version" +require "activerecord_spanner_adapter/json_conversion" if defined?(Rails) module ActiveRecord diff --git a/lib/activerecord_spanner_adapter/float_formatter.rb b/lib/activerecord_spanner_adapter/float_formatter.rb new file mode 100644 index 00000000..0fbf41ae --- /dev/null +++ b/lib/activerecord_spanner_adapter/float_formatter.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +# +# Ported from nlohmann/json 3.11.3: +# https://github.com/nlohmann/json/blob/v3.11.3/include/nlohmann/detail/conversions/to_chars.hpp +# Copyright (c) 2009 Florian Loitsch +# Copyright (c) 2013-2023 Niels Lohmann +# SPDX-License-Identifier: MIT +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +module ActiveRecordSpannerAdapter + # Reproduces nlohmann/json's binary64 Grisu2 conversion, including its choice + # of decimal digits when multiple representations round to the same Float. + module FloatFormatter + HIDDEN_BIT = 1 << 52 + SIGN_BIT = 1 << 63 + CACHED_POWERS = [ + [0xAB70FE17C79AC6CA, -1060, -300].freeze, + [0xFF77B1FCBEBCDC4F, -1034, -292].freeze, + [0xBE5691EF416BD60C, -1007, -284].freeze, + [0x8DD01FAD907FFC3C, -980, -276].freeze, + [0xD3515C2831559A83, -954, -268].freeze, + [0x9D71AC8FADA6C9B5, -927, -260].freeze, + [0xEA9C227723EE8BCB, -901, -252].freeze, + [0xAECC49914078536D, -874, -244].freeze, + [0x823C12795DB6CE57, -847, -236].freeze, + [0xC21094364DFB5637, -821, -228].freeze, + [0x9096EA6F3848984F, -794, -220].freeze, + [0xD77485CB25823AC7, -768, -212].freeze, + [0xA086CFCD97BF97F4, -741, -204].freeze, + [0xEF340A98172AACE5, -715, -196].freeze, + [0xB23867FB2A35B28E, -688, -188].freeze, + [0x84C8D4DFD2C63F3B, -661, -180].freeze, + [0xC5DD44271AD3CDBA, -635, -172].freeze, + [0x936B9FCEBB25C996, -608, -164].freeze, + [0xDBAC6C247D62A584, -582, -156].freeze, + [0xA3AB66580D5FDAF6, -555, -148].freeze, + [0xF3E2F893DEC3F126, -529, -140].freeze, + [0xB5B5ADA8AAFF80B8, -502, -132].freeze, + [0x87625F056C7C4A8B, -475, -124].freeze, + [0xC9BCFF6034C13053, -449, -116].freeze, + [0x964E858C91BA2655, -422, -108].freeze, + [0xDFF9772470297EBD, -396, -100].freeze, + [0xA6DFBD9FB8E5B88F, -369, -92].freeze, + [0xF8A95FCF88747D94, -343, -84].freeze, + [0xB94470938FA89BCF, -316, -76].freeze, + [0x8A08F0F8BF0F156B, -289, -68].freeze, + [0xCDB02555653131B6, -263, -60].freeze, + [0x993FE2C6D07B7FAC, -236, -52].freeze, + [0xE45C10C42A2B3B06, -210, -44].freeze, + [0xAA242499697392D3, -183, -36].freeze, + [0xFD87B5F28300CA0E, -157, -28].freeze, + [0xBCE5086492111AEB, -130, -20].freeze, + [0x8CBCCC096F5088CC, -103, -12].freeze, + [0xD1B71758E219652C, -77, -4].freeze, + [0x9C40000000000000, -50, 4].freeze, + [0xE8D4A51000000000, -24, 12].freeze, + [0xAD78EBC5AC620000, 3, 20].freeze, + [0x813F3978F8940984, 30, 28].freeze, + [0xC097CE7BC90715B3, 56, 36].freeze, + [0x8F7E32CE7BEA5C70, 83, 44].freeze, + [0xD5D238A4ABE98068, 109, 52].freeze, + [0x9F4F2726179A2245, 136, 60].freeze, + [0xED63A231D4C4FB27, 162, 68].freeze, + [0xB0DE65388CC8ADA8, 189, 76].freeze, + [0x83C7088E1AAB65DB, 216, 84].freeze, + [0xC45D1DF942711D9A, 242, 92].freeze, + [0x924D692CA61BE758, 269, 100].freeze, + [0xDA01EE641A708DEA, 295, 108].freeze, + [0xA26DA3999AEF774A, 322, 116].freeze, + [0xF209787BB47D6B85, 348, 124].freeze, + [0xB454E4A179DD1877, 375, 132].freeze, + [0x865B86925B9BC5C2, 402, 140].freeze, + [0xC83553C5C8965D3D, 428, 148].freeze, + [0x952AB45CFA97A0B3, 455, 156].freeze, + [0xDE469FBD99A05FE3, 481, 164].freeze, + [0xA59BC234DB398C25, 508, 172].freeze, + [0xF6C69A72A3989F5C, 534, 180].freeze, + [0xB7DCBF5354E9BECE, 561, 188].freeze, + [0x88FCF317F22241E2, 588, 196].freeze, + [0xCC20CE9BD35C78A5, 614, 204].freeze, + [0x98165AF37B2153DF, 641, 212].freeze, + [0xE2A0B5DC971F303A, 667, 220].freeze, + [0xA8D9D1535CE3B396, 694, 228].freeze, + [0xFB9B7CD9A4A7443C, 720, 236].freeze, + [0xBB764C4CA7A44410, 747, 244].freeze, + [0x8BAB8EEFB6409C1A, 774, 252].freeze, + [0xD01FEF10A657842C, 800, 260].freeze, + [0x9B10A4E5E9913129, 827, 268].freeze, + [0xE7109BFBA19C0C9D, 853, 276].freeze, + [0xAC2820D9623BF429, 880, 284].freeze, + [0x80444B5E7AA7CF85, 907, 292].freeze, + [0xBF21E44003ACDD2D, 933, 300].freeze, + [0x8E679C2F5E44FF8F, 960, 308].freeze, + [0xD433179D9C8CB841, 986, 316].freeze, + [0x9E19DB92B4E31BA9, 1013, 324].freeze + ].freeze + + class << self + def serialize_float value + raise TypeError, "expected a Float" unless value.is_a? Float + raise ArgumentError, "expected a finite Float" unless value.finite? + + bits = [value].pack("G").unpack1("Q>") + sign = bits & SIGN_BIT == 0 ? "" : "-" + bits &= SIGN_BIT - 1 + return "#{sign}0.0" if bits.zero? + + digits, exponent = grisu2 bits + sign + format_digits(digits, exponent) + end + + private + + def grisu2 bits # rubocop:disable Metrics/AbcSize + ieee_exponent = bits >> 52 + fraction = bits & (HIDDEN_BIT - 1) + significand = ieee_exponent.zero? ? fraction : fraction + HIDDEN_BIT + exponent = ieee_exponent.zero? ? -1074 : ieee_exponent - 1075 + + plus = (2 * significand) + 1 + plus_exponent = exponent - 1 + shift = 64 - plus.bit_length + plus <<= shift + plus_exponent -= shift + + # Powers of two have a closer lower neighbor, except at the normal/subnormal boundary. + if fraction.zero? && ieee_exponent > 1 + minus = (4 * significand) - 1 + minus_exponent = exponent - 2 + else + minus = (2 * significand) - 1 + minus_exponent = exponent - 1 + end + minus <<= minus_exponent - plus_exponent + significand <<= 64 - significand.bit_length + + cached_significand, cached_exponent, cached_decimal_exponent = cached_power plus_exponent + scaled_exponent = plus_exponent + cached_exponent + 64 + scaled_value = multiply significand, cached_significand + # Tighten both bounds to account for multiplication rounding error. + scaled_minus = multiply(minus, cached_significand) + 1 + scaled_plus = multiply(plus, cached_significand) - 1 + + generate_digits(scaled_minus, scaled_value, scaled_plus, scaled_exponent, -cached_decimal_exponent) + end + + # The native multiplication retains the high 64 bits and rounds ties up. + def multiply left, right + ((left * right) + SIGN_BIT) >> 64 + end + + def cached_power exponent + f = -61 - exponent + # C++ integer division truncates toward zero; Ruby's division floors. + k = f.positive? ? ((f * 78_913) >> 18) + 1 : -((-f * 78_913) >> 18) + CACHED_POWERS[(300 + k + 7) / 8] + end + + def generate_digits minus, value, plus, exponent, decimal_exponent # rubocop:disable Metrics/AbcSize + delta = plus - minus + distance = plus - value + shift = -exponent + one = 1 << shift + integral = plus >> shift + fraction = plus & (one - 1) + length = integral.to_s.length + power_of_ten = 10**(length - 1) + digits = +"" + + while length.positive? + digit, integral = integral.divmod power_of_ten + digits << (48 + digit) + length -= 1 + rest = (integral << shift) + fraction + if rest <= delta + round_digits digits, distance, delta, rest, power_of_ten << shift + return [digits, decimal_exponent + length] + end + power_of_ten /= 10 + end + + loop do + fraction *= 10 + digit = fraction >> shift + fraction &= one - 1 + digits << (48 + digit) + decimal_exponent -= 1 + delta *= 10 + distance *= 10 + next if fraction > delta + + round_digits digits, distance, delta, fraction, one + return [digits, decimal_exponent] + end + end + + def round_digits digits, distance, delta, rest, unit + while rest < distance && delta - rest >= unit && + (rest + unit < distance || distance - rest > rest + unit - distance) + last = digits.length - 1 + digits.setbyte last, digits.getbyte(last) - 1 + rest += unit + end + end + + def format_digits digits, exponent + length = digits.length + point = length + exponent + if length <= point && point <= 15 + "#{digits}#{'0' * (point - length)}.0" + elsif point.positive? && point <= 15 + "#{digits[0, point]}.#{digits[point..]}" + elsif point > -4 && point <= 0 + "0.#{'0' * -point}#{digits}" + else + mantissa = length == 1 ? digits : "#{digits[0]}.#{digits[1..]}" + scientific_exponent = point - 1 + sign = scientific_exponent.negative? ? "-" : "+" + "#{mantissa}e#{sign}#{scientific_exponent.abs.to_s.rjust 2, '0'}" + end + end + end + end +end diff --git a/lib/activerecord_spanner_adapter/json_conversion.rb b/lib/activerecord_spanner_adapter/json_conversion.rb new file mode 100644 index 00000000..0482f6e3 --- /dev/null +++ b/lib/activerecord_spanner_adapter/json_conversion.rb @@ -0,0 +1,18 @@ +require "google/cloud/spanner" +require "activerecord_spanner_adapter/json_encoder" + +module ActiveRecordSpannerAdapter + module JsonConversion + def object_to_grpc_value obj, field = nil + return super if obj.respond_to?(:to_column_value) || obj.respond_to?(:to_grpc_value_and_type) + + # Mutation rows have no field metadata; query STRUCTs must keep their native encoding. + if obj.is_a?(Hash) && !field.is_a?(Google::Cloud::Spanner::Fields) + obj = JsonEncoder.new.encode obj + end + super obj, field + end + end +end + +Google::Cloud::Spanner::Convert.singleton_class.prepend ActiveRecordSpannerAdapter::JsonConversion diff --git a/lib/activerecord_spanner_adapter/json_encoder.rb b/lib/activerecord_spanner_adapter/json_encoder.rb new file mode 100644 index 00000000..9209b640 --- /dev/null +++ b/lib/activerecord_spanner_adapter/json_encoder.rb @@ -0,0 +1,18 @@ +require "active_support" +require "active_support/json" +require "activerecord_spanner_adapter/float_formatter" + +module ActiveRecordSpannerAdapter + class JsonEncoder < ActiveSupport::JSON::Encoding::JSONGemEncoder + private + + def jsonify value + if value.is_a?(Float) && value.finite? + # Spanner checks decimal round trips using nlohmann/json's float formatter. + ::JSON::Fragment.new FloatFormatter.serialize_float(value) + else + super + end + end + end +end diff --git a/test/activerecord_spanner_adapter/float_formatter_test.rb b/test/activerecord_spanner_adapter/float_formatter_test.rb new file mode 100644 index 00000000..aec5d4df --- /dev/null +++ b/test/activerecord_spanner_adapter/float_formatter_test.rb @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: MIT + +require "minitest/autorun" +require "digest" +require_relative "../../lib/activerecord_spanner_adapter/float_formatter" + +class SpannerFloatFormatterTest < Minitest::Test + FIXTURES = File.expand_path("../fixtures/float_formatter", __dir__) + + def test_matches_nlohmann_tokens_for_boundary_and_representative_values + fixture_lines("tokens.txt").each do |line| + hex, expected, label = line.split("\t") + value = [hex.to_i(16)].pack("Q>").unpack1("G") + actual = ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(value) + + assert_equal expected, actual, "#{label} (#{hex})" + assert_equal [value].pack("G"), [Float(actual)].pack("G"), "#{label} round trip" + end + end + + def test_matches_nlohmann_across_all_binary_exponents + assert_oracle_digest("exponent_boundaries", method(:each_boundary_bits)) + end + + def test_matches_nlohmann_for_deterministic_random_bits + assert_oracle_digest("random_bits", method(:each_random_bits)) + end + + def test_rejects_nonfinite_floats + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |value| + assert_raises(ArgumentError) do + ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(value) + end + end + end + + def test_rejects_values_that_are_not_floats + [nil, true, 1, "1.0", Rational(1, 2), Object.new].each do |value| + assert_raises(TypeError) do + ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(value) + end + end + end + + private + + def fixture_lines(name) + File.readlines(File.join(FIXTURES, name), chomp: true).reject do |line| + line.empty? || line.start_with?("#") + end + end + + def assert_oracle_digest(name, corpus) + fixture = fixture_lines("corpora.sha256").find { |line| line.start_with?("#{name} ") } + _, expected_count, expected_digest = fixture.split + digest = Digest::SHA256.new + count = 0 + corpus.call do |bits| + value = [bits].pack("Q>").unpack1("G") + token = ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(value) + digest << "#{bits.to_s(16).rjust(16, '0')} #{token}\n" + count += 1 + end + + assert_equal expected_count.to_i, count + assert_equal expected_digest, digest.hexdigest, "#{name}: Nlohmann 3.11.3 token mismatch" + end + + def each_boundary_bits + fractions = [0, 1, 2, (1 << 51) - 1, 1 << 51, (1 << 52) - 2, (1 << 52) - 1] + (0..2046).each do |exponent| + fractions.each do |fraction| + bits = (exponent << 52) | fraction + yield bits + yield bits | (1 << 63) + end + end + end + + # This explicit generator keeps the fixture corpus stable across Ruby engines. + def each_random_bits + bits = 0x0123456789abcdef + 20_000.times do + bits = (bits * 6_364_136_223_846_793_005 + 1_442_695_040_888_963_407) & ((1 << 64) - 1) + next if ((bits >> 52) & 0x7ff) == 0x7ff + + yield bits + end + end +end diff --git a/test/activerecord_spanner_adapter/json_test.rb b/test/activerecord_spanner_adapter/json_test.rb new file mode 100644 index 00000000..6006b7dd --- /dev/null +++ b/test/activerecord_spanner_adapter/json_test.rb @@ -0,0 +1,226 @@ +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require "open3" + +class SpannerJsonTest < Minitest::Test + def setup + @encoder = ActiveRecordSpannerAdapter::JsonEncoder.new + @type = ActiveRecord::Type::Spanner::Json.new + end + + def test_uses_nlohmann_float_tokens + { + 0.0023529 => "0.0023528999999999998", + 1.240425 => "1.240425", + -0.0 => "-0.0", + 1.0e-7 => "1e-07", + Float::MIN => "2.2250738585072014e-308", + Float::MAX => "1.7976931348623157e+308" + }.each do |value, expected| + assert_equal expected, ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(value) + assert_equal expected, @encoder.encode(value) + assert_equal expected, @type.serialize(value) + end + end + + def test_preserves_binary_float_values + random = Random.new(42) + 500.times do + bytes = random.bytes(8) + value = bytes.unpack1("G") + next unless value.finite? + + serialized = @encoder.encode(value) + assert_equal bytes, [JSON.parse(serialized)].pack("G"), serialized + end + end + + def test_float_formatter_rejects_unsupported_values + [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |value| + assert_raises(ArgumentError) { ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(value) } + end + assert_raises(TypeError) { ActiveRecordSpannerAdapter::FloatFormatter.serialize_float(1) } + assert_raises(TypeError) { ActiveRecordSpannerAdapter::FloatFormatter.serialize_float("1.25") } + end + + def test_encodes_nested_values_without_mutating_them + value = { amount: 0.0023529, nested: [1.240425, { amount: -0.0 }], nil: nil } + original = Marshal.load(Marshal.dump(value)) + + assert_equal '{"amount":0.0023528999999999998,"nested":[1.240425,{"amount":-0.0}],"nil":null}', + @type.serialize(value) + assert_equal original, value + assert_equal 0.0023529, value[:amount] + end + + def test_preserves_rails_json_semantics + value = { + decimal: BigDecimal("1.23"), + date: Date.new(2026, 9, 28), + time: Time.utc(2026, 9, 28, 12, 34, 56), + text: "