Переопределение методов получения / установки моделей Rails с подклассом Array - PullRequest
1 голос
/ 02 февраля 2020

У меня есть Сегмент модели Rails, который имеет атрибут, состоящий из массива диапазонов: [0..20, 32..41, 90..200], например.

Есть несколько методов, которые я хотел бы добавить в массив, которые были бы полезны специально для работы с массивами диапазонов, например, методом для суммирования длины каждого диапазона в массиве:

class RangeArray < Array
  def sum
    inject(0) { |total, range| total + (1 + range.max - range.min) }
  end
end

В идеале, я мог бы переопределить методы получения и установки в сегменте так, чтобы при Массив диапазонов читается из базы данных, он автоматически преобразуется в RangeArray, и всякий раз, когда он записывается в базу данных, он записывается как массив. Кажется, что это должно быть легко сделать, так как RangeArray наследуется от Array, но у меня есть некоторые реальные проблемы с некоторыми вещами. Я довольно близко подошел к этой настройке:

class Segment < ApplicationRecord
  attr_accessor :ranges

  def ranges
    puts "read"
    RangeArray.new(super)
  end

  def ranges=(var)
    puts "write"
    super(RangeArray.new(var))
  end
end

С этой настройкой я могу прочитать диапазоны, установить их явно, сохранить в базе данных и т. Д. c:

2.6.5 :071 > s = Segment.new
 => #<Segment id: nil, ranges: [], type: nil, created_at: nil, updated_at: nil> 
2.6.5 :072 > s.ranges = [2000..3000]
write
 => [2000..3000] 
2.6.5 :073 > s.ranges.class
read
 => RangeArray 
2.6.5 :074 > s.ranges.sum
read
 => 1001 

У меня проблемы с методами push и <<, которые RangeArray должен наследовать от Array:

2.6.5 :075 > s.ranges << (40..50)
read
 => [2000..3000, 40..50] 
2.6.5 :076 > s.ranges
read
 => [2000..3000] 

Кажется, что это не удается, потому что попытка добавить элемент в диапазоны с помощью << сначала вызывает читателя, который генерирует новый объект RangeArray, помещает в него 40..50, а затем отбрасывает объект.

Сохранение значений в промежуточной переменной экземпляра кажется, что оно должно работать, но не работает кажется, что многое изменится:

  def ranges
    puts "read"
    @ranges = RangeArray.new(super)
  end

  def ranges=(var)
    puts "write"
    @ranges = super(RangeArray.new(var))
  end

Я чувствую, что я здесь близко, но я что-то упускаю, и я не уверен, как заставить писателя быть вызванным во время << или push. Что мне делать? Вместо того, чтобы переопределять методы получения и установки, есть ли другой способ изменить тип, к которому ActiveRecord приводит атрибут ranges? Или, может быть, мне просто нужно добавить модуль, который добавляет методы непосредственно в массив, и include, который используется для каждой модели, которая в этом нуждается?

1 Ответ

2 голосов
/ 02 февраля 2020

В Rails 5 представлен API-интерфейс атрибутов, который ранее был просто внутренним API.

Он позволяет вам объявлять атрибуты и обрабатывать значения по умолчанию и типизацию, как это делает ActiveRecord автоматически для столбцов вашей базы данных. Это также делает шаг вперед - он позволяет вам объявлять ваши собственные типы для сериализации / десериализации.

В этом примере используется Postgres и столбец типа JSONB в качестве основного механизма хранения.

Начать с объявив свой пользовательский тип:

# app/types/range_array_type.rb
# This is the type that handles casting the attribute from 
# user input and serializing/deserializing the attribute from the database
# @see https://api.rubyonrails.org/classes/ActiveModel/Type/Value.html
class RangeArrayType < ActiveRecord::Type::Value
  # Type casts a value from user input (e.g. from a setter).
  def cast(value)
    value.is_a?(RangeArray) ? value : deserialize(value)
  end
  # Casts the value from the ruby type to a type that the database knows
  # how to understand.
  # in this case an array of pairs representing the bounds of the array
  # which can be serialized as JSON
  def serialize(value)
    value.map {|range| [range.begin, range.end] }.to_json
  end
  # Casts the value from an array of pairs representing the bounds or ranges
  def deserialize(value)
    case value
    when Array
      RangeArray.new( value.map {|x| x.is_a?(Range) ? x : Range.new(*x) } )
    when String
      deserialize(JSON.parse(value)) # recursion 
    else
      nil
    end
  end
end

И вы хотите настроить свой класс RangeArray таким образом, чтобы он не наследовал от Array:

require 'delegate'
class RangeArray < SimpleDelegator
  def sum
    __getobj__.sum(&:size)
  end
end

Наследование от классов базовой библиотеки может быть проблематичным c, поскольку они реализованы в C и не ведут себя как другие классы. См. Остерегайтесь подклассов Ruby базовые классы .

Затем зарегистрируйте тип в инициализаторе:

# config/initializers/types.rb
ActiveRecord::Type.register(:range_array, RangeArrayType)

И затем используйте новый модный тип в модели:

class Segment < ApplicationRecord
  # segments.ranges is a JSONB column
  attribute :ranges, :range_array
end

Переопределяет тип, который ActiveRecord наследует от схемы базы данных.

Давайте попробуем:

Loading development environment (Rails 6.0.2.1)
[1] pry(main)> segment = Segment.new(ranges: [[1,2], 3..4])
=> #<Segment:0x0000000005481868 id: nil, ranges: [1..2, 3..4], test_array: nil, created_at: nil, updated_at: nil>
[2] pry(main)> segment.save!
   (0.3ms)  BEGIN
  Segment Create (1.0ms)  INSERT INTO "segments" ("ranges", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"  [["ranges", "[[1,2],[3,4]]"], ["created_at", "2020-02-02 07:20:30.982678"], ["updated_at", "2020-02-02 07:20:30.982678"]]
   (1.0ms)  COMMIT
=> true
[3] pry(main)> s = Segment.first
  Segment Load (0.9ms)  SELECT "segments".* FROM "segments" ORDER BY "segments"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<Segment:0x0000000006518820
 id: 1,
 ranges: [1..2, 3..4],
 test_array: nil,
 created_at: Sun, 02 Feb 2020 07:02:21 UTC +00:00,
 updated_at: Sun, 02 Feb 2020 07:02:21 UTC +00:00>
[4] pry(main)> s.ranges
=> [1..2, 3..4]
[5] pry(main)> s.ranges.class.name
=> "RangeArray"
...