Fingertips-revision_san 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,29 @@
1
+ = RevisionSan
2
+
3
+ A simple Rails plugin which creates revisions of your model and comes with an equally simple HTML differ.
4
+
5
+ == Install
6
+
7
+ * As a gem:
8
+ $ sudo gem install Fingertips-revision_san -s http://gems.github.com
9
+
10
+ * Vendor:
11
+ $ cd vendor/plugins && git clone git@github.com:Fingertips/revision_san.git
12
+
13
+ == Usage
14
+
15
+ Include the RevisionSan module into the model for which you'd like to keep
16
+ revisions.
17
+
18
+ class Artist < ActiveRecord::Base
19
+ include RevisionSan
20
+ end
21
+
22
+ And create a migration to add the columns needed by RevisionSan to your model:
23
+
24
+ add_column :artists, :revision, :default => 1
25
+ add_column :artists, :revision_parent_id, :default => nil
26
+
27
+ add_index :artists, :revision_parent_id
28
+
29
+ Copyright © 2008 Fingertips, Eloy Duran <eloy@fngtps.com>, released under the MIT license
data/VERSION.yml ADDED
@@ -0,0 +1,4 @@
1
+ ---
2
+ :patch: 0
3
+ :major: 0
4
+ :minor: 1
@@ -0,0 +1,50 @@
1
+ require File.expand_path('../revision_san/diff', __FILE__)
2
+
3
+ module RevisionSan
4
+ def self.included(klass)
5
+ klass.class_eval do
6
+ before_update :create_new_revision
7
+ named_scope :current_revisions, { :conditions => { :revision_parent_id => nil } }
8
+ klass.extend ClassMethods
9
+ end
10
+ end
11
+
12
+ module ClassMethods
13
+ def find_with_current_revisions(*args)
14
+ current_revisions.find_without_current_revisions(*args)
15
+ end
16
+
17
+ def count_with_current_revisions(*args)
18
+ current_revisions.count_without_current_revisions(*args)
19
+ end
20
+
21
+ def self.extended(klass)
22
+ class << klass
23
+ alias_method_chain :find, :current_revisions
24
+ alias_method_chain :count, :current_revisions
25
+ end
26
+ end
27
+ end
28
+
29
+ def revisions
30
+ self.class.find_without_current_revisions(:all, :conditions => { :revision_parent_id => id }, :order => 'id ASC') + [self]
31
+ end
32
+
33
+ def fetch_revision(revision)
34
+ revision = revision.to_i
35
+ return self if self.revision == revision
36
+ sub_query = revision_parent_id.blank? ? "revision_parent_id = #{id}" : "(id = #{revision_parent_id} OR revision_parent_id = #{revision_parent_id})"
37
+ self.class.find_without_current_revisions :first, :conditions => "#{sub_query} AND revision = #{revision}"
38
+ end
39
+
40
+ def create_new_revision
41
+ if changed?
42
+ record = self.class.new(:revision_parent_id => id)
43
+ attributes.except('id', 'revision_parent_id').each do |key, value|
44
+ record.write_attribute(key, changes.has_key?(key) ? changes[key].first : value)
45
+ end
46
+ record.save(false)
47
+ self.revision += 1
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,101 @@
1
+ require 'diff/lcs'
2
+
3
+ module RevisionSan
4
+ # Return a RevisionSan::Diff object which compares this revision to the specified revision.
5
+ #
6
+ # artist = Artist.create(:name => 'van Gogh')
7
+ # artist.update_attribute(:name => 'Vincent van Gogh')
8
+ #
9
+ # revision_1 = artist.fetch_revision(1)
10
+ # diff = revision_1.compare_against_revision(2)
11
+ # diff.name # => '<ins>Vincent </ins>van Gogh'
12
+ def compare_against_revision(revision)
13
+ Diff.new(self, fetch_revision(revision))
14
+ end
15
+
16
+ class Diff
17
+ attr_reader :from, :to
18
+
19
+ def initialize(from, to)
20
+ @from, @to = from, to
21
+ end
22
+
23
+ def diff_for_column(column)
24
+ from_lines, to_lines = [@from.send(column), @to.send(column)].map do |res|
25
+ text = res.blank? ? '' : res.to_s
26
+ text = yield(text) if block_given?
27
+ text.scan(/\n+|.+/)
28
+ end
29
+ SimpleHTMLFormatter.diff(from_lines, to_lines)
30
+ end
31
+
32
+ def method_missing(method, *args, &block)
33
+ if @from.respond_to?(method) || @from.class.column_names.include?(method.to_s)
34
+ define_and_call_singleton_method(method, &block)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def define_and_call_singleton_method(method, &block)
41
+ instance_eval %{
42
+ def #{method}(&block)
43
+ diff_for_column('#{method}', &block)
44
+ end
45
+
46
+ #{method}(&block)
47
+ }
48
+ end
49
+
50
+ module SimpleHTMLFormatter
51
+ def self.diff(from_lines, to_lines)
52
+ LineDiffHTMLFormatter.new(from_lines, to_lines).output
53
+ end
54
+
55
+ class LineDiffHTMLFormatter
56
+ def output
57
+ @output.join.gsub(%r{</ins><ins>|</del><del>|<del></del>}, '')
58
+ end
59
+
60
+ def initialize(from, to)
61
+ @output = []
62
+ @went_deep = false
63
+ ::Diff::LCS.traverse_sequences(from, to, self)
64
+ end
65
+
66
+ def discard_a(change)
67
+ from_words, to_words = [change.old_element, change.new_element].map { |text| text.to_s.scan(/\w+|\W|\s/) }
68
+ changes = ::Diff::LCS.diff(from_words, to_words).length
69
+ if changes > 1 && changes > (from_words.length / 5)
70
+ @output << "<del>#{change.old_element}</del>"
71
+ else
72
+ @output << WordDiffHTMLFormatter.new(from_words, to_words).output
73
+ @went_deep = true
74
+ end
75
+ end
76
+
77
+ def discard_b(change)
78
+ if @went_deep
79
+ @went_deep = false
80
+ else
81
+ @output << "<ins>#{change.new_element}</ins>"
82
+ end
83
+ end
84
+
85
+ def match(match)
86
+ @output << match.new_element
87
+ end
88
+ end
89
+
90
+ class WordDiffHTMLFormatter < LineDiffHTMLFormatter
91
+ def output
92
+ @output.join
93
+ end
94
+
95
+ def discard_a(change)
96
+ @output << "<del>#{change.old_element}</del>"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
data/test/diff_test.rb ADDED
@@ -0,0 +1,123 @@
1
+ require File.expand_path('../test_helper', __FILE__)
2
+
3
+ class DiffTestEntity
4
+ def self.column_names
5
+ ["text"]
6
+ end
7
+
8
+ attr_accessor :text
9
+
10
+ def initialize(text)
11
+ @text = text
12
+ end
13
+ end
14
+
15
+ describe "RevisionSan::Diff" do
16
+ it "should return correctly formatted html" do
17
+ [
18
+ [long_text[:before], long_text[:after], long_text[:diff]],
19
+ [nil, "First words.", "<ins>First words.</ins>"],
20
+ ["First words.", nil, "<del>First words.</del>"],
21
+ ["No changes.", "No changes.", "No changes."],
22
+ ["Bla", "Foo", "<del>Bla</del><ins>Foo</ins>"],
23
+ ["Bar!", "Baz!", "<del>Bar</del><ins>Baz</ins>!"],
24
+ ["Begin\n\nEnd", "Begin\n\nMiddle\n\nEnd", "Begin\n\n<ins>Middle\n\n</ins>End"],
25
+ ["Multiple added.", "Multiple words are added.", "Multiple <ins>words are </ins>added."],
26
+ ["Multiple words are removed.", "Multiple removed.", "Multiple <del>words are </del>removed."]
27
+ ].each do |from, to, html|
28
+ diff_html(from, to).should == html
29
+ end
30
+ end
31
+
32
+ def long_text
33
+ {
34
+ :before => %{Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Fusce gravida. Ut orci orci, molestie et, scelerisque ut, faucibus pharetra, enim. Morbi vehicula consequat nunc. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Quisque ac orci. Proin adipiscing tempor erat. Phasellus gravida tincidunt sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In venenatis libero sit amet quam. Nam sapien diam, tempor placerat, feugiat quis, congue in, elit. Vivamus nec enim eget elit posuere tincidunt. Quisque scelerisque lobortis risus. Quisque cursus dolor sit amet arcu.
35
+
36
+ Suspendisse auctor. Quisque sodales dapibus pede. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Cras blandit tellus id libero. Morbi sed purus sed sapien ornare facilisis. Vestibulum rutrum egestas mauris. Vestibulum luctus velit vitae ante. In dictum, metus sed lacinia sagittis, leo diam elementum tortor, rutrum elementum justo tellus eget risus. Curabitur faucibus mauris eget nisi. Nam mattis nunc eget turpis. In porta. Aliquam risus ante, sodales quis, consequat vitae, fermentum ut, nisi. Etiam congue ipsum id ante aliquet dictum.},
37
+
38
+ :after => %{Landscape architecture involves the investigation and designed response to the landscape. The scope of the profession includes architectural design, site planning, environmental restoration, town or urban planning, urban design, parks and recreation planning. A practitioner in the field of landscape architecture is called a landscape architect.
39
+
40
+ Suspendisse auctor. Quisque sodales dapibus pede. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Cras blandit tellus id libero. Morbi sed purus sed sapien ornare facilisis. Vestibulum rutrum egestas mauris. Vestibulum luctus velit vitae ante. In dictum, metus sed lacinia sagittis, leo diam elementum tortor, rutrum elementum justo tellus eget risus. Curabitur faucibus mauris eget nisi. Nam mattis nunc eget turpis. In porta. Aliquam risus ante, sodales quis, consequat vitae, fermentum ut, nisi. Etiam congue ipsum id ante aliquet dictum.},
41
+
42
+ :diff => %{<del>Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Fusce gravida. Ut orci orci, molestie et, scelerisque ut, faucibus pharetra, enim. Morbi vehicula consequat nunc. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Quisque ac orci. Proin adipiscing tempor erat. Phasellus gravida tincidunt sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In venenatis libero sit amet quam. Nam sapien diam, tempor placerat, feugiat quis, congue in, elit. Vivamus nec enim eget elit posuere tincidunt. Quisque scelerisque lobortis risus. Quisque cursus dolor sit amet arcu.</del><ins>Landscape architecture involves the investigation and designed response to the landscape. The scope of the profession includes architectural design, site planning, environmental restoration, town or urban planning, urban design, parks and recreation planning. A practitioner in the field of landscape architecture is called a landscape architect.</ins>
43
+
44
+ Suspendisse auctor. Quisque sodales dapibus pede. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Cras blandit tellus id libero. Morbi sed purus sed sapien ornare facilisis. Vestibulum rutrum egestas mauris. Vestibulum luctus velit vitae ante. In dictum, metus sed lacinia sagittis, leo diam elementum tortor, rutrum elementum justo tellus eget risus. Curabitur faucibus mauris eget nisi. Nam mattis nunc eget turpis. In porta. Aliquam risus ante, sodales quis, consequat vitae, fermentum ut, nisi. Etiam congue ipsum id ante aliquet dictum.}
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def diff_html(from, to)
51
+ RevisionSan::Diff.new(DiffTestEntity.new(from), DiffTestEntity.new(to)).text
52
+ end
53
+ end
54
+
55
+ class Artist
56
+ def real_method
57
+ obj = Object.new
58
+ def obj.to_s
59
+ 'Real method'
60
+ end
61
+ obj
62
+ end
63
+ end
64
+
65
+ describe "RevisionSan, looking at diff methods" do
66
+ before do
67
+ RevisionSanTest::Initializer.setup_database
68
+
69
+ @artist_rev_2 = Artist.create(:name => 'van Gogh', :bio => 'He painted a lot.')
70
+ @artist_rev_2.update_attributes(:name => 'Vincent van Gogh', :bio => 'He occasionally drew a lot.')
71
+ @artist_rev_1 = @artist_rev_2.revisions.first
72
+
73
+ @diff = @artist_rev_1.compare_against_revision(2)
74
+ end
75
+
76
+ after do
77
+ RevisionSanTest::Initializer.teardown_database
78
+ end
79
+
80
+ it "should take an older revision number to compare against" do
81
+ @artist_rev_2.compare_against_revision(1).should.be.instance_of RevisionSan::Diff
82
+ end
83
+
84
+ it "should have instantiated a RevisionSan::Diff object with the correct revisions" do
85
+ @diff.from.should == @artist_rev_1
86
+ @diff.to.should == @artist_rev_2
87
+ end
88
+
89
+ it "should lazy define accessors for requested columns" do
90
+ @diff.name
91
+ @diff.bio
92
+
93
+ @diff.should.respond_to :name
94
+ @diff.should.respond_to :bio
95
+ end
96
+
97
+ it "should also work with real existing methods instead of a column" do
98
+ @diff.real_method
99
+ @diff.should.respond_to :real_method
100
+ end
101
+
102
+ it "should coerce the value to a string before trying to diff them" do
103
+ @diff.real_method.should == 'Real method'
104
+ end
105
+
106
+ it "should yield the from and to strings, if a block is given, so the user can adjust the text before diffing" do
107
+ @diff.real_method { |text| text.reverse }.should == 'dohtem laeR'
108
+ end
109
+
110
+ it "should only define the accessors on the singleton, not the class" do
111
+ @diff.name
112
+ RevisionSan::Diff.instance_methods.should.not.include 'name'
113
+ end
114
+
115
+ it "should still raise a NoMethodError for column names that don't exist" do
116
+ lambda { @diff.foo }.should.raise NoMethodError
117
+ end
118
+
119
+ it "should return html with the diff for a requested column" do
120
+ @diff.name.should == "<ins>Vincent </ins>van Gogh"
121
+ @diff.bio.should == "He <del>painted</del><ins>occasionally drew</ins> a lot."
122
+ end
123
+ end
@@ -0,0 +1,116 @@
1
+ require File.expand_path('../test_helper', __FILE__)
2
+
3
+ require 'active_support/testing/core_ext/test/unit/assertions'
4
+
5
+ describe "RevisionSan, looking at model methods" do
6
+ before do
7
+ RevisionSanTest::Initializer.setup_database
8
+
9
+ @artist = Artist.create(:name => 'van Gogh', :bio => 'He painted a lot.')
10
+ @artist.update_attributes(:name => 'Vincent van Gogh', :bio => 'He painted a lot of nice paintings.')
11
+ @artist.update_attributes(:name => 'Vincent van Gogh JR', :bio => 'Was never born.')
12
+ end
13
+
14
+ after do
15
+ RevisionSanTest::Initializer.teardown_database
16
+ end
17
+
18
+ it "should only return current revisions when using ::find" do
19
+ Artist.find(:all).should == [@artist]
20
+ end
21
+
22
+ it "should return all revisions" do
23
+ revs = @artist.revisions
24
+ revs.length.should == 3
25
+ revs[0].should == Artist.find_without_current_revisions(:first, :conditions => { :revision => 1 })
26
+ revs[1].should == Artist.find_without_current_revisions(:first, :conditions => { :revision => 2 })
27
+ revs[2].should == Artist.find_without_current_revisions(:first, :conditions => { :revision => 3 })
28
+ end
29
+
30
+ it "should return a specific revision" do
31
+ @artist.fetch_revision(1).should == @artist.revisions[0]
32
+ @artist.fetch_revision(2).should == @artist.revisions[1]
33
+ @artist.fetch_revision(3).should == @artist
34
+
35
+ @artist.fetch_revision('1').should == @artist.revisions[0]
36
+ @artist.fetch_revision('2').should == @artist.revisions[1]
37
+ @artist.fetch_revision('3').should == @artist
38
+ end
39
+
40
+ it "should also be able to return a specific revision from an older revision" do
41
+ @artist.fetch_revision(1).fetch_revision(2).fetch_revision(3).should == @artist
42
+ end
43
+
44
+ it "should convert a requested revision to an integer before using in the conditions" do
45
+ @artist.fetch_revision('some evil sql that will be coerced to 0').should.be nil
46
+ end
47
+ end
48
+
49
+ describe "RevisionSan, when updating a record" do
50
+ before do
51
+ RevisionSanTest::Initializer.setup_database
52
+
53
+ @artist = Artist.create(:name => 'van Gogh', :bio => 'He painted a lot.')
54
+ @artist.update_attributes(:name => 'Vincent van Gogh', :bio => 'He painted a lot of nice paintings.')
55
+ end
56
+
57
+ after do
58
+ RevisionSanTest::Initializer.teardown_database
59
+ end
60
+
61
+ it "should insert a new revision of a record" do
62
+ assert_difference('Artist.count_without_current_revisions', +1) do
63
+ @artist.update_attributes(:name => 'Vincent van Gogh JR', :bio => 'Was never born.')
64
+ end
65
+ end
66
+
67
+ it "should add the original attributes to the new revision record" do
68
+ @artist.revisions.first.name.should == 'van Gogh'
69
+ @artist.revisions.first.bio.should == 'He painted a lot.'
70
+ end
71
+
72
+ it "should not take over the created_at value" do
73
+ created_at_before = @artist.created_at
74
+ sleep 1
75
+ @artist.update_attribute(:name, 'Gogh')
76
+
77
+ @artist.revisions.first.created_at.should.not == created_at_before
78
+ end
79
+
80
+ it "should not create a new revision record if no attributes were changed" do
81
+ assert_no_difference('Artist.count') do
82
+ @artist.update_attributes({})
83
+ end
84
+ end
85
+
86
+ it "should not create a new revision if validation fails on the original record" do
87
+ assert_no_difference('@artist.revision') do
88
+ assert_no_difference('Artist.count') do
89
+ @artist.update_attributes({ :name => '', :bio => 'Lost his name...' })
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ describe "RevisionSan, class methods" do
96
+ before do
97
+ RevisionSanTest::Initializer.setup_database
98
+
99
+ @gogh = Artist.create(:name => 'van Gogh, 1', :bio => 'He painted a lot.')
100
+ 9.times { |i| @gogh.update_attribute(:name, "van Gogh, #{i+2}") }
101
+
102
+ @picasso = Artist.create(:name => 'Picasso, 1', :bio => 'He drank a lot.')
103
+ 9.times { |i| @picasso.update_attribute(:name, "Picasso, #{i+2}") }
104
+ end
105
+
106
+ after do
107
+ RevisionSanTest::Initializer.teardown_database
108
+ end
109
+
110
+ it "should add a named_scope that only returns the latest revisions" do
111
+ revisions = Artist.current_revisions
112
+ revisions.map(&:id).should == [@gogh.id, @picasso.id]
113
+ revisions.map(&:name).should == ["van Gogh, 10", "Picasso, 10"]
114
+ revisions.map(&:revision).should == [10, 10]
115
+ end
116
+ end
@@ -0,0 +1,91 @@
1
+ module RevisionSanTest
2
+ module Initializer
3
+ VENDOR_RAILS = File.expand_path('../../../../rails', __FILE__)
4
+ OTHER_RAILS = File.expand_path('../../../rails', __FILE__)
5
+ PLUGIN_ROOT = File.expand_path('../../', __FILE__)
6
+
7
+ def self.rails_directory
8
+ if File.exist?(VENDOR_RAILS)
9
+ VENDOR_RAILS
10
+ elsif File.exist?(OTHER_RAILS)
11
+ OTHER_RAILS
12
+ end
13
+ end
14
+
15
+ def self.load_dependencies
16
+ if rails_directory
17
+ $:.unshift(File.join(rails_directory, 'activesupport', 'lib'))
18
+ $:.unshift(File.join(rails_directory, 'activerecord', 'lib'))
19
+ else
20
+ require 'rubygems' rescue LoadError
21
+ end
22
+
23
+ require 'activesupport'
24
+ require 'activerecord'
25
+
26
+ require 'rubygems' rescue LoadError
27
+
28
+ require 'test/spec'
29
+ require File.join(PLUGIN_ROOT, 'lib', 'revision_san')
30
+ end
31
+
32
+ def self.configure_database
33
+ ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
34
+ ActiveRecord::Migration.verbose = false
35
+ end
36
+
37
+ def self.setup_database
38
+ ActiveRecord::Schema.define(:version => 1) do
39
+ create_table :members do |t|
40
+ t.timestamps
41
+ end
42
+
43
+ create_table :artists do |t|
44
+ t.integer :revision, :default => 1
45
+ t.integer :revision_parent_id, :default => nil
46
+
47
+ t.integer :member_id
48
+ t.string :name
49
+ t.text :bio
50
+ t.timestamps
51
+ end
52
+
53
+ create_table :category_assignments do |t|
54
+ t.integer :artist_id
55
+ t.integer :category_id
56
+ t.timestamps
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.teardown_database
62
+ ActiveRecord::Base.connection.tables.each do |table|
63
+ ActiveRecord::Base.connection.drop_table(table)
64
+ end
65
+ end
66
+
67
+ def self.start
68
+ load_dependencies
69
+ configure_database
70
+ end
71
+ end
72
+ end
73
+
74
+ RevisionSanTest::Initializer.start
75
+
76
+ class Member < ActiveRecord::Base
77
+ has_one :artist
78
+ end
79
+
80
+ class Artist < ActiveRecord::Base
81
+ belongs_to :member
82
+ has_many :category_assignments
83
+
84
+ include RevisionSan
85
+
86
+ validates_presence_of :name
87
+ end
88
+
89
+ class CategoryAssignment < ActiveRecord::Base
90
+ belongs_to :artist
91
+ end
metadata ADDED
@@ -0,0 +1,61 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: Fingertips-revision_san
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eloy Duran
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-03-03 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: A simple Rails plugin which creates revisions of your model and comes with an equally simple HTML differ.
17
+ email: eloy.de.enige@gmail.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - README.rdoc
26
+ - VERSION.yml
27
+ - lib/revision_san
28
+ - lib/revision_san/diff.rb
29
+ - lib/revision_san.rb
30
+ - test/diff_test.rb
31
+ - test/revision_san_test.rb
32
+ - test/test_helper.rb
33
+ has_rdoc: true
34
+ homepage: http://github.com/Fingertips/revision_san
35
+ post_install_message:
36
+ rdoc_options:
37
+ - --inline-source
38
+ - --charset=UTF-8
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: "0"
46
+ version:
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: "0"
52
+ version:
53
+ requirements: []
54
+
55
+ rubyforge_project:
56
+ rubygems_version: 1.2.0
57
+ signing_key:
58
+ specification_version: 2
59
+ summary: A simple Rails plugin which creates revisions of your model and comes with an equally simple HTML differ.
60
+ test_files: []
61
+