From b9a4860073e76ce91f869edc4f53e15b068ecbc4 Mon Sep 17 00:00:00 2001 From: Connor Dunn Date: Mon, 28 Sep 2026 13:08:37 -0700 Subject: [PATCH] fix: serialize Spanner JSON floats with a pure Ruby formatter Encode floating-point values in Spanner JSON using a Ruby port of Nlohmann's Grisu2 algorithm. This produces the decimal representations expected by Spanner's precision checks while keeping the formatting scoped to Spanner JSON values. Cover JSON attributes and arrays, query parameters, and SDK mutations, with exact-output fixtures and serialization isolation tests. Co-authored-by: Codex --- README.md | 28 ++ acceptance/cases/type/json_test.rb | 14 +- activerecord-spanner-adapter.gemspec | 1 + .../connection_adapters/spanner_adapter.rb | 6 +- lib/active_record/type/spanner/json.rb | 19 ++ lib/activerecord-spanner-adapter.rb | 1 + .../float_formatter.rb | 242 ++++++++++++++++++ .../json_conversion.rb | 18 ++ .../json_encoder.rb | 18 ++ .../float_formatter_test.rb | 90 +++++++ .../activerecord_spanner_adapter/json_test.rb | 226 ++++++++++++++++ .../json_float_test.rb | 43 ++++ test/fixtures/float_formatter/corpora.sha256 | 9 + test/fixtures/float_formatter/tokens.txt | 71 +++++ 14 files changed, 783 insertions(+), 3 deletions(-) create mode 100644 lib/active_record/type/spanner/json.rb create mode 100644 lib/activerecord_spanner_adapter/float_formatter.rb create mode 100644 lib/activerecord_spanner_adapter/json_conversion.rb create mode 100644 lib/activerecord_spanner_adapter/json_encoder.rb create mode 100644 test/activerecord_spanner_adapter/float_formatter_test.rb create mode 100644 test/activerecord_spanner_adapter/json_test.rb create mode 100644 test/activerecord_spanner_mock_server/json_float_test.rb create mode 100644 test/fixtures/float_formatter/corpora.sha256 create mode 100644 test/fixtures/float_formatter/tokens.txt 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: "