Categories

ActiveRecord, method_missing и stack level too deep

Переопределяя у ActiveRecord модели #method_missing важно помнить, что методы чтения атрибутов генерятся через сам #method_missing

Так, например, код

class Appearance < ActiveRecord::Base
  serialize :prefereneces, HashWithIndifferentAccess

  def method_missing(name, *args)
    preferences[name] || super
  end
end

вывалит Exception SystemStackError: stack level too deep

Обойти это можно, вызвав сначала super и перехватив NoMethodError, а потом его рейзануть обратно если не выполнилось необходимое условие.

def method_missing(name, *args)
  super
rescue NoMethodError
  preferences[name] || raise
end

Побочные эффекты в рельсах. ActiveRecord::Base#update_attribute

Метод ActiveRecord::Base#update_attribute согласно документации предназначен для обновления отдельного атрибута и … дальше мало кто читает внимательно — сохранения без прохождения процесса валидации, что приводит к некоторым побочным эффектам (side effects), когда метод может обновлять любое кол-во атрибутов и даже сохранять ассоциации! Смотрите.

Создаем тестовый проект

$ rails side_effects_in_rails_1_active_record_update_attribute
$ cd side_effects_in_rails_1_active_record_update_attribute
$ script/generate rspec_model Post title:string body:string
$ rake db:migrate
$ rake db:test:clone
$ script/generate rspec

Напишем тест

# spec/models/post_spec.rb
describe Post, "#update_attribute" do
  before(:each) do
    @post = Post.create :title => "Old Title", :body => "Old Body"
    @post.should_not be_new_record
  end

  # строим наше предположение

  it "should update title and should NOT update body" do
    @post.body = "New Body"
    @post.update_attribute(:title, "New Title")

    @post.reload         # делаем reload, чтобы "сбросить" присвоение body

    @post.title.should == "New Title"
    @post.body.should == "Old Body"
  end
end

Запускаем

$ spec -c -fs spec/models/post_spec.rb


Post#update_attribute
- should update title and should NOT update body (FAILED - 1)

1)
'Post#update_attribute should update title and should NOT update body' FAILED
expected: "Old Body",
     got: "New Body" (using ==)
./spec/models/post_spec.rb:18:

Finished in 0.12975 seconds

Атрибут body сохранился, хотя мы этого совсем не хотели. Интересно? Идем дальше.

Сохраняем ассоциации через #update_attribute

Создадим модель Comment

$ script/generate rspec_model Comment body:string post:belongs_to
$ rake db:migrate
$ rake db:test:clone    

добавим минимальную валидацию

# app/model/comment.rb
class Comment < ActiveRecord::Base
  validates_presence_of :body
end

и в Post добавим ассоциацию comments

# app/model/post.rb
class Post < ActiveRecord::Base
  has_many :comments
end

Добавим несколько тестов

  • добавление валидного коментария
  • добавление невалидного коментария
  • добавление сразу двух валидного и невалидного комментариев

 

# spec/models/post_spec.rb

it "should update title and should NOT save a valid associated comment" do
  @post.comments.should be_blank
  # build добвляет объект в ассоциацию, но не сохраняет его
  @post.comments.build :body => "Comment"

  @post.update_attribute(:title, "New Title")

  @post.reload

  @post.title.should == "New Title"

  # вместо ...should be_blank, напишем should == [], чтобы получить @post.comments.inspect
  @post.comments.should == []
end

it "should update title and should NOT save an invalid comment" do
  @post.comments.should be_blank
  @post.comments.build :body => ""

  @post.update_attribute(:title, "New Title")

  @post.reload

  @post.title.should == "New Title"
  @post.comments.should == []
end

it "should update title and should NOT save any comment" do
  @post.comments.should be_blank
  @post.comments.build :body => ""        # invalid comment
  @post.comments.build :body => "Comment" # valid comment

  @post.update_attribute(:title, "New Title")

  @post.reload

  @post.title.should == "New Title"
  @post.comments.should == []
end

Снова запускаем

$ spec -c -fs spec/models/post_spec.rb

Post#update_attribute
- should update title and should NOT update body (FAILED - 1)
- should update title and should NOT save a valid associated comment (FAILED - 2)
- should update title and should NOT save an invalid comment
- should update title and should NOT save any comment (FAILED - 3)

1)
'Post#update_attribute should update title and should NOT update body' FAILED
expected: "Old Body",
     got: "New Body" (using ==)
./spec/models/post_spec.rb:19:

2)
'Post#update_attribute should update title and should NOT save a valid associated comment' FAILED
expected: [],
     got: [#<Comment id: 1, post_id: 1, body: "Comment", created_at: "2009-07-22 09:11:35", updated_at: "2009-07-22 09:11:35">] (using ==)
./spec/models/post_spec.rb:50:

3)
'Post#update_attribute should update title and should NOT save any comment' FAILED
expected: [],
     got: [#<Comment id: 1, post_id: 1, body: "Comment", created_at: "2009-07-22 09:11:35", updated_at: "2009-07-22 09:11:35">] (using ==)
./spec/models/post_spec.rb:75:

Finished in 0.174812 seconds

4 examples, 3 failures

Как видим — валидный коментарий сохранился, невалидный — нет, при добалении обоих в ассоциацию сохранился только 1 валидный и никакого сообщения об ошибке мы не получили. А ведь мы всего лишь хотели обновить один аттрибут!

Какой выход?

Использовать ActiveRecord::Base#update_attribute только в тех случаях, когда известно на 100%, что объект не изменяется, в других же ситуациях использовать ActiveRecord::Base::update

Напишем тест

it "should update title and should NOT update body (v2)" do
  @post.body = "New Body"

  Post.update(@post, :title => "New Title")

  @post.reload

  @post.title.should == "New Title"
  @post.body.should == "Old Body"
end

Запускаем тест

$ spec -c -fs spec/models/post_spec.rb -l 70

Post#update_attribute
- should update title and should NOT update body (v2)

Finished in 0.224997 seconds

1 example, 0 failures

Вуаля! работает (-l 70 означает запустить отдельный тест, обрамляющий 70-ю строку)

Я создал на гитхабе репозиторий, чтобы можно было пощупать пример целиком.

P.S. Проверено на rails 2.3.2 и 2.3.3

UPD. Этот вопрос уже поднимали в англоязычной среде

Элегантный код. Округление до n-го знака после запятой.

Не первый раз натыкаюсь в коде на округление до 3-го знака после запятой таким способом

require 'active_support'
# ...
(object.to_f*1000).round/1000.0

Я не даром отметил require ‘active_support’, если бы этого не было выше по коду — этой заметки не было бы.

Дело в том, что ActiveSupport расширяет метод Float#round, добавляя в него дополнительный аргумент precision, и в случае если он задан, он делает то же самое, что и код выше, но итоговый код намного понятнее.

object.to_f.round(3)

P.S. ActiveSupport много чего еще добавляет — хватит на большую статью. Вместо изобретения велосипедов почитайте исходники того, чем пользуетесь.

Как я патчил и рефакторил плагин what_column для rails

Ничего не предвещало беды

В текущем проекте у нас используется довольно полезный плагин what_column, который после каждой миграции обновляет файлы в app/models, добавляя в коментарии название атрибутов и типы полей. Примерно вот так:

class Post < ActiveRecord::Base
  # === List of columns ===
  #   id         : integer 
  #   title      : string 
  #   body       : string 
  #   created_at : datetime 
  #   updated_at : datetime 
  # =======================
end

Очень удобно видеть какие поля присутствуют у той или иной модели, не залезая в db/scheme.rb

Но не все с плагином оказалось хорошо

Недавно, пришлось добавить исключение (exception) в модель

class Post < ActiveRecord::Base
  class SomeOneIsLookingAtMyBeautifulPostException < ::StandardError; end
end

И после очередной миграции начались проблемы

rake db:migrate
uninitialized constant SomeOneIsLookingAtMyBeautifulPostException

Коментирование вложенного класса

# class SomeOneIsLookingAtMyBeautifulPostException < ::StandardError; end

не дало никакого результата.

Начинаем копать

Идем в исходники плагина и после небольшого изучения видим следующее

# vendor/plugins/what_column/lib/what_column.rb:41

if line.match(/class (.*)\</) and $1.strip.constantize == ar_class

Опа! Регулярка матчит любое вхождение class, не учитывая начало строки, то есть перед class может стоять все что угодно! Именно поэтому, когда я закоментировал определение класса, то получил ту же ошибку с uninitialized constant

Осталось понять почему же так происходит. Текст ошибки очень похож на NameError. Его может генерировать $1.strip.constantize

$1 содержит имя класса выдернутое регуляркой, #strip просто убирает пробелы по краям строки, значит смотрим код #constantize

# active_support-2.3.2/lib/inflector.rb:355-364

def constantize(camel_cased_word)
  names = camel_cased_word.split('::')
  names.shift if names.empty? || names.first.empty?

  constant = Object
  names.each do |name|
    constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
  end
  constant
end

Код говорит — чтобы превратить строку “Post::SomeOneIsLookingAtMyBeautifulPostException” в константу, нужно у Object‘a запросить Post, а затем у Post‘a запросить SomeOneIsLookingAtMyBeautifulPostException, то есть, так как определение SomeOneIsLookingAtMyBeautifulPostException находится внутри класса Post, значит напрямую запросив у Object‘a SomeOneIsLookingAtMyBeautifulPostException (что у нас и происходит) мы получим исключение

uninitialized constant SomeOneIsLookingAtMyBeautifulPostException

Бинго! С корнем проблемы разобрались.

Пишем патч

Добавим наши “случаи” в тесты, благо они в плагине присутствуют.

class Shop::Product < ActiveRecord::Base
  class ProductError < ::StandardError; end # <========== 
end

# class Shop::HotProduct < Product          # <==========

Пофиксим сначала случай с коментариями. Для этого дополним регулярку (она встречается в 2-х местах) символами ^\s*

/class (.*)\</ 
/^\s*class (.*)\</

^\s* означает, что в начале строки могут быть только пробелы или ничего, проверяем и видим, что закоментированные классы игнорируются — уже хорошо :-) пол дела сделали.

Добавим теперь rescue NameError чтобы перехватывать наши “неудавшиеся” классы

klass = begin
  line.match(/^\s*class (.*)\</) && $1.strip.constantize
rescue NameError
  # it means that we have defined subclasses in the class
  # for example
  #
  # class Order < ActiveRecord::Base
  #   class TrackingError < StandardError; end
  # end
  # 
  # "TrackingError".constantize will raise NameError because TrackingError doesn't exists in Object space
end

if klass == ar_class

Вуа-ля! теперь все работает!

Точнее почти все, еще возможен случай, что имя вложенного класса будет совпадать с именем в общем пространстве имен, но такие случаи редки, и самое худшее, что может сделать плагин — это добавить пару лишних коментариев, что не критично. Поэтому забьем.

Выводы

  1. Баги есть везде, даже в плагинах (на самом деле, глючных плагинов очень много)
  2. Изучайте исходники перед тем как добавлять плагин в проект, даже беглый взгляд по коду может выявить его неадекватность
  3. В опенсорсе баг вседа можно пофиксить, написать свой патч или форкнуться

Форк и рефакторинг

Я написал небольшой патч, который фиксит what_column, но после того как я внимательно посмотрел код плагина, мне он очень не понравился, я и форкнулся на гитхабе и потратил почти всю ночь на рефакторинг. Можете сравнить что было и что стало

P.S. Спасибо Крису (автору плагина) за то, что в проекте были тесты, они очень, нет, даже ОЧЕНЬ облегчили рефакторинг.

Пишите тесты :-)