Получение больших десятичных знаков обратно из поля, упорядоченного по ямлу, в базе данных с помощью Ruby on Rails

Использование Ruby on Rails У меня есть несколько полей, которые сериализованы (в основном массивы или хеши). Некоторые из них содержат BigDecimal s. Очень важно, чтобы эти большие десятичные знаки оставались большими десятичными знаками, но Rails превращает их в поплавки. Как мне вернуть BigDecimal назад?

В этой проблеме я обнаружил, что сериализация большого десятичного разряда в простом Ruby, без Rails, работает так, как ожидалось:

BigDecimal.new("42.42").to_yaml
 => "--- !ruby/object:BigDecimal 18:0.4242E2\n...\n"

но в консоли Rails нет:

BigDecimal.new("42.42").to_yaml
 => "--- 42.42\n"

Это число представляет собой строковое представление большого десятичного знака, поэтому оно все правильно. Но когда я его читаю, он читается как float, поэтому, даже если я конвертирую его в BigDecimal (что-то, что я не хочу делать с ошибкой), возможно, я потеряю точность, t приемлемо для моего приложения.

Я выследил виновника до activesupport-3.2.11/lib/active_support/core_ext/big_decimal/conversions.rb, который переопределяет следующий метод в BigDecimal:

YAML_TAG = 'tag:yaml.org,2002:float'
YAML_MAPPING = { 'Infinity' => '.Inf', '-Infinity' => '-.Inf', 'NaN' => '.NaN' }

# This emits the number without any scientific notation.
# This is better than self.to_f.to_s since it doesn't lose precision.
#
# Note that reconstituting YAML floats to native floats may lose precision.
def to_yaml(opts = {})
  return super if defined?(YAML::ENGINE) && !YAML::ENGINE.syck?

  YAML.quick_emit(nil, opts) do |out|
    string = to_s
    out.scalar(YAML_TAG, YAML_MAPPING[string] || string, :plain)
  end
end

Зачем им это делать? И что еще более важно, как мне это сделать?

Ответ 1

Указанный вами внутренний код расширения ActiveSupport "уже" исправлен в основной ветке (commit составляет около года и отменяет реализацию, старую как Rails 2.1.0), но так как Rails 3.2 получает обновления для системы безопасности, ваше приложение может зависеть от старой реализации.

Я думаю, у вас будет три варианта:

  • Портируйте приложение Rails в Rails 4.
  • Backport Psych BigDecimal#to_yaml реализация (обезьяна патч патч обезьяны).
  • Перейдите к Syck как движок YAML.

У каждого варианта есть свой недостаток:

Портирование в Rails 4 представляется мне лучшей альтернативой, если у вас есть время сделать это (упомянутое выше соглашение доступно в Rails с версии 4.0.0.beta1). Поскольку он еще не выпущен, вам придется работать с бета-версией. Я не подозреваю о каких-то крупных изменениях, хотя некоторые GSoC ideas читают так, как будто они все равно могут попасть в версию 4.0...

Патч для обезьян Патч обезьяны ActiveSupport должен быть менее сложным. Хотя я не нашел оригинальную реализацию BigDecimal#to_yaml, несколько связанный с ней вопрос , привел к this совершить. Думаю, я оставлю это вам (или другим пользователям StackOverflow), как выполнить резервное копирование этого конкретного метода.

Как быстрое "обходное" решение, вы можете просто использовать Syck как движок YAML. В том же вопросе пользователь rampion опубликовал этот фрагмент кода (который вы могли бы разместить в инициализаторе файл):

YAML::ENGINE.yamler = 'syck'

class BigDecimal
  def to_yaml(opts={})
    YAML::quick_emit(object_id, opts) do |out|
      out.scalar("tag:induktiv.at,2007:BigDecimal", self.to_s)
    end
  end
end

YAML.add_domain_type("induktiv.at,2007", "BigDecimal") do |type, val|
  BigDecimal.new(val)
end

Основным недостатком здесь (помимо недоступности Syck on Ruby 2.0.0) является то, что вы не можете читать нормальные дампы BigDecimal в своем контексте Rails, и всем, кто хочет читать ваши дампы YAML, нужен тот же вид погрузчик:

BigDecimal.new('43.21').to_yaml
#=> "--- !induktiv.at,2007/BigDecimal 43.21\n"

(Изменение тега на "tag:ruby/object:BigDecimal" тоже не поможет, так как оно дает !ruby/object/BigDecimal...)


Обновление - все, что я узнал до сих пор

  • Нечетное поведение, похоже, относится ко временам Rails 1.2 (вы также можете сказать, что февраль 2007 года), согласно этой записи в блоге.

  • Модификация config/application.rb таким образом помогла не:

    require File.expand_path('../boot', __FILE__)
    
    # (a)
    
    %w[yaml psych bigdecimal].each {|lib| require lib }
    class BigDecimal
      # backup old method definitions
      @@old_to_yaml = instance_method :to_yaml
      @@old_to_s    = instance_method :to_s
    end
    
    require 'rails/all'
    
    # (b)
    
    class BigDecimal
      # restore the old behavior
      define_method :to_yaml do |opts={}|
        @@old_to_yaml.bind(self).(opts)
      end
      define_method :to_s do |format='E'|
        @@old_to_s.bind(self).(format)
      end
    end
    
    # (c)
    

    В разных точках (здесь a, b и c) a BigDecimal.new("42.21").to_yaml дал некоторый интересный результат:

    # (a) => "--- !ruby/object:BigDecimal 18:0.4221E2\n...\n"
    # (b) => "--- 42.21\n...\n"
    # (c) => "--- 0.4221E2\n...\n"
    

    где a - поведение по умолчанию, b вызвано расширением Core ActiveSupport, а c должен быть тем же результатом, что и. Может быть, я что-то пропустил...

  • Внимательно перечитывая вопрос, у меня возникла такая идея: почему бы не сериализовать в другом формате, например, JSON? Добавьте еще один столбец в свою базу данных и выполните переход со временем следующим образом:

    class Person < ActiveRecord::Base
      # the old serialized field
      serialize :preferences
    
      # the new one. once fully migrated, drop old preferences column
      # rename this to preferences and remove the getter/setter methods below
      serialize :pref_migration, JSON
    
      def preferences
        if pref_migration.blank?
          pref_migration = super
          save! # maybe don't use bang here
        end
        pref_migration
      end
    
      def preferences=(*data)
        pref_migration = *data
      end
    end
    

Ответ 2

Если вы используете Rails 4.0 или выше (но ниже 4.2), вы можете обойти его, удалив метод BigDecimal#encode_with.

Вы можете архивировать это, используя undef_method:

require 'bigdecimal'
require 'active_support/core_ext/big_decimal'

class BigDecimal
  undef_method :encode_with
end

Я помещаю этот код в инициализатор, и теперь он работает. Этот "возврат" патча обезьяны Rails не понадобится в Rails 4.2, так как this commit удаляет патч обезьяны.

Ответ 3

Для рельсов 3.2 выполняются следующие работы:

# config/initializers/backport_yaml_bigdecimal.rb

require "bigdecimal"
require "active_support/core_ext/big_decimal"

class BigDecimal
  remove_method :encode_with
  remove_method :to_yaml
end

Без этого патча в консоли rails 3.2:

irb> "0.3".to_d.to_yaml
=> "--- 0.3\n...\n"

С этим патчем:

irb> "0.3".to_d.to_yaml
=> "--- !ruby/object:BigDecimal 18:0.3E0\n...\n"

Вам может потребоваться обернуть это в тесте версии с документацией и предупреждениями об отказе, например:

# BigDecimals should be correctly tagged and encoded in YAML as ruby objects
# instead of being cast to/from floating point representation which may lose
# precision.
#
# This is already upstream in Rails 4.2, so this is a backport for now.
#
# See http://stackoverflow.com/info/16031850/getting-big-decimals-back-from-a-yaml-serialized-field-in-the-database-with-ruby
#
# Without this patch:
#
#   irb> "0.3".to_d.to_yaml
#   => "--- 0.3\n...\n"
#
# With this patch:
#
#   irb> "0.3".to_d.to_yaml
#   => "--- !ruby/object:BigDecimal 18:0.3E0\n...\n"
#
if Gem::Version.new(Rails.version) < Gem::Version.new("4.2")
  require "bigdecimal"
  require "active_support/core_ext/big_decimal"

  class BigDecimal
    # Rails 4.0.0 removed #to_yaml
    # https://github.com/rails/rails/commit/d8ed247c7f11b1ca4756134e145d2ec3bfeb8eaf
    if Gem::Version.new(Rails.version) < Gem::Version.new("4")
      remove_method :to_yaml
    else
      ActiveSupport::Deprecation.warn "Hey, you can remove this part of the backport!"
    end

    # Rails 4.2.0 removed #encode_with
    # https://github.com/rails/rails/commit/98ea19925d6db642731741c3b91bd085fac92241
    remove_method :encode_with
  end
else
  ActiveSupport::Deprecation.warn "Hey, you can remove this backport!"
end