вторник, ноября 06, 2007

On Auto Migrations

There is an auto_migrations plugin that people nowadays start using more and more often. This plugin allows to maintain only one file with database schema definitions and it changes DB structure to match that definition automagically.

Giles Bowkett blogged about it recently and about problems with regular migrations. But wait a minute, I've seen (and experienced) only one type of situations when you can't migrate from zero up to latest migration: it's situation when your model has changed through version and e.g. some method vital for that migration was removed or some new validation was added and you try to create objects in migration that have that field set to an invalid value.

How can changing DB structure make migrations invalid ?

And having that in mind, how that auto_migrations thing can help ? Also, how will you actually migrate your data with auto_migrations ?

PS btw, the way to fix broken migrations in situation described above is to use schema dump that rails creates anyways in db/ directory: when you create from scratch, there could be no data to migrate, so you could just create the DB structure. But if there are migrations to e.g. create an admin user, then you could need to run actual migrations. And the only way I see (when they fail half way) is to use version control system to update to each revision where new migration was added (so that source code match the migration), do the migration, then update to next version where new migration was introduced, do the migration and so forth.
Probably, this could be automated. Assuming it is needed only when new member joins the development team, it is a rear operation so the performance is not critical.

четверг, июля 05, 2007

Завершение саги о датах


Никак не мог успокоиться: все решения, которые я находил, не содержали решения для выдачи ошибок, если дату невозможно распарсить. Чаще всего, если попытка перевода строки в дату не была успешной, вместо даты - nil и никаких тебе ошибок.

Наконец мне удалось сложить все кусочки воедино и получить достаточно неплохое, на мой взгляд, решение.

1. Как редактировать.
Как уже обсуждалось в моих предыдущих постах, самое правильное при редактировании даты - отказаться от трех списков и оставить только одно текстовое поле, куда можно вводить строковое представление даты в определенном формате. Плюс можно прикрутить жаваскриптовый календарик к этому текстовому полю.
Как же высветить введенное значение, если распарсить строку не удалось ? Вспоминаем, что для этого есть *_before_type_cast атрибуты. Напомню: когда мы присваиваем значения конкретному атрибуту, это значение сохраняется внутри экземпляра модели нетронутым (про многопараметровые значения мы не говорим). Конвертация же происходит, когда мы пытаемся получить значение атрибута экзепляра класса модели, а *_before_type_cast возвращает значение, обходя конвертацию. Поэтому, надо только сделать date_select хэлпер, который будет пытаться сконвертировать значение атрибута (сконвертированное, т.е. типа Date) в строку, если это значение не nil, иначе - выкладывать строковое представление значение *_before_type_cast. Плюс код для яваскриптового календарика (например, этого).
Отлично, теперь можно вводить даты в стандартных форматах (например, YYYY-MM-DD) и не терять введенное значение, если преобразовать его в дату не удастся.

2. Нужные форматы даты.
В моем приложении просто необходимо, чтобы дату можно было вводить в русском формате (т.е. DD.MM.YYYY). Поэтому, надо как-то научить дату понимать такой формат.
Поизучав исходники, я пришел к тому, что конвертация из строки в дату производится экземпляром объекта, который представляет соответствующий столбец базы данных. По реализации становится ясно, что конвертация осуществляется методом ParseDate#parsedate стандартной библиотеки Ruby. Недолго думая, расширяем этот метод, чтобы он понимал нужный нам формат даты:
ParseDate.class_eval do
class << self
def parsedate_with_ru_format(str, comp=false)
str = str.to_s
str = "#{$3}-#{$2}-#{$1}" if /(
\d{2})\.(\d{2})\.(\d{4})/ =~ str
parsedate_without_ru_format(str, comp)
end

alias_method_chain :parsedate, :ru_format
end
end

Подключаем этот код в config/environment.rb и вуаля: Rails понимает нужный нам формат даты.

3. Ошибка при неправильном формате даты.
Теперь формат понимаем, неправильное значение никуда не девается, осталось только понять, что формат даты неверный. В текущем состоянии при ошибке конвертации вместо даты имеем nil. Это может быть немного confusing для пользователя, если он думал, что он заполнил (необязательное) поле с датой и сохранил его, а потом выяснит, что в базе вместо значения получился nil.
При этом, не хотелось бы добавлять никаких явных валидаций (типа, validates_date_format :foo и т.п.). Итак: прикручиваем к ActiveRecord::Base валидацию по умолчанию, которая перебирает все столбцы модели и для всех столбцов типа :date проверяет, что если сконвертированное значение == nil, а значение *_before_type_cast не пустое (!foo_before_type_cast.empty?), то добавляет ошибку на это поле.
ActiveRecord::Base.class_eval do
class << self
def date_columns
columns.select { |column| column.type == :date }
end
end

def validate_dates_format(record)
record.class.date_columns.each do |column|
if record.send(column.name).nil? &&
record.send("#{column.name}_before_type_cast").empty?
record.errors.add(column.name, "has invalid date format")
end
end
end

validate :validate_dates_format
end

Данное решение избавляет еще и от проблем с невозможностью легко выставить дату для атрибута, который защищен от массового присвоения (посредством, например, attr_protected).

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

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

среда, июня 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 ?