amountable 0.0.8 → 0.1.0

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
2
  SHA1:
3
- metadata.gz: 21da815a77cf49ea8198ec6a9ee67072afed01a4
4
- data.tar.gz: 525fec01a9c194b843de35586bc8959e0feeedeb
3
+ metadata.gz: a50976a37bd54567185d9369a702a40dd5b38795
4
+ data.tar.gz: df9bce5b74e8ce773e8b068787be2b80a2184234
5
5
  SHA512:
6
- metadata.gz: 17529880cfb83aa122f7d2a78f9ec4ff5fb6aec4b168db82e697f2c5f8398519bd8bbc3d6d20631c75a7b7d24c9c54dc9e4f32283e61860d44d3ade1dfe63182
7
- data.tar.gz: 5dcd1a9780767a74ade416770b254b425994dacc572eb05c71ac0950c186e394a2fb39645508794538252ee2fd6f9e73bcbc63fd9f40ebd152aed72ac7870b61
6
+ metadata.gz: f644fbc7e790baa2cb469b681e80bd0c1f5dfb461d0364b808fe0a414cb37cbf9513c6c9063de6803eb7dd4b26b97235f382fff620bf9522973f55f1c11cf190
7
+ data.tar.gz: 121c40b69da25971c5bcd59a2c7d385d26ea8e19e9d296e4054e24be18c93973cd910a80d2cc52ca736a2599f7bb860c14b9cfe50a74f0c6df119f52c828288b
data/README.md CHANGED
@@ -40,6 +40,7 @@ Setup your model
40
40
  ```ruby
41
41
  class Order < ActiveRecord::Base
42
42
  include Amountable
43
+ act_as_amountable
43
44
  amount :subtotal, sets: [:total]
44
45
  amount :delivery_fee, sets: [:total, :fees]
45
46
  amount :bags_fee, sets: [:total, :fees]
@@ -48,6 +49,14 @@ class Order < ActiveRecord::Base
48
49
  end
49
50
  ```
50
51
 
52
+ `act_as_amountable` can take the `storage` option:
53
+
54
+ ```
55
+ act_as_amountable storage: :table
56
+ ```
57
+
58
+ where `storage` can be either `:table`, the `amounts` table will be used to store amounts, or `:jsonb`, a JSONB field will be used on the amountable object to store amounts are json. If you use the JSONB format, you can specify the name of the column with the `column` option. The default value for the `storage` option is `:table`.
59
+
51
60
  then create it
52
61
 
53
62
  ```ruby
@@ -74,3 +83,5 @@ When you run the migration, an `amounts` table will be created with a polymorphi
74
83
  When the an order is created with some amounts, the associated `Amount` objects are persisted.
75
84
 
76
85
  `Amount` objects are persisted in bulk, so there are no N + 1 queries. If an amount is zero, no `Amount` model is created.
86
+
87
+ If you choose the JSONB storage option, the `amounts` table will not be used. Instead a JSONB column on the target model will be used.
data/lib/amountable.rb CHANGED
@@ -4,31 +4,37 @@ module Amountable
4
4
  extend ActiveSupport::Autoload
5
5
  autoload :Amount
6
6
  autoload :VERSION
7
+ autoload :TableMethods
8
+ autoload :JsonbMethods
7
9
 
8
10
  class InvalidAmountName < StandardError; end
11
+ class MissingColumn < StandardError; end
12
+
13
+ ALLOWED_STORAGE = %i(table json).freeze
9
14
 
10
15
  def self.included(base)
11
16
 
12
17
  base.extend Amountable::ClassMethods
13
18
 
14
19
  base.class_eval do
15
- has_many :amounts, as: :amountable, dependent: :destroy, autosave: false
16
20
  validate :validate_amount_names
17
21
  class_attribute :amount_names
18
22
  class_attribute :amount_sets
23
+ class_attribute :amounts_column_name
19
24
  self.amount_sets = Hash.new { |h, k| h[k] = Set.new }
20
25
  self.amount_names = Set.new
26
+ self.amounts_column_name = 'amounts'
21
27
 
22
28
  def all_amounts
23
29
  @all_amounts ||= amounts.to_set
24
30
  end
25
31
 
26
32
  def find_amount(name)
27
- (@amounts_by_name ||= {})[name.to_sym] ||= all_amounts.find { |am| am.name == name.to_s }
33
+ (@amounts_by_name ||= {})[name.to_sym] ||= amounts.to_set.find { |am| am.name == name.to_s }
28
34
  end
29
35
 
30
36
  def find_amounts(names)
31
- all_amounts.select { |am| names.include?(am.name.to_sym) }
37
+ amounts.to_set.select { |am| names.include?(am.name.to_sym) }
32
38
  end
33
39
 
34
40
  def validate_amount_names
@@ -49,48 +55,31 @@ module Amountable
49
55
  end
50
56
  end
51
57
  end
52
-
53
- def save(args = {})
54
- ActiveRecord::Base.transaction do
55
- save_amounts if super(args)
56
- end
57
- end
58
-
59
- def save!(args = {})
60
- ActiveRecord::Base.transaction do
61
- save_amounts! if super(args)
62
- end
63
- end
64
-
65
- def save_amounts(bang: false)
66
- amounts_to_insert = []
67
- amounts.each do |amount|
68
- if amount.new_record?
69
- amount.amountable_id = self.id
70
- amounts_to_insert << amount
71
- else
72
- bang ? amount.save! : amount.save
73
- end
74
- end
75
- Amount.import(amounts_to_insert, timestamps: true, validate: false)
76
- amounts_to_insert.each do |amount|
77
- amount.instance_variable_set(:@new_record, false)
78
- end
79
- true
80
- end
81
-
82
- def save_amounts!; save_amounts(bang: true); end
83
-
84
58
  end
85
59
  end
86
60
 
87
61
  module ClassMethods
88
62
 
63
+ # Possible storage values: [:table, :jsonb]
64
+ def act_as_amountable(options = {})
65
+ case (options[:storage] || :table).to_sym
66
+ when :table
67
+ has_many :amounts, as: :amountable, dependent: :destroy, autosave: false
68
+ include Amountable::TableMethods
69
+ when :jsonb
70
+ self.amounts_column_name = options[:column].to_s if options[:column]
71
+ raise MissingColumn.new("You need an amounts jsonb field on the #{self.table_name} table.") unless column_names.include?(self.amounts_column_name)
72
+ include Amountable::JsonbMethods
73
+ else
74
+ raise ArgumentError.new("Please specify a storage: #{ALLOWED_STORAGE}")
75
+ end
76
+ end
77
+
89
78
  def amount_set(set_name, component)
90
79
  self.amount_sets[set_name.to_sym] << component.to_sym
91
80
 
92
81
  define_method set_name do
93
- find_amounts(self.amount_sets[set_name.to_sym]).sum(Money.zero, &:value)
82
+ get_set(set_name)
94
83
  end
95
84
  end
96
85
 
@@ -98,22 +87,11 @@ module Amountable
98
87
  (self.amount_names ||= Set.new) << name
99
88
 
100
89
  define_method name do
101
- (find_amount(name) || NilAmount.new).value
90
+ (find_amount(name) || ::NilAmount.new).value
102
91
  end
103
92
 
104
93
  define_method "#{name}=" do |value|
105
- amount = find_amount(name) || amounts.build(name: name)
106
- amount.value = value.to_money
107
- if value.zero?
108
- amounts.delete(amount)
109
- all_amounts.delete(amount)
110
- @amounts_by_name.delete(name)
111
- amount.destroy if amount.persisted?
112
- else
113
- all_amounts << amount if amount.new_record?
114
- (@amounts_by_name ||= {})[name.to_sym] = amount
115
- end
116
- value.to_money
94
+ set_amount(name, value)
117
95
  end
118
96
 
119
97
  Array(options[:summable] || options[:summables] || options[:set] || options[:sets] || options[:amount_set] || options[:amount_sets]).each do |set|
@@ -9,6 +9,13 @@ class Amount < ActiveRecord::Base
9
9
  validates :name, presence: true
10
10
  validates :name, uniqueness: {scope: [:amountable_id, :amountable_type]}
11
11
 
12
+ attr_accessor :persistable
13
+
14
+ def save
15
+ raise StandardError.new("Can't persist amount to database") if persistable == false
16
+ super
17
+ end
18
+
12
19
  module Operations
13
20
 
14
21
  def +(other_value)
@@ -0,0 +1,51 @@
1
+ # Copyright 2015-2016, Instacart
2
+
3
+ module Amountable
4
+ module JsonbMethods
5
+ extend ActiveSupport::Autoload
6
+
7
+ def amounts
8
+ @_amounts ||= attribute(amounts_column_name).to_h['amounts'].to_h.map do |name, amount|
9
+ Amount.new(name: name, value_cents: amount['cents'], value_currency: amount['value_currency'], persistable: false, amountable: self)
10
+ end.to_set
11
+ end
12
+
13
+ def set_amount(name, value)
14
+ value = value.to_money
15
+ return value if value.zero?
16
+ initialize_column
17
+ amounts_json = attribute(amounts_column_name)
18
+ amounts_json['amounts'] ||= {}
19
+ amounts_json['amounts'][name.to_s] = {'cents' => value.fractional, 'currency' => value.currency.iso_code}
20
+ set_json(amounts_json)
21
+ @_amounts = nil
22
+ @amounts_by_name = nil
23
+ refresh_sets
24
+ value
25
+ end
26
+
27
+ def refresh_sets
28
+ initialize_column
29
+ amounts_json = attribute(amounts_column_name)
30
+ amounts_json['sets'] = {}
31
+ amount_sets.each do |name, amount_names|
32
+ sum = find_amounts(amount_names).sum(Money.zero, &:value)
33
+ amounts_json['sets'][name.to_s] = {'cents' => sum.fractional, 'currency' => sum.currency.iso_code}
34
+ end
35
+ set_json(amounts_json)
36
+ end
37
+
38
+ def get_set(name)
39
+ value = attribute(amounts_column_name).to_h['sets'].to_h[name.to_s].to_h
40
+ Money.new(value['cents'].to_i, value['currency'] || 'USD')
41
+ end
42
+
43
+ def set_json(json)
44
+ send("#{amounts_column_name}=", json)
45
+ end
46
+
47
+ def initialize_column
48
+ send("#{amounts_column_name}=", {}) if attribute(amounts_column_name).nil?
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,58 @@
1
+ # Copyright 2015-2016, Instacart
2
+
3
+ module Amountable
4
+ module TableMethods
5
+ extend ActiveSupport::Autoload
6
+
7
+ def set_amount(name, value)
8
+ amount = find_amount(name) || amounts.build(name: name)
9
+ amount.value = value.to_money
10
+ if value.zero?
11
+ amounts.delete(amount)
12
+ all_amounts.delete(amount)
13
+ @amounts_by_name.delete(name)
14
+ amount.destroy if amount.persisted?
15
+ else
16
+ all_amounts << amount if amount.new_record?
17
+ (@amounts_by_name ||= {})[name.to_sym] = amount
18
+ end
19
+ amount.value
20
+ end
21
+
22
+ def save(args = {})
23
+ ActiveRecord::Base.transaction do
24
+ save_amounts if super(args)
25
+ end
26
+ end
27
+
28
+ def save!(args = {})
29
+ ActiveRecord::Base.transaction do
30
+ save_amounts! if super(args)
31
+ end
32
+ end
33
+
34
+ def save_amounts(bang: false)
35
+ amounts_to_insert = []
36
+ amounts.each do |amount|
37
+ if amount.new_record?
38
+ amount.amountable_id = self.id
39
+ amounts_to_insert << amount
40
+ else
41
+ bang ? amount.save! : amount.save
42
+ end
43
+ end
44
+ Amount.import(amounts_to_insert, timestamps: true, validate: false)
45
+ amounts_to_insert.each do |amount|
46
+ amount.instance_variable_set(:@new_record, false)
47
+ end
48
+ true
49
+ end
50
+
51
+ def save_amounts!; save_amounts(bang: true); end
52
+
53
+ def get_set(name)
54
+ find_amounts(self.amount_sets[name.to_sym]).sum(Money.zero, &:value)
55
+ end
56
+
57
+ end
58
+ end
@@ -1,5 +1,5 @@
1
1
  # Copyright 2015-2016, Instacart
2
2
 
3
3
  module Amountable
4
- VERSION = '0.0.8'
4
+ VERSION = '0.1.0'
5
5
  end
@@ -34,4 +34,8 @@ describe Amount do
34
34
  expect(Amount.new(value: Money.new(2)).to_money).to eq(Money.new(2))
35
35
  end
36
36
 
37
+ it 'should not save if not persistable' do
38
+ expect { Amount.new(persistable: false).save }.to raise_exception(StandardError)
39
+ end
40
+
37
41
  end
@@ -4,55 +4,85 @@ require 'spec_helper'
4
4
 
5
5
  describe Amountable do
6
6
 
7
- it 'should' do
8
- order = Order.new
9
- expect { order.save }.not_to change { Amount.count }
10
- %i(sub_total taxes total).each do |name|
11
- expect(order.send(name)).to eq(Money.zero)
12
- end
13
- order.sub_total = Money.new(100)
14
- expect(order.sub_total).to eq(Money.new(100))
15
- expect(order.total).to eq(Money.new(100))
16
- expect(order.all_amounts.size).to eq(1)
17
- order.all_amounts.first.tap do |amount|
18
- expect(amount.name).to eq('sub_total')
19
- expect(amount.value).to eq(Money.new(100))
20
- expect(amount.new_record?).to be true
21
- expect { order.save }.to change { Amount.count }.by(1)
22
- expect(amount.persisted?).to be true
7
+ context 'storage == :table' do
8
+ it 'should' do
9
+ order = Order.new
10
+ expect { order.save }.not_to change { Amount.count }
11
+ %i(sub_total taxes total).each do |name|
12
+ expect(order.send(name)).to eq(Money.zero)
13
+ end
14
+ order.sub_total = Money.new(100)
15
+ expect(order.sub_total).to eq(Money.new(100))
16
+ expect(order.total).to eq(Money.new(100))
17
+ expect(order.all_amounts.size).to eq(1)
18
+ order.all_amounts.first.tap do |amount|
19
+ expect(amount.name).to eq('sub_total')
20
+ expect(amount.value).to eq(Money.new(100))
21
+ expect(amount.new_record?).to be true
22
+ expect { order.save }.to change { Amount.count }.by(1)
23
+ expect(amount.persisted?).to be true
24
+ end
25
+ expect do
26
+ expect(order.update_attributes(sub_total: Money.new(200)))
27
+ end.not_to change { Amount.count }
23
28
  end
24
- expect do
25
- expect(order.update_attributes(sub_total: Money.new(200)))
26
- end.not_to change { Amount.count }
27
- end
28
29
 
29
- describe 'name=' do
30
- let (:order) { Order.create }
30
+ describe 'name=' do
31
+ let (:order) { Order.create }
31
32
 
32
- it 'should not persist Money.zero' do
33
- expect(order.sub_total = Money.zero).to eq(Money.zero)
34
- expect { order.save }.not_to change { Amount.count }
35
- end
33
+ it 'should not persist Money.zero' do
34
+ expect(order.sub_total = Money.zero).to eq(Money.zero)
35
+ expect { order.save }.not_to change { Amount.count }
36
+ end
36
37
 
37
- it 'should not persist Money.zero if using ActiveRecord persistence' do
38
- expect { order.update(sub_total: Money.zero) }.not_to change { Amount.count }
39
- end
38
+ it 'should not persist Money.zero if using ActiveRecord persistence' do
39
+ expect { order.update(sub_total: Money.zero) }.not_to change { Amount.count }
40
+ end
41
+
42
+ it 'should work with ActiveRecord#update' do
43
+ expect { order.update(sub_total: Money.new(1)) }.to change { Amount.count }.by(1)
44
+ end
40
45
 
41
- it 'should work with ActiveRecord#update' do
42
- expect { order.update(sub_total: Money.new(1)) }.to change { Amount.count }.by(1)
46
+ it 'should destroy Amount if exist and assigning Money.zero' do
47
+ order.update(sub_total: Money.new(1))
48
+ expect { order.sub_total = Money.zero }.to change { Amount.count }.by(-1)
49
+ expect(order.amounts.empty?).to be true
50
+ end
43
51
  end
44
52
 
45
- it 'should destroy Amount if exist and assigning Money.zero' do
46
- order.update(sub_total: Money.new(1))
47
- expect { order.sub_total = Money.zero }.to change { Amount.count }.by(-1)
48
- expect(order.amounts.empty?).to be true
53
+ it 'should insert amounts in bulk' do
54
+ order = Order.create
55
+ expect do
56
+ order.update(sub_total: Money.new(100), taxes: Money.new(200))
57
+ end.to make_database_queries(count: 1, manipulative: true)
49
58
  end
50
59
  end
51
60
 
52
- it 'should insert amounts in bulk' do
53
- order = Order.create
54
- expect do
55
- order.update(sub_total: Money.new(100), taxes: Money.new(200))
56
- end.to make_database_queries(count: 1, manipulative: true)
61
+ context 'storage == :jsonb' do
62
+ it 'should' do
63
+ subscription = Subscription.new
64
+ expect { subscription.save }.not_to change { Amount.count }
65
+ expect(subscription.amounts).to eq(Set.new)
66
+ expect(subscription.attributes['amounts']).to be_nil
67
+ %i(sub_total taxes total).each do |name|
68
+ expect(subscription.send(name)).to eq(Money.zero)
69
+ end
70
+ subscription.sub_total = Money.new(100)
71
+ expect(subscription.sub_total).to eq(Money.new(100))
72
+ expect(subscription.attributes['amounts']).to eq({'amounts' => {'sub_total' => {'cents' => 100, 'currency' => 'USD'}}, 'sets' => {'total' => {'cents' => 100, 'currency' => 'USD'}}})
73
+ expect(subscription.total).to eq(Money.new(100))
74
+ expect(subscription.amounts.size).to eq(1)
75
+ subscription.amounts.first.tap do |amount|
76
+ expect(amount.name).to eq('sub_total')
77
+ expect(amount.value).to eq(Money.new(100))
78
+ expect(amount.new_record?).to be true
79
+ expect { subscription.save }.not_to change { Amount.count }
80
+ expect(amount.persisted?).to be false
81
+ end
82
+ subscription.update_attributes(sub_total: Money.new(200))
83
+ expect(subscription.sub_total).to eq(Money.new(200))
84
+ expect(subscription.total).to eq(Money.new(200))
85
+ end
57
86
  end
87
+
58
88
  end
@@ -3,7 +3,7 @@
3
3
  class Order < ActiveRecord::Base
4
4
 
5
5
  include Amountable
6
-
6
+ act_as_amountable
7
7
  amount :sub_total, sets: [:total]
8
8
  amount :taxes, sets: [:total]
9
9
 
@@ -0,0 +1,12 @@
1
+ # Copyright 2015-2016, Instacart
2
+
3
+ if jsonb_available?
4
+ class Subscription < ActiveRecord::Base
5
+
6
+ include Amountable
7
+ act_as_amountable storage: :jsonb
8
+ amount :sub_total, sets: [:total]
9
+ amount :taxes, sets: [:total]
10
+
11
+ end
12
+ end
@@ -17,4 +17,12 @@ ActiveRecord::Schema.define do
17
17
  t.datetime "created_at"
18
18
  t.datetime "updated_at"
19
19
  end
20
+
21
+ if jsonb_available?
22
+ create_table "subscriptions", force: :cascade do |t|
23
+ t.datetime "created_at"
24
+ t.datetime "updated_at"
25
+ t.jsonb "amounts"
26
+ end
27
+ end
20
28
  end
@@ -29,6 +29,16 @@ rescue
29
29
  ActiveRecord::Base.establish_connection(config)
30
30
  end
31
31
 
32
+ def jsonb_available?
33
+ return @@jsonb_available if defined?(@@jsonb_available)
34
+ @@jsonb_available = if ActiveRecord::Base.connection.class.ancestors.include?(ActiveRecord::Import::PostgreSQLAdapter)
35
+ version = /PostgreSQL\s(\d+.\d+.\d+)\s/.match(ActiveRecord::Base.connection.execute("select version();")[0]['version'])[1].split('.').map(&:to_i)
36
+ version[0] >= 9 && version[1] >= 3
37
+ else
38
+ false
39
+ end
40
+ end
41
+
32
42
  require_relative '../internal/db/schema'
33
43
 
34
44
  Dir[spec_dir.join('internal/app/models/*.rb')].each { |file| require_relative file }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amountable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emmanuel Turlay
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-07-07 00:00:00.000000000 Z
11
+ date: 2016-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -209,11 +209,14 @@ files:
209
209
  - db/migrate/0_create_amounts.rb
210
210
  - lib/amountable.rb
211
211
  - lib/amountable/amount.rb
212
+ - lib/amountable/jsonb_methods.rb
213
+ - lib/amountable/table_methods.rb
212
214
  - lib/amountable/version.rb
213
215
  - spec/amountable/amount_spec.rb
214
216
  - spec/amountable/amountable_spec.rb
215
217
  - spec/amountable/nil_amount_spec.rb
216
218
  - spec/internal/app/models/order.rb
219
+ - spec/internal/app/models/subscription.rb
217
220
  - spec/internal/config/database.yml
218
221
  - spec/internal/config/database.yml.sample
219
222
  - spec/internal/db/schema.rb
@@ -239,7 +242,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
239
242
  version: '0'
240
243
  requirements: []
241
244
  rubyforge_project:
242
- rubygems_version: 2.5.1
245
+ rubygems_version: 2.4.5
243
246
  signing_key:
244
247
  specification_version: 4
245
248
  summary: Easy Money fields for your Rails models.
@@ -248,6 +251,7 @@ test_files:
248
251
  - spec/amountable/amountable_spec.rb
249
252
  - spec/amountable/nil_amount_spec.rb
250
253
  - spec/internal/app/models/order.rb
254
+ - spec/internal/app/models/subscription.rb
251
255
  - spec/internal/config/database.yml
252
256
  - spec/internal/config/database.yml.sample
253
257
  - spec/internal/db/schema.rb