HornsAndHooves-moribus 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +35 -0
  3. data/.rspec +4 -0
  4. data/.ruby-gemset +1 -0
  5. data/.ruby-version +1 -0
  6. data/.simplecov +42 -0
  7. data/.travis.yml +8 -0
  8. data/Gemfile +17 -0
  9. data/HornsAndHooves-moribus.gemspec +31 -0
  10. data/LICENSE +21 -0
  11. data/README.md +110 -0
  12. data/Rakefile +15 -0
  13. data/lib/colorized_text.rb +33 -0
  14. data/lib/moribus.rb +138 -0
  15. data/lib/moribus/aggregated_behavior.rb +80 -0
  16. data/lib/moribus/aggregated_cache_behavior.rb +76 -0
  17. data/lib/moribus/alias_association.rb +111 -0
  18. data/lib/moribus/extensions.rb +37 -0
  19. data/lib/moribus/extensions/delegate_associated.rb +48 -0
  20. data/lib/moribus/extensions/has_aggregated_extension.rb +94 -0
  21. data/lib/moribus/extensions/has_current_extension.rb +17 -0
  22. data/lib/moribus/macros.rb +135 -0
  23. data/lib/moribus/tracked_behavior.rb +91 -0
  24. data/lib/moribus/version.rb +3 -0
  25. data/spec/dummy/README.rdoc +261 -0
  26. data/spec/dummy/Rakefile +7 -0
  27. data/spec/dummy/app/assets/javascripts/application.js +15 -0
  28. data/spec/dummy/app/assets/stylesheets/application.css +13 -0
  29. data/spec/dummy/app/controllers/application_controller.rb +3 -0
  30. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  31. data/spec/dummy/app/mailers/.gitkeep +0 -0
  32. data/spec/dummy/app/models/.gitkeep +0 -0
  33. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  34. data/spec/dummy/config.ru +4 -0
  35. data/spec/dummy/config/application.rb +53 -0
  36. data/spec/dummy/config/boot.rb +10 -0
  37. data/spec/dummy/config/database.yml +25 -0
  38. data/spec/dummy/config/environment.rb +5 -0
  39. data/spec/dummy/config/environments/development.rb +31 -0
  40. data/spec/dummy/config/environments/production.rb +70 -0
  41. data/spec/dummy/config/environments/test.rb +34 -0
  42. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  43. data/spec/dummy/config/initializers/inflections.rb +15 -0
  44. data/spec/dummy/config/initializers/mime_types.rb +5 -0
  45. data/spec/dummy/config/initializers/secret_token.rb +7 -0
  46. data/spec/dummy/config/initializers/session_store.rb +8 -0
  47. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  48. data/spec/dummy/config/locales/en.yml +5 -0
  49. data/spec/dummy/config/routes.rb +58 -0
  50. data/spec/dummy/db/test.sqlite3 +0 -0
  51. data/spec/dummy/lib/assets/.gitkeep +0 -0
  52. data/spec/dummy/log/.gitkeep +0 -0
  53. data/spec/dummy/public/404.html +26 -0
  54. data/spec/dummy/public/422.html +26 -0
  55. data/spec/dummy/public/500.html +25 -0
  56. data/spec/dummy/public/favicon.ico +0 -0
  57. data/spec/dummy/script/rails +6 -0
  58. data/spec/moribus/alias_association_spec.rb +88 -0
  59. data/spec/moribus/macros_spec.rb +7 -0
  60. data/spec/moribus_spec.rb +332 -0
  61. data/spec/spec_helper.rb +15 -0
  62. data/spec/support/moribus_spec_model.rb +57 -0
  63. metadata +247 -0
@@ -0,0 +1,80 @@
1
+ module Moribus
2
+ # Adds aggregated behavior to a model. An aggregated model tries to insure
3
+ # it will not duplicate itself for whatever parents it belongs to. Whenever
4
+ # an aggregated model is about to be saved, it uses its attributes to
5
+ # perform a lookup for an existing record with the same attributes. If the
6
+ # lookup succeeds, its id is used to replace id of model being saved, and
7
+ # no 'INSERT' statement is executed. If the lookup fails, the original AR
8
+ # save routines are performed.
9
+ #
10
+ # This behavior ignores by default the columns it doesn't consider to
11
+ # contain content such as the ones created and used by ActiveRecord. These
12
+ # can be expanded through the API:
13
+ # @example:
14
+ # acts_as_aggregated :non_content_columns => %w(some other colums)
15
+ module AggregatedBehavior
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ # specifies a list of attributes to exclude from lookup
20
+ class_attribute :aggregated_behaviour_non_content_columns, :instance_writer => false
21
+ self.aggregated_behaviour_non_content_columns = %w(id created_at updated_at lock_version)
22
+ end
23
+
24
+ # Override the original AR::Base #save method with the aggregated
25
+ # behavior. This cannot be done using a before_save callback, because, if
26
+ # the lookup succeeds, we don't want the original #save to be executed.
27
+ # But if +false+ is returned by the callback, it will also be returned
28
+ # by the #save method, wrongly indicating the result of saving.
29
+ def save(*)
30
+ @updated_as_aggregated = false
31
+ run_callbacks(:save) do
32
+ return (lookup_self_and_replace or super) if new_record?
33
+
34
+ is_any_content_attr_changed =
35
+ attributes.except(*aggregated_behaviour_non_content_columns).keys.
36
+ any?{ |attr| attribute_changed?(attr) }
37
+
38
+ if is_any_content_attr_changed
39
+ to_new_record!
40
+ lookup_self_and_replace or return super
41
+
42
+ true
43
+ else
44
+ super
45
+ end
46
+ end
47
+ end
48
+
49
+ # Bang version of #save.
50
+ def save!(*args)
51
+ save(*args) or raise ActiveRecord::RecordNotSaved
52
+ end
53
+
54
+ # Use the +lookup_relation+ to get the very first existing record that
55
+ # corresponds to +self+.
56
+ def lookup_self
57
+ lookup_relation.first
58
+ end
59
+ private :lookup_self
60
+
61
+ # Use the attributes of +self+ to generate a relation that corresponds to
62
+ # the existing record in the table with the same attributes.
63
+ def lookup_relation
64
+ self.class.unscoped.where(attributes.except(*aggregated_behaviour_non_content_columns))
65
+ end
66
+ private :lookup_relation
67
+
68
+ # If #lookup_self successfully returns a record, 'replace' +self+ by it
69
+ # (using its id, created_at, updated_at values).
70
+ def lookup_self_and_replace
71
+ @updated_as_aggregated = true
72
+ existing = lookup_self
73
+
74
+ if existing.present? then
75
+ to_persistent!(existing)
76
+ end
77
+ end
78
+ private :lookup_self_and_replace
79
+ end
80
+ end
@@ -0,0 +1,76 @@
1
+ module Moribus
2
+ # This module provides additional in-memory caching for model that
3
+ # behaves in aggregated way. For that reason, <tt>:aggregated_records_cache</tt>
4
+ # hash class instance variable is added, and the <tt>@aggregated_caching_column</tt>
5
+ # class instance variable should be defined by class. The value of
6
+ # corresponding attribute of the model is used as a cache key.
7
+ #
8
+ # The original +lookup_self+ method is overloaded to lookup in cache
9
+ # first. If this lookup fails, native aggregated routines are performed
10
+ # and resulting record is added to the cache.
11
+ #
12
+ # Please note that this module is not to be included manually. Use
13
+ # class-level +acts_as_aggregated+ instead, supplied with an <tt>:cache_by</tt>
14
+ # option:
15
+ #
16
+ # class EmailDomain < ActiveRecord::Base
17
+ # acts_as_aggregated :cache_by => :domain
18
+ # # .. rest of definition
19
+ # end
20
+ module AggregatedCacheBehavior
21
+ extend ActiveSupport::Concern
22
+
23
+ # Raised when trying to include the module to a non-aggregated model.
24
+ NotAggregatedError = Class.new(::ArgumentError)
25
+
26
+ included do
27
+ unless self < AggregatedBehavior
28
+ raise NotAggregatedError, 'AggregatedCache can be used only in Aggregated models'
29
+ end
30
+
31
+ class_attribute :aggregated_records_cache
32
+ self.aggregated_records_cache = {}
33
+
34
+ after_save :cache_aggregated_record, :on => :create
35
+ end
36
+
37
+ # Class methods for model that includes AggregatedCacheBehavior
38
+ module ClassMethods
39
+ # Empty the cache of aggregated records.
40
+ def clear_cache
41
+ self.aggregated_records_cache = {}
42
+ end
43
+
44
+ # Return the column (attribute). Its value is used as a key for
45
+ # caching records.
46
+ def aggregated_caching_column
47
+ @aggregated_caching_column
48
+ end
49
+ end
50
+
51
+ # Overridden for caching support.
52
+ def lookup_self
53
+ cache = self.class.aggregated_records_cache
54
+ cache_by = caching_attribute
55
+ return cache[cache_by] if cache.key? cache_by
56
+ lookup_result = super
57
+ cache[cache_by] = lookup_result if lookup_result
58
+ lookup_result
59
+ end
60
+ private :lookup_self
61
+
62
+ # Cache the record.
63
+ def cache_aggregated_record
64
+ cache_by = caching_attribute
65
+ self.class.aggregated_records_cache[cache_by] = dup.tap{ |d| d.to_persistent!(self); d.freeze }
66
+ end
67
+ private :cache_aggregated_record
68
+
69
+ # Return the value of the caching column (attribute) used as a key of
70
+ # the records cache.
71
+ def caching_attribute
72
+ read_attribute(self.class.aggregated_caching_column)
73
+ end
74
+ private :caching_attribute
75
+ end
76
+ end
@@ -0,0 +1,111 @@
1
+ module Moribus
2
+ # When included, adds alias_association public class method for aliasing
3
+ # specific association. Association name and association-specific methods
4
+ # will be aliased. Example:
5
+ #
6
+ # class Customer
7
+ # belongs_to :person_name
8
+ # has_many :orders
9
+ # alias_association :name, :person_name
10
+ # alias_association :bookings, :orders
11
+ # end
12
+ #
13
+ # Customer.reflect_on_association(:name) # => #<ActiveRecord::Reflection::AssociationReflection ...>
14
+ # c = Customer.includes(:bookings).first
15
+ # c.booking_ids # => [1, 2, 3]
16
+ # c.build_name(:first_name => 'John', :last_name => 'Smith') # => #<PersonName ...>
17
+ module AliasAssociation
18
+ extend ActiveSupport::Concern
19
+
20
+ # Class methods for ActiveRecord::Base
21
+ module ClassMethods
22
+ # Aliases association reflection in reflections hash and
23
+ # association-specific methods. See module description for example
24
+ def alias_association(alias_name, association_name)
25
+ if reflection = reflect_on_association(association_name)
26
+ reflections[alias_name] = reflections[association_name]
27
+ alias_association_methods(alias_name, reflection)
28
+ reflection
29
+ end
30
+ end
31
+
32
+ # Allows :alias option to alias belongs_to association
33
+ def belongs_to(name, scope = nil, options = {})
34
+ options = scope if scope.is_a?(Hash)
35
+
36
+ alias_name = options.delete(:alias)
37
+ reflection = super(name, scope, options)
38
+ alias_association(alias_name, name) if alias_name
39
+ reflection
40
+ end
41
+
42
+ # Allows :alias option to alias has_many association
43
+ def has_many(name, scope = nil, options = {}, &extension)
44
+ options = scope if scope.is_a?(Hash)
45
+
46
+ alias_name = options.delete(:alias)
47
+ reflection = super(name, scope, options, &extension)
48
+ alias_association(alias_name, name) if alias_name
49
+ reflection
50
+ end
51
+
52
+ # Allows :alias option to alias has_one association
53
+ def has_one(name, scope = nil, options = {})
54
+ options = scope if scope.is_a?(Hash)
55
+
56
+ alias_name = options.delete(:alias)
57
+ reflection = super(name, scope, options)
58
+ alias_association(alias_name, name) if alias_name
59
+ reflection
60
+ end
61
+
62
+ # Aliases association methods for a given association reflections: creates
63
+ # association accessors aliases (such as <tt>other</tt> and <tt>other=</tt>),
64
+ # also aliases association-specific methods(such as <tt>build_other</tt> and
65
+ # <tt>create_other</tt> for singular association, and <tt>other_ids</tt> and
66
+ # <tt>other_ids=</tt> for collection association)
67
+ def alias_association_methods(alias_name, reflection)
68
+ association_name = reflection.name
69
+ alias_association_accessor_methods(alias_name, association_name)
70
+ case reflection.macro
71
+ when :has_one, :belongs_to
72
+ alias_singular_association_methods(alias_name, association_name)
73
+ when :has_many, :has_and_belongs_to_many
74
+ alias_collection_association_methods(alias_name, association_name)
75
+ end
76
+ end
77
+ private :alias_association_methods
78
+
79
+ # Aliases association accessor methods:
80
+ # * <tt>other</tt>
81
+ # * <tt>other=</tt>
82
+ def alias_association_accessor_methods(alias_name, association_name)
83
+ alias_method alias_name, association_name
84
+ alias_method "#{alias_name}=", "#{association_name}="
85
+ end
86
+ private :alias_association_accessor_methods
87
+
88
+ # Aliases singular association methods:
89
+ # * <tt>build_other</tt>
90
+ # * <tt>create_other</tt>
91
+ # * <tt>create_other!</tt>
92
+ def alias_singular_association_methods(alias_name, association_name)
93
+ alias_method "build_#{alias_name}", "build_#{association_name}"
94
+ alias_method "create_#{alias_name}", "create_#{association_name}"
95
+ alias_method "create_#{alias_name}!", "create_#{association_name}!"
96
+ end
97
+ private :alias_singular_association_methods
98
+
99
+ # Aliases collection association methods:
100
+ # * <tt>other_ids</tt>
101
+ # * <tt>other_ids=</tt>
102
+ def alias_collection_association_methods(alias_name, association_name)
103
+ singularized_alias_name = alias_name.to_s.singularize
104
+ singularized_association_name = association_name.to_s.singularize
105
+ alias_method "#{singularized_alias_name}_ids", "#{singularized_association_name}_ids"
106
+ alias_method "#{singularized_alias_name}_ids=", "#{singularized_association_name}_ids="
107
+ end
108
+ private :alias_collection_association_methods
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,37 @@
1
+ module Moribus
2
+ # Extends the default Rails +has_one+ and +belongs_to+ associations
3
+ # to enable tracked and aggregated behaviors.
4
+ module Extensions
5
+ extend ActiveSupport::Concern
6
+ extend ActiveSupport::Autoload
7
+
8
+ autoload :HasAggregatedExtension
9
+ autoload :HasCurrentExtension
10
+ autoload :DelegateAssociated
11
+
12
+ # :nodoc:
13
+ module ClassMethods
14
+ # Adds special delegation for +has_aggregated+ association to Rails'
15
+ # +belongs_to+ reflection object.
16
+ def extend_has_aggregated_reflection(reflection)
17
+ HasAggregatedExtension::Helper.new(self, reflection).extend
18
+ end
19
+ private :extend_has_aggregated_reflection
20
+ end
21
+
22
+ # Overrides Rails' default #association method to extend resulting
23
+ # +association+ objects by custom behaviors.
24
+ def association(name)
25
+ association = super
26
+ reflection = self.class.reflect_on_association(name)
27
+ case reflection.macro
28
+ when :belongs_to
29
+ association.extend(HasAggregatedExtension) if reflection.options[:aggregated]
30
+ when :has_one
31
+ association.extend(HasCurrentExtension) if reflection.options[:is_current]
32
+ else # do nothing
33
+ end
34
+ association
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,48 @@
1
+ module Moribus
2
+ module Extensions
3
+ # This module is included by the class on the first call to
4
+ # +delegate_associated+ method. When included, it will add
5
+ # +classes_delegating_to+ class attribute to memorize classes
6
+ # of delegated associations. This information will be used
7
+ # for multi-parameter attributes assignment.
8
+ module DelegateAssociated
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ class_attribute :classes_delegating_to
13
+ self.classes_delegating_to = []
14
+ end
15
+
16
+ # Overloaded AR::Base method that will additionally check column
17
+ # in delegated associations classes. Purpose:
18
+ #
19
+ # class Customer < ActiveRecord::Base
20
+ # has_one_current :customer_info
21
+ #
22
+ # delegate_associated :date_of_birth, :to => :customer_info
23
+ # end
24
+ #
25
+ # customer = Customer.new({
26
+ # 'date_of_birth(1i)' => '1950',
27
+ # 'date_of_birth(2i)' => '03',
28
+ # 'date_of_birth(3i)' => '18'
29
+ # })
30
+ #
31
+ # Here, for multi-parameter attribute assignment, Rails will try to
32
+ # get the column class of 'date_of_birth' attribute. Since it is not
33
+ # presented in Customer, the code will result in exception without
34
+ # the following hook:
35
+ def column_for_attribute(name)
36
+ unless (column = super).nil?
37
+ return column
38
+ end
39
+
40
+ self.class.classes_delegating_to.each do |klass|
41
+ column = klass.columns_hash[name.to_s]
42
+ return column unless column.nil?
43
+ end
44
+ nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,94 @@
1
+ module Moribus
2
+ module Extensions
3
+ # Minor extension for Rails' +belongs_to+ association that will correct
4
+ # foreign key assignment during association autosave.
5
+ module HasAggregatedExtension
6
+ # Return +true+ if the association has an @updated value (set by
7
+ # default Rails behavior) or if the target record was updated during
8
+ # lookup, indicating that the association owner's foreign key should
9
+ # be updated also.
10
+ def updated?
11
+ @updated || target.try(:updated_as_aggregated?)
12
+ end
13
+
14
+ # This helper class is used to effectively extend +has_aggregated+
15
+ # association by adding attribute delegation of attributes and enum
16
+ # readers to the effective reader of the association owner. Behaves
17
+ # much like ActiveRecord::Associations::Builder classes.
18
+ class Helper
19
+ # Among all attribute methods, we're interested only in reader and
20
+ # writers - discard the rest
21
+ EXCLUDE_METHODS_REGEXP = /^_|\?$|^reset|_cast$|_was$|_change!?$|lock_version|created_at|updated_at/
22
+
23
+ attr_reader :model, :reflection
24
+
25
+ # Save association owner and reflection for subsequent processing.
26
+ def initialize(model, reflection)
27
+ @model, @reflection = model, reflection
28
+ end
29
+
30
+ # Extend association: add the delegation module to the reflection,
31
+ # fill it with the delegation methods and include it into the model.
32
+ def extend
33
+ define_delegation_module(reflection)
34
+ add_delegated_methods(reflection)
35
+ include_delegation_module(reflection)
36
+ end
37
+
38
+ # Define the delegation module for a reflection, available through
39
+ # #delegated_attribute_methods* method.
40
+ def define_delegation_module(reflection)
41
+ reflection.define_singleton_method(:delegated_attribute_methods) do
42
+ @delegated_attribute_methods ||= Module.new
43
+ end
44
+ end
45
+ private :define_delegation_module
46
+
47
+ # Add all the interesting methods of the association's klass:
48
+ # generated attribute readers and writers, as well as enum readers
49
+ # and writers.
50
+ def add_delegated_methods(reflection)
51
+ mod = reflection.delegated_attribute_methods
52
+ model.define_attribute_methods
53
+ methods_to_delegate = methods_to_delegate_to(reflection) - model.instance_methods.map(&:to_sym)
54
+ methods_to_delegate.each do |method|
55
+ mod.delegate method, :to => name
56
+ end
57
+ end
58
+ private :add_delegated_methods
59
+
60
+ # Return a list of methods we want to delegate to the association:
61
+ # will generate attribute methods for a klass if they have not yet
62
+ # been generated by Rails, and will select reader and writer methods
63
+ # from the generated model. Also, adds enum readers and writers to
64
+ # a result. Also, it selects methods that can be treated as
65
+ # 'custom_writers' - they were declared within the class itself
66
+ # and have a names like '<column_name>='
67
+ def methods_to_delegate_to(reflection)
68
+ klass = reflection.klass
69
+ enum_methods = klass.reflect_on_all_enumerated.map do |reflection|
70
+ name = reflection.name
71
+ [name, "#{name}="]
72
+ end
73
+ klass.define_attribute_methods
74
+ attribute_methods = klass.generated_attribute_methods.instance_methods.select{ |m| m !~ EXCLUDE_METHODS_REGEXP }
75
+ custom_writers = klass.instance_methods(false).map(&:to_s) & klass.column_names.map{ |name| "#{name}=" }
76
+ (attribute_methods + enum_methods.flatten + custom_writers).map(&:to_sym)
77
+ end
78
+ private :methods_to_delegate_to
79
+
80
+ # Include the reflection's attributes delegation module into a model.
81
+ def include_delegation_module(reflection)
82
+ model.send(:include, reflection.delegated_attribute_methods)
83
+ end
84
+ private :include_delegation_module
85
+
86
+ # Return the effective name of the association we're delegating to.
87
+ def name
88
+ @reflection_name ||= "effective_#{reflection.name}"
89
+ end
90
+ private :name
91
+ end
92
+ end
93
+ end
94
+ end