Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSON>` 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).

Expand Down
14 changes: 13 additions & 1 deletion acceptance/cases/type/json_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
end
1 change: 1 addition & 0 deletions activerecord-spanner-adapter.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions lib/active_record/connection_adapters/spanner_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -270,7 +272,7 @@ def register_array_types m
m.register_type %r{^ARRAY<INT64>}i, Type::Spanner::Array.new(Type::Integer.new(limit: 8))
m.register_type %r{^ARRAY<STRING\((MAX|d+)\)>}i, Type::Spanner::Array.new(Type::String.new)
m.register_type %r{^ARRAY<TIMESTAMP>}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Time.new)
m.register_type %r{^ARRAY<JSON>}i, Type::Spanner::Array.new(ActiveRecord::Type::Json.new)
m.register_type %r{^ARRAY<JSON>}i, Type::Spanner::Array.new(Type::Spanner::Json.new)
m.register_type %r{^ARRAY<UUID>}i, Type::Spanner::Array.new(ActiveRecord::Type::Spanner::Uuid.new)
end

Expand Down
19 changes: 19 additions & 0 deletions lib/active_record/type/spanner/json.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/activerecord-spanner-adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
242 changes: 242 additions & 0 deletions lib/activerecord_spanner_adapter/float_formatter.rb
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Ruby 3.2 and later, passing an integer to String#<< raises a TypeError (e.g., no implicit conversion of Integer into String). Since the gemspec specifies required_ruby_version = ">= 3.2", this will cause a runtime crash when formatting floats. Converting the character code to a string using .chr resolves this issue.

          digits << (48 + digit).chr

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Ruby 3.2 and later, passing an integer to String#<< raises a TypeError (e.g., no implicit conversion of Integer into String). Since the gemspec specifies required_ruby_version = ">= 3.2", this will cause a runtime crash when formatting floats. Converting the character code to a string using .chr resolves this issue.

          digits << (48 + digit).chr

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
18 changes: 18 additions & 0 deletions lib/activerecord_spanner_adapter/json_conversion.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions lib/activerecord_spanner_adapter/json_encoder.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading