versions 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|