versions 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -1
- data/History.txt +6 -1
- data/README.rdoc +41 -7
- data/Rakefile +1 -0
- data/lib/versions.rb +3 -2
- data/lib/versions/after_commit.rb +69 -0
- data/lib/versions/attachment.rb +104 -0
- data/lib/versions/auto.rb +3 -2
- data/lib/versions/multi.rb +44 -16
- data/lib/versions/shared_attachment.rb +62 -2
- data/lib/versions/version.rb +1 -1
- data/test/fixtures.rb +8 -4
- data/test/fixtures/files/bird.jpg +0 -0
- data/test/fixtures/files/lake.jpg +0 -0
- data/test/helper.rb +35 -3
- data/test/unit/after_commit_test.rb +107 -0
- data/test/unit/attachment_test.rb +258 -220
- data/test/unit/auto_test.rb +20 -3
- data/test/unit/multi_test.rb +58 -14
- data/versions.gemspec +11 -5
- metadata +18 -5
- data/lib/versions/shared_attachment/attachment.rb +0 -66
- data/lib/versions/shared_attachment/owner.rb +0 -77
- data/lib/versions/transparent.rb +0 -98
data/.gitignore
CHANGED
data/History.txt
CHANGED
data/README.rdoc
CHANGED
@@ -12,13 +12,19 @@ Duplicate on save if should_clone? returns true.
|
|
12
12
|
|
13
13
|
== Multi (status: beta)
|
14
14
|
|
15
|
-
Hide many versions behind a single current one.
|
15
|
+
Hide many versions behind a single current one. For example if you want
|
16
|
+
to version the content of a Page class, you should add a 'version_id' in
|
17
|
+
the "pages" table to store the current version and you will need a Version
|
18
|
+
model with a "page_id" to link back:
|
16
19
|
|
17
|
-
|
20
|
+
class Version < ActiveRecord::Base
|
21
|
+
include Versions::Auto
|
22
|
+
end
|
18
23
|
|
19
|
-
|
20
|
-
|
21
|
-
|
24
|
+
class Page < ActiveRecord::Base
|
25
|
+
include Versions::Multi
|
26
|
+
has_multiple :versions
|
27
|
+
end
|
22
28
|
|
23
29
|
=== Properties integration (status: beta)
|
24
30
|
|
@@ -37,9 +43,37 @@ storing properties in the version:
|
|
37
43
|
end
|
38
44
|
end
|
39
45
|
|
46
|
+
== AfterCommit (status: beta)
|
40
47
|
|
41
|
-
|
48
|
+
Requiring 'versions' adds an 'after_commit' method to your models. The code in the after_commit block
|
49
|
+
will only be executed if the top-most transaction succeeds (after the database commit).
|
50
|
+
Example:
|
51
|
+
|
52
|
+
class Document < ActiveRecord::Base
|
53
|
+
include Versions::AfterCommit
|
54
|
+
after_save :save_file
|
55
|
+
|
56
|
+
def save_file
|
57
|
+
if @file
|
58
|
+
after_commit do
|
59
|
+
# write file to disk
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
== Attachment (status: beta)
|
42
66
|
|
43
67
|
Enable file attachments linked to versions. The attachments are shared between versions
|
44
|
-
and deleted when no more versions are using them.
|
68
|
+
and deleted when no more versions are using them. Example:
|
69
|
+
|
70
|
+
|
71
|
+
# Mock a document class with many versions
|
72
|
+
class Document < ActiveRecord::Base
|
73
|
+
include Versions::Multi
|
74
|
+
has_multiple :versions
|
75
|
+
|
76
|
+
include Versions::Attachment
|
77
|
+
store_attachments_in :version, :attachment_class => 'Attachment'
|
78
|
+
end
|
45
79
|
|
data/Rakefile
CHANGED
data/lib/versions.rb
CHANGED
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'active_record/connection_adapters/abstract_adapter'
|
2
|
+
module AfterCommit
|
3
|
+
module Initializer
|
4
|
+
def self.included(base)
|
5
|
+
base.alias_method_chain :new_connection, :after_commit
|
6
|
+
end
|
7
|
+
private
|
8
|
+
def new_connection_with_after_commit(*args)
|
9
|
+
conn = new_connection_without_after_commit(*args)
|
10
|
+
conn.class_eval do
|
11
|
+
include AfterCommit::Connection
|
12
|
+
end
|
13
|
+
conn
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module Connection
|
18
|
+
def self.included(base)
|
19
|
+
base.alias_method_chain :commit_db_transaction, :after_commit
|
20
|
+
base.alias_method_chain :transaction, :after_commit
|
21
|
+
end
|
22
|
+
|
23
|
+
def after_commit(&block)
|
24
|
+
if block_given?
|
25
|
+
if open_transactions == 0
|
26
|
+
raise Exception.new("'after_commit' should only be used inside a transaction")
|
27
|
+
else
|
28
|
+
(@after_commit ||= []) << block
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def commit_db_transaction_with_after_commit
|
34
|
+
after_commit_actions = @after_commit
|
35
|
+
@after_commit = nil
|
36
|
+
commit_db_transaction_without_after_commit
|
37
|
+
if after_commit_actions
|
38
|
+
after_commit_actions.each do |block|
|
39
|
+
block.call
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def transaction_with_after_commit(*args, &block)
|
45
|
+
transaction_without_after_commit(*args, &block)
|
46
|
+
ensure
|
47
|
+
@after_commit = nil if open_transactions == 0
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
module ModelMethods
|
52
|
+
module ClassMethods
|
53
|
+
def after_commit(&block)
|
54
|
+
connection.after_commit(&block)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.included(base)
|
59
|
+
base.extend ClassMethods
|
60
|
+
end
|
61
|
+
|
62
|
+
def after_commit(&block)
|
63
|
+
self.class.connection.after_commit(&block)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
ActiveRecord::ConnectionAdapters::ConnectionPool.send(:include, AfterCommit::Initializer)
|
69
|
+
ActiveRecord::Base.send(:include, AfterCommit::ModelMethods)
|
@@ -0,0 +1,104 @@
|
|
1
|
+
require 'versions/shared_attachment'
|
2
|
+
|
3
|
+
module Versions
|
4
|
+
|
5
|
+
# The attachement module provides shared file attachments to a class with a copy-on-write
|
6
|
+
# pattern.
|
7
|
+
# Basically the module provides 'file=' and 'file' methods.
|
8
|
+
# The file is shared between versions if it is not changed. The Attachment only stores a
|
9
|
+
# reference to the file which is saved in the filesystem.
|
10
|
+
module Attachment
|
11
|
+
|
12
|
+
def self.included(base)
|
13
|
+
base.class_eval do
|
14
|
+
extend ClassMethods
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
module ClassMethods
|
19
|
+
def store_attachments_in(accessor, options = {})
|
20
|
+
attachment_class = options[:attachment_class] || '::Versions::SharedAttachment'
|
21
|
+
|
22
|
+
if accessor.nil? || accessor == self
|
23
|
+
belongs_to :attachment,
|
24
|
+
:class_name => attachment_class,
|
25
|
+
:foreign_key => 'attachment_id'
|
26
|
+
include Owner
|
27
|
+
else
|
28
|
+
klass = (options[:class_name] || accessor.to_s.capitalize).constantize
|
29
|
+
klass.class_eval do
|
30
|
+
belongs_to :attachment,
|
31
|
+
:class_name => attachment_class,
|
32
|
+
:foreign_key => 'attachment_id'
|
33
|
+
include Owner
|
34
|
+
end
|
35
|
+
|
36
|
+
line = __LINE__
|
37
|
+
definitions = <<-EOF
|
38
|
+
def file=(file)
|
39
|
+
#{accessor}.file = file
|
40
|
+
end
|
41
|
+
|
42
|
+
def file
|
43
|
+
#{accessor}.file
|
44
|
+
end
|
45
|
+
EOF
|
46
|
+
|
47
|
+
methods_module = Module.new
|
48
|
+
methods_module.class_eval(definitions, __FILE__, line + 2)
|
49
|
+
include methods_module
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
module Owner
|
56
|
+
def self.included(base)
|
57
|
+
base.class_eval do
|
58
|
+
before_create :save_attachment
|
59
|
+
before_update :attachment_before_update
|
60
|
+
before_destroy :attachment_before_destroy
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def file=(file)
|
65
|
+
if attachment
|
66
|
+
@attachment_to_unlink = self.attachment
|
67
|
+
self.attachment = nil
|
68
|
+
end
|
69
|
+
@attachment_need_save = true
|
70
|
+
self.build_attachment(:file => file)
|
71
|
+
end
|
72
|
+
|
73
|
+
def filepath
|
74
|
+
attachment ? attachment.filepath : nil
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
def save_attachment
|
79
|
+
if @attachment_need_save
|
80
|
+
@attachment_need_save = nil
|
81
|
+
attachment.save
|
82
|
+
else
|
83
|
+
true
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def attachment_before_update
|
88
|
+
if @attachment_to_unlink
|
89
|
+
@attachment_to_unlink.unlink(self)
|
90
|
+
@attachment_to_unlink = nil
|
91
|
+
end
|
92
|
+
save_attachment
|
93
|
+
end
|
94
|
+
|
95
|
+
def attachment_before_destroy
|
96
|
+
if attachment = self.attachment
|
97
|
+
attachment.unlink(self)
|
98
|
+
else
|
99
|
+
true
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end # Owner
|
103
|
+
end # Attachment
|
104
|
+
end # Versions
|
data/lib/versions/auto.rb
CHANGED
@@ -12,7 +12,8 @@ module Versions
|
|
12
12
|
end
|
13
13
|
|
14
14
|
def should_clone?
|
15
|
-
|
15
|
+
# Always clone on update
|
16
|
+
true
|
16
17
|
end
|
17
18
|
|
18
19
|
# This method provides a hook to alter values after a clone operation (just before save: no validation).
|
@@ -27,7 +28,7 @@ module Versions
|
|
27
28
|
def prepare_save_or_clone
|
28
29
|
if new_record?
|
29
30
|
self[:number] = 1
|
30
|
-
elsif should_clone?
|
31
|
+
elsif changed? && should_clone?
|
31
32
|
@previous_id = self[:id]
|
32
33
|
@previous_number ||= self[:number]
|
33
34
|
self[:number] = @previous_number + 1
|
data/lib/versions/multi.rb
CHANGED
@@ -8,32 +8,57 @@ module Versions
|
|
8
8
|
|
9
9
|
module ClassMethods
|
10
10
|
|
11
|
+
# Hide many instances behind a single current one.
|
12
|
+
# === Example
|
13
|
+
# A page with many versions and a current one representing the latest content:
|
14
|
+
# <tt>has_multiple :versions</tt>
|
15
|
+
#
|
16
|
+
# === Supported options
|
17
|
+
# [:class_name]
|
18
|
+
# Specify the class name of the association. Use it only if that name can't be inferred
|
19
|
+
# from the association name. So <tt>has_multiple :versions</tt> will by default be linked to the Version class
|
20
|
+
# [:inverse]
|
21
|
+
# Specify the name of the relation from the associated record back to this record. By default the name
|
22
|
+
# will be infered from the name of the current class. Note that this setting also defines the default
|
23
|
+
# the foreign key name.
|
24
|
+
# [:foreign_key]
|
25
|
+
# Specify the foreign key used for the association. By default this is guessed to be the name of this class
|
26
|
+
# (or the inverse) in lower-case and "_id" suffixed. So a Person class that makes a +has_multiple+ association will
|
27
|
+
# use "person_id" as the default <tt>:foreign_key</tt>.
|
28
|
+
# [:local_key]
|
29
|
+
# Specify the local key to retrieve the current associated record. By default this is guessed from the name of the
|
30
|
+
# association in lower-case and "_id" suffixed. So a model that <tt>has_multiple :pages</tt> would use "page_id" as
|
31
|
+
# local key to get the current page. Note that the local key does not need to live in the database if the model
|
32
|
+
# defines <tt>set_current_[assoc]_before_update</tt> and <tt>set_current_[assoc]_after_create</tt> where '[assoc]'
|
33
|
+
# represents the association name.
|
11
34
|
def has_multiple(versions, options = {})
|
12
|
-
name
|
13
|
-
klass
|
14
|
-
owner_name
|
35
|
+
name = versions.to_s.singularize
|
36
|
+
klass = (options[:class_name] || name.capitalize).constantize
|
37
|
+
owner_name = options[:inverse] || self.to_s.split('::').last.underscore
|
38
|
+
foreign_key = (options[:foreign_key] || "#{owner_name}_id").to_s
|
39
|
+
local_key = (options[:local_key] || "#{name}_id").to_s
|
15
40
|
|
16
41
|
raise TypeError.new("Missing 'number' field in table #{klass.table_name}.") unless klass.column_names.include?('number')
|
17
|
-
raise TypeError.new("Missing '#{
|
42
|
+
raise TypeError.new("Missing '#{foreign_key}' in table #{klass.table_name}.") unless klass.column_names.include?(foreign_key)
|
18
43
|
|
19
|
-
has_many versions, :order => 'number DESC', :dependent => :destroy
|
44
|
+
has_many versions, :order => 'number DESC', :class_name => klass.to_s, :foreign_key => foreign_key, :dependent => :destroy
|
20
45
|
validate :"validate_#{name}"
|
21
46
|
after_create :"save_#{name}_after_create"
|
22
47
|
before_update :"save_#{name}_before_update"
|
23
48
|
|
24
|
-
include module_for_multiple(name, klass, owner_name)
|
49
|
+
include module_for_multiple(name, klass, owner_name, foreign_key, local_key)
|
25
50
|
klass.belongs_to owner_name, :class_name => self.to_s
|
26
51
|
end
|
27
52
|
|
28
53
|
protected
|
29
|
-
def module_for_multiple(name, klass, owner_name)
|
54
|
+
def module_for_multiple(name, klass, owner_name, foreign_key, local_key)
|
30
55
|
|
31
56
|
# Eval is ugly, but it's the fastest solution I know of
|
32
57
|
line = __LINE__
|
33
58
|
definitions = <<-EOF
|
34
59
|
def #{name} # def version
|
35
60
|
@#{name} ||= begin # @version ||= begin
|
36
|
-
if v_id = #{
|
61
|
+
if v_id = #{local_key} # if v_id = version_id
|
37
62
|
version = ::#{klass}.find(v_id) # version = ::Version.find(v_id)
|
38
63
|
else # else
|
39
64
|
version = ::#{klass}.new # version = ::Version.new
|
@@ -60,6 +85,7 @@ module Versions
|
|
60
85
|
|
61
86
|
def save_#{name}_before_update # def save_version_before_update
|
62
87
|
return true if !@#{name}.changed? # return true if !@version.changed?
|
88
|
+
@#{name}.#{foreign_key} = self[:id] # @version.owner_id = self[:id]
|
63
89
|
if !@#{name}.save(false) # if !@version.save_with_validation(false)
|
64
90
|
merge_multi_errors('#{name}', @#{name}) # merge_multi_errors('version', @version)
|
65
91
|
false # false
|
@@ -70,10 +96,12 @@ module Versions
|
|
70
96
|
end # end
|
71
97
|
#
|
72
98
|
def save_#{name}_after_create # def save_version_after_create
|
73
|
-
@#{name}.#{
|
74
|
-
if !@#{name}.save(false) # if !@version.
|
99
|
+
@#{name}.#{foreign_key} = self[:id] # version.owner_id = self[:id]
|
100
|
+
if !@#{name}.save(false) # if !@version.save(false)
|
75
101
|
merge_multi_errors('#{name}', @#{name}) # merge_multi_errors('version', @version)
|
76
|
-
|
102
|
+
self[:id] = nil
|
103
|
+
@new_record = true
|
104
|
+
raise ActiveRecord::Rollback # raise ActiveRecord::Rollback
|
77
105
|
else # else
|
78
106
|
set_current_#{name}_after_create # set_current_version_after_create
|
79
107
|
end # end
|
@@ -85,7 +113,7 @@ module Versions
|
|
85
113
|
# master record is updated. This method is usually overwritten
|
86
114
|
# in the class.
|
87
115
|
def set_current_#{name}_before_update # def set_current_version_before_update
|
88
|
-
self[:#{
|
116
|
+
self[:#{local_key}] = @#{name}.id # self[:version_id] = @version.id
|
89
117
|
end # end
|
90
118
|
|
91
119
|
# This method is triggered when the version is saved, after the
|
@@ -97,17 +125,17 @@ module Versions
|
|
97
125
|
|
98
126
|
# conn.execute("UPDATE pages SET \#{conn.quote_column_name("version_id")} = \#{conn.quote(@version.id)} WHERE id = \#{conn.quote(self.id)}")
|
99
127
|
conn.execute(
|
100
|
-
"UPDATE
|
101
|
-
"SET \#{conn.quote_column_name("#{
|
128
|
+
"UPDATE \#{self.class.table_name} " +
|
129
|
+
"SET \#{conn.quote_column_name("#{local_key}")} = \#{conn.quote(@#{name}.id)} " +
|
102
130
|
"WHERE id = \#{conn.quote(self.id)}"
|
103
131
|
)
|
104
|
-
self[:#{
|
132
|
+
self[:#{local_key}] = @#{name}.id # self[:version_id] = @version.id
|
105
133
|
changed_attributes.clear # changed_attributes.clear
|
106
134
|
end # end
|
107
135
|
EOF
|
108
136
|
|
109
137
|
methods_module = Module.new
|
110
|
-
methods_module.class_eval(definitions, __FILE__, line +
|
138
|
+
methods_module.class_eval(definitions, __FILE__, line + 2)
|
111
139
|
methods_module
|
112
140
|
end # module_for_multiple
|
113
141
|
end # ClassMethods
|
@@ -1,2 +1,62 @@
|
|
1
|
-
require '
|
2
|
-
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
module Versions
|
4
|
+
class SharedAttachment < ::ActiveRecord::Base
|
5
|
+
set_table_name 'attachments'
|
6
|
+
after_destroy :destroy_file
|
7
|
+
after_save :write_file
|
8
|
+
|
9
|
+
def unlink(model)
|
10
|
+
link_count = model.class.count(:conditions => ["attachment_id = ? AND id != ?", self.id, model.id])
|
11
|
+
if link_count == 0
|
12
|
+
destroy
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def file=(file)
|
17
|
+
@file = file
|
18
|
+
self[:filename] = get_filename(file)
|
19
|
+
end
|
20
|
+
|
21
|
+
def filepath
|
22
|
+
@filepath ||= begin digest = ::Digest::SHA1.hexdigest(self[:id].to_s)
|
23
|
+
"#{digest[0..0]}/#{digest[1..1]}/#{filename}"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
def write_file
|
29
|
+
after_commit do
|
30
|
+
make_file(filepath, @file)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def destroy_file
|
35
|
+
after_commit do
|
36
|
+
remove_file
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def make_file(path, data)
|
41
|
+
FileUtils::mkpath(File.dirname(path)) unless File.exist?(File.dirname(path))
|
42
|
+
if data.respond_to?(:rewind)
|
43
|
+
data.rewind
|
44
|
+
end
|
45
|
+
File.open(path, "wb") { |f| f.syswrite(data.read) }
|
46
|
+
end
|
47
|
+
|
48
|
+
def remove_file
|
49
|
+
FileUtils.rm(filepath)
|
50
|
+
end
|
51
|
+
|
52
|
+
def get_filename(file)
|
53
|
+
# make sure name is not corrupted
|
54
|
+
fname = file.original_filename.gsub(/[^a-zA-Z\-_0-9\.]/,'')
|
55
|
+
if fname[0..0] == '.'
|
56
|
+
# Forbid names starting with a dot
|
57
|
+
fname = Digest::SHA1.hexdigest(Time.now.to_i.to_s)[0..6]
|
58
|
+
end
|
59
|
+
fname
|
60
|
+
end
|
61
|
+
end # SharedAttachment
|
62
|
+
end # Versions
|