moribus 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.
Files changed (63) hide show
  1. checksums.yaml +15 -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 +19 -0
  9. data/LICENSE +20 -0
  10. data/README.md +104 -0
  11. data/Rakefile +15 -0
  12. data/lib/colorized_text.rb +33 -0
  13. data/lib/moribus.rb +133 -0
  14. data/lib/moribus/aggregated_behavior.rb +80 -0
  15. data/lib/moribus/aggregated_cache_behavior.rb +76 -0
  16. data/lib/moribus/alias_association.rb +106 -0
  17. data/lib/moribus/extensions.rb +37 -0
  18. data/lib/moribus/extensions/delegate_associated.rb +48 -0
  19. data/lib/moribus/extensions/has_aggregated_extension.rb +94 -0
  20. data/lib/moribus/extensions/has_current_extension.rb +17 -0
  21. data/lib/moribus/macros.rb +120 -0
  22. data/lib/moribus/tracked_behavior.rb +91 -0
  23. data/lib/moribus/version.rb +3 -0
  24. data/moribus.gemspec +33 -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 +61 -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 +37 -0
  40. data/spec/dummy/config/environments/production.rb +67 -0
  41. data/spec/dummy/config/environments/test.rb +37 -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 +209 -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,106 @@
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, opts = {})
34
+ alias_name = opts.delete(:alias)
35
+ reflection = super(name, opts)
36
+ alias_association(alias_name, name) if alias_name
37
+ reflection
38
+ end
39
+
40
+ # Allows :alias option to alias has_many association
41
+ def has_many(name, opts = {})
42
+ alias_name = opts.delete(:alias)
43
+ reflection = super(name, opts)
44
+ alias_association(alias_name, name) if alias_name
45
+ reflection
46
+ end
47
+
48
+ # Allows :alias option to alias has_one association
49
+ def has_one(name, opts = {})
50
+ alias_name = opts.delete(:alias)
51
+ reflection = super(name, opts)
52
+ alias_association(alias_name, name) if alias_name
53
+ reflection
54
+ end
55
+
56
+
57
+ # Aliases association methods for a given association reflections: creates
58
+ # association accessors aliases (such as <tt>other</tt> and <tt>other=</tt>),
59
+ # also aliases association-specific methods(such as <tt>build_other</tt> and
60
+ # <tt>create_other</tt> for singular association, and <tt>other_ids</tt> and
61
+ # <tt>other_ids=</tt> for collection association)
62
+ def alias_association_methods(alias_name, reflection)
63
+ association_name = reflection.name
64
+ alias_association_accessor_methods(alias_name, association_name)
65
+ case reflection.macro
66
+ when :has_one, :belongs_to
67
+ alias_singular_association_methods(alias_name, association_name)
68
+ when :has_many, :has_and_belongs_to_many
69
+ alias_collection_association_methods(alias_name, association_name)
70
+ end
71
+ end
72
+ private :alias_association_methods
73
+
74
+ # Aliases association accessor methods:
75
+ # * <tt>other</tt>
76
+ # * <tt>other=</tt>
77
+ def alias_association_accessor_methods(alias_name, association_name)
78
+ alias_method alias_name, association_name
79
+ alias_method "#{alias_name}=", "#{association_name}="
80
+ end
81
+ private :alias_association_accessor_methods
82
+
83
+ # Aliases singular association methods:
84
+ # * <tt>build_other</tt>
85
+ # * <tt>create_other</tt>
86
+ # * <tt>create_other!</tt>
87
+ def alias_singular_association_methods(alias_name, association_name)
88
+ alias_method "build_#{alias_name}", "build_#{association_name}"
89
+ alias_method "create_#{alias_name}", "create_#{association_name}"
90
+ alias_method "create_#{alias_name}!", "create_#{association_name}!"
91
+ end
92
+ private :alias_singular_association_methods
93
+
94
+ # Aliases collection association methods:
95
+ # * <tt>other_ids</tt>
96
+ # * <tt>other_ids=</tt>
97
+ def alias_collection_association_methods(alias_name, association_name)
98
+ singularized_alias_name = alias_name.to_s.singularize
99
+ singularized_association_name = association_name.to_s.singularize
100
+ alias_method "#{singularized_alias_name}_ids", "#{singularized_association_name}_ids"
101
+ alias_method "#{singularized_alias_name}_ids=", "#{singularized_association_name}_ids="
102
+ end
103
+ private :alias_collection_association_methods
104
+ end
105
+ end
106
+ 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 unless model.attribute_methods_generated?
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 unless klass.attribute_methods_generated?
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
@@ -0,0 +1,17 @@
1
+ module Moribus
2
+ module Extensions
3
+ # Minor extension for Rails' +has_one+ association that will help
4
+ # dealing with current record assignment.
5
+ module HasCurrentExtension
6
+ # Sets 'is_current' flag of overridden record to false, instead
7
+ # of deleting it or setting foreign key to nil.
8
+ def remove_target!(*)
9
+ if target.new_record?
10
+ target.is_current = false
11
+ else
12
+ target.update_attribute(:is_current, false)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end