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 CHANGED
@@ -2,4 +2,5 @@
2
2
  coverage
3
3
  rdoc
4
4
  pkg
5
- *.gem
5
+ *.gem
6
+ test/test.log
@@ -1,4 +1,9 @@
1
- == 0.2.0
1
+ == 0.2.0 2010-02-15
2
+
3
+ * 1 major enhancement
4
+ * Changed inverse option from 'as' to 'inverse' in Multi
5
+ * Implemented the Attachment module
6
+ * Added AfterCommit module
2
7
 
3
8
  == 0.1.0 2010-02-14
4
9
 
@@ -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
- == Transparent (status: alpha)
20
+ class Version < ActiveRecord::Base
21
+ include Versions::Auto
22
+ end
18
23
 
19
- Hide versions from outside world (simulate read/writes on the node but
20
- the actions are done on the version). Uses method_missing to forward
21
- method calls.
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
- == SharedAttachment (status: alpha)
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
@@ -41,6 +41,7 @@ begin
41
41
 
42
42
  gemspec.add_development_dependency('shoulda')
43
43
  gemspec.add_development_dependency('property', '>= 0.8.1')
44
+ gemspec.add_development_dependency('activesupport')
44
45
 
45
46
  gemspec.add_dependency('activerecord')
46
47
  end
@@ -1,5 +1,6 @@
1
1
  require 'active_record'
2
- require 'versions/shared_attachment'
2
+ require 'versions/after_commit'
3
+ require 'versions/attachment'
3
4
  require 'versions/auto'
4
5
  require 'versions/multi'
5
- require 'versions/transparent'
6
+ require 'versions/version'
@@ -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
@@ -12,7 +12,8 @@ module Versions
12
12
  end
13
13
 
14
14
  def should_clone?
15
- raise NoMethodError.new("You should implement 'should_clone?' in your model (return true for a new version, false to update).")
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
@@ -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 = versions.to_s.singularize
13
- klass = (options[:class_name] || name.capitalize).constantize
14
- owner_name = options[:as] || 'owner'
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 '#{owner_name}_id' in table #{klass.table_name}.") unless klass.column_names.include?("#{owner_name}_id")
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 = #{name}_id # if v_id = version_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}.#{owner_name}_id = self[:id] # version.owner_id = self[:id]
74
- if !@#{name}.save(false) # if !@version.save_with_validation(false)
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
- raise ActiveRecord::RecordInvalid.new(self) # raise ActiveRecord::RecordInvalid.new(self)
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[:#{name}_id] = @#{name}.id # self[:version_id] = @version.id
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 #{table_name} " +
101
- "SET \#{conn.quote_column_name("#{name}_id")} = \#{conn.quote(@#{name}.id)} " +
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[:#{name}_id] = @#{name}.id # self[:version_id] = @version.id
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 + 1)
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 'versions/shared_attachment/attachment'
2
- require 'versions/shared_attachment/owner'
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