среда, июня 27, 2007


После долгих раздумий, я пришел к мнению, что многопараметровые значения все-таки зло:

  1. Пользоваться ими без ActiveRecord нельзя. Представьте вариант, когда у Вас есть модель (назовем ее Task) с кучей полей + поле с датой, которое вы хотите заполнять только в опрделенном случае (скажем, при завершени Task'а), соответственно, не хотите, чтобы пользователь мог его менять самостоятельно (назовем это поле closed_at). Для этого поле делается защищенным от массового присвоения (attr_protected :closed_at). Теперь у вас есть действие контроллера, которое закрывает Task. Там надо поменять значения состояния Task'а и выставить дату. Если у Вас дата - мультипараметр (представлена тремя списками), то Вам надо превратить этот мультипараметр в дату.
    Сборка мультипараметров предусмотрена только через метод attributes=, который в то же время делает проверку на атрибуты, защищенные от массового присвоения (т.е. в нашем случае это не будет работать). Придется собирать вручную (или прибегать к черной магии, способ описывать не буду).

  2. Ошибки сборки мультипараметров надо обрабатывать отдельно, нет способа показать неправильное значение, если ошибка произошла.


Я для себя решил не пользоваться мультипараметрами, а собирать единое значение на клиенте скриптом, а потом обрабатывать (парсить) его на сервере. С одной стороны, это плохо, т.к. требует поддержки и активации яваскрипта в браузере. Есть альтернатива: иметь текстовое поле, в котором можно будет ввести дату в определенном формате + кнопочку для активации яваскриптового календаря для наглядного выбора даты (который в свою очередь вставит нужную дату в текстовое поле). Таким образом все будут довольны. А на сервере преобразовать дату будет так же просто: надо будет всего лишь сделать Date.strptime(params[:date_value], date_format).

Осталось только выбрать подходящий плагин и жаваскриптовую библиотеку для скриптового календаря.

вторник, июня 26, 2007

Валидация многопараметровых значений

Работал над тем, чтобы добавить нормальную обработку неправильных значений для дат. У меня одно из требований заказчика - чтобы даты можно было вводить и изменять с клавиатуры. При этом вводить день и особенно год проще текстом, нежели выбирать их из списка. Поэтому элементы для редактирования дат у меня выглядят как текстбокс для дня, список для месяца и текстбокс для года.

Далее, хочется, чтобы пустые значения в любом из тех текст боксов отрабатывались правильно. Как известно, внутри рельсов даты являются многопараметровым (multiparameter) значением (т.е. оно складывается из значений нескольких элементов на форме), и логика преобразования типов там простая: позвать to_i для каждого значения и скормить получившиеся значения в Date.new... При этом (!) там стоит логика, что пустые значения не обрабатываются. Из за чего, если, например, у вас на форме не заполнен год (а он должен идти первым аргументом в конструкторе Date), то в результате его значение обработано не будет и на вход Date.new будет передано не 3, а 2 значения, что означает, что номер месяца (который идет вслед за годом в аргументах Date.new) будет расценен как номер года, а номер дня - как номер месяца... Такая же проблема, насколько я понимаю, происходит и со стандартным date_select (тот, который состоит из трех списков), если включен :include_blank...

Я считаю, такое поведение неоправданным, поэтому я сделал запил N1 - не выбрасывать пустые атрбуты (см код ниже, метод extract_callstack_for_multiparameter_attributes).

Зачем же вообще код в ActiveRecord был написан так, чтобы выбрасывать пустые значения ? Сделано это, по видимому, было для того, чтобы обрабатывать пустые значения (как видно из

if values.empty?
send(name + "=", nil)

в execute_callstack_for_multiparameter_attributes). Вот это очень важно, т.к. не хотелось бы в последствие иметь ошибки на пустых и не обязательных к заполнению датах. Поэтому, (!) запил N2 - в конце обработки параметров проверить, если для любого из атрибутов заготовлен только массив из nil'ов, то заменить этот массив на пустой.

Таким образом, если у вас любой из трех компонент даты окажется пустым, то он так и будет занимать свое законное место в списке аргументов, но будучи сконвертированным в нужный тип данных (в нашем случае - целое число). Конвертация осуществляется вызовом метода, начинающегося с "to_" и продолжающегося одной буквой соответствующего типа (например, "i" - "to_i").. to_i, как известно, пустые строки превращает в 0... Значит, если у вас дата будет пустая, то это превратится в Date.new(0, 0, 0), что не очень хорошо, т.к. если 0 в качестве параметра для года и допускается, то передача 0 для месяца или дня порождает исключение (которое потом порождает "ошибку присвоение атрибута с несколькими параметрами" - MultiparameterAssignmentError)... Хотелось бы и с этим забороться, а также не трактовать пустое поле год как нулевой год.

И тут мне пришел в голову самый аццкий запил (N3):

class String
def to_n
Integer(self) rescue nil
end
end

Если кто не знает, Integer(string) преобразует строковое представление числа в, собссно, число, но кидает исключение, если строка хоть немного не число (содержит посторонние символы, пустая и т.п.)

Далее, модифицируем хелпер date_select (который мы и так уже модифицировали, см. требования заказчика в начале) так, чтобы к названию полей он добавлял не (1i) / (2i) / (3i), а (1n) / (2n) и (3n). Тогда ActiveRecord будет пытаться преобразовать значения этих полей, используя наш метод String#to_n.

Отлично! Теперь у нас вылетает MultiparameterAssignmentError, если данные дата не является корректными. Теперь осталось только обработать эту ошибку. Вообще, мне не понятно, почему такие ошибки не включаются в список ошибок валидации, а выбрасываются отдельным исключением. Некоторые люди даже научились бороться с этим разными извращенными способами (например, так)

Я же хотел, чтобы мне ничего для этого менять не нужно было (ни ловить исключение, ни доставать список ошибок из каких-то дополнительных мест). Поэтому: 1) исключение надо ловить внутри, 2) ошибки аккуратно складывать все в тот же errors. Я уже было начал делать очередной запил =), но наткнулся на этот замечательный плагин .. Добрые люди уже все сделали именно так, как я хотел.

Еще бы мои запилы оформить в виде плагина, а лучше (если нет каких-то концептуальных препятствий) - закомитить в само ядро.

Надеюсь, вам, люди, эта информация пригодится.

Ну и, собссно, мои запилы:

def extract_callstack_for_multiparameter_attributes(pairs)
attributes = { }

for pair in pairs
multiparameter_name, value = pair
attribute_name = multiparameter_name.split("(").first
attributes[attribute_name] = [] unless attributes.include?(attribute_name)

# запил N1: не пропускать пустые значения
#unless value.empty?
attributes[attribute_name] <<
[ find_parameter_position(multiparameter_name), type_cast_attribute_value(multiparameter_name, value) ]
#end
end

# запил N2: заменять массив из только nil'ов на пустой
attributes.each do |name, values|
attributes[name] = values.sort_by{ |v| v.first }.collect { |v| v.last }
attributes[name] = [] unless attributes[name].detect { |x| !x.nil? }
end
end

пятница, декабря 22, 2006

Что если нет никаких богов ?

Очень интересный пост по поводу использования разных языков программирования в IT индустрии. Очень рекомендую:

What if there are no gods ?

среда, ноября 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. Будьте осторожны с этим. Вам в конструкторе могут подсунуть аргументов меньше, чем должно быть.