├── .gitignore ├── CHANGES.md ├── Gemfile ├── LICENSE ├── README.rdoc ├── Rakefile ├── TODO ├── generators └── memento_migration │ ├── memento_migration_generator.rb │ └── templates │ └── migration.rb ├── init.rb ├── lib ├── memento.rb └── memento │ ├── action.rb │ ├── action │ ├── create.rb │ ├── destroy.rb │ └── update.rb │ ├── action_controller_methods.rb │ ├── active_record_methods.rb │ ├── railtie.rb │ ├── result.rb │ ├── session.rb │ ├── state.rb │ └── version.rb ├── memento.gemspec └── spec ├── memento ├── action │ ├── create_spec.rb │ ├── destroy_spec.rb │ └── update_spec.rb ├── action_controller_methods_spec.rb ├── active_record_methods_spec.rb ├── result_spec.rb ├── session_spec.rb └── state_spec.rb ├── memento_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw? 2 | .DS_Store 3 | coverage 4 | rdoc 5 | pkg 6 | .rvmrc 7 | .ruby-version 8 | Gemfile.lock -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ### dev 2 | 3 | [full changelog](http://github.com/yolk/valvat/compare/v0.5.2...master) 4 | 5 | ### 0.5.2 / 2020-07-02 6 | [full changelog](http://github.com/yolk/valvat/compare/v0.5.1...v0.5.2) 7 | 8 | * Defer patching of rails base classes for zeitwerk loading 9 | 10 | ### 0.5.1 / 2019-07-04 11 | [full changelog](http://github.com/yolk/valvat/compare/v0.5.0...v0.5.1) 12 | 13 | * Check attribute_without_formatting method for current value 14 | 15 | ### 0.5.0 / 2019-06-13 16 | [full changelog](http://github.com/yolk/valvat/compare/v0.4.3...v0.5.0) 17 | 18 | * Added exclusive support for 1Rails 5.1 and upwards 19 | * Renamed record_* methods to memento_record_after_* 20 | * Fixed generators for Rails 4 21 | 22 | ### 0.4.3 / 2014-04-07 23 | 24 | [full changelog](http://github.com/yolk/valvat/compare/v0.4.2...v0.4.3) 25 | 26 | * Full Rails 4 compatibility 27 | 28 | ### 0.4.2 / 2013-07-19 29 | 30 | [full changelog](http://github.com/yolk/valvat/compare/v0.4.1...v0.4.2) 31 | 32 | * Replaced find_by_* calls for compatibility with ActiveRecord 4.0 33 | 34 | ### 0.4.1 / 2012-11-01 35 | 36 | [full changelog](http://github.com/yolk/valvat/compare/v0.4.0...v0.4.1) 37 | 38 | * Prevent all mass assignment to Memento::Session and Memento::State 39 | 40 | ### 0.4.0 / 2012-10-29 41 | 42 | [full changelog](http://github.com/yolk/valvat/compare/v0.3.7...v0.4.0) 43 | 44 | * Memento is a Module now, not a Singleton: Use Memento directly and not Memento.instance 45 | * Memento module is threadsafe now 46 | * Changed main api: instead of Memento.memento() use Memento.watch() or Memento() 47 | * Some code cleanup 48 | 49 | ### 0.3.7 / 2012-08-13 50 | 51 | [full changelog](http://github.com/yolk/valvat/compare/v0.3.6...v0.3.7) 52 | 53 | * Removed usage of ActiveRecord::Base#update_attribute for Rails 3.2.7 compatibility 54 | 55 | ### 0.3.6 / 2012-02-06 56 | 57 | [full changelog](http://github.com/yolk/valvat/compare/v0.3.5...v0.3.6) 58 | 59 | * Fixed deprecation warning: set_table_name => self.table_name = ... 60 | 61 | ### 0.3.5 / 2012-02-06 62 | 63 | [full changelog](http://github.com/yolk/valvat/compare/v0.3.4...v0.3.5) 64 | 65 | * Compatiblity with Rails 3.2 66 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in valvat.gemspec 4 | gemspec 5 | 6 | gem "rake" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009 Yolk Sebastian Munz & Julia Soergel GbR 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = memento 2 | 3 | RubyGem/Plugin for undo in Rails/ActiveRecord - covers destroy, update and create actions. 4 | 5 | == Install 6 | 7 | memento will only work with Rails 3.0 and Ruby 1.9.2. 8 | 9 | == as a ruby-gem 10 | 11 | Add this line to your config/environment.rb: 12 | 13 | config.gem "yolk-memento", :lib => 'memento', :source => 'http://gems.github.com' 14 | 15 | and run 16 | 17 | rake gems:install 18 | 19 | === as rails plugin 20 | 21 | script/plugin install git://github.com/yolk/memento.git 22 | 23 | == Setup 24 | 25 | memento needs two tables in your database, one to store "sessions" (sets of states) and the other to store "states" (aka snapshots of single models). To generate the necessary migration and migrate your database run: 26 | 27 | script/generate memento_migration 28 | rake db:migrate 29 | 30 | memento assumes you have a user-model. Every session is owned by a user. 31 | 32 | == Configure your models 33 | 34 | Then you have to tell every model you want to undo actions on that it should be watched by memento: 35 | 36 | class Person < ActiveRecord::Base 37 | memento_changes 38 | end 39 | 40 | This will tell memento to create snapshots of the model when an new instance is created, an exisiting one is updated or destroyed. 41 | 42 | If you want memento to only take snapshots on specific actions: 43 | 44 | memento_changes :update, :destroy 45 | 46 | This will take a snapshot only when an instance is updated or destroyed. 47 | 48 | By default memento will ignore changes to the :updated_at and :created_at attributes. You can add further attributes to ignore with the :ignore option: 49 | 50 | memento_changes :ignore => [:calculated_birthday, :friends_count] 51 | 52 | This will ignore changes on the calculated_birthday and the firends_count-attributes. When memento saves a whole instance of your model before it is destroyed, those attributes will not be stored for later recovery. Only ignore attributes you can re-calculate from other data! 53 | 54 | == Action! 55 | 56 | When you perform any of the configured actions on your model in isolation in your controller memento will not store any changes: 57 | 58 | Person.create!(:name => "Blah") 59 | Memento::Session.count # => 0 60 | 61 | You have to wrap every action block you want memento to track in your controller with the memento-method: 62 | 63 | memento do 64 | Person.create!(:name => "Blah") 65 | end 66 | Memento::Session.count # => 1 67 | 68 | This assumes there is an method called "current_user" in your controllers. It will also set the HTTP-Header 'X-Memento-Session-Id' on your response. 69 | 70 | If you want memento to watch changes outside of your controllers (for example inside the console) you can use: 71 | 72 | Memento(user) do 73 | Person.create!(:name => "Blah") 74 | end 75 | 76 | Where the variable user is assumed to hold an instance of User. 77 | 78 | == Undo! 79 | 80 | Undoing this changes is as simple as calling #undo on an memento-session-instance. 81 | 82 | Memento::Session.first.undo 83 | 84 | 85 | Copyright (c) 2009-2022 mite GmbH 86 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task :default => :spec 9 | 10 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * [DONE] scrip/generate memento_migration 2 | * configs for current_user & User-Object 3 | * [DONE] readme/documentation -------------------------------------------------------------------------------- /generators/memento_migration/memento_migration_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/active_record' 3 | 4 | class MementoMigrationGenerator < Rails::Generators::Base 5 | include ::Rails::Generators::Migration 6 | include ActiveRecord::Generators::Migration 7 | 8 | source_root File.expand_path '../templates', __FILE__ 9 | 10 | def add_memento_migration 11 | migration_template "migration.rb", 'db/migrate/memento_migration.rb' 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /generators/memento_migration/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class MementoMigration < ActiveRecord::Migration 2 | 3 | def change 4 | create_table :memento_sessions do |t| 5 | t.references :user 6 | t.timestamps null: false 7 | end 8 | 9 | create_table :memento_states do |t| 10 | t.string :action_type 11 | t.binary :record_data, :limit => 16777215 12 | t.references :record, :polymorphic => true 13 | t.references :session 14 | t.timestamps null: false 15 | end 16 | end 17 | 18 | end 19 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'memento' -------------------------------------------------------------------------------- /lib/memento.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | module Memento 4 | 5 | class ErrorOnRewind < StandardError;end 6 | 7 | class << self 8 | 9 | # For backwards compatibility (was a Singleton) 10 | def instance 11 | self 12 | end 13 | 14 | def watch(user_or_id) 15 | start(user_or_id) 16 | yield 17 | if session && session.tmp_states.any? 18 | session.save 19 | session 20 | else 21 | false 22 | end 23 | ensure 24 | stop 25 | end 26 | 27 | def start(user_or_id) 28 | user = user_or_id.is_a?(User) ? user_or_id : User.where(:id => user_or_id).first 29 | self.session = user ? Memento::Session.new(:user => user) : nil 30 | end 31 | 32 | def stop 33 | session.save if session && session.tmp_states.any? 34 | self.session = nil 35 | end 36 | 37 | def add_state(action_type, record) 38 | return unless active? 39 | session.add_state(action_type, record) 40 | end 41 | 42 | def active? 43 | !!session && !ignore? 44 | end 45 | 46 | def ignore 47 | Thread.current[:memento_ignore] = true 48 | yield 49 | ensure 50 | Thread.current[:memento_ignore] = false 51 | end 52 | 53 | def serializer=(serializer) 54 | @serializer = serializer 55 | end 56 | 57 | def serializer 58 | @serializer ||= YAML 59 | end 60 | 61 | def ignore? 62 | !!Thread.current[:memento_ignore] 63 | end 64 | 65 | def session 66 | Thread.current[:memento_session] 67 | end 68 | 69 | private 70 | 71 | def session=(session) 72 | Thread.current[:memento_session] = session 73 | end 74 | end 75 | end 76 | 77 | def Memento(user_or_id, &block) 78 | Memento.watch(user_or_id, &block) 79 | end 80 | 81 | require 'memento/railtie' 82 | require 'memento/result' 83 | require 'memento/action' 84 | require 'memento/active_record_methods' 85 | require 'memento/action_controller_methods' 86 | require 'memento/state' 87 | require 'memento/session' 88 | -------------------------------------------------------------------------------- /lib/memento/action.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/class/attribute' 2 | 3 | module Memento::Action 4 | class Base 5 | def initialize(state) 6 | @state = state 7 | end 8 | 9 | attr_reader :state 10 | class_attribute :action_types, :instance_reader => false, :instance_writer => false 11 | self.action_types = [] 12 | 13 | def record 14 | @state.record 15 | end 16 | 17 | def record_data 18 | @state.record_data 19 | end 20 | 21 | def fetch? 22 | true 23 | end 24 | 25 | def self.inherited(child) 26 | self.action_types << child.name.demodulize.underscore 27 | end 28 | 29 | private 30 | 31 | def new_object 32 | object = @state.record_type.constantize.new 33 | yield(object) if block_given? 34 | object 35 | end 36 | end 37 | end 38 | 39 | Dir["#{File.dirname(__FILE__)}/action/*.rb"].each { |action| require action } -------------------------------------------------------------------------------- /lib/memento/action/create.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class Action::Create < Memento::Action::Base 3 | 4 | def fetch;end 5 | 6 | def undo 7 | if record.nil? 8 | build_fake_object 9 | elsif record_was_changed? 10 | was_changed 11 | else 12 | destroy_record 13 | end 14 | end 15 | 16 | private 17 | 18 | def record_was_changed? 19 | record.updated_at > record.created_at rescue false 20 | end 21 | 22 | def build_fake_object 23 | new_object do |object| 24 | object.id = state.record_id 25 | end 26 | end 27 | 28 | def was_changed 29 | record.errors.add(:memento_undo, ActiveSupport::StringInquirer.new("was_changed")) 30 | record 31 | end 32 | 33 | def destroy_record 34 | record.destroy 35 | record 36 | end 37 | 38 | end 39 | end -------------------------------------------------------------------------------- /lib/memento/action/destroy.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class Action::Destroy < Memento::Action::Base 3 | 4 | def fetch 5 | record.attributes_for_memento 6 | end 7 | 8 | def undo 9 | rebuild_object do |object| 10 | begin 11 | object.save! 12 | rescue 13 | object.id = nil 14 | object.save! 15 | end 16 | state.record = object 17 | state.save 18 | end 19 | end 20 | 21 | private 22 | 23 | def rebuild_object 24 | new_object do |object| 25 | state.record_data.each do |attribute, value| 26 | object.send(:"#{attribute}=", value) 27 | end 28 | yield(object) if block_given? 29 | end 30 | end 31 | end 32 | end -------------------------------------------------------------------------------- /lib/memento/action/update.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class Action::Update < Memento::Action::Base 3 | 4 | def fetch 5 | record.changes_for_memento 6 | end 7 | 8 | def fetch? 9 | record.changes_for_memento.any? 10 | end 11 | 12 | def undo 13 | if !record 14 | was_destroyed 15 | elsif mergable? 16 | update_record 17 | else 18 | was_changed 19 | end 20 | end 21 | 22 | private 23 | 24 | def update_record 25 | record.tap do |object| 26 | record_data.each do |attribute, values| 27 | object.send(:"#{attribute}=", values.first) 28 | end 29 | object.save! 30 | end 31 | end 32 | 33 | def mergable? 34 | record_data.all? do |attribute, values| 35 | # ugly fix to compare times 36 | values = values.map{|v| v.is_a?(Time) ? v.to_fs(:db) : v } 37 | current_value = record.attributes[attribute] 38 | current_value = current_value.utc.to_fs(:db) if current_value.is_a?(Time) 39 | values.include?(current_value) || (current_value.is_a?(String) && values.include?(current_value.gsub(/\r\n/, "\n"))) 40 | end || record_data.size.zero? 41 | end 42 | 43 | def was_destroyed 44 | new_object do |object| 45 | object.errors[:memento_undo] << ActiveSupport::StringInquirer.new("was_destroyed") 46 | object.id = state.record_id 47 | end 48 | end 49 | 50 | def was_changed 51 | record.errors[:memento_undo] << ActiveSupport::StringInquirer.new("was_changed") 52 | record 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/memento/action_controller_methods.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | module ActionControllerMethods 3 | 4 | def memento 5 | block_result = nil 6 | memento_session = Memento.watch(current_user) do 7 | block_result = yield 8 | end 9 | if memento_session 10 | response.headers["X-Memento-Session-Id"] = memento_session.id.to_s 11 | end 12 | block_result 13 | end 14 | private :memento 15 | end 16 | end 17 | 18 | ActiveSupport.on_load(:action_controller) do 19 | include Memento::ActionControllerMethods 20 | end 21 | -------------------------------------------------------------------------------- /lib/memento/active_record_methods.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/class/attribute' 2 | 3 | module Memento::ActiveRecordMethods 4 | 5 | IGNORE_ATTRIBUTES = [:updated_at, :created_at] 6 | 7 | def self.included(base) 8 | base.class_attribute(:memento_options_store, :instance_reader => false, :instance_writer => false) 9 | base.memento_options_store = {} 10 | base.extend ClassMethods 11 | end 12 | 13 | module ClassMethods 14 | 15 | def memento_changes(*action_types) 16 | if defined?(@memento_initialized) && @memento_initialized 17 | raise "Memento initialized twice. Use memento_changes only once per model" 18 | end 19 | 20 | @memento_initialized = true 21 | 22 | include InstanceMethods 23 | 24 | self.memento_options = action_types.last.is_a?(Hash) ? action_types.pop : {} 25 | 26 | action_types = Memento::Action::Base.action_types if action_types.empty? 27 | action_types.map!(&:to_s).uniq! 28 | unless (invalid = action_types - Memento::Action::Base.action_types).empty? 29 | raise ArgumentError.new("Invalid memento_changes: #{invalid.to_sentence}; allowed are only #{Memento::Action::Base.action_types.to_sentence}") 30 | end 31 | 32 | action_types.each do |action_type| 33 | send :"after_#{action_type}", :"memento_record_after_#{action_type}" 34 | end 35 | 36 | has_many :memento_states, :class_name => "Memento::State", :as => :record 37 | end 38 | 39 | def memento_options 40 | self.memento_options_store ||= {} 41 | end 42 | 43 | def memento_options=(options) 44 | options.symbolize_keys! 45 | options[:ignore] = [options[:ignore]].flatten.map(&:to_sym) if options[:ignore] 46 | self.memento_options_store = memento_options_store.merge(options) 47 | end 48 | end 49 | 50 | module InstanceMethods 51 | 52 | def attributes_for_memento 53 | filter_attributes_for_memento(attributes) 54 | end 55 | 56 | def changes_for_memento 57 | filter_attributes_for_memento(saved_changes) 58 | end 59 | 60 | def filter_attributes_for_memento(hash) 61 | hash.delete_if do |key, value| 62 | ignore_attributes_for_memento.include?(key.to_sym) 63 | end 64 | end 65 | private :filter_attributes_for_memento 66 | 67 | def ignore_attributes_for_memento 68 | Memento::ActiveRecordMethods::IGNORE_ATTRIBUTES + (self.class.memento_options[:ignore] || []) 69 | end 70 | private :ignore_attributes_for_memento 71 | 72 | Memento::Action::Base.action_types.each do |action_type| 73 | define_method :"memento_record_after_#{action_type}" do 74 | Memento.add_state(action_type, self) 75 | end 76 | private :"memento_record_after_#{action_type}" 77 | end 78 | end 79 | 80 | end 81 | 82 | ActiveSupport.on_load(:active_record) do 83 | include Memento::ActiveRecordMethods 84 | end 85 | -------------------------------------------------------------------------------- /lib/memento/railtie.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class Railtie < Rails::Railtie 3 | generators do 4 | require File.join File.dirname(__FILE__), '..', '..', 'generators', 'memento_migration', 'memento_migration_generator' 5 | end 6 | end 7 | end if defined?(Rails) 8 | -------------------------------------------------------------------------------- /lib/memento/result.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class ResultArray < Array 3 | def errors 4 | find_all{ |result| result.failed? } 5 | end 6 | 7 | def failed? 8 | any?{ |result| result.failed? } 9 | end 10 | 11 | def success? 12 | !failed? 13 | end 14 | 15 | end 16 | 17 | class Result 18 | 19 | attr_reader :object, :state 20 | 21 | def initialize(object, state) 22 | @object, @state = object, state 23 | end 24 | 25 | def error 26 | error = @object.errors[:memento_undo] 27 | error.present? ? error : nil 28 | end 29 | 30 | def failed? 31 | !!error 32 | end 33 | 34 | def success? 35 | !failed? 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /lib/memento/session.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class Session < ActiveRecord::Base 3 | self.table_name = "memento_sessions" 4 | 5 | has_many :states, -> { order "id DESC" }, 6 | :class_name => "Memento::State", :dependent => :delete_all 7 | belongs_to :user 8 | 9 | # attr_accessible nil 10 | 11 | validates_presence_of :user 12 | 13 | def add_state(action_type, record) 14 | state = Memento::State.add(action_type, record) 15 | tmp_states << state if state 16 | end 17 | 18 | def undo 19 | states.map(&:undo).inject(Memento::ResultArray.new) do |results, result| 20 | result.state.destroy if result.success? 21 | results << result 22 | end 23 | ensure 24 | destroy if states.count.zero? 25 | end 26 | 27 | def undo! 28 | transaction do 29 | undo.tap do |results| 30 | raise Memento::ErrorOnRewind if results.failed? 31 | end 32 | end 33 | end 34 | 35 | def tmp_states 36 | @tmp_states ||= [] 37 | end 38 | 39 | private 40 | 41 | after_save :store_tmp_states 42 | 43 | def store_tmp_states 44 | return unless tmp_states.any? 45 | tmp_states.each do |state| 46 | self.states << state 47 | state.save 48 | end 49 | @tmp_states = [] 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/memento/state.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | class State < ActiveRecord::Base 3 | self.table_name = "memento_states" 4 | 5 | belongs_to :session, :class_name => "Memento::Session" 6 | belongs_to :record, :polymorphic => true 7 | 8 | validates_presence_of :session 9 | validates_presence_of :record 10 | validates_presence_of :action_type 11 | validates_inclusion_of :action_type, :in => Memento::Action::Base.action_types, :allow_blank => true 12 | 13 | def self.add(action_type, record) 14 | state = new do |state| 15 | state.action_type = action_type.to_s 16 | state.record = record 17 | end 18 | if state.fetch? 19 | state.set_record_data 20 | state 21 | else 22 | nil 23 | end 24 | end 25 | 26 | def undo 27 | Memento::Result.new(action.undo, self) 28 | end 29 | 30 | def record_data 31 | @record_data ||= begin 32 | raw = read_attribute(:record_data) 33 | if raw.blank? 34 | {} 35 | else 36 | data = Memento.serializer.load(raw) 37 | data.is_a?(Hash) ? data.with_indifferent_access : data 38 | end 39 | end 40 | end 41 | 42 | def record_data=(data) 43 | @record_data = nil 44 | write_attribute(:record_data, data.is_a?(String) ? data : 45 | Memento.serializer.dump(data.is_a?(Hash) ? data.to_hash : data) 46 | ) 47 | end 48 | 49 | def fetch? 50 | action.fetch? 51 | end 52 | 53 | def set_record_data 54 | self.record_data = action.fetch 55 | end 56 | 57 | private 58 | 59 | def action 60 | "memento/action/#{action_type}".classify.constantize.new(self) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/memento/version.rb: -------------------------------------------------------------------------------- 1 | module Memento 2 | VERSION = "0.5.2" 3 | end 4 | -------------------------------------------------------------------------------- /memento.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "memento/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "memento" 7 | s.version = Memento::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Yolk Sebastian Munz & Julia Soergel GbR"] 10 | s.email = %q{sebastian@yo.lk} 11 | s.homepage = %q{http://github.com/yolk/memento} 12 | s.summary = %q{Undo for Rails/ActiveRecord - covers destroy, update and create} 13 | s.description = %q{Undo for Rails/ActiveRecord - covers destroy, update and create} 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 18 | s.require_paths = ["lib"] 19 | 20 | s.add_dependency 'activerecord', '>= 5.1' 21 | s.add_dependency 'actionpack', '>= 5.1' 22 | 23 | s.add_development_dependency 'rspec', '>= 2.4.0' 24 | s.add_development_dependency 'sqlite3', '>= 1.3.5' 25 | end 26 | 27 | -------------------------------------------------------------------------------- /spec/memento/action/create_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper') 2 | 3 | describe Memento::Action::Create, "when object is created" do 4 | before do 5 | setup_db 6 | setup_data 7 | Memento.start(@user) 8 | @project = Project.create!(:name => "P1", :closed_at => 3.days.ago).reload 9 | Memento.stop 10 | end 11 | 12 | after do 13 | shutdown_db 14 | end 15 | 16 | it "should create memento_state for ar-object with no data" do 17 | Memento::State.count.should eql(1) 18 | Memento::State.first.action_type.should eql("create") 19 | Memento::State.first.record.should eql(@project) # it was destroyed, remember? 20 | Memento::State.first.reload.record_data.should eql(nil) 21 | end 22 | 23 | it "should create object" do 24 | Project.where(:id => @project.id).first.should_not be_nil 25 | Project.count.should eql(1) 26 | end 27 | 28 | it "should allow undoing the creation" do 29 | Memento::Session.last.undo 30 | Project.count.should eql(0) 31 | end 32 | 33 | describe "when undoing the creation" do 34 | it "should give back undone_object" do 35 | Memento::Session.last.undo.map{|e| e.object.class }.should eql([Project]) 36 | end 37 | 38 | it "should not undo the creatio if object was modified" do 39 | Project.last.update(:created_at => 1.minute.ago) 40 | undone = Memento::Session.last.undo 41 | Project.count.should eql(1) 42 | undone.first.should_not be_success 43 | undone.first.error.first.should be_was_changed 44 | end 45 | 46 | describe "when record was already destroyed" do 47 | it "should give back fake unsaved record with id set" do 48 | Project.last.destroy 49 | @undone = Memento::Session.last.undo 50 | @undone.size.should eql(1) 51 | @undone.first.object.should be_kind_of(Project) 52 | @undone.first.object.id.should eql(@project.id) 53 | @undone.first.object.name.should be_nil 54 | @undone.first.object.should be_new_record 55 | Project.count.should eql(0) 56 | end 57 | end 58 | end 59 | 60 | 61 | 62 | end 63 | 64 | describe Memento::Action::Create, "when object without timestamp is created" do 65 | before do 66 | setup_db 67 | setup_data 68 | Memento(@user) do 69 | @obj = TimestamplessObject.create!(:name => "O1").reload 70 | end 71 | end 72 | 73 | after do 74 | shutdown_db 75 | end 76 | 77 | describe "when undoing the creation" do 78 | it "should give back undone_object" do 79 | Memento::Session.last.undo.map{|e| e.object.class }.should eql([TimestamplessObject]) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/memento/action/destroy_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper') 2 | 3 | describe Memento::Action::Destroy, "when object gets destroyed" do 4 | before do 5 | setup_db 6 | setup_data 7 | @project = Project.create!(:name => "P1", :closed_at => 3.days.ago).reload 8 | Memento.start(@user) 9 | @project.destroy 10 | Memento.stop 11 | end 12 | 13 | after do 14 | shutdown_db 15 | end 16 | 17 | it "should create memento_state for ar-object with full attributes_for_memento" do 18 | Memento::State.count.should eql(1) 19 | Memento::State.first.action_type.should eql("destroy") 20 | Memento::State.first.record.should be_nil # it was destroyed, remember? 21 | Memento::State.first.reload.record_data.should == @project.attributes_for_memento 22 | end 23 | 24 | it "should destroy object" do 25 | Project.where(:id => @project.id).first.should be_nil 26 | Project.count.should be_zero 27 | end 28 | 29 | it "should allow undoing the destruction" do 30 | Project.count.should be_zero 31 | Memento::Session.last.undo 32 | Project.count.should eql(1) 33 | Project.first.attributes_for_memento.reject{|k, v| k.to_sym == :id }.should == ( 34 | @project.attributes_for_memento.reject{|k, v| k.to_sym == :id } 35 | ) 36 | end 37 | 38 | it "should give back undone_object on undoing the destruction" do 39 | Memento::Session.last.undo.map{|e| e.object.class }.should eql([Project]) 40 | end 41 | 42 | 43 | end 44 | -------------------------------------------------------------------------------- /spec/memento/action/update_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', '..', 'spec_helper') 2 | 3 | describe Memento::Action::Update do 4 | before do 5 | setup_db 6 | setup_data 7 | @time1 = 3.days.ago 8 | @time2 = 2.days.ago 9 | @project = Project.create!(:name => "P1", :closed_at => @time1, :notes => "Bla bla").reload 10 | @customer = Customer.create!(:name => "C1") 11 | end 12 | 13 | after do 14 | shutdown_db 15 | end 16 | 17 | describe "when object gets updated" do 18 | 19 | before do 20 | Memento(@user) do 21 | @project.update(:name => "P2", :closed_at => @time2, :customer => @customer, :notes => "Bla bla") 22 | end 23 | end 24 | 25 | it "should create memento_state for ar-object from changes_for_memento" do 26 | Memento::State.count.should eql(1) 27 | Memento::State.first.action_type.should eql("update") 28 | Memento::State.first.record.should eql(@project) 29 | Memento::State.first.record_data.keys.sort.should eql(%w(name closed_at customer_id).sort) 30 | Memento::State.first.record_data["name"].should eql(["P1", "P2"]) 31 | Memento::State.first.record_data["customer_id"].should eql([nil, @customer.id]) 32 | Memento::State.first.record_data["closed_at"][0].utc.to_s.should eql(@time1.utc.to_s) 33 | Memento::State.first.record_data["closed_at"][1].utc.to_s.should eql(@time2.utc.to_s) 34 | end 35 | 36 | it "should update object" do 37 | @project.reload.name.should eql("P2") 38 | @project.customer.should eql(@customer) 39 | @project.closed_at.to_s.should eql(@time2.to_s) 40 | @project.should_not be_changed 41 | Project.count.should eql(1) 42 | end 43 | 44 | it "should allow undoing the update" do 45 | undone = Memento::Session.last.undo.first 46 | undone.should be_success 47 | undone.object.should_not be_changed 48 | undone.object.name.should eql("P1") 49 | undone.object.customer.should be_nil 50 | undone.object.closed_at.to_s.should eql(@time1.to_s) 51 | end 52 | 53 | describe "when record was destroyed before undo" do 54 | before do 55 | @project.destroy 56 | end 57 | 58 | it "should return fake object with error" do 59 | undone = Memento::Session.last.undo.first 60 | undone.should_not be_success 61 | undone.error.first.should be_was_destroyed 62 | undone.object.class.should eql(Project) 63 | undone.object.id.should eql(1) 64 | end 65 | end 66 | 67 | describe "when record was changed before undo" do 68 | 69 | describe "with mergeable unrecorded changes" do 70 | before do 71 | @project.update({:notes => "Bla!"}) 72 | @result = Memento::Session.first.undo.first 73 | @object = @result.object 74 | end 75 | 76 | it "should be success" do 77 | @result.should be_success 78 | end 79 | 80 | it "should return correctly updated object" do 81 | @object.class.should eql(Project) 82 | @object.name.should eql("P1") 83 | @object.customer.should be_nil 84 | @object.closed_at.to_s.should eql(@time1.to_s) 85 | @object.attributes['notes'].should eql("Bla!") 86 | end 87 | end 88 | 89 | describe "with mergeable recorded changes" do 90 | before do 91 | Memento(@user) do 92 | @project.update({:notes => "Bla\nBla!"}) 93 | end 94 | Memento::State.last.update(:created_at => 1.minute.from_now) 95 | @result = Memento::Session.first.undo.first 96 | @object = @result.object 97 | end 98 | 99 | it "should be success" do 100 | @result.should be_success 101 | end 102 | 103 | it "should return correctly updated object" do 104 | @object.class.should eql(Project) 105 | @object.name.should eql("P1") 106 | @object.customer.should be_nil 107 | @object.closed_at.to_s.should eql(@time1.to_s) 108 | @object.attributes['notes'].should eql("Bla\nBla!") 109 | end 110 | 111 | describe "when second state is undone" do 112 | before do 113 | @result = Memento::Session.first.undo.first 114 | @object = @result.object 115 | end 116 | 117 | it "should be success" do 118 | @result.should be_success 119 | end 120 | 121 | it "should return correctly updated object" do 122 | @object.class.should eql(Project) 123 | @object.name.should eql("P1") 124 | @object.customer.should be_nil 125 | @object.closed_at.to_s.should eql(@time1.to_s) 126 | @object.attributes['notes'].should eql("Bla bla") 127 | end 128 | end 129 | end 130 | 131 | describe "with unmergeable unrecorded changes" do 132 | before do 133 | @project.update({:name => "P3"}) 134 | @result = Memento::Session.last.undo.first 135 | @object = @result.object 136 | end 137 | 138 | it "should fail" do 139 | @result.should be_failed 140 | end 141 | 142 | it "should set error" do 143 | @result.error.first.should be_was_changed 144 | end 145 | 146 | it "should return not undone object" do 147 | @object.name.should eql("P3") 148 | @object.customer.should eql(@customer) 149 | @object.closed_at.to_s.should eql(@time2.to_s) 150 | @object.should_not be_changed 151 | end 152 | end 153 | 154 | describe "with unmergeable recorded changes" do 155 | before do 156 | Memento(@user) do 157 | @project.update!({:name => "P3"}) 158 | end 159 | Memento::State.last.update(:created_at => 1.minute.from_now) 160 | @result = Memento::Session.first.undo.first 161 | @object = @result.object 162 | end 163 | 164 | it "should fail" do 165 | @result.should be_failed 166 | end 167 | 168 | it "should return not undone object" do 169 | @object.name.should eql("P3") 170 | @object.customer.should eql(@customer) 171 | @object.closed_at.to_s.should eql(@time2.to_s) 172 | @object.should_not be_changed 173 | end 174 | 175 | describe "when second state is undone" do 176 | before do 177 | @result = Memento::Session.last.undo.first 178 | @object = @result.object 179 | end 180 | 181 | it "should be success" do 182 | @result.should be_success 183 | end 184 | 185 | it "should return correctly updated object" do 186 | @object.class.should eql(Project) 187 | @object.name.should eql("P2") 188 | @object.customer.should eql(@customer) 189 | @object.closed_at.to_s.should eql(@time2.to_s) 190 | @object.attributes['notes'].should eql("Bla bla") 191 | end 192 | end 193 | end 194 | end 195 | 196 | end 197 | 198 | describe "when object gets updated with no changes" do 199 | 200 | before do 201 | Memento(@user) do 202 | @project.update(:name => "P1", :customer => nil, :notes => "Bla bla") 203 | end 204 | end 205 | 206 | it "should not create session/state" do 207 | #Memento::Session.count.should eql(0) 208 | Memento::State.count.should eql(0) 209 | end 210 | 211 | end 212 | 213 | end 214 | -------------------------------------------------------------------------------- /spec/memento/action_controller_methods_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | class FooController < ActionController::Base 4 | 5 | end 6 | 7 | describe Memento::ActionControllerMethods do 8 | 9 | before do 10 | setup_db 11 | setup_data 12 | @controller = FooController.new 13 | @controller.stub(:current_user => @user) 14 | @headers = {} 15 | @controller.stub_chain(:response, :headers => @headers) 16 | end 17 | 18 | after do 19 | shutdown_db 20 | end 21 | 22 | it "should add memento-method to ActionController::Base" do 23 | FooController.private_instance_methods.map(&:to_sym).should include(:memento) 24 | end 25 | 26 | it "should call memento#memento with user and block" do 27 | project = Project.create! 28 | @controller.send(:memento) do 29 | project.update(:name => "P7") 30 | end 31 | project.reload.name.should eql("P7") 32 | project.memento_states.count.should eql(1) 33 | Memento::Session.count.should eql(1) 34 | end 35 | 36 | it "should set header X-MementoSessionId" do 37 | @controller.send(:memento) { Project.create!.update(:name => "P7") } 38 | @headers.should == {'X-Memento-Session-Id' => Memento::Session.last.id.to_s } 39 | end 40 | 41 | it "should return result of given block" do 42 | @controller.send(:memento) do 43 | 1 + 2 44 | end.should eql(3) 45 | end 46 | 47 | it "should not set header when no session stored" do 48 | @controller.send(:memento) { Customer.create! } # not stored 49 | @headers['X-MementoSessionId'].should be_nil 50 | @headers.should_not have_key('X-Memento-Session-Id') 51 | end 52 | 53 | end 54 | -------------------------------------------------------------------------------- /spec/memento/active_record_methods_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | describe Memento::ActiveRecordMethods do 4 | 5 | before do 6 | setup_db 7 | setup_data 8 | end 9 | 10 | it "should declare protected methods on Project" do 11 | Project.private_instance_methods.map(&:to_sym).should include(:memento_record_after_destroy, :memento_record_after_update, :memento_record_after_create) 12 | end 13 | 14 | it "should set hook on create to call Memento" do 15 | project = Project.new(:name => "Project X") 16 | Memento.should_receive(:add_state).once().with("create", project) 17 | project.save! 18 | end 19 | 20 | it "should set hook on update to call Memento" do 21 | project = Project.create!(:name => "Project X") 22 | Memento.should_receive(:add_state).once().with("update", project) 23 | project.update(:name => "Project XY") 24 | end 25 | 26 | it "should set hook on destroy to call Memento" do 27 | project = Project.create!(:name => "Project X") 28 | Memento.should_receive(:add_state).once().with("destroy", project) 29 | project.destroy 30 | end 31 | 32 | it "should define attributes_for_memento and ignore attributes given by options" do 33 | Project.create!(:name => "Project X").attributes_for_memento.should == { 34 | "id"=>1, "name"=>"Project X", "notes"=>nil, "customer_id"=>nil, "closed_at"=>nil 35 | } 36 | end 37 | 38 | it "should set memento_options" do 39 | Project.memento_options.should eql({:ignore=>[:ignore_this]}) 40 | end 41 | 42 | it "should define has_many association to memento_states" do 43 | project = Project.create!(:name => "Project X") 44 | project.memento_states.should be_empty 45 | Memento(@user) { project.update(:name => "Project Y") } 46 | project.memento_states.count.should eql(1) 47 | Memento(@user) { project.update(:name => "Project Y") } 48 | project.memento_states.count.should eql(1) 49 | Memento(@user) { Project.create!.update(:name => "Project X") } 50 | project.memento_states.count.should eql(1) 51 | Project.last.memento_states.count.should eql(2) 52 | Memento::State.count.should eql(3) 53 | end 54 | 55 | after do 56 | shutdown_db 57 | end 58 | 59 | end 60 | -------------------------------------------------------------------------------- /spec/memento/result_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | describe Memento::Result do 4 | 5 | describe "when initalized with valid object" do 6 | before do 7 | @object = double("object", :errors => {}) 8 | @state = double("state1") 9 | @result = Memento::Result.new(@object, @state) 10 | end 11 | 12 | it "should have an object attribute" do 13 | @result.object.should eql(@object) 14 | end 15 | 16 | it "should have an state attribute" do 17 | @result.state.should eql(@state) 18 | end 19 | 20 | it "should have an error attribute" do 21 | @result.error.should be_nil 22 | end 23 | 24 | it "should be valid" do 25 | @result.should be_success 26 | @result.should_not be_failed 27 | end 28 | end 29 | 30 | describe "when initalized with object with errors" do 31 | before do 32 | @object = double("object", :errors => {:memento_undo => "123"}) 33 | @result = Memento::Result.new(@object, double("state1")) 34 | end 35 | 36 | it "should have an object attribute" do 37 | @result.object.should eql(@object) 38 | end 39 | 40 | it "should return error" do 41 | @result.error.should eql("123") 42 | end 43 | 44 | it "should be invalid" do 45 | @result.should be_failed 46 | @result.should_not be_success 47 | end 48 | end 49 | 50 | end 51 | 52 | describe Memento::ResultArray do 53 | 54 | before do 55 | @results = Memento::ResultArray.new() 56 | end 57 | 58 | it "should have an empty errors array" do 59 | @results.errors.should eql([]) 60 | end 61 | 62 | it "should have no errors" do 63 | @results.should be_success 64 | @results.should_not be_failed 65 | end 66 | 67 | describe "when Memento::Result without errors added" do 68 | before do 69 | @object = double("object", :errors => {:memento_undo => "123"}) 70 | @results << Memento::Result.new(double("object2", :errors => {}), double("state1")) 71 | @results << (@with_error = Memento::Result.new(@object, double("state2"))) 72 | end 73 | 74 | it "should have two entrys" do 75 | @results.size.should eql(2) 76 | end 77 | 78 | it "should have one error" do 79 | @results.errors.size.should eql(1) 80 | @results.errors.should eql([@with_error]) 81 | end 82 | 83 | it "should have an error" do 84 | @results.should_not be_success 85 | @results.should be_failed 86 | end 87 | end 88 | 89 | end -------------------------------------------------------------------------------- /spec/memento/session_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | describe Memento::Session do 4 | 5 | before do 6 | setup_db 7 | setup_data 8 | @session = Memento::Session.create({:user => @user}) 9 | end 10 | 11 | it "should belong to user" do 12 | @session.user.should eql(@user) 13 | end 14 | 15 | it "should require user" do 16 | Memento::Session.create.errors[:user].should eql(["can't be blank"]) 17 | end 18 | 19 | it "should have_many states" do 20 | @session.states.should be_empty 21 | @session.states.create!({:action_type => "destroy", :record => Project.create!}) 22 | @session.states.count.should eql(1) 23 | end 24 | 25 | context "undo" do 26 | before do 27 | @state1 = @session.states.create!({:action_type => "update", :record => @p1 = Project.create!}) 28 | @other = Memento::Session.create!({:user => @user}).states.create!({:action_type => "destroy", :record => Project.create!}) 29 | @state2 = @session.states.create!({:action_type => "update", :record => @p2 = Project.create!}) 30 | end 31 | 32 | describe "and all states succeed" do 33 | it "should return ResultsArray" do 34 | @session.undo.should be_a(Memento::ResultArray) 35 | end 36 | 37 | it "should remove all states" do 38 | @session.undo 39 | Memento::State.count.should eql(1) 40 | end 41 | 42 | it "should remove itself" do 43 | @session.undo 44 | Memento::Session.where(:id => @session.id).first.should be_nil 45 | end 46 | end 47 | 48 | describe "and all states fail" do 49 | before do 50 | @state1.update({:record_data => {:name => ["A", "B"]}}) 51 | @p1.update(:name => "C") 52 | @state2.update({:record_data => {:name => ["A", "B"]}}) 53 | @p2.update(:name => "C") 54 | end 55 | 56 | it "should keep all states" do 57 | @session.undo 58 | Memento::State.count.should eql(3) 59 | end 60 | 61 | it "should keep itself" do 62 | @session.undo 63 | @session.reload 64 | end 65 | 66 | it "should raise Memento::ErrorOnRewind on undo!" do 67 | lambda{ @session.undo! }.should raise_error(Memento::ErrorOnRewind) 68 | end 69 | end 70 | 71 | describe "and some states succeed, some fail" do 72 | before do 73 | @state1.update({:record_data => {:name => ["A", "B"]}}) 74 | @p1.update(:name => "C") 75 | end 76 | 77 | it "should keep all states when using undo!" do 78 | @session.undo! rescue 79 | Memento::State.count.should eql(3) 80 | end 81 | 82 | it "should NOT keep all states when using undo" do 83 | @session.undo 84 | Memento::State.count.should eql(2) 85 | end 86 | 87 | it "should keep itself" do 88 | @session.undo rescue nil 89 | @session.reload 90 | end 91 | 92 | it "should raise Memento::ErrorOnRewind on undo!" do 93 | lambda{ @session.undo! }.should raise_error(Memento::ErrorOnRewind) 94 | end 95 | end 96 | 97 | it "should undo states in reverse order of creation (newest first)" do 98 | @session.undo.map(&:state).map(&:id).should eql([@state2.id, @state1.id]) 99 | end 100 | end 101 | 102 | describe "with states" do 103 | before do 104 | @session.states.create!({:action_type => "destroy", :record => Project.create!}) 105 | Memento::Session.create!({:user => @user}).states.create!({:action_type => "destroy", :record => Project.create!}) 106 | @state2 = @session.states.create!({:action_type => "update", :record => Project.create!}) 107 | end 108 | 109 | it "should destroy all states when destroyed" do 110 | Memento::State.count.should eql(3) 111 | @session.destroy 112 | Memento::State.count.should eql(1) 113 | end 114 | 115 | end 116 | 117 | after do 118 | shutdown_db 119 | end 120 | 121 | end 122 | -------------------------------------------------------------------------------- /spec/memento/state_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), '..', 'spec_helper') 2 | 3 | describe Memento::State do 4 | 5 | before do 6 | setup_db 7 | setup_data 8 | @session = Memento::Session.create({:user => @user}) 9 | end 10 | 11 | it "should belong to session" do 12 | Memento::State.new({:session => @session}).session.should eql(@session) 13 | end 14 | 15 | it "should require session" do 16 | Memento::State.create.errors[:session].should eql(["can't be blank"]) 17 | end 18 | 19 | it "should require action_type to be one of Memento::State::RECORD_CAUSES" do 20 | Memento::State.create.errors[:action_type].should eql(["can't be blank"]) 21 | Memento::State.create({:action_type => "move"}).errors[:action_type].should eql(["is not included in the list"]) 22 | end 23 | 24 | it "should belong to polymorphic record" do 25 | Memento::State.new({:record => @user}).record.should eql(@user) 26 | Memento::State.new({:record => @session}).record.should eql(@session) 27 | end 28 | 29 | it "should require record" do 30 | Memento::State.create.errors[:record].should eql(["can't be blank"]) 31 | end 32 | 33 | describe "valid State" do 34 | before do 35 | @state = @session.states.add("destroy", @project = Project.create(:name => "A")) 36 | @state.save if @state 37 | @project.destroy 38 | end 39 | 40 | it "should give back Memento::Result on undo" do 41 | result = @state.undo 42 | result.should be_a(Memento::Result) 43 | result.object.should be_a(Project) 44 | result.state.should eql(@state) 45 | end 46 | 47 | it "should give back old data on record_data" do 48 | @state.record_data.should == (@project.attributes_for_memento) 49 | end 50 | end 51 | 52 | after do 53 | shutdown_db 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /spec/memento_spec.rb: -------------------------------------------------------------------------------- 1 | require File.join(File.dirname(__FILE__), 'spec_helper') 2 | 3 | describe Memento do 4 | before do 5 | setup_db 6 | setup_data 7 | end 8 | 9 | after do 10 | shutdown_db 11 | end 12 | 13 | it "should be (like) a singleton" do 14 | Memento.instance.should eql(Memento.instance) 15 | Memento.instance.should eql(Memento) 16 | lambda{ Memento.new }.should raise_error(NoMethodError) 17 | end 18 | 19 | it "should not be memento by default" do 20 | Memento.should_not be_active 21 | end 22 | 23 | describe "start" do 24 | 25 | before do 26 | Memento.start(@user) 27 | @session = Memento.send(:session) 28 | end 29 | 30 | it "should require user or user_id on start" do 31 | lambda{ Memento.start }.should raise_error(ArgumentError) 32 | end 33 | 34 | it "should set an unsaved memento_session when starting" do 35 | Memento::Session.count.should eql(0) 36 | @session.should be_kind_of(Memento::Session) 37 | @session.should be_new_record 38 | end 39 | 40 | it "should set user on session" do 41 | @session.user.should eql(User.first) 42 | end 43 | 44 | it "should set user when passing in id as integer" do 45 | Memento.start(User.create(:name => "MyUser2").id) 46 | Memento.send(:session).user.should eql(User.last) 47 | end 48 | 49 | it "should not start memento when user does not exists/is invalid" do 50 | Memento.stop 51 | Memento.start(123333) 52 | Memento.should_not be_active 53 | Memento.start("123") 54 | Memento.should_not be_active 55 | end 56 | 57 | it "should be memento" do 58 | Memento.should be_active 59 | end 60 | 61 | end 62 | 63 | describe "stop" do 64 | before do 65 | Memento.start(@user) 66 | Memento.stop 67 | end 68 | 69 | it "should not be memento" do 70 | Memento.should_not be_active 71 | end 72 | 73 | it "should remove session if no states created" do 74 | Memento::Session.count.should eql(0) 75 | end 76 | end 77 | 78 | describe "memento block" do 79 | 80 | it "should record inside of block and stop after" do 81 | Memento.should_not be_active 82 | Memento(@user) do 83 | Memento.should be_active 84 | end 85 | Memento.should_not be_active 86 | end 87 | 88 | it "should give back session when states created" do 89 | Memento(@user) do 90 | Project.create! 91 | end.should be_a(Memento::Session) 92 | end 93 | 94 | it "should give back false when no states created" do 95 | Memento(@user) do 96 | 1 + 1 97 | end.should eql(false) 98 | end 99 | 100 | it "should raise error in block and stop session" do 101 | lambda { 102 | Memento(@user) do 103 | raise StandardError 104 | end.should be_nil 105 | }.should raise_error(StandardError) 106 | Memento.should_not be_active 107 | end 108 | 109 | end 110 | 111 | describe "when active" do 112 | before do 113 | @project = Project.create(:name => "P1") 114 | Memento.start(@user) 115 | end 116 | 117 | after do 118 | Memento.stop 119 | end 120 | 121 | it "should store tmp states" do 122 | Memento::State.count.should eql(0) 123 | Memento.add_state :destroy, @project 124 | Memento.session.should be_new_record 125 | Memento.session.tmp_states.size.should eql(1) 126 | Memento::State.count.should eql(0) 127 | end 128 | 129 | it "should save session and states when stopping" do 130 | Memento::Session.count.should eql(0) 131 | Memento::State.count.should eql(0) 132 | Memento.add_state :destroy, @project 133 | Memento::Session.count.should eql(0) 134 | Memento::State.count.should eql(0) 135 | Memento.stop 136 | Memento::Session.count.should eql(1) 137 | Memento::State.count.should eql(1) 138 | end 139 | 140 | describe "when ignoring" do 141 | it "should NOT create memento_state for ar-object with action_type" do 142 | Memento.ignore do 143 | Memento.add_state :destroy, Project.create(:name => "P1") 144 | end 145 | 146 | Memento::State.count.should eql(0) 147 | end 148 | end 149 | 150 | end 151 | 152 | describe "when not active" do 153 | 154 | it "should NOT create memento_state for ar-object with action_type" do 155 | Memento.add_state :destroy, Project.create(:name => "P1") 156 | Memento::State.count.should eql(0) 157 | end 158 | 159 | end 160 | 161 | context "serializer" do 162 | 163 | it "should default to yaml" do 164 | Memento.serializer.should eql(YAML) 165 | end 166 | 167 | it "should be changeable" do 168 | Memento.serializer = Marshal 169 | Memento.serializer.should eql(Marshal) 170 | end 171 | 172 | end 173 | 174 | describe "multiple threads" do 175 | describe "start" do 176 | before do 177 | Thread.new do 178 | Memento.start(@user) 179 | end 180 | end 181 | 182 | it "should start Memento not in main thread" do 183 | sleep(0.1) 184 | Memento.should_not be_active 185 | end 186 | 187 | it "should start Memento not in separat thread" do 188 | sleep(0.1) 189 | t = Thread.new do 190 | Memento.should_not be_active 191 | end 192 | t.join 193 | end 194 | end 195 | 196 | describe "ignore" do 197 | before do 198 | @t = Thread.new do 199 | Memento.ignore { Memento.should be_ignore;sleep(0.2) } 200 | end 201 | end 202 | 203 | after do 204 | @t.join 205 | end 206 | 207 | it "should set ignore status by thread" do 208 | Memento.should_not be_ignore 209 | sleep(0.1) 210 | Memento.should_not be_ignore 211 | end 212 | end 213 | end 214 | 215 | end 216 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_support/time' 3 | require 'active_record' 4 | require 'action_controller' 5 | require 'rspec' 6 | 7 | I18n.enforce_available_locales = false 8 | 9 | # Initialize time_zones from rails 10 | Time.zone = "Berlin" 11 | ActiveRecord::Base.time_zone_aware_attributes = true 12 | ActiveRecord::Base.default_timezone = :utc 13 | 14 | $:.unshift(File.dirname(__FILE__)) 15 | $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 16 | require 'memento' 17 | 18 | ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:") 19 | # catch AR schema statements 20 | # $stdout = StringIO.new 21 | 22 | RSpec.configure do |config| 23 | config.filter_run :focus 24 | config.run_all_when_everything_filtered = true 25 | config.expect_with(:rspec) { |c| c.syntax = :should } 26 | end 27 | 28 | def setup_db 29 | ActiveRecord::Schema.define(:version => 1) do 30 | create_table :projects do |t| 31 | t.column :name, :string 32 | t.column :closed_at, :datetime 33 | t.column :notes, :text 34 | t.references :customer 35 | t.integer :ignore_this 36 | t.timestamps null: false 37 | end 38 | 39 | create_table :users do |t| 40 | t.column :email, :string 41 | t.column :name, :string 42 | t.timestamps null: false 43 | end 44 | 45 | create_table :customers do |t| 46 | t.column :name, :string 47 | t.timestamps null: false 48 | end 49 | 50 | create_table :timestampless_objects do |t| 51 | t.column :name, :string 52 | end 53 | 54 | create_table :memento_sessions do |t| 55 | t.references :user 56 | t.timestamps null: false 57 | end 58 | 59 | create_table :memento_states do |t| 60 | t.string :action_type 61 | t.binary :record_data, :limit => 16777215 62 | t.references :record, :polymorphic => true 63 | t.references :session 64 | t.timestamps null: false 65 | end 66 | 67 | end 68 | end 69 | 70 | def setup_data 71 | @user = User.create(:name => "MyUser") 72 | end 73 | 74 | def shutdown_db 75 | ActiveRecord::Base.connection.tables.each do |table| 76 | ActiveRecord::Base.connection.drop_table(table) 77 | end 78 | end 79 | 80 | class User < ActiveRecord::Base 81 | end unless defined?(User) 82 | 83 | class Customer < ActiveRecord::Base 84 | has_many :projects 85 | end unless defined?(Customer) 86 | 87 | class Project < ActiveRecord::Base 88 | belongs_to :customer, optional: true 89 | 90 | memento_changes :ignore => :ignore_this 91 | 92 | def notes 93 | super.reverse 94 | end 95 | end unless defined?(Project) 96 | 97 | class TimestamplessObject < ActiveRecord::Base 98 | memento_changes 99 | end unless defined?(TimestamplessObject) 100 | --------------------------------------------------------------------------------