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 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