submodel 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 +7 -0
- data/.gitignore +22 -0
- data/.rspec +2 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +22 -0
- data/README.md +107 -0
- data/Rakefile +1 -0
- data/lib/submodel/active_record.rb +102 -0
- data/lib/submodel/version.rb +3 -0
- data/lib/submodel.rb +11 -0
- data/spec/spec_helper.rb +48 -0
- data/spec/submodel_spec.rb +354 -0
- data/spec/support/macros/database_macros.rb +25 -0
- data/submodel.gemspec +29 -0
- metadata +172 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 6a7f2069e601f125d05758bcccf692a42dbb5144
|
4
|
+
data.tar.gz: eb2f1f535261d908eec8a26bb9c7c4922e4f8d0e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 030119f82f68abd4d07d19d666ad62dd96f4c3b4fd501bb1bdaf00346e1c61d0df9a85bc47bafcd725e293d56416d550335370548ddbc3a2609d806e01248044
|
7
|
+
data.tar.gz: 31bd5140a8133d4c4a1c1a3fcf3a14944aa47bca5007127535e18d0aa68b2f64ad8c885f038561095953ea6ffe647897feebe148738ce1cd7f84b05483858c2e
|
data/.gitignore
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Rafaël Blais Masson
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
# Submodel
|
2
|
+
|
3
|
+
Submodel maps ActiveRecord columns to ActiveModel models, so that [hstore](http://www.postgresql.org/docs/9.3/static/hstore.html) or [serialized](http://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html) hash columns can share validations and be augmented with some methods. This can greatly help cleanup your business logic.
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
# Gemfile
|
9
|
+
gem 'submodel'
|
10
|
+
```
|
11
|
+
|
12
|
+
Create a submodel with `ActiveModel::Model`. Here’s an example `Address` model using [Carmen](http://github.com/jim/carmen) to provide country and state validations.
|
13
|
+
|
14
|
+
```ruby
|
15
|
+
# app/submodels/mailing_address.rb
|
16
|
+
|
17
|
+
class Address
|
18
|
+
include ActiveModel::Model
|
19
|
+
|
20
|
+
COUNTRY_CODES = Carmen::Country.all.map(&:code)
|
21
|
+
CA_STATE_CODES = Carmen::Country.coded('CA').subregions.map(&:code)
|
22
|
+
US_STATE_CODES = Carmen::Country.coded('US').subregions.map(&:code)
|
23
|
+
|
24
|
+
attr_accessor :street_1, :street_2, :city, :state, :country, :postal_code
|
25
|
+
|
26
|
+
validates_inclusion_of :country, in: COUNTRY_CODES
|
27
|
+
validates_inclusion_of :state, in: CA_STATE_CODES, if: :canada?
|
28
|
+
validates_inclusion_of :state, in: US_STATE_CODES, if: :united_states?
|
29
|
+
|
30
|
+
def canada?
|
31
|
+
country == 'CA'
|
32
|
+
end
|
33
|
+
|
34
|
+
def united_states?
|
35
|
+
country == 'US'
|
36
|
+
end
|
37
|
+
end
|
38
|
+
```
|
39
|
+
|
40
|
+
Use the `submodel` method to map your ActiveRecord columns to the submodel.
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
# app/models/order.rb
|
44
|
+
|
45
|
+
class Order < ActiveRecord::Base
|
46
|
+
submodel :billing_address, Address
|
47
|
+
end
|
48
|
+
```
|
49
|
+
|
50
|
+
Then, accessing `#billing_address` will return an instance created with `Address.new`. Similarly, passing a hash to `#billing_address=` will create a new instance with the hash as argument.
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
order = Order.new
|
54
|
+
order.attributes # => { "id" => nil, "billing_address" => nil }
|
55
|
+
|
56
|
+
order.billing_address # => #<Address>
|
57
|
+
order.billing_address.blank? # => true
|
58
|
+
|
59
|
+
order.billing_address.street_1 = '123 Fake Street'
|
60
|
+
order.billing_address # => #<Address street_1="123 Fake Street">
|
61
|
+
order.billing_address.blank? # => false
|
62
|
+
|
63
|
+
order.billing_address = { country: 'CA', state: 'QC' }
|
64
|
+
order.billing_address # => #<Address state="QC" country="CA">
|
65
|
+
```
|
66
|
+
|
67
|
+
Note: While the getter creates an instance on demand, blank submodels are persisted as `NULL`.
|
68
|
+
|
69
|
+
## Comparison
|
70
|
+
|
71
|
+
When using `==`, your submodel columns will be compared based on the stringified hash of their instance variables. Blank variables are ignored.
|
72
|
+
|
73
|
+
```ruby
|
74
|
+
order = Order.new
|
75
|
+
order.billing_address # => #<Address>
|
76
|
+
|
77
|
+
order.billing_address == Address.new # => true
|
78
|
+
order.billing_address == {} # => true
|
79
|
+
order.billing_address == { street_1: '', street_2: ' ' } # => true
|
80
|
+
order.billing_address == { street_1: 'foo', street_2: 'bar' } # => false
|
81
|
+
|
82
|
+
order.billing_address.country = 'CA'
|
83
|
+
order.billing_address.state = 'QC'
|
84
|
+
order.billing_address == { 'country' => 'CA', :state => 'QC' } # => true
|
85
|
+
order.billing_address == Address.new(country: 'CA') # => false
|
86
|
+
```
|
87
|
+
|
88
|
+
## Extending submodels per-column
|
89
|
+
|
90
|
+
You can pass the `submodel` method a block to be executed at the class level. For instance, this adds an (unfortunate) validation to `shipping_address`, leaving `billing_address` as is.
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
class Order < ActiveRecord::Base
|
94
|
+
submodel :billing_address, Address
|
95
|
+
submodel :shipping_address, Address do
|
96
|
+
validates :country, inclusion: { in: %w[US CA] }
|
97
|
+
end
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
## This gem seems overkill.
|
102
|
+
|
103
|
+
You might think “Why not just override the getter and setter?” In my experience, getting this *right* is always more complex. If you want proper behavior (validation, comparison, FormBuilder support, persistence) you basically have to repeat [this code](lib/submodel/active_record.rb) for every column.
|
104
|
+
|
105
|
+
---
|
106
|
+
|
107
|
+
© 2014 [Rafaël Blais Masson](http://rafbm.com). Submodel is released under the MIT license.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'bundler/gem_tasks'
|
@@ -0,0 +1,102 @@
|
|
1
|
+
module Submodel
|
2
|
+
def self.values(object)
|
3
|
+
object.instance_values.select { |key, value|
|
4
|
+
# This filters out ActiveModel::Validation’s @error variable since it has no setter
|
5
|
+
object.respond_to?("#{key}=") && value.present?
|
6
|
+
}
|
7
|
+
end
|
8
|
+
|
9
|
+
module ActiveRecord
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def submodel(attr, klass, validation_options = {}, &block)
|
14
|
+
column = columns_hash[attr.to_s]
|
15
|
+
|
16
|
+
augmented_klass = Class.new(klass) do
|
17
|
+
define_singleton_method :name do
|
18
|
+
klass.name
|
19
|
+
end
|
20
|
+
|
21
|
+
define_method :inspect do
|
22
|
+
attrs = Submodel.values(self).map { |k,v| "#{k}=#{v.inspect}" }.join(' ').presence
|
23
|
+
string = [klass.name, attrs].compact.join(' ')
|
24
|
+
"#<#{string}>"
|
25
|
+
end
|
26
|
+
alias_method :to_s, :inspect
|
27
|
+
|
28
|
+
define_method :blank? do
|
29
|
+
Submodel.values(self).blank?
|
30
|
+
end
|
31
|
+
|
32
|
+
define_method :== do |other|
|
33
|
+
hash = Submodel.values(self)
|
34
|
+
|
35
|
+
if other.is_a? klass
|
36
|
+
hash == Submodel.values(other)
|
37
|
+
elsif other.is_a? Hash
|
38
|
+
hash == other.stringify_keys
|
39
|
+
else
|
40
|
+
hash == other
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
if block_given?
|
46
|
+
augmented_klass.class_eval &block
|
47
|
+
end
|
48
|
+
|
49
|
+
serialize attr, Module.new {
|
50
|
+
define_singleton_method :load do |value|
|
51
|
+
if value.is_a? String
|
52
|
+
case column.type
|
53
|
+
when :hstore
|
54
|
+
value = ::ActiveRecord::ConnectionAdapters::PostgreSQLColumn.string_to_hstore(value)
|
55
|
+
when :json
|
56
|
+
value = JSON.parse(value)
|
57
|
+
else
|
58
|
+
value = YAML.load(value)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
value.present? ? augmented_klass.new(value) : nil
|
62
|
+
end
|
63
|
+
|
64
|
+
define_singleton_method :dump do |object|
|
65
|
+
if hash = Submodel.values(object).presence
|
66
|
+
case column.type
|
67
|
+
when :hstore, :json then hash
|
68
|
+
else YAML.dump(hash)
|
69
|
+
end
|
70
|
+
else
|
71
|
+
nil
|
72
|
+
end
|
73
|
+
end
|
74
|
+
}
|
75
|
+
|
76
|
+
# Include as module so we can override accessors and use `super`
|
77
|
+
include Module.new {
|
78
|
+
define_method attr do
|
79
|
+
self[attr] ||= augmented_klass.new
|
80
|
+
end
|
81
|
+
|
82
|
+
define_method :"#{attr}=" do |value|
|
83
|
+
if value.nil?
|
84
|
+
self[attr] = nil
|
85
|
+
elsif value.is_a? klass
|
86
|
+
self[attr] = value.dup
|
87
|
+
else
|
88
|
+
self[attr] = augmented_klass.new(value)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
alias_method :"#{attr}_attributes=", :"#{attr}="
|
92
|
+
}
|
93
|
+
|
94
|
+
validates_each(attr, validation_options) do |record, attribute, object|
|
95
|
+
if object.try(:invalid?)
|
96
|
+
record.errors.add(attribute, object.errors.full_messages.to_sentence.downcase)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
data/lib/submodel.rb
ADDED
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'carmen'
|
3
|
+
|
4
|
+
Dir[File.expand_path('../../spec/support/**/*.rb', __FILE__)].map(&method(:require))
|
5
|
+
|
6
|
+
RSpec.configure do |config|
|
7
|
+
config.include DatabaseMacros
|
8
|
+
|
9
|
+
config.before :suite do
|
10
|
+
DatabaseMacros.create_databases
|
11
|
+
end
|
12
|
+
|
13
|
+
config.filter_run :focus
|
14
|
+
config.run_all_when_everything_filtered = true
|
15
|
+
|
16
|
+
if config.files_to_run.one?
|
17
|
+
# RSpec filters the backtrace by default so as not to be so noisy.
|
18
|
+
# This causes the full backtrace to be printed when running a single
|
19
|
+
# spec file (e.g. to troubleshoot a particular spec failure).
|
20
|
+
config.full_backtrace = false
|
21
|
+
|
22
|
+
# Use the documentation formatter for detailed output,
|
23
|
+
# unless a formatter has already been configured
|
24
|
+
# (e.g. via a command-line flag).
|
25
|
+
config.formatter = 'doc' if config.formatters.none?
|
26
|
+
end
|
27
|
+
|
28
|
+
# Run specs in random order to surface order dependencies. If you find an
|
29
|
+
# order dependency and want to debug it, you can fix the order by providing
|
30
|
+
# the seed, which is printed after each run.
|
31
|
+
# --seed 1234
|
32
|
+
config.order = :random
|
33
|
+
|
34
|
+
# Seed global randomization in this process using the `--seed` CLI option.
|
35
|
+
# Setting this allows you to use `--seed` to deterministically reproduce
|
36
|
+
# test failures related to randomization by passing the same `--seed` value
|
37
|
+
# as the one that triggered the failure.
|
38
|
+
Kernel.srand config.seed
|
39
|
+
|
40
|
+
config.expect_with :rspec do |expectations|
|
41
|
+
expectations.syntax = :expect
|
42
|
+
end
|
43
|
+
|
44
|
+
config.mock_with :rspec do |mocks|
|
45
|
+
mocks.syntax = :expect
|
46
|
+
mocks.verify_partial_doubles = true
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,354 @@
|
|
1
|
+
require 'submodel'
|
2
|
+
|
3
|
+
# Suppress annoying deprecation notice
|
4
|
+
I18n.enforce_available_locales = false
|
5
|
+
|
6
|
+
class Address
|
7
|
+
include ActiveModel::Model
|
8
|
+
|
9
|
+
COUNTRY_CODES = Carmen::Country.all.map(&:code)
|
10
|
+
US_STATE_CODES = Carmen::Country.coded('US').subregions.map(&:code)
|
11
|
+
CA_STATE_CODES = Carmen::Country.coded('CA').subregions.map(&:code)
|
12
|
+
|
13
|
+
attr_accessor :street_1, :street_2, :city, :state, :country, :postal_code
|
14
|
+
|
15
|
+
validates :country, inclusion: { in: COUNTRY_CODES }
|
16
|
+
|
17
|
+
with_options if: -> { country == 'US' } do |o|
|
18
|
+
o.validates :state, inclusion: { in: US_STATE_CODES }
|
19
|
+
o.validates :postal_code, format: /\d{5}/
|
20
|
+
end
|
21
|
+
|
22
|
+
with_options if: -> { country == 'CA' } do |o|
|
23
|
+
o.validates :state, inclusion: { in: CA_STATE_CODES }
|
24
|
+
o.validates :postal_code, format: /[a-z]\d[a-z]\W*\d[a-z]\d/i
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
shared_examples Submodel do
|
29
|
+
before do
|
30
|
+
Object.send(:remove_const, 'Order') if Object.const_defined? 'Order'
|
31
|
+
|
32
|
+
class Order < ActiveRecord::Base
|
33
|
+
submodel :billing_address, Address
|
34
|
+
submodel :shipping_address, Address, allow_blank: true do
|
35
|
+
validates :country, inclusion: { in: %w[US CA ME] }, allow_blank: true
|
36
|
+
end
|
37
|
+
|
38
|
+
# This tests that accessors are included as a module
|
39
|
+
def billing_address=(value)
|
40
|
+
super
|
41
|
+
end
|
42
|
+
def shipping_address
|
43
|
+
super
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
let(:valid_address_hash) { { 'country' => 'CA', :state => 'QC', 'postal_code' => 'G1K 3J3' } }
|
49
|
+
let(:valid_address) { Address.new(valid_address_hash) }
|
50
|
+
let(:order) { Order.new }
|
51
|
+
|
52
|
+
it 'class name is the same' do
|
53
|
+
expect(order.billing_address.class.name).to eq 'Address'
|
54
|
+
expect(order.shipping_address.class.name).to eq 'Address'
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'when model attribute is nil' do
|
58
|
+
it 'leaves attribute as nil' do
|
59
|
+
expect(order.attributes).to eq({
|
60
|
+
'id' => nil, 'billing_address' => nil, 'shipping_address' => nil
|
61
|
+
})
|
62
|
+
expect(order[:billing_address]).to eq nil
|
63
|
+
expect(order[:shipping_address]).to eq nil
|
64
|
+
end
|
65
|
+
|
66
|
+
describe 'getter' do
|
67
|
+
it 'sets attribute to blank submodel instance' do
|
68
|
+
expect(order.billing_address).to be_a Address
|
69
|
+
expect(order.billing_address.blank?).to eq true
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
describe 'setter' do
|
74
|
+
it 'sets attribute to object with passed value' do
|
75
|
+
order.billing_address = { street_1: '123 Fake Street' }
|
76
|
+
expect(order[:billing_address]).to be_a Address
|
77
|
+
expect(order[:billing_address].street_1).to eq '123 Fake Street'
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe '== comparison' do
|
82
|
+
it 'returns true when passed empty hash' do
|
83
|
+
expect(order.billing_address == {}).to eq true
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'returns false when passed non-empty hash' do
|
87
|
+
expect(order.billing_address == { foo: 'bar' }).to eq false
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
[:inspect, :to_s].each do |meth|
|
92
|
+
describe "##{meth}" do
|
93
|
+
it 'outputs the right class name' do
|
94
|
+
expect(order.billing_address.public_send(meth)).to eq '#<Address>'
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
context 'when model attribute has value' do
|
101
|
+
let(:order) { Order.new(billing_address: { street_1: '123 Foo Street' }) }
|
102
|
+
let(:equivalent_address) { Address.new(street_1: '123 Foo Street', street_2: '') }
|
103
|
+
let(:different_address) { Address.new(street_1: '123 Bar Street') }
|
104
|
+
|
105
|
+
describe '== comparison' do
|
106
|
+
it 'returns true when passed equivalent object' do
|
107
|
+
expect(order.billing_address == equivalent_address).to eq true
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'returns false when passed different object' do
|
111
|
+
expect(order.billing_address == different_address).to eq false
|
112
|
+
end
|
113
|
+
|
114
|
+
it 'returns false when passed different hash' do
|
115
|
+
expect(order.billing_address == { 'street_1' => 'blah blah blah' }).to eq false
|
116
|
+
end
|
117
|
+
|
118
|
+
it 'returns true when passed equivalent hash with string keys' do
|
119
|
+
expect(order.billing_address == { 'street_1' => '123 Foo Street' }).to eq true
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'returns true when passed equivalent hash with symbol keys' do
|
123
|
+
expect(order.billing_address == { street_1: '123 Foo Street' }).to eq true
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'returns false when passed irrelevant object' do
|
127
|
+
expect(order.billing_address == 1).to eq false
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
describe 'setter' do
|
132
|
+
it 'dups object when passed other object' do
|
133
|
+
order.billing_address = different_address
|
134
|
+
expect(order.billing_address.street_1).to eq '123 Bar Street'
|
135
|
+
|
136
|
+
order.billing_address.street_1 = 'blah blah blah'
|
137
|
+
expect(order.billing_address.street_1).to eq 'blah blah blah'
|
138
|
+
expect(different_address.street_1).to eq '123 Bar Street'
|
139
|
+
end
|
140
|
+
|
141
|
+
it 'sets attribute to nil when passed nil' do
|
142
|
+
order.billing_address = nil
|
143
|
+
expect(order[:billing_address]).to eq nil
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
[:inspect, :to_s].each do |meth|
|
148
|
+
describe "##{meth}" do
|
149
|
+
it 'outputs the right class name and variables' do
|
150
|
+
order.billing_address.street_2 = 'apt. 2'
|
151
|
+
expect(order.billing_address.public_send(meth)).to eq(
|
152
|
+
'#<Address street_1="123 Foo Street" street_2="apt. 2">'
|
153
|
+
)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
describe 'persistence' do
|
160
|
+
context 'passing empty hash to setter' do
|
161
|
+
it 'persists NULL' do
|
162
|
+
order = Order.create!(
|
163
|
+
billing_address: valid_address,
|
164
|
+
shipping_address: {},
|
165
|
+
)
|
166
|
+
expect(raw_select(:orders, :shipping_address, id: order.id)).to eq nil
|
167
|
+
|
168
|
+
order = Order.first
|
169
|
+
expect(order.billing_address).to eq valid_address
|
170
|
+
expect(order.shipping_address).to eq({})
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
context 'passing hash with blank values to setter' do
|
175
|
+
it 'persists NULL' do
|
176
|
+
order = Order.create!(
|
177
|
+
billing_address: valid_address,
|
178
|
+
shipping_address: { street_1: '', city: ' ' },
|
179
|
+
)
|
180
|
+
expect(raw_select(:orders, :shipping_address, id: order.id)).to eq nil
|
181
|
+
|
182
|
+
order = Order.first
|
183
|
+
expect(order.billing_address).to eq valid_address
|
184
|
+
expect(order.shipping_address).to eq({})
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
context 'passing hash with non-blank values to setter' do |example|
|
189
|
+
it 'persists hash' do
|
190
|
+
order = Order.create!(
|
191
|
+
billing_address: valid_address,
|
192
|
+
shipping_address: { street_1: '123 Fake Street', city: 'Springfield', country: 'ME' },
|
193
|
+
)
|
194
|
+
|
195
|
+
case example.metadata[:billing_address_type]
|
196
|
+
when :hstore
|
197
|
+
billing_string = '"state"=>"QC", "country"=>"CA", "postal_code"=>"G1K 3J3"'
|
198
|
+
when :json
|
199
|
+
billing_string = '{"country":"CA","state":"QC","postal_code":"G1K 3J3"}'
|
200
|
+
else
|
201
|
+
billing_string = "---\ncountry: CA\nstate: QC\npostal_code: G1K 3J3\n"
|
202
|
+
end
|
203
|
+
expect(raw_select(:orders, :billing_address, id: order.id)).to eq billing_string
|
204
|
+
|
205
|
+
case example.metadata[:shipping_address_type]
|
206
|
+
when :hstore
|
207
|
+
shipping_string = '"city"=>"Springfield", "country"=>"ME", "street_1"=>"123 Fake Street"'
|
208
|
+
when :json
|
209
|
+
shipping_string = '{"street_1":"123 Fake Street","city":"Springfield","country":"ME"}'
|
210
|
+
else
|
211
|
+
shipping_string = "---\nstreet_1: 123 Fake Street\ncity: Springfield\ncountry: ME\n"
|
212
|
+
end
|
213
|
+
expect(raw_select(:orders, :shipping_address, id: order.id)).to eq shipping_string
|
214
|
+
|
215
|
+
order = Order.first
|
216
|
+
expect(order.billing_address).to eq valid_address
|
217
|
+
expect(order.shipping_address).to eq({
|
218
|
+
street_1: '123 Fake Street', city: 'Springfield', country: 'ME'
|
219
|
+
})
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
it 'doesn’t persist ActiveModel::Validation’s @error variable' do |example|
|
224
|
+
order = Order.new(billing_address: { state: 'QC', country: 'US' })
|
225
|
+
order.valid?
|
226
|
+
order.save! validate: false
|
227
|
+
|
228
|
+
case example.metadata[:billing_address_type]
|
229
|
+
when :hstore
|
230
|
+
billing_string = '"state"=>"QC", "country"=>"US"'
|
231
|
+
when :json
|
232
|
+
billing_string = '{"state":"QC","country":"US"}'
|
233
|
+
else
|
234
|
+
billing_string = "---\nstate: QC\ncountry: US\n"
|
235
|
+
end
|
236
|
+
expect(raw_select(:orders, :billing_address, id: order.id)).to eq billing_string
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
context 'when validating presence of submodel' do
|
241
|
+
let(:order) { Order.new(billing_address: { state: ' ', postal_code: '' }) }
|
242
|
+
|
243
|
+
it 'doesn’t accept blank object' do
|
244
|
+
expect(order.valid?).to eq false
|
245
|
+
expect(order.errors.keys).to include :billing_address
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
context 'when not validating presence of submodel' do
|
250
|
+
let(:order) {
|
251
|
+
Order.new(billing_address: valid_address_hash, shipping_address: { state: ' ', postal_code: '' })
|
252
|
+
}
|
253
|
+
|
254
|
+
it 'accepts blank object' do
|
255
|
+
expect(order.valid?).to eq true
|
256
|
+
end
|
257
|
+
|
258
|
+
it 'doesn’t accept invalid object' do
|
259
|
+
order.shipping_address = Address.new(country: 'CA', state: 'FOO')
|
260
|
+
expect(order.valid?).to eq false
|
261
|
+
expect(order.errors.keys).to include :shipping_address
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
context 'when extending one submodel column' do
|
266
|
+
let(:order) {
|
267
|
+
Order.new(
|
268
|
+
billing_address: { country: 'NL' },
|
269
|
+
shipping_address: { country: 'NL' },
|
270
|
+
)
|
271
|
+
}
|
272
|
+
|
273
|
+
it 'affects only the said column' do
|
274
|
+
expect(order.invalid?).to eq true
|
275
|
+
expect(order.errors.keys).to eq [:shipping_address]
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
describe 'submodel validation error message' do
|
280
|
+
it 'makes a sentence with all submodel errors' do
|
281
|
+
order = Order.new(
|
282
|
+
billing_address: { country: 'US', state: 'QC', postal_code: 'H0H 0H0' },
|
283
|
+
)
|
284
|
+
expect(order.valid?).to eq false
|
285
|
+
expect(order.errors.full_messages).to eq [
|
286
|
+
'Billing address state is not included in the list and postal code is invalid'
|
287
|
+
]
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
describe 'XXX_attributes= methods' do
|
292
|
+
it 'works' do
|
293
|
+
order = Order.new(billing_address_attributes: valid_address_hash)
|
294
|
+
expect(order.billing_address).to eq valid_address
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
describe 'attributes_before_type_cast' do
|
299
|
+
it 'does not break' do
|
300
|
+
order = Order.create!(billing_address: valid_address_hash, shipping_address: valid_address_hash)
|
301
|
+
expect(Order.last.attributes_before_type_cast.keys).to eq ['id', 'billing_address', 'shipping_address']
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
describe 'PostgreSQL', billing_address_type: :hstore, shipping_address_type: :json do
|
307
|
+
before do
|
308
|
+
ActiveRecord::Base.establish_connection(
|
309
|
+
adapter: 'postgresql', username: `whoami`.chomp, database: 'submodel_spec')
|
310
|
+
|
311
|
+
run_migration do
|
312
|
+
enable_extension :hstore
|
313
|
+
|
314
|
+
create_table :orders, force: true do |t|
|
315
|
+
t.hstore :billing_address
|
316
|
+
t.json :shipping_address
|
317
|
+
end
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
it_behaves_like Submodel
|
322
|
+
end
|
323
|
+
|
324
|
+
describe 'SQLite', billing_address_type: :text, shipping_address_type: :text do
|
325
|
+
before do
|
326
|
+
ActiveRecord::Base.establish_connection(
|
327
|
+
adapter: 'sqlite3', database: 'tmp/submodel.sqlite3')
|
328
|
+
|
329
|
+
run_migration do
|
330
|
+
create_table :orders, force: true do |t|
|
331
|
+
t.text :billing_address
|
332
|
+
t.text :shipping_address
|
333
|
+
end
|
334
|
+
end
|
335
|
+
end
|
336
|
+
|
337
|
+
it_behaves_like Submodel
|
338
|
+
end
|
339
|
+
|
340
|
+
describe 'MySQL', billing_address_type: :text, shipping_address_type: :text do
|
341
|
+
before do
|
342
|
+
ActiveRecord::Base.establish_connection(
|
343
|
+
adapter: 'mysql2', username: 'root', database: 'submodel_spec')
|
344
|
+
|
345
|
+
run_migration do
|
346
|
+
create_table :orders, force: true do |t|
|
347
|
+
t.text :billing_address
|
348
|
+
t.text :shipping_address
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|
352
|
+
|
353
|
+
it_behaves_like Submodel
|
354
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module DatabaseMacros
|
2
|
+
def self.create_databases
|
3
|
+
[
|
4
|
+
{ adapter: 'postgresql', username: `whoami`.chomp, database: 'postgres' },
|
5
|
+
{ adapter: 'mysql2', username: 'root', database: 'mysql' },
|
6
|
+
].each do |config|
|
7
|
+
ActiveRecord::Base.establish_connection(config)
|
8
|
+
ActiveRecord::Base.connection.drop_database 'submodel_spec' rescue nil
|
9
|
+
ActiveRecord::Base.connection.create_database 'submodel_spec'
|
10
|
+
end
|
11
|
+
|
12
|
+
ActiveRecord::Base.logger = ActiveRecord::Migration.verbose = false
|
13
|
+
end
|
14
|
+
|
15
|
+
# Taken from https://github.com/mirego/partisan/blob/master/spec/support/macros/database_macros.rb
|
16
|
+
def run_migration(&block)
|
17
|
+
klass = Class.new(ActiveRecord::Migration)
|
18
|
+
klass.send(:define_method, :up) { instance_exec &block }
|
19
|
+
klass.new.up
|
20
|
+
end
|
21
|
+
|
22
|
+
def raw_select(table, column, id: nil)
|
23
|
+
ActiveRecord::Base.connection.select_one("SELECT #{column} FROM #{table} WHERE id = #{id}")[column.to_s]
|
24
|
+
end
|
25
|
+
end
|
data/submodel.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'submodel/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'submodel'
|
8
|
+
spec.version = Submodel::VERSION
|
9
|
+
spec.authors = ['Rafaël Blais Masson']
|
10
|
+
spec.email = ['rafbmasson@gmail.com']
|
11
|
+
spec.summary = 'Submodel maps ActiveRecord columns to ActiveModel models.'
|
12
|
+
spec.homepage = 'http://github.com/rafBM/submodel'
|
13
|
+
spec.license = 'MIT'
|
14
|
+
|
15
|
+
spec.files = `git ls-files -z`.split("\x0")
|
16
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
18
|
+
spec.require_paths = ['lib']
|
19
|
+
|
20
|
+
spec.add_dependency 'activerecord', '>= 3.0'
|
21
|
+
|
22
|
+
spec.add_development_dependency 'bundler', '~> 1.6'
|
23
|
+
spec.add_development_dependency 'rake'
|
24
|
+
spec.add_development_dependency 'rspec', '>= 3.0.0.beta2'
|
25
|
+
spec.add_development_dependency 'pg'
|
26
|
+
spec.add_development_dependency 'sqlite3'
|
27
|
+
spec.add_development_dependency 'mysql2'
|
28
|
+
spec.add_development_dependency 'carmen'
|
29
|
+
end
|
metadata
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: submodel
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Rafaël Blais Masson
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-05-28 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '3.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.6'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.6'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: 3.0.0.beta2
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: 3.0.0.beta2
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: pg
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: sqlite3
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: mysql2
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: carmen
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description:
|
126
|
+
email:
|
127
|
+
- rafbmasson@gmail.com
|
128
|
+
executables: []
|
129
|
+
extensions: []
|
130
|
+
extra_rdoc_files: []
|
131
|
+
files:
|
132
|
+
- ".gitignore"
|
133
|
+
- ".rspec"
|
134
|
+
- Gemfile
|
135
|
+
- LICENSE.txt
|
136
|
+
- README.md
|
137
|
+
- Rakefile
|
138
|
+
- lib/submodel.rb
|
139
|
+
- lib/submodel/active_record.rb
|
140
|
+
- lib/submodel/version.rb
|
141
|
+
- spec/spec_helper.rb
|
142
|
+
- spec/submodel_spec.rb
|
143
|
+
- spec/support/macros/database_macros.rb
|
144
|
+
- submodel.gemspec
|
145
|
+
homepage: http://github.com/rafBM/submodel
|
146
|
+
licenses:
|
147
|
+
- MIT
|
148
|
+
metadata: {}
|
149
|
+
post_install_message:
|
150
|
+
rdoc_options: []
|
151
|
+
require_paths:
|
152
|
+
- lib
|
153
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
154
|
+
requirements:
|
155
|
+
- - ">="
|
156
|
+
- !ruby/object:Gem::Version
|
157
|
+
version: '0'
|
158
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
159
|
+
requirements:
|
160
|
+
- - ">="
|
161
|
+
- !ruby/object:Gem::Version
|
162
|
+
version: '0'
|
163
|
+
requirements: []
|
164
|
+
rubyforge_project:
|
165
|
+
rubygems_version: 2.2.2
|
166
|
+
signing_key:
|
167
|
+
specification_version: 4
|
168
|
+
summary: Submodel maps ActiveRecord columns to ActiveModel models.
|
169
|
+
test_files:
|
170
|
+
- spec/spec_helper.rb
|
171
|
+
- spec/submodel_spec.rb
|
172
|
+
- spec/support/macros/database_macros.rb
|