среда, ноября 08, 2006

Работа с данными в ActiveRecord

Недавно я продолжил изучение внутренностей ActiveRecord, потому как для меня до сих пор оставалось загадкой, как же правильно работать с данными в ActiveRecord (например, осуществлять конвертацию).

Начнем с сердца экземпляра ActiveRecord - переменной @attributes. Это то самое хранилище, где хранятся все данные экземпляра AR. По сути, это хэш "атрибут => значение" и именно его (и ничего больше) инициализирует класс, вынимая запись из базы данных.

Но изменять значения напрямую - не самая хорошая идея. Для доступа к значениям есть методы read_attribute(name) / write_attribute(name, value). Помимо вынимания/засовывания значения из/в @attributes, эти методы делают некоторые преобразования:

- read_attribute осуществляет приведение типа к типу, соответствующему типу столбца БД (так, например, данные sql типа данных date превращаются в экземпляры класса Date). Также этот метод осуществляет десериализацию данных из YAML (помните еще о такой возможности ? =)). Если атрибуту не соответствует столбец базы данных - значение возвращается как есть.

- write_attribute проще: он преобразовывает boolean данные в числа, пустую строку - в nil для числовых столбцов.

Для этой парочки есть укороченные варианты: [] / []=. Т.е. если вы переопределили аццессор для вашего атрибута, то читать / писать данные внутри аццессора надо посредством этих "индексных" методов.

Далее, есть свойство attributes. При чтении, оно, в принципе, просто возвращает копии всех атрибутов (не пытайтесь менять значения того, что вернул attributes - сам экземпляр AR не изменится), прогнанных сквозь read_attribute (т.е. данные с правильными типами и десериализованные). Также этот метод поддерживает опции, позволяющие исключить некоторые атрибуты или оставить конкретные из них (опции :except и :only).
Врайтер attributes= или же направляет значение соответствующему врайтеру атрибута (например, firstname=), если это обычный атрибут, или же собирает мультиатрибут (тот, элементы которыго, имеют одинаковое имя и суффикс в виле порякового номера (и опционально - типа данных) в круглых скобках).

И теперь самое интересное: каждый атрибут поддерживает _before_type_cast ридер. Этот ридер возвращает значение соответствующего ключа @attributes напрямую, минуя преобразование данных как в read_attribute.

Выводы:
* нельзя получить значения частей мультиатрибута (потому что они не сохраняются в @attributes. В предыдущей статье я описывал один из способов сделать сборку / разборку данных посредством composed_of. Так вот в этом способе нельзя сохранить на форме введенные неправильные данные.
* при обновлении атрибутов можно получить сырые обновленные данные с поправкой на то, что для числовых столбцов True/False будет преобразован в 1/0, пустая строка - в nil, посредством _before_type_cast аццессора.

Теперь попробуем переделать предыдущий пример с временем в календаре: сделаем так, чтобы можно было редактировать время как текст HH:MM. Для этого:

1. Сделаем отдельный аттрибут для текстового представления:

class Event < ActiveRecord::Base
  def time_text
    "%d:%02d" % [self.time/60, self.time%60]
  end

  def time_text=(value)
    self.time = begin
      parts = value.split ':'
      parts[0].to_i*60+parts.to_i
    rescue
      nil
    end
  end
end
Отлично! Теперь делаем на форме text_field :event, :time_text и оно уже отображается. Но он стирает текст, если в нем есть ошибки. Хотелось бы сохранять его. 2. Надо сохранять несконвертированное значение:

class Event < ActiveRecord::Base
  def time_text
    self[:time_text] || ("%d:%02d" % [self.time/60, self.time%60])
  end

  def time_text=(value)
    self[:time_text] = value
    self.time = begin
      parts = value.split ':'
      parts[0].to_i*60+parts[1].to_i
    rescue
      nil
    end
  end
end
text_field один из немногих (если не единственный) хелперов, которые используют _before_type_cast аццессоры. Теперь, поскольку мы сохранили неконвертированное значение, он сможет вывести его, если возникли ошибки. Теперь надо подумать про валидацию:

class Event < ActiveRecord::Base
  validates_format_of :time_text, :with => /\d?\d\:\d\d/
end


Ну вот, собственно, и все.

четверг, ноября 02, 2006

Сборка / разборка данных

Недавно понадобился такой функционал:

В базе в поле типа integer хранится время события (кол-во минут с начала суток).
Надо организовать редактирование этих данных в привычном для пользователя виде (т.е. часы : минуты). Пришлось залазить в кишки рельсов.

В HTML редактирование времени выглядит как 2 SELECT'а (часы и минуты). Проблема: собрать два этих параметра в одно поле (общее_количество_минут), чтобы потом сохранить его в базе.

В рельсах есть поддержка механизма сборки одного поля из нескольких request-параметров. Она активируется, если поле имеет суффикс вида "(x)", где x - порядковый номер параметра. Если рельсы встречают такие параметры, они их собирают в отдельную кучку и начинают по ним создавать "правильный" тип данных. Для этого они сначала выводят тип (класс) будущего значения, и вызывают у него метод new с соответствующим (соответствующим кол-во параметров в мультипараметре) количеством аргументов.

Теперь надо понять, как подсунуть нужный класс. Класс будет или классом типа данных соответствующего столбца (имеющего то же имя) в базе данных, или именем класса аггрегации. Нас интересует именно эта аггрегация. В рельсах объявление агрегации осуществляется посредством вызова метода composed_of.

Итак начнем писать наш тип данных для времени. Нам понадобится конструктор с одним параметром - число минут с начала суток (данные, которые будут храниться в базе). Также, сразу же объявим ридеры для часов и минут, и метод to_s:

class CalendarTime
attr_reader :minutes_since_midnight

def initialize(minutes)
@minutes_since_midnight = minutes
end

def hour
@minutes_since_midnight / 60 rescue nil
end

def min
@minutes_since_midnight % 60 rescue nil
end

def to_s
"%d:%02d" % [ hour, min ]
end
end

Теперь добавим агрегацию в модель:

class CalendarEvent < ActiveRecord::Base
composed_of :time,
:class_name => 'CalendarTime',
# отображаем AR атрибут time на поле
# minutes_since_midnight нашего класса
:mapping => [:time, :minutes_since_midnight ],
# разрешить nil в качестве значения агрегата
:allow_nil => true
end


Отлично. Теперь при обращении к event.time мы получим экземпляр CalendarTime.
Теперь представление. Надо сделать хелпер который будет выдавать два селекта с хитрыми именами.

module ApplicationHelper
def time_select(object_name, method, options = {})
# Получим сам объект
object = options[:object] ||
instance_variable_get('@'+object_name)
# и значение
value = object.send(method)

# Подготовим опции
options.delete :object
object[:discard_type] = true

# Подготовим шаблон для имени элементов
name = "#{object_name}[#{attr}(%di)]"

# Наш объект CalendarTime поддерживает свойства hour и minute
# поэтому можно будет воспользоваться стандартными хэлперами
select_hour(value, options.merge :prefix => name%1) + ' : ' +
select_minute(value, options.merge :prefix => name%2)
end
end

Теперь в представлении сделаем вызов этого хелпера:

<p>
<label for="event_time">Time</label><br/>
<%= time_select 'event', 'time', :include_blank => true %>
</p>

Отлично, осталось только собрать данные назад.
Как я уже говорил, при обработке мультипараметров ActiveRecord сконструирует агрегирующий объект (в нашем случае - CalendarTime) с соответствующим количеством параметров. Значит надо обработать два параметра в конструкторе CalendarTime. Для этого перепишем его так:

class CalendarTime
def initialize(*args)
if args.size == 1 && args[0].is_a?(Numeric)
@minutes_since_midnight = args[0]
elsif (args.size == 2 &&
args[0].is_a?(Numeric) &&
args[1].is_a?(Numeric))
@minutes_since_midnight = args[0]*60 + args[1]
end
end
end

Собственно, все.

Чтобы еще больше понимать механизмы работы рельсов, было полезно узнать, как (и когда) работает отображение данных в composed_of. Так вот, агрегат создается по каждому требованию и ему в качестве аргемнтов конструктора передаются значения всех замапленых атрибутов (перечисленных в :mapping опции) в указанном порядке (поэтому значение этой опции или массив из двух элементов, или массив массивов из двух элементов - чтобы можно было отследить порядок аргументов). Обратное присвоение происходит при присвоении модели нового значения агрегата.

PS есть один gotcha: при сборке агрегата из мультипараметра, рельсы по каким-то причинам отфильтровывают все пустые строки или nil. Будьте осторожны с этим. Вам в конструкторе могут подсунуть аргументов меньше, чем должно быть.