versions 0.0.1
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/.document +5 -0
- data/.gitignore +21 -0
- data/LICENSE +20 -0
- data/README.rdoc +24 -0
- data/Rakefile +50 -0
- data/lib/versions/auto.rb +38 -0
- data/lib/versions/multi.rb +142 -0
- data/lib/versions/property.rb +98 -0
- data/lib/versions/shared_attachment/attachment.rb +66 -0
- data/lib/versions/shared_attachment/owner.rb +77 -0
- data/lib/versions/shared_attachment.rb +2 -0
- data/lib/versions/transparent.rb +98 -0
- data/lib/versions.rb +5 -0
- data/test/fixtures.rb +34 -0
- data/test/helper.rb +16 -0
- data/test/unit/attachment_test.rb +222 -0
- data/test/unit/auto_test.rb +85 -0
- data/test/unit/multi_test.rb +7 -0
- data/test/unit/transparent_test.rb +7 -0
- metadata +118 -0
data/.document
ADDED
data/.gitignore
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2010 Gaspard Bucher (http://teti.ch)
|
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.
|
data/README.rdoc
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
== Versions
|
2
|
+
|
3
|
+
A list of libraries to work with ActiveRecord model versioning.
|
4
|
+
|
5
|
+
website: http://zenadmin.org/650
|
6
|
+
|
7
|
+
license: MIT
|
8
|
+
|
9
|
+
== Auto
|
10
|
+
|
11
|
+
Duplicate on save if should_clone? returns true.
|
12
|
+
|
13
|
+
== Multi
|
14
|
+
|
15
|
+
Hide many versions behind a single current one.
|
16
|
+
|
17
|
+
== Transparent
|
18
|
+
|
19
|
+
Hide versions from outside world.
|
20
|
+
|
21
|
+
== Property
|
22
|
+
|
23
|
+
Define properties on model, store them in versions.
|
24
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
$LOAD_PATH.unshift((Pathname(__FILE__).dirname + 'lib').expand_path)
|
3
|
+
|
4
|
+
require 'versions/version'
|
5
|
+
require 'rake'
|
6
|
+
require 'rake/testtask'
|
7
|
+
|
8
|
+
Rake::TestTask.new(:test) do |test|
|
9
|
+
test.libs << 'lib' << 'test'
|
10
|
+
test.pattern = 'test/**/**_test.rb'
|
11
|
+
test.verbose = true
|
12
|
+
end
|
13
|
+
|
14
|
+
begin
|
15
|
+
require 'rcov/rcovtask'
|
16
|
+
Rcov::RcovTask.new do |test|
|
17
|
+
test.libs << 'test' << 'lib'
|
18
|
+
test.pattern = 'test/**/auto_test.rb'
|
19
|
+
test.verbose = true
|
20
|
+
test.rcov_opts = ['-T', '--exclude-only', '"test\/,^\/"']
|
21
|
+
end
|
22
|
+
rescue LoadError
|
23
|
+
task :rcov do
|
24
|
+
abort "RCov is not available. In order to run rcov, you must: sudo gem install rcov"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
task :default => :test
|
29
|
+
|
30
|
+
# GEM management
|
31
|
+
begin
|
32
|
+
require 'jeweler'
|
33
|
+
Jeweler::Tasks.new do |gemspec|
|
34
|
+
gemspec.version = Versions::VERSION
|
35
|
+
gemspec.name = "versions"
|
36
|
+
gemspec.summary = %Q{A list of libraries to work with ActiveRecord model versioning}
|
37
|
+
gemspec.description = %Q{A list of libraries to work with ActiveRecord model versioning: Auto (duplicate on save), Multi (hide many versions behind a single one), Transparent (hide versions from outside world), Property (define properties on model, store them in versions)}
|
38
|
+
gemspec.email = "gaspard@teti.ch"
|
39
|
+
gemspec.homepage = "http://zenadmin.org/650"
|
40
|
+
gemspec.authors = ["Gaspard Bucher"]
|
41
|
+
gemspec.add_development_dependency "shoulda", ">= 0"
|
42
|
+
|
43
|
+
gemspec.add_development_dependency('shoulda')
|
44
|
+
gemspec.add_dependency('activerecord')
|
45
|
+
gemspec.add_dependency('property', '>= 0.8.0')
|
46
|
+
end
|
47
|
+
Jeweler::GemcutterTasks.new
|
48
|
+
rescue LoadError
|
49
|
+
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
|
50
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Versions
|
2
|
+
# When you include this module into a class, it will automatically clone itself
|
3
|
+
# depending on the call to should_clone?
|
4
|
+
module Auto
|
5
|
+
attr_reader :previous_id
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.before_save :prepare_save_or_clone
|
9
|
+
end
|
10
|
+
|
11
|
+
def should_clone?
|
12
|
+
raise NoMethodError.new("You should implement 'should_clone?' in your model (return true for a new version, false to update).")
|
13
|
+
end
|
14
|
+
|
15
|
+
# This method provides a hook to alter values after a clone operation (just before save: no validation).
|
16
|
+
def cloned
|
17
|
+
end
|
18
|
+
|
19
|
+
# Return true if the record was cloned just before the last save
|
20
|
+
def cloned?
|
21
|
+
!@previous_id.nil?
|
22
|
+
end
|
23
|
+
|
24
|
+
def prepare_save_or_clone
|
25
|
+
if !new_record? && should_clone?
|
26
|
+
@previous_id = self[:id]
|
27
|
+
self[:id] = nil
|
28
|
+
self[:created_at] = nil
|
29
|
+
self[:updated_at] = nil
|
30
|
+
@new_record = true
|
31
|
+
cloned
|
32
|
+
else
|
33
|
+
@previous_id = nil
|
34
|
+
end
|
35
|
+
true
|
36
|
+
end
|
37
|
+
end # Auto
|
38
|
+
end # Versions
|
@@ -0,0 +1,142 @@
|
|
1
|
+
module Zena
|
2
|
+
module Use
|
3
|
+
# This module hides 'has_many' versions as if there was only a 'belongs_to' version,
|
4
|
+
# automatically registering the latest version's id.
|
5
|
+
module MultiVersion
|
6
|
+
# This module should be included in the model that serves as version.
|
7
|
+
module Version
|
8
|
+
def self.included(base)
|
9
|
+
|
10
|
+
base.class_eval do
|
11
|
+
attr_accessor :__destroy
|
12
|
+
belongs_to :node
|
13
|
+
before_create :setup_version_on_create
|
14
|
+
attr_protected :number, :user_id
|
15
|
+
|
16
|
+
alias_method_chain :node, :secure
|
17
|
+
alias_method_chain :save, :destroy
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def node_with_secure
|
22
|
+
@node ||= begin
|
23
|
+
if n = node_without_secure
|
24
|
+
visitor.visit(n)
|
25
|
+
n.version = self
|
26
|
+
end
|
27
|
+
n
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def save_with_destroy(*args)
|
32
|
+
if @__destroy
|
33
|
+
node = self.node
|
34
|
+
if destroy
|
35
|
+
# reset @version
|
36
|
+
node.send(:version_destroyed)
|
37
|
+
true
|
38
|
+
end
|
39
|
+
else
|
40
|
+
save_without_destroy(*args)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
def setup_version_on_create
|
46
|
+
raise "You should define 'setup_version_on_create' method in '#{self.class}' class."
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.included(base)
|
51
|
+
base.has_many :versions, :order=>"number DESC", :dependent => :destroy #, :inverse_of => :node
|
52
|
+
base.validate :validate_version
|
53
|
+
base.before_update :save_version_before_update
|
54
|
+
base.after_create :save_version_after_create
|
55
|
+
#base.accepts_nested_attributes_for :version
|
56
|
+
|
57
|
+
base.alias_method_chain :save, :destroy
|
58
|
+
end
|
59
|
+
|
60
|
+
def save_with_destroy(*args)
|
61
|
+
version = self.version
|
62
|
+
# TODO: we could use 'version.mark_for_destruction' instead of __destroy...
|
63
|
+
if version.__destroy && versions.count == 1
|
64
|
+
self.destroy # will destroy last version
|
65
|
+
else
|
66
|
+
self.save_without_destroy(*args)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def version_attributes=(attributes)
|
71
|
+
version.attributes = attributes
|
72
|
+
end
|
73
|
+
|
74
|
+
# The logic to get the 'current' version should be
|
75
|
+
# rewritten in class including MultiVersion.
|
76
|
+
def version
|
77
|
+
raise "You should define 'version' method in '#{self.class}' class."
|
78
|
+
end
|
79
|
+
|
80
|
+
def version=(v)
|
81
|
+
@version = v
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def validate_version
|
87
|
+
# We force the existence of at least one version with this code
|
88
|
+
unless version.valid?
|
89
|
+
merge_version_errors
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def save_version_before_update
|
94
|
+
if !@version.save #_with_validation(false)
|
95
|
+
merge_version_errors
|
96
|
+
else
|
97
|
+
current_version_before_update
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def save_version_after_create
|
102
|
+
version.node_id = self[:id]
|
103
|
+
if !@version.save #_with_validation(false)
|
104
|
+
merge_version_errors
|
105
|
+
rollback!
|
106
|
+
else
|
107
|
+
current_version_after_create
|
108
|
+
end
|
109
|
+
true
|
110
|
+
end
|
111
|
+
|
112
|
+
# This method is triggered when the version is saved, but before the
|
113
|
+
# master record is updated.
|
114
|
+
# The role of this method is typically to do things like:
|
115
|
+
# self[:version_id] = version.id
|
116
|
+
def current_version_before_update
|
117
|
+
end
|
118
|
+
|
119
|
+
# This method is triggered when the version is saved, after the
|
120
|
+
# master record has been created.
|
121
|
+
# The role of this method is typically to do things like:
|
122
|
+
# update_attribute(:version_id, version.id)
|
123
|
+
def current_version_after_create
|
124
|
+
end
|
125
|
+
|
126
|
+
# This is called after a version is destroyed
|
127
|
+
def version_destroyed
|
128
|
+
# remove from versions list
|
129
|
+
if versions.loaded?
|
130
|
+
node.versions -= [@version]
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def merge_version_errors
|
135
|
+
@version.errors.each_error do |attribute, message|
|
136
|
+
attribute = "version_#{attribute}"
|
137
|
+
errors.add(attribute, message) unless errors[attribute] # FIXME: rails 3: if errors[attribute].empty?
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Zena
|
2
|
+
module Use
|
3
|
+
# This module lets the user use a node as if it was not versioned and will
|
4
|
+
# take care of routing the attributes between the node and the version.
|
5
|
+
module TransparentVersion
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.class_eval do
|
9
|
+
# When writing attributes, we send everything that we do not know of
|
10
|
+
# to the version.
|
11
|
+
def attributes_with_multi_version=(attributes)
|
12
|
+
columns = self.class.column_names
|
13
|
+
version_attributes = {}
|
14
|
+
attributes.keys.each do |k|
|
15
|
+
if !respond_to?("#{k}=") && !columns.include?(k)
|
16
|
+
version_attributes[k] = attributes.delete(k)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
version.attributes = version_attributes
|
20
|
+
self.attributes_without_multi_version = attributes
|
21
|
+
end
|
22
|
+
|
23
|
+
alias_method_chain :attributes=, :multi_version
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
# We need method_missing in forms, normal access in templates should be made
|
29
|
+
# through 'node.version.xxxx', not 'node.xxxx'.
|
30
|
+
def method_missing(meth, *args)
|
31
|
+
method = meth.to_s
|
32
|
+
if !args.empty? || method[-1..-1] == '?' || self.class.column_names.include?(method)
|
33
|
+
super
|
34
|
+
elsif version.respond_to?(meth)
|
35
|
+
version.send(meth)
|
36
|
+
else
|
37
|
+
#version.prop[meth.to_s]
|
38
|
+
super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Any attribute starting with 'v_' belongs to the 'version' or 'redaction'
|
43
|
+
# Any attribute starting with 'c_' belongs to the 'version' or 'redaction' content
|
44
|
+
# FIXME: performance: create methods on the fly so that next calls will not pass through 'method_missing'. #189.
|
45
|
+
# FIXME: this should not be used anymore. Remove.
|
46
|
+
# def method_missing(meth, *args)
|
47
|
+
# if meth.to_s =~ /^(v_|c_|d_)(([\w_\?]+)(=?))$/
|
48
|
+
# target = $1
|
49
|
+
# method = $2
|
50
|
+
# value = $3
|
51
|
+
# mode = $4
|
52
|
+
# if mode == '='
|
53
|
+
# begin
|
54
|
+
# # set
|
55
|
+
# unless recipient = redaction
|
56
|
+
# # remove trailing '='
|
57
|
+
# redaction_error(meth.to_s[0..-2], "could not be set (no redaction)")
|
58
|
+
# return
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# case target
|
62
|
+
# when 'c_'
|
63
|
+
# if recipient.content_class && recipient = recipient.redaction_content
|
64
|
+
# recipient.send(method,*args)
|
65
|
+
# else
|
66
|
+
# redaction_error(meth.to_s[0..-2], "cannot be set") # remove trailing '='
|
67
|
+
# end
|
68
|
+
# when 'd_'
|
69
|
+
# recipient.prop[method[0..-2]] = args[0]
|
70
|
+
# else
|
71
|
+
# recipient.send(method,*args)
|
72
|
+
# end
|
73
|
+
# rescue NoMethodError
|
74
|
+
# # bad attribute, just ignore
|
75
|
+
# end
|
76
|
+
# else
|
77
|
+
# # read
|
78
|
+
# recipient = version
|
79
|
+
# if target == 'd_'
|
80
|
+
# version.prop[method]
|
81
|
+
# else
|
82
|
+
# recipient = recipient.content if target == 'c_'
|
83
|
+
# return nil unless recipient
|
84
|
+
# begin
|
85
|
+
# recipient.send(method,*args)
|
86
|
+
# rescue NoMethodError
|
87
|
+
# # bad attribute
|
88
|
+
# return nil
|
89
|
+
# end
|
90
|
+
# end
|
91
|
+
# end
|
92
|
+
# else
|
93
|
+
# super
|
94
|
+
# end
|
95
|
+
# end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Versions
|
2
|
+
# This module enables file attachments to versions. The file is shared between versions if
|
3
|
+
# it is not changed. The Attachment only stores a reference to the file which is saved in the
|
4
|
+
# filesystem.
|
5
|
+
module SharedAttachment
|
6
|
+
class Attachment < ::ActiveRecord::Base
|
7
|
+
set_table_name 'attachments'
|
8
|
+
after_destroy :destroy_file
|
9
|
+
after_save :write_file
|
10
|
+
|
11
|
+
def unlink(model)
|
12
|
+
link_count = model.class.count(:conditions => ["attachment_id = ? AND id != ?", self.id, model.id])
|
13
|
+
if link_count == 0
|
14
|
+
destroy
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def file=(file)
|
19
|
+
@file = file
|
20
|
+
self[:filename] = get_filename(file)
|
21
|
+
end
|
22
|
+
|
23
|
+
def filepath
|
24
|
+
@filepath ||= begin
|
25
|
+
digest = Digest::SHA1.hexdigest(self[:id].to_s)
|
26
|
+
"#{digest[0..0]}/#{digest[1..1]}/#{filename}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
def write_file
|
32
|
+
after_commit do
|
33
|
+
make_file(filepath, @file)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def destroy_file
|
38
|
+
after_commit do
|
39
|
+
remove_file
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def make_file(path, data)
|
44
|
+
FileUtils::mkpath(File.dirname(path)) unless File.exist?(File.dirname(path))
|
45
|
+
if data.respond_to?(:rewind)
|
46
|
+
data.rewind
|
47
|
+
end
|
48
|
+
File.open(path, "wb") { |f| f.syswrite(data.read) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def remove_file
|
52
|
+
FileUtils.rm(filepath)
|
53
|
+
end
|
54
|
+
|
55
|
+
def get_filename(file)
|
56
|
+
# make sure name is not corrupted
|
57
|
+
fname = file.original_filename.gsub(/[^a-zA-Z\-_0-9\.]/,'')
|
58
|
+
if fname[0..0] == '.'
|
59
|
+
# Forbid names starting with a dot
|
60
|
+
fname = Digest::SHA1.hexdigest(Time.now.to_i.to_s)[0..6]
|
61
|
+
end
|
62
|
+
fname
|
63
|
+
end
|
64
|
+
end # Attachment
|
65
|
+
end # SharedAttachment
|
66
|
+
end # Versions
|
@@ -0,0 +1,77 @@
|
|
1
|
+
|
2
|
+
module Zena
|
3
|
+
module Use
|
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
|
+
module SharedAttachment
|
9
|
+
module ClassMethods
|
10
|
+
def set_attachment_class(class_name)
|
11
|
+
belongs_to :attachment,
|
12
|
+
:class_name => class_name,
|
13
|
+
:foreign_key => 'attachment_id'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.included(base)
|
18
|
+
base.class_eval do
|
19
|
+
before_create :save_attachment
|
20
|
+
before_update :attachment_before_update
|
21
|
+
before_destroy :attachment_before_destroy
|
22
|
+
|
23
|
+
extend Zena::Use::SharedAttachment::ClassMethods
|
24
|
+
set_attachment_class 'Zena::Use::SharedAttachment::Attachment'
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def file=(file)
|
29
|
+
if attachment
|
30
|
+
@attachment_to_unlink = self.attachment
|
31
|
+
self.attachment = nil
|
32
|
+
end
|
33
|
+
@attachment_need_save = true
|
34
|
+
self.build_attachment(:file => file)
|
35
|
+
end
|
36
|
+
|
37
|
+
def filepath
|
38
|
+
attachment ? attachment.filepath : nil
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def save_attachment
|
43
|
+
if @attachment_need_save
|
44
|
+
@attachment_need_save = nil
|
45
|
+
attachment.save
|
46
|
+
else
|
47
|
+
true
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def attachment_before_update
|
52
|
+
if @attachment_to_unlink
|
53
|
+
@attachment_to_unlink.unlink(self)
|
54
|
+
@attachment_to_unlink = nil
|
55
|
+
end
|
56
|
+
save_attachment
|
57
|
+
end
|
58
|
+
|
59
|
+
def attachment_before_destroy
|
60
|
+
if attachment = self.attachment
|
61
|
+
attachment.unlink(self)
|
62
|
+
else
|
63
|
+
true
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def unlink_attachment_mark
|
68
|
+
@attachment_to_unlink = self.attachment
|
69
|
+
end
|
70
|
+
|
71
|
+
def unlink_attachment
|
72
|
+
end
|
73
|
+
|
74
|
+
|
75
|
+
end # SharedAttachment
|
76
|
+
end # Use
|
77
|
+
end # Zena
|
@@ -0,0 +1,98 @@
|
|
1
|
+
module Zena
|
2
|
+
module Use
|
3
|
+
# This module lets the user use a node as if it was not versioned and will
|
4
|
+
# take care of routing the attributes between the node and the version.
|
5
|
+
module TransparentVersion
|
6
|
+
|
7
|
+
def self.included(base)
|
8
|
+
base.class_eval do
|
9
|
+
# When writing attributes, we send everything that we do not know of
|
10
|
+
# to the version.
|
11
|
+
def attributes_with_multi_version=(attributes)
|
12
|
+
columns = self.class.column_names
|
13
|
+
version_attributes = {}
|
14
|
+
attributes.keys.each do |k|
|
15
|
+
if !respond_to?("#{k}=") && !columns.include?(k)
|
16
|
+
version_attributes[k] = attributes.delete(k)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
version.attributes = version_attributes
|
20
|
+
self.attributes_without_multi_version = attributes
|
21
|
+
end
|
22
|
+
|
23
|
+
alias_method_chain :attributes=, :multi_version
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
# We need method_missing in forms, normal access in templates should be made
|
29
|
+
# through 'node.version.xxxx', not 'node.xxxx'.
|
30
|
+
def method_missing(meth, *args)
|
31
|
+
method = meth.to_s
|
32
|
+
if !args.empty? || method[-1..-1] == '?' || self.class.column_names.include?(method)
|
33
|
+
super
|
34
|
+
elsif version.respond_to?(meth)
|
35
|
+
version.send(meth)
|
36
|
+
else
|
37
|
+
#version.prop[meth.to_s]
|
38
|
+
super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Any attribute starting with 'v_' belongs to the 'version' or 'redaction'
|
43
|
+
# Any attribute starting with 'c_' belongs to the 'version' or 'redaction' content
|
44
|
+
# FIXME: performance: create methods on the fly so that next calls will not pass through 'method_missing'. #189.
|
45
|
+
# FIXME: this should not be used anymore. Remove.
|
46
|
+
# def method_missing(meth, *args)
|
47
|
+
# if meth.to_s =~ /^(v_|c_|d_)(([\w_\?]+)(=?))$/
|
48
|
+
# target = $1
|
49
|
+
# method = $2
|
50
|
+
# value = $3
|
51
|
+
# mode = $4
|
52
|
+
# if mode == '='
|
53
|
+
# begin
|
54
|
+
# # set
|
55
|
+
# unless recipient = redaction
|
56
|
+
# # remove trailing '='
|
57
|
+
# redaction_error(meth.to_s[0..-2], "could not be set (no redaction)")
|
58
|
+
# return
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# case target
|
62
|
+
# when 'c_'
|
63
|
+
# if recipient.content_class && recipient = recipient.redaction_content
|
64
|
+
# recipient.send(method,*args)
|
65
|
+
# else
|
66
|
+
# redaction_error(meth.to_s[0..-2], "cannot be set") # remove trailing '='
|
67
|
+
# end
|
68
|
+
# when 'd_'
|
69
|
+
# recipient.prop[method[0..-2]] = args[0]
|
70
|
+
# else
|
71
|
+
# recipient.send(method,*args)
|
72
|
+
# end
|
73
|
+
# rescue NoMethodError
|
74
|
+
# # bad attribute, just ignore
|
75
|
+
# end
|
76
|
+
# else
|
77
|
+
# # read
|
78
|
+
# recipient = version
|
79
|
+
# if target == 'd_'
|
80
|
+
# version.prop[method]
|
81
|
+
# else
|
82
|
+
# recipient = recipient.content if target == 'c_'
|
83
|
+
# return nil unless recipient
|
84
|
+
# begin
|
85
|
+
# recipient.send(method,*args)
|
86
|
+
# rescue NoMethodError
|
87
|
+
# # bad attribute
|
88
|
+
# return nil
|
89
|
+
# end
|
90
|
+
# end
|
91
|
+
# end
|
92
|
+
# else
|
93
|
+
# super
|
94
|
+
# end
|
95
|
+
# end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
data/lib/versions.rb
ADDED
data/test/fixtures.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
begin
|
2
|
+
class VersionsMigration < ActiveRecord::Migration
|
3
|
+
def self.down
|
4
|
+
drop_table 'pages'
|
5
|
+
drop_table 'versions'
|
6
|
+
end
|
7
|
+
def self.up
|
8
|
+
create_table 'pages' do |t|
|
9
|
+
t.integer 'version_id'
|
10
|
+
t.string 'name'
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table 'versions' do |t|
|
15
|
+
t.string 'title'
|
16
|
+
t.text 'text'
|
17
|
+
t.string 'properties'
|
18
|
+
t.integer 'attachment_id'
|
19
|
+
t.timestamps
|
20
|
+
end
|
21
|
+
|
22
|
+
create_table 'attachments' do |t|
|
23
|
+
t.string 'owner_table'
|
24
|
+
t.string 'filename'
|
25
|
+
t.timestamps
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
ActiveRecord::Base.establish_connection(:adapter=>'sqlite3', :database=>':memory:')
|
31
|
+
ActiveRecord::Migration.verbose = false
|
32
|
+
VersionsMigration.migrate(:up)
|
33
|
+
ActiveRecord::Migration.verbose = true
|
34
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'shoulda'
|
4
|
+
|
5
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
6
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
7
|
+
|
8
|
+
require 'active_record'
|
9
|
+
require 'active_support'
|
10
|
+
require 'active_support/testing/assertions'
|
11
|
+
require 'versions'
|
12
|
+
require 'fixtures'
|
13
|
+
|
14
|
+
class Test::Unit::TestCase
|
15
|
+
include ActiveSupport::Testing::Assertions
|
16
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
#class SharedAttachmentTest < Zena::Unit::TestCase
|
4
|
+
# self.use_transactional_fixtures = false
|
5
|
+
#
|
6
|
+
# Attachment = Class.new(Zena::Use::SharedAttachment::Attachment) do
|
7
|
+
# def filepath
|
8
|
+
# File.join(RAILS_ROOT, 'tmp', 'attachments', super)
|
9
|
+
# end
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# # Mock a document class with many versions
|
13
|
+
# class Document < ActiveRecord::Base
|
14
|
+
# include Zena::Use::MultiVersion
|
15
|
+
#
|
16
|
+
# set_table_name :nodes
|
17
|
+
# before_save :set_dummy_defaults
|
18
|
+
#
|
19
|
+
# def title=(title)
|
20
|
+
# version.title = title
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# def file=(file)
|
24
|
+
# version.file = file
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# def version
|
28
|
+
# @version ||= new_record? ? versions.build : versions.first(:order => 'id DESC')
|
29
|
+
# end
|
30
|
+
# private
|
31
|
+
# def set_dummy_defaults
|
32
|
+
# self[:user_id] ||= 0
|
33
|
+
# end
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# # Mock a version class with shared attachments (between versions of the same document)
|
37
|
+
# class Version < ActiveRecord::Base
|
38
|
+
# include Zena::Use::MultiVersion::Version
|
39
|
+
# include Zena::Use::AutoVersion
|
40
|
+
# include Zena::Use::SharedAttachment
|
41
|
+
# set_attachment_class 'SharedAttachmentTest::Attachment'
|
42
|
+
# set_table_name :versions
|
43
|
+
#
|
44
|
+
# def should_clone?
|
45
|
+
# true
|
46
|
+
# end
|
47
|
+
#
|
48
|
+
# private
|
49
|
+
# def setup_version_on_create
|
50
|
+
# # Dummy values when testing Version without a Document
|
51
|
+
# self[:node_id] ||= 0
|
52
|
+
# self[:user_id] ||= 0
|
53
|
+
# self[:status] ||= 0
|
54
|
+
# end
|
55
|
+
# end
|
56
|
+
#
|
57
|
+
# def setup
|
58
|
+
# FileUtils.rmtree(File.join(RAILS_ROOT, 'tmp', 'attachments'))
|
59
|
+
# end
|
60
|
+
#
|
61
|
+
# context 'When creating a new owner' do
|
62
|
+
# setup do
|
63
|
+
# @owner = Version.create(:file => uploaded_jpg('bird.jpg'))
|
64
|
+
# end
|
65
|
+
#
|
66
|
+
# should 'store file in the filesystem' do
|
67
|
+
# assert File.exist?(@owner.filepath)
|
68
|
+
# assert_equal uploaded_jpg('bird.jpg').read, File.read(@owner.filepath)
|
69
|
+
# end
|
70
|
+
#
|
71
|
+
# should 'restore the filepath from the database' do
|
72
|
+
# attachment = Attachment.find(@owner.attachment_id)
|
73
|
+
# assert_equal @owner.filepath, attachment.filepath
|
74
|
+
# end
|
75
|
+
# end
|
76
|
+
#
|
77
|
+
# context 'When the transaction fails' do
|
78
|
+
# should 'not write file on create' do
|
79
|
+
# owner = nil
|
80
|
+
# filepath = nil
|
81
|
+
# assert_difference('Attachment.count', 0) do
|
82
|
+
# Version.transaction do
|
83
|
+
# owner = Version.create(:file => uploaded_jpg('bird.jpg'))
|
84
|
+
# filepath = owner.filepath
|
85
|
+
# assert !File.exist?(filepath)
|
86
|
+
# raise ActiveRecord::Rollback
|
87
|
+
# end
|
88
|
+
# end
|
89
|
+
# assert !File.exist?(filepath)
|
90
|
+
# end
|
91
|
+
#
|
92
|
+
# should 'not remove file on destroy' do
|
93
|
+
# @owner = Version.create(:file => uploaded_jpg('bird.jpg'))
|
94
|
+
# filepath = @owner.filepath
|
95
|
+
# assert File.exist?(filepath)
|
96
|
+
# assert_difference('Attachment.count', 0) do
|
97
|
+
# Version.transaction do
|
98
|
+
# @owner.destroy
|
99
|
+
# assert File.exist?(filepath)
|
100
|
+
# raise ActiveRecord::Rollback
|
101
|
+
# end
|
102
|
+
# end
|
103
|
+
# assert File.exist?(filepath)
|
104
|
+
# end
|
105
|
+
# end
|
106
|
+
#
|
107
|
+
# context 'On an owner with a file' do
|
108
|
+
# setup do
|
109
|
+
# @owner = Version.create(:file => uploaded_jpg('bird.jpg'))
|
110
|
+
# @owner = Version.find(@owner.id)
|
111
|
+
# end
|
112
|
+
#
|
113
|
+
# should 'remove file in the filesystem when updating file' do
|
114
|
+
# old_filepath = @owner.filepath
|
115
|
+
# puts "Start"
|
116
|
+
# assert_difference('Attachment.count', 0) do # destroy + create
|
117
|
+
# assert @owner.update_attributes(:file => uploaded_jpg('lake.jpg'))
|
118
|
+
# end
|
119
|
+
# assert_not_equal old_filepath, @owner.filepath
|
120
|
+
# assert File.exist?(@owner.filepath)
|
121
|
+
# assert_equal uploaded_jpg('lake.jpg').read, File.read(@owner.filepath)
|
122
|
+
# assert !File.exist?(old_filepath)
|
123
|
+
# end
|
124
|
+
# end
|
125
|
+
#
|
126
|
+
# context 'Updating document' do
|
127
|
+
# setup do
|
128
|
+
# begin
|
129
|
+
# @doc = Document.create(:title => 'birdy', :file => uploaded_jpg('bird.jpg'))
|
130
|
+
# rescue => err
|
131
|
+
# puts err.message
|
132
|
+
# puts err.backtrace.join("\n")
|
133
|
+
# end
|
134
|
+
# end
|
135
|
+
#
|
136
|
+
# # Updating document ...attributes
|
137
|
+
# context 'attributes' do
|
138
|
+
# setup do
|
139
|
+
# assert_difference('Version.count', 1) do
|
140
|
+
# @doc.update_attributes(:title => 'hopla')
|
141
|
+
# end
|
142
|
+
# end
|
143
|
+
#
|
144
|
+
# should 'reuse the same filepath in new versions' do
|
145
|
+
# filepath = nil
|
146
|
+
# @doc.versions.each do |version|
|
147
|
+
# if filepath
|
148
|
+
# assert_equal filepath, version.filepath
|
149
|
+
# else
|
150
|
+
# filepath = version.filepath
|
151
|
+
# end
|
152
|
+
# end
|
153
|
+
# end
|
154
|
+
# end
|
155
|
+
#
|
156
|
+
# # Updating document ...file
|
157
|
+
# context 'file' do
|
158
|
+
# setup do
|
159
|
+
# assert_difference('Version.count', 1) do
|
160
|
+
# @doc.update_attributes(:file => uploaded_jpg('lake.jpg'))
|
161
|
+
# end
|
162
|
+
# end
|
163
|
+
#
|
164
|
+
# should 'create new filepath' do
|
165
|
+
# filepath = nil
|
166
|
+
# @doc.versions.each do |version|
|
167
|
+
# if filepath
|
168
|
+
# assert_not_equal filepath, version.filepath
|
169
|
+
# else
|
170
|
+
# filepath = version.filepath
|
171
|
+
# end
|
172
|
+
# end
|
173
|
+
# end
|
174
|
+
# end # Updating document .. file
|
175
|
+
# end # Updating document
|
176
|
+
#
|
177
|
+
# context 'On a document with many versions' do
|
178
|
+
# setup do
|
179
|
+
# assert_difference('Version.count', 2) do
|
180
|
+
# @doc = Document.create(:title => 'birdy', :file => uploaded_jpg('bird.jpg'))
|
181
|
+
# @doc.update_attributes(:title => 'Vögel')
|
182
|
+
# @doc = Document.find(@doc.id)
|
183
|
+
# end
|
184
|
+
# end
|
185
|
+
#
|
186
|
+
# context 'removing a version' do
|
187
|
+
#
|
188
|
+
# should 'not remove shared attachment' do
|
189
|
+
# filepath = @doc.version.filepath
|
190
|
+
#
|
191
|
+
# assert_difference('Version.count', -1) do
|
192
|
+
# assert_difference('Attachment.count', 0) do
|
193
|
+
# assert @doc.version.destroy
|
194
|
+
# end
|
195
|
+
# end
|
196
|
+
# assert File.exist?(filepath)
|
197
|
+
# end
|
198
|
+
# end
|
199
|
+
#
|
200
|
+
# context 'removing the last version' do
|
201
|
+
#
|
202
|
+
# should 'remove shared attachment' do
|
203
|
+
# filepath = @doc.version.filepath
|
204
|
+
#
|
205
|
+
# assert_difference('Version.count', -2) do
|
206
|
+
# assert_difference('Attachment.count', -1) do
|
207
|
+
# @doc.versions.each do |version|
|
208
|
+
# assert version.destroy
|
209
|
+
# end
|
210
|
+
# end
|
211
|
+
# end
|
212
|
+
# assert !File.exist?(filepath)
|
213
|
+
# end
|
214
|
+
# end
|
215
|
+
# end
|
216
|
+
#
|
217
|
+
# private
|
218
|
+
# def filepath(attachment_id, filename)
|
219
|
+
# digest = Digest::SHA1.hexdigest(attachment_id.to_s)
|
220
|
+
# "#{digest[0..0]}/#{digest[1..1]}/#{filename}"
|
221
|
+
# end
|
222
|
+
#end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'helper'
|
2
|
+
|
3
|
+
class AutoTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
class BadVersion < ActiveRecord::Base
|
6
|
+
include Versions::Auto
|
7
|
+
set_table_name :versions
|
8
|
+
end
|
9
|
+
|
10
|
+
class Version < ActiveRecord::Base
|
11
|
+
attr_accessor :should_clone, :messages
|
12
|
+
include Versions::Auto
|
13
|
+
|
14
|
+
def should_clone?
|
15
|
+
@should_clone
|
16
|
+
end
|
17
|
+
|
18
|
+
def cloned
|
19
|
+
@messages ||= []
|
20
|
+
@messages << 'cloned'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context 'An instance of a class with Auto included' do
|
25
|
+
subject { @version }
|
26
|
+
|
27
|
+
context 'without should_clone' do
|
28
|
+
setup do
|
29
|
+
@version = BadVersion.create('title' => 'Socrate')
|
30
|
+
end
|
31
|
+
|
32
|
+
should 'raise an exception on update' do
|
33
|
+
assert_raise(NoMethodError) { subject.update_attributes('title' => 'Aristotle') }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'with should_clone' do
|
38
|
+
setup do
|
39
|
+
@version = Version.create('title' => 'Socrate')
|
40
|
+
end
|
41
|
+
|
42
|
+
context 'returning false' do
|
43
|
+
should 'update record if should_clone is false' do
|
44
|
+
assert_difference('Version.count', 0) do
|
45
|
+
assert subject.update_attributes('title' => 'Aristotle')
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
should 'not call cloned before saving' do
|
50
|
+
assert_nil subject.messages
|
51
|
+
subject.update_attributes('title' => 'Aristotle')
|
52
|
+
assert_nil subject.messages
|
53
|
+
end
|
54
|
+
|
55
|
+
should 'return false on cloned?' do
|
56
|
+
subject.update_attributes('title' => 'Aristotle')
|
57
|
+
assert !subject.cloned?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
context 'returning true' do
|
62
|
+
setup do
|
63
|
+
subject.should_clone = true
|
64
|
+
end
|
65
|
+
|
66
|
+
should 'duplicate record' do
|
67
|
+
assert_difference('Version.count', 1) do
|
68
|
+
assert subject.update_attributes('title' => 'Aristotle')
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
should 'call cloned before saving' do
|
73
|
+
assert_nil subject.messages
|
74
|
+
subject.update_attributes('title' => 'Aristotle')
|
75
|
+
assert_equal ['cloned'], subject.messages
|
76
|
+
end
|
77
|
+
|
78
|
+
should 'return true on cloned?' do
|
79
|
+
subject.update_attributes('title' => 'Aristotle')
|
80
|
+
assert subject.cloned?
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
metadata
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: versions
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Gaspard Bucher
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2010-02-13 00:00:00 +01:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: shoulda
|
17
|
+
type: :development
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: "0"
|
24
|
+
version:
|
25
|
+
- !ruby/object:Gem::Dependency
|
26
|
+
name: shoulda
|
27
|
+
type: :development
|
28
|
+
version_requirement:
|
29
|
+
version_requirements: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: "0"
|
34
|
+
version:
|
35
|
+
- !ruby/object:Gem::Dependency
|
36
|
+
name: activerecord
|
37
|
+
type: :runtime
|
38
|
+
version_requirement:
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: "0"
|
44
|
+
version:
|
45
|
+
- !ruby/object:Gem::Dependency
|
46
|
+
name: property
|
47
|
+
type: :runtime
|
48
|
+
version_requirement:
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 0.8.0
|
54
|
+
version:
|
55
|
+
description: "A list of libraries to work with ActiveRecord model versioning: Auto (duplicate on save), Multi (hide many versions behind a single one), Transparent (hide versions from outside world), Property (define properties on model, store them in versions)"
|
56
|
+
email: gaspard@teti.ch
|
57
|
+
executables: []
|
58
|
+
|
59
|
+
extensions: []
|
60
|
+
|
61
|
+
extra_rdoc_files:
|
62
|
+
- LICENSE
|
63
|
+
- README.rdoc
|
64
|
+
files:
|
65
|
+
- .document
|
66
|
+
- .gitignore
|
67
|
+
- LICENSE
|
68
|
+
- README.rdoc
|
69
|
+
- Rakefile
|
70
|
+
- lib/versions.rb
|
71
|
+
- lib/versions/auto.rb
|
72
|
+
- lib/versions/multi.rb
|
73
|
+
- lib/versions/property.rb
|
74
|
+
- lib/versions/shared_attachment.rb
|
75
|
+
- lib/versions/shared_attachment/attachment.rb
|
76
|
+
- lib/versions/shared_attachment/owner.rb
|
77
|
+
- lib/versions/transparent.rb
|
78
|
+
- test/fixtures.rb
|
79
|
+
- test/helper.rb
|
80
|
+
- test/unit/attachment_test.rb
|
81
|
+
- test/unit/auto_test.rb
|
82
|
+
- test/unit/multi_test.rb
|
83
|
+
- test/unit/transparent_test.rb
|
84
|
+
has_rdoc: true
|
85
|
+
homepage: http://zenadmin.org/650
|
86
|
+
licenses: []
|
87
|
+
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options:
|
90
|
+
- --charset=UTF-8
|
91
|
+
require_paths:
|
92
|
+
- lib
|
93
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: "0"
|
98
|
+
version:
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: "0"
|
104
|
+
version:
|
105
|
+
requirements: []
|
106
|
+
|
107
|
+
rubyforge_project:
|
108
|
+
rubygems_version: 1.3.5
|
109
|
+
signing_key:
|
110
|
+
specification_version: 3
|
111
|
+
summary: A list of libraries to work with ActiveRecord model versioning
|
112
|
+
test_files:
|
113
|
+
- test/fixtures.rb
|
114
|
+
- test/helper.rb
|
115
|
+
- test/unit/attachment_test.rb
|
116
|
+
- test/unit/auto_test.rb
|
117
|
+
- test/unit/multi_test.rb
|
118
|
+
- test/unit/transparent_test.rb
|