DataMapperでOptimisticLock(楽観ロック)を利用する

dm-core-1.2.0の話です。

DataMapperでOptimisticLock(楽観ロック)を実現するプラグインはいくつか検索でヒットするのですが、古すぎて利用できなかったり、UPDATE文のWHERE句に条件が入るのではなく、プログラム側で判断しているためアトミック性がなかったりします。 ということでモンキーパッチです。*1

モンキーパッチ

module DataMapper
  class StaleObjectError < PersistenceError
    def initialize(query = nil)
      @query = query
    end

    def message
      if @query
        "Attempted to update a stale object: #{@query.model.name}"
      else
        "Attempted to update a stale object"
      end
    end
  end

  module Adapters

    # dm-do-adapter-1.2.0/lib/dm-do-adapter/adapter.rb
    DataObjectsAdapter.class_eval do
      DEFAULT_LOCKING_COLUMN = 'lock_version'

      alias_method :original_update, :update
      def update(attributes, collection)
        query = collection.query
        locking_property = query.model.properties(name)[locking_column.to_sym]

        if locking_property.blank? or collection.length != 1
          return original_update(attributes, collection)
        end

        properties  = []
        bind_values = []

        # make the order of the properties consistent
        query.model.properties(name).each do |property|
          next unless attributes.key?(property)
          properties  << property
          bind_values << attributes[property]
        end

        # Add a condition to confirm whether a locking column is the same version.
        query = query.dup.update(locking_column.to_sym => collection.first.__send__(locking_column.to_sym))
        properties  << locking_property
        next_locking_column_value = collection.first.__send__(locking_column.to_sym) + 1
        bind_values << next_locking_column_value

        statement, conditions_bind_values = update_statement(properties, query)

        bind_values.concat(conditions_bind_values)

        affected_rows = with_connection do |connection|
          connection.create_command(statement).execute_non_query(*bind_values)
        end.affected_rows

        unless affected_rows == 1
          raise DataMapper::StaleObjectError.new(query)
        end

        collection.first.__send__("#{locking_column}=".to_sym, next_locking_column_value)

        affected_rows
      end

      def locking_column=(value)
        @original_locking_column = @locking_column if defined?(@locking_column)
        @locking_column          = value.to_s
      end

      def locking_column
        reset_locking_column unless defined?(@locking_column)
        @locking_column
      end

      def reset_locking_column
        self.locking_column = DEFAULT_LOCKING_COLUMN
      end

    end
  end
end

使い方

モデルにlock_versionというカラムを追加します。

class User
  include DataMapper::Resource

  property :id, Serial
  property :name, String
  property :lock_version, Integer, default: 0
end

モデルのlock_versionとDBのlock_versionの値と異なるとDataMapper::StaleObjectErrorが発生します。

a = User.create(name: 'troter')
# INSERT INTO `users` (`name`, `lock_version`) VALUES ('troter', 0)
# => #<User @id=1 @name="troter" @lock_version=0>
a.name = 'TROTER'
a.save
# UPDATE `users` SET `name` = 'TROTER', `lock_version` = 1 WHERE (`id` = 1 AND `lock_version` = 0)
a
# => #<User @id=1 @name="TROTER" @lock_version=1>
a.name = 'troter'
a.lock_version = 0
a.save
# UPDATE `users` SET `name` = 'troter', `lock_version` = 0, `lock_version` = 1 WHERE (`id` = 1 AND `lock_version` = 0)
# DataMapper::StaleObjectError: DataMapper::StaleObjectError
#         from /home/troter/sandbox/datamapper-sandbox/project_name/config/initializers/data_mapper.rb:143:in `update'        from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/repository.rb:180:in `update'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource/persistence_state/dirty.rb:54:in `update_resource'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource/persistence_state/dirty.rb:22:in `commit'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:956:in `_persist'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:987:in `block in update_with_hooks'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:984:in `catch'
#         from /home/troter/.rbenv/versions/1.9.3-p392/lib/ruby/gems/1.9.1/gems/dm-core-1.2.0/lib/dm-core/resource.rb:984:in `update_with_hooks'
#         ...省略

これで、安心して楽観ロックが利用できます。

*1:誰かgemにして。。