amountable 0.0.8 → 0.1.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.
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