HornsAndHooves-moribus 0.4.3 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 3f9194654bbeebef4db4c98f24246c8c444a307a
4
- data.tar.gz: b21548da9a34e71cf86d81fc4f3223bccac26f8c
2
+ SHA256:
3
+ metadata.gz: f2abb615268f2c42283de6414f05bb5de13f1bbd96de147b72a5f78f23ef7cad
4
+ data.tar.gz: 15bac2500a6014c65f6bd9e2d7dbce2ca8a9df37a76d4d282e9abd5e46ed61a4
5
5
  SHA512:
6
- metadata.gz: 3e010e70ad8cf22b9a909563faed39839e59dd6efb30bde31366502f31c75484ce1b7722e52945a8bb91aba842efd320ec59b28f68f3233d6a630b292cd8eb39
7
- data.tar.gz: 0f3133f27b5a5896525ba3d2a7a8fcca56a25b65497fc949bdfb9579fc8248b74fd871f87c4c0f501a41f595916ebc95c1f3e2cb5b073a89110dc83241ac852a
6
+ metadata.gz: 908ec06e853f32c14c53efaa2fd9e6eda1e03a992eaec495d84eeb1ec392c23843907a69930de5b983a1be254681c0c097759157839071c2aa10e2a95dd77234
7
+ data.tar.gz: 4b9abac4e3e538999c714284f263bbc7f4180f19fb50fc0185b18522252a41ac72673690a036dc03e08aadf58cd2a99bd10991f0cfec171e1d10dfe2547bbd79
@@ -1 +1 @@
1
- 2.2
1
+ 2.4.6
data/.simplecov CHANGED
@@ -12,7 +12,7 @@ SimpleCov.start do
12
12
  # Fail the build when coverage is weak:
13
13
  at_exit do
14
14
  SimpleCov.result.format!
15
- threshold, actual = 96.875, SimpleCov.result.covered_percent
15
+ threshold, actual = 100, SimpleCov.result.covered_percent
16
16
  if actual < threshold
17
17
  msg = "\nLow coverage: "
18
18
  msg << red("#{actual}%")
@@ -6,8 +6,8 @@ require "moribus/version"
6
6
  Gem::Specification.new do |s|
7
7
  s.name = "HornsAndHooves-moribus"
8
8
  s.version = Moribus::VERSION
9
- s.authors = ["HornsAndHooves", "Artem Kuzko", "Sergey Potapov"]
10
- s.email = ["a.kuzko@gmail.com", "blake131313@gmail.com"]
9
+ s.authors = ["HornsAndHooves", "Arthur Shagall", "Artem Kuzko", "Sergey Potapov"]
10
+ s.email = ["arthur.shagall@gmail.com", "a.kuzko@gmail.com", "blake131313@gmail.com"]
11
11
  s.homepage = "https://github.com/HornsAndHooves/moribus"
12
12
  s.licenses = ["MIT"]
13
13
  s.summary = %q{Introduces Aggregated and Tracked behavior to ActiveRecord::Base models}
@@ -20,12 +20,12 @@ Gem::Specification.new do |s|
20
20
  s.require_paths = ["lib"]
21
21
 
22
22
  # specify any dependencies here; for example:
23
- s.add_dependency "rails", "> 4.0", "< 5"
23
+ s.add_dependency "rails", "~> 5"
24
24
  s.add_dependency "power_enum", ">= 2.7.0"
25
25
  s.add_dependency "yard", ">= 0"
26
26
 
27
27
  s.add_development_dependency "rake"
28
28
  s.add_development_dependency "rspec"
29
29
  s.add_development_dependency "rspec-rails"
30
- s.add_development_dependency "sqlite3"
30
+ s.add_development_dependency "sqlite3", "~> 1.3.6"
31
31
  end
data/README.md CHANGED
@@ -6,7 +6,7 @@ Moribus is a set of tools for managing complex graphs of ActiveRecord objects
6
6
  for which there are many inbound foreign keys, attributes and associations with
7
7
  high rates of change, and business demands for well-tracked change history.
8
8
 
9
- ##AggregatedBehavior
9
+ ## AggregatedBehavior
10
10
 
11
11
  AggregatedBehavior implements a pattern in which an object's identity
12
12
  is modeled apart from its attributes and outbound associations. This enables
@@ -26,7 +26,7 @@ to change. This is useful for objects with many inbound foreign keys and
26
26
  high-traffic attributes/associations, such as statuses. Without this pattern
27
27
  it would be difficult to avoid many StaleObjectErrors.
28
28
 
29
- ##TrackedBehavior
29
+ ## TrackedBehavior
30
30
 
31
31
  TrackedBehavior implements history tracking on the stack of objects
32
32
  representing the identity object's attributes and outbound associations.
@@ -36,7 +36,7 @@ and will be saved as a new record with new attribute values and the
36
36
  'is_current' column as 'true'. Thus, under the hood, new attributes
37
37
  will supersede old attributes, leaving the old record as historical.
38
38
 
39
- ##Macros, Associations and Combination
39
+ ## Macros, Associations and Combination
40
40
 
41
41
  Despite the fact that Moribus may be used by models on its own,
42
42
  its main purpose is to be used within associations, and in conjunction
@@ -105,6 +105,6 @@ rake spec
105
105
 
106
106
  ## Copyright
107
107
 
108
- Copyright (c) 2014 HornsAndHooves.
108
+ Copyright (c) 2014-2018 HornsAndHooves.
109
109
 
110
110
  Copyright (c) 2013 TMX Credit.
@@ -96,7 +96,8 @@ module Moribus
96
96
  self.id = existing.id
97
97
  self.created_at = existing.created_at if respond_to?(:created_at)
98
98
  self.updated_at = existing.updated_at if respond_to?(:updated_at)
99
- @changed_attributes = {}
99
+
100
+ clear_changes_information
100
101
  else
101
102
  restore_before_to_new_record_values
102
103
  end
@@ -145,14 +146,10 @@ module Moribus
145
146
 
146
147
  if respond_to?(:updated_at=)
147
148
  self.updated_at = nil
148
- @changed_attributes =
149
- ActiveSupport::HashWithIndifferentAccess.new(changed_attributes.dup.merge(updated_at: nil))
150
149
  end
151
150
 
152
151
  if respond_to?(:created_at=)
153
152
  self.created_at = nil
154
- @changed_attributes =
155
- ActiveSupport::HashWithIndifferentAccess.new(changed_attributes.dup.merge(created_at: nil))
156
153
  end
157
154
 
158
155
  # mark all other attributes is changing
@@ -48,9 +48,18 @@ module Moribus
48
48
 
49
49
  # Bang version of #save.
50
50
  def save!(*args)
51
- save(*args) or raise ActiveRecord::RecordNotSaved
51
+ save(*args) or raise_record_not_saved_error
52
52
  end
53
53
 
54
+ # Raise "record not saved".
55
+ def raise_record_not_saved_error
56
+ args =
57
+ (Rails::VERSION::MAJOR == 4 && Rails::VERSION::MINOR < 2) ? [] : ["Failed to save record"]
58
+
59
+ raise ActiveRecord::RecordNotSaved, *args
60
+ end
61
+ private :raise_record_not_saved_error
62
+
54
63
  # Use the +lookup_relation+ to get the very first existing record that
55
64
  # corresponds to +self+.
56
65
  def lookup_self
@@ -23,30 +23,25 @@ module Moribus
23
23
  # association-specific methods. See module description for example.
24
24
  def alias_association(alias_name, association_name)
25
25
  if reflection = reflect_on_association(association_name)
26
- # Use Rails 4.1.x+ behavior, if available:
27
- if ActiveRecord::Reflection.respond_to? :add_reflection then
28
- ActiveRecord::Reflection.add_reflection self, alias_name, reflection
29
- else
30
- # Rails 4.0.x behavior:
31
- reflections[alias_name] = reflections[association_name]
32
- end
26
+ ActiveRecord::Reflection.add_reflection self, alias_name, reflection
27
+
33
28
  alias_association_methods(alias_name, reflection)
34
29
  reflection
35
30
  end
36
31
  end
37
32
 
38
33
  # Allows :alias option to alias belongs_to association
39
- def belongs_to(name, scope = nil, options = {})
34
+ def belongs_to(name, scope = nil, **options)
40
35
  options = scope if scope.is_a?(Hash)
41
36
 
42
37
  alias_name = options.delete(:alias)
43
- reflection = super(name, scope, options)
38
+ reflection = super(name, scope, **options)
44
39
  alias_association(alias_name, name) if alias_name
45
40
  reflection
46
41
  end
47
42
 
48
43
  # Allows :alias option to alias has_many association
49
- def has_many(name, scope = nil, options = {}, &extension)
44
+ def has_many(name, scope = nil, **options, &extension)
50
45
  options = scope if scope.is_a?(Hash)
51
46
 
52
47
  alias_name = options.delete(:alias)
@@ -56,7 +51,7 @@ module Moribus
56
51
  end
57
52
 
58
53
  # Allows :alias option to alias has_one association
59
- def has_one(name, scope = nil, options = {})
54
+ def has_one(name, scope = nil, **options)
60
55
  options = scope if scope.is_a?(Hash)
61
56
 
62
57
  alias_name = options.delete(:alias)
@@ -24,12 +24,12 @@ module Moribus
24
24
  def association(name)
25
25
  association = super
26
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
27
+ case reflection.try(: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
33
  end
34
34
  association
35
35
  end
@@ -33,7 +33,7 @@ module Moribus
33
33
  # presented in Customer, the code will result in exception without
34
34
  # the following hook:
35
35
  def column_for_attribute(name)
36
- unless (column = super).nil?
36
+ unless (column = super).nil? || column.is_a?(ActiveRecord::ConnectionAdapters::NullColumn)
37
37
  return column
38
38
  end
39
39
 
@@ -71,7 +71,7 @@ module Moribus
71
71
  [name, "#{name}="]
72
72
  end
73
73
  klass.define_attribute_methods
74
- attribute_methods = klass.generated_attribute_methods.instance_methods.select{ |m| m !~ EXCLUDE_METHODS_REGEXP }
74
+ attribute_methods = klass.__send__(:generated_attribute_methods).instance_methods.select{ |m| m !~ EXCLUDE_METHODS_REGEXP }
75
75
  custom_writers = klass.instance_methods(false).map(&:to_s) & klass.column_names.map{ |name| "#{name}=" }
76
76
  (attribute_methods + enum_methods.flatten + custom_writers).map(&:to_sym)
77
77
  end
@@ -11,14 +11,12 @@ module Moribus
11
11
  else
12
12
  # Use custom update to avoid running ActiveRecord optimistic locking
13
13
  # and to avoid updating lock_version column:
14
- klass = target.class
15
- is_current_col = klass.columns.detect { |c| c.name == "is_current" }
16
- id_column = klass.columns.detect { |c| c.name == klass.primary_key }
14
+ klass = target.class
17
15
 
18
16
  sql = "UPDATE #{klass.quoted_table_name} " \
19
- "SET \"is_current\" = #{klass.quote_value(false, is_current_col)} "
17
+ "SET \"is_current\" = #{klass.connection.quote(false)} "
20
18
  sql << "WHERE #{klass.quoted_primary_key} = " \
21
- "#{klass.quote_value(target.send(klass.primary_key), id_column)} "
19
+ "#{klass.connection.quote(target.send(klass.primary_key))} "
22
20
 
23
21
  klass.connection.update sql
24
22
  end
@@ -75,8 +75,20 @@ module Moribus
75
75
  result[parent_key] = read_attribute(parent_key)
76
76
  result
77
77
  end
78
+ relation = klass.unscoped.where(criteria)
78
79
 
79
- lock_value = klass.unscoped.where(criteria).count
80
+ sql = <<-SQL
81
+ #{relation.select("COUNT(#{lock_column_name}) AS value, 0 sort_order").to_sql}
82
+ UNION
83
+ #{relation.select("MAX(#{lock_column_name}) AS value, 1 sort_order").to_sql}
84
+ ORDER BY sort_order
85
+ SQL
86
+
87
+ result = klass.connection.execute(sql)
88
+ current_count = result[0]["value"]
89
+ current_max = result[1]["value"]
90
+
91
+ lock_value = (current_count.to_s == "0" ? 0 : current_max.to_i + 1)
80
92
 
81
93
  write_attribute(lock_column_name, lock_value)
82
94
  end
@@ -104,7 +116,6 @@ module Moribus
104
116
  # TODO: need to find way to track stale objects
105
117
  def current_to_false_sql_statement
106
118
  klass = self.class
107
- is_current_col = klass.columns.detect { |c| c.name == "is_current" }
108
119
  lock_column_name = klass.locking_column
109
120
  lock_value = current_lock_value
110
121
  lock_column = if lock_value
@@ -112,17 +123,16 @@ module Moribus
112
123
  else
113
124
  nil
114
125
  end
115
- id_column = klass.columns.detect { |c| c.name == klass.primary_key }
116
126
  quoted_lock_column = klass.connection.quote_column_name(lock_column_name)
117
127
 
118
128
  "UPDATE #{klass.quoted_table_name} " \
119
- "SET \"is_current\" = #{klass.quote_value(false, is_current_col)} ".tap do |sql|
129
+ "SET \"is_current\" = #{klass.connection.quote(false)} ".tap do |sql|
120
130
  sql << "WHERE #{klass.quoted_primary_key} = " \
121
- "#{klass.quote_value(@_before_to_new_record_values[:id], id_column)}"
131
+ "#{klass.connection.quote(@_before_to_new_record_values[:id])}"
122
132
 
123
133
  if lock_value
124
- sql << " AND \"is_current\" = #{klass.quote_value(true, is_current_col)}"
125
- sql << " AND #{quoted_lock_column} = #{klass.quote_value(lock_value, lock_column)}"
134
+ sql << " AND \"is_current\" = #{klass.connection.quote(true)}"
135
+ sql << " AND #{quoted_lock_column} = #{klass.connection.quote(lock_value)}"
126
136
  end
127
137
  end
128
138
  end
@@ -1,3 +1,3 @@
1
1
  module Moribus # :nodoc:
2
- VERSION = "0.4.3" # :nodoc:
2
+ VERSION = "0.7.1" # :nodoc:
3
3
  end
@@ -0,0 +1 @@
1
+ 02c924139ba4207bce788dd378b8f1f99976099325bbad4e85204e34d04970e039409db0f08ceea070238f4f5a41bdbc94fb6e51aeb0996a4ee32bcd3c31ede1
@@ -0,0 +1,147 @@
1
+ require "spec_helper"
2
+
3
+ describe Moribus::AggregatedBehavior do
4
+ before do
5
+ class SpecSuffix < MoribusSpecModel(name: :string, description: :string)
6
+ acts_as_enumerated
7
+
8
+ self.enumeration_model_updates_permitted = true
9
+ create!(name: "none", description: "")
10
+ create!(name: "jr" , description: "Junior")
11
+ end
12
+
13
+ class SpecTag < MoribusSpecModel()
14
+ has_and_belongs_to_many :person_names
15
+ end
16
+
17
+ class SpecPersonNamesTags < MoribusSpecModel(spec_tag_id: :integer,
18
+ spec_person_name_id: :integer
19
+ )
20
+ end
21
+
22
+ class SpecPersonName < MoribusSpecModel(first_name: :string,
23
+ last_name: :string,
24
+ spec_suffix_id: :integer
25
+ )
26
+ acts_as_aggregated
27
+ has_enumerated :spec_suffix, default: ""
28
+ has_and_belongs_to_many :spec_tags
29
+
30
+ validates_presence_of :first_name, :last_name
31
+
32
+ # custom writer that additionally strips first name
33
+ def first_name=(value)
34
+ self[:first_name] = value.strip
35
+ end
36
+ end
37
+
38
+ class SpecCustomerFeature < MoribusSpecModel(feature_name: :string)
39
+ acts_as_aggregated cache_by: :feature_name
40
+ end
41
+ end
42
+
43
+ after do
44
+ MoribusSpecModel.cleanup!
45
+ end
46
+
47
+ describe "Aggregated" do
48
+ it "supports has_and_belongs_to_many association reflections which do not have a macro" do
49
+ tags = [SpecTag.new, SpecTag.new]
50
+ name = SpecPersonName.create!(first_name: "John", last_name: "Smith", spec_tags: tags)
51
+ expect(name.spec_tags.size).to eq(2)
52
+ end
53
+
54
+ context "definition" do
55
+ it "raises an error on an unknown option" do
56
+ expect{
57
+ Class.new(ActiveRecord::Base).class_eval do
58
+ acts_as_aggregated invalid_key: :error
59
+ end
60
+ }.to raise_error(ArgumentError)
61
+ end
62
+
63
+ it "raises an error when including AggregatedCacheBehavior without AggregatedBehavior" do
64
+ expect{
65
+ Class.new(ActiveRecord::Base).class_eval do
66
+ include Moribus::AggregatedCacheBehavior
67
+ end
68
+ }.to raise_error(Moribus::AggregatedCacheBehavior::NotAggregatedError)
69
+ end
70
+ end
71
+
72
+ before do
73
+ @existing = SpecPersonName.create! first_name: "John", last_name: "Smith"
74
+ end
75
+
76
+ it "doesn't duplicate records" do
77
+ expect {
78
+ SpecPersonName.create first_name: " John ", last_name: "Smith"
79
+ }.not_to change(SpecPersonName, :count)
80
+ end
81
+
82
+ it "looks up self and replaces id with existing on create" do
83
+ name = SpecPersonName.new first_name: "John", last_name: "Smith"
84
+ name.save
85
+ expect(name.id).to eq @existing.id
86
+ end
87
+
88
+ it "creates a new record if lookup fails" do
89
+ expect {
90
+ SpecPersonName.create first_name: "Alice", last_name: "Smith"
91
+ }.to change(SpecPersonName, :count).by(1)
92
+ end
93
+
94
+ it "looks up self and replaces id with existing on update" do
95
+ name = SpecPersonName.create first_name: "Alice", last_name: "Smith"
96
+ name.update_attributes first_name: "John"
97
+ expect(name.id).to eq @existing.id
98
+ end
99
+
100
+ it "calls super in save if any aggregated behaviour non content columns wasn't changed" do
101
+ name = SpecPersonName.create first_name: "Alice", last_name: "Smith"
102
+ expect {
103
+ name.save
104
+ }.to change(SpecPersonName, :count).by(0)
105
+ end
106
+
107
+ it "raises the expected error when 'save!' fails" do
108
+ name = SpecPersonName.create first_name: "Alice", last_name: "Smith"
109
+ name.last_name = nil
110
+ expect {
111
+ name.save!
112
+ }.to raise_error(ActiveRecord::RecordNotSaved)
113
+ end
114
+
115
+ context "with caching" do
116
+ before do
117
+ @existing = SpecCustomerFeature.create(feature_name: "Pays")
118
+ SpecCustomerFeature.clear_cache
119
+ end
120
+
121
+ it "looks up the existing value and adds it to the cache" do
122
+ feature = SpecCustomerFeature.new feature_name: @existing.feature_name
123
+
124
+ expect{ feature.save }.
125
+ to change(SpecCustomerFeature.aggregated_records_cache, :length).by(1)
126
+
127
+ expect(feature.id).to eq @existing.id
128
+ end
129
+
130
+ it "adds the freshly-created record to the cache" do
131
+ expect{ SpecCustomerFeature.create(feature_name: "Fraud") }.
132
+ to change(SpecCustomerFeature.aggregated_records_cache, :length).by(1)
133
+ end
134
+
135
+ it "freezes the cached object" do
136
+ feature = SpecCustomerFeature.create(feature_name: "Cancelled")
137
+ expect(SpecCustomerFeature.aggregated_records_cache[feature.feature_name]).to be_frozen
138
+ end
139
+
140
+ it "caches the clone of the record, not the record itself" do
141
+ feature = SpecCustomerFeature.create(feature_name: "Returned")
142
+ expect(SpecCustomerFeature.aggregated_records_cache[feature.feature_name].object_id).
143
+ not_to eq feature.object_id
144
+ end
145
+ end
146
+ end
147
+ end
@@ -1,26 +1,26 @@
1
- require 'spec_helper'
1
+ require "spec_helper"
2
2
 
3
3
  describe Moribus::AliasAssociation do
4
4
  before do
5
- class SpecPost < MoribusSpecModel(:spec_author_id => :integer, :body => :string)
6
- belongs_to :spec_author , :alias => :creator
7
- has_many :spec_comments , :alias => :remarks
8
- has_one :spec_post_info, :alias => :information
5
+ class SpecPost < MoribusSpecModel(spec_author_id: :integer, body: :string)
6
+ belongs_to :spec_author , alias: :creator
7
+ has_many :spec_comments , alias: :remarks
8
+ has_one :spec_post_info, alias: :information
9
9
 
10
10
  alias_association :author , :spec_author
11
11
  alias_association :comments , :spec_comments
12
12
  alias_association :post_info, :spec_post_info
13
13
  end
14
14
 
15
- class SpecAuthor < MoribusSpecModel(:name => :string)
15
+ class SpecAuthor < MoribusSpecModel(name: :string)
16
16
  has_many :spec_posts
17
17
  end
18
18
 
19
- class SpecPostInfo < MoribusSpecModel(:spec_post_id => :integer, :ip => :string)
20
- belongs_to :spec_post, :alias => :note
19
+ class SpecPostInfo < MoribusSpecModel(spec_post_id: :integer, ip: :string)
20
+ belongs_to :spec_post, alias: :note
21
21
  end
22
22
 
23
- class SpecComment < MoribusSpecModel(:spec_post_id => :integer, :body => :string)
23
+ class SpecComment < MoribusSpecModel(spec_post_id: :integer, body: :string)
24
24
  belongs_to :spec_post
25
25
  end
26
26
  end
@@ -30,10 +30,10 @@ describe Moribus::AliasAssociation do
30
30
  end
31
31
 
32
32
  before do
33
- author = SpecAuthor.create(:name => 'John')
34
- @post = author.spec_posts.create(:body => 'Post Body')
35
- @post.spec_comments.create(:body => 'Fabulous!')
36
- @post.create_spec_post_info(:ip => '127.0.0.1')
33
+ author = SpecAuthor.create(name: "John")
34
+ @post = author.spec_posts.create(body: "Post Body")
35
+ @post.spec_comments.create(body: "Fabulous!")
36
+ @post.create_spec_post_info(ip: "127.0.0.1")
37
37
  end
38
38
 
39
39
  describe "reflection aliasing" do
@@ -41,7 +41,7 @@ describe Moribus::AliasAssociation do
41
41
  expect(SpecPost.reflect_on_association(:author)).not_to be_nil
42
42
  end
43
43
 
44
- it "should not raise error when using aliased name in scopes" do
44
+ it "does not raise error when using aliased name in scopes" do
45
45
  expect{
46
46
  SpecPost.includes(:comments).first
47
47
  }.not_to raise_error