nested_record 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +8 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +152 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +6 -0
- data/bin/console +7 -0
- data/bin/setup +8 -0
- data/lib/nested_record.rb +15 -0
- data/lib/nested_record/base.rb +190 -0
- data/lib/nested_record/collection.rb +99 -0
- data/lib/nested_record/errors.rb +5 -0
- data/lib/nested_record/lookup_const.rb +22 -0
- data/lib/nested_record/macro.rb +15 -0
- data/lib/nested_record/setup.rb +225 -0
- data/lib/nested_record/type.rb +64 -0
- data/lib/nested_record/version.rb +5 -0
- data/nested_record.gemspec +32 -0
- data/spec/nested_record/base_spec.rb +181 -0
- data/spec/nested_record/collection_spec.rb +27 -0
- data/spec/nested_record_spec.rb +286 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/model.rb +66 -0
- metadata +153 -0
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class NestedRecord::Collection
|
4
|
+
class << self
|
5
|
+
attr_reader :record_class
|
6
|
+
end
|
7
|
+
|
8
|
+
include Enumerable
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@ary = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize_dup(orig)
|
15
|
+
@ary = orig.to_ary
|
16
|
+
end
|
17
|
+
|
18
|
+
def as_json
|
19
|
+
@ary.as_json
|
20
|
+
end
|
21
|
+
|
22
|
+
def each
|
23
|
+
return to_enum(:each) unless block_given?
|
24
|
+
@ary.each(&proc)
|
25
|
+
end
|
26
|
+
|
27
|
+
def to_ary
|
28
|
+
@ary.dup
|
29
|
+
end
|
30
|
+
|
31
|
+
def ==(other)
|
32
|
+
@ary == other.to_ary
|
33
|
+
end
|
34
|
+
|
35
|
+
def <<(obj)
|
36
|
+
unless obj.kind_of?(record_class)
|
37
|
+
raise NestedRecord::TypeMismatchError, "#{obj.inspect} should be a #{record_class}"
|
38
|
+
end
|
39
|
+
@ary << obj
|
40
|
+
self
|
41
|
+
end
|
42
|
+
|
43
|
+
def build(attributes = {})
|
44
|
+
record_class.new(attributes).tap do |obj|
|
45
|
+
self << obj
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def inspect
|
50
|
+
@ary.inspect
|
51
|
+
end
|
52
|
+
|
53
|
+
def empty?
|
54
|
+
@ary.empty?
|
55
|
+
end
|
56
|
+
|
57
|
+
def clear
|
58
|
+
@ary.clear
|
59
|
+
self
|
60
|
+
end
|
61
|
+
|
62
|
+
def length
|
63
|
+
@ary.length
|
64
|
+
end
|
65
|
+
|
66
|
+
def select!
|
67
|
+
return to_enum(:select!) unless block_given?
|
68
|
+
@ary.select!(&proc)
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
def reject!
|
73
|
+
return to_enum(:reject!) unless block_given?
|
74
|
+
@ary.reject!(&proc)
|
75
|
+
self
|
76
|
+
end
|
77
|
+
|
78
|
+
def reject_by!(attrs)
|
79
|
+
return to_enum(:reject_by!) unless block_given?
|
80
|
+
attrs = attrs.stringify_keys
|
81
|
+
reject! { |obj| obj.match?(attrs) }
|
82
|
+
end
|
83
|
+
|
84
|
+
def find_by(attrs)
|
85
|
+
attrs = attrs.stringify_keys
|
86
|
+
find { |obj| obj.match?(attrs) }
|
87
|
+
end
|
88
|
+
|
89
|
+
def find_or_initialize_by(attrs)
|
90
|
+
attrs = attrs.stringify_keys
|
91
|
+
find_by(attrs) || build(attrs)
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def record_class
|
97
|
+
self.class.record_class
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NestedRecord
|
4
|
+
# Derived from Rails source: https://github.com/rails/rails/blob/98a57aa5f610bc66af31af409c72173cdeeb3c9e/activerecord/lib/active_record/inheritance.rb#L181-L207
|
5
|
+
|
6
|
+
def self.lookup_const(owner, type_name)
|
7
|
+
if type_name.start_with?('::')
|
8
|
+
ActiveSupport::Dependencies.constantize(type_name)
|
9
|
+
else
|
10
|
+
candidates = []
|
11
|
+
owner.name.scan(/::|$/) { candidates.unshift "#{$`}::#{type_name}" }
|
12
|
+
candidates << type_name
|
13
|
+
|
14
|
+
candidates.each do |candidate|
|
15
|
+
constant = ActiveSupport::Dependencies.safe_constantize(candidate)
|
16
|
+
return constant if candidate == constant.to_s
|
17
|
+
end
|
18
|
+
|
19
|
+
raise NameError.new("uninitialized constant #{candidates.first}", candidates.first)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module NestedRecord::Macro
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
module ClassMethods
|
7
|
+
def has_many_nested(name, **options, &block)
|
8
|
+
NestedRecord::Setup::HasMany.new(self, name, **options, &block)
|
9
|
+
end
|
10
|
+
|
11
|
+
def has_one_nested(name, **options)
|
12
|
+
NestedRecord::Setup::HasOne.new(self, name, **options)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class NestedRecord::Setup
|
4
|
+
def initialize(owner, name, **options, &extension)
|
5
|
+
@options = options
|
6
|
+
@owner = owner
|
7
|
+
@record_class = options[:class_name] || name.to_s.classify
|
8
|
+
@name = name
|
9
|
+
@extension = extension
|
10
|
+
|
11
|
+
define_methods
|
12
|
+
|
13
|
+
@owner.attribute @name, type, default: default_value
|
14
|
+
@owner.validate validation_method_name
|
15
|
+
end
|
16
|
+
|
17
|
+
def record_class
|
18
|
+
if @record_class.is_a? String
|
19
|
+
@record_class = NestedRecord.lookup_const(@owner, @record_class)
|
20
|
+
end
|
21
|
+
@record_class
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def writer_method_name
|
27
|
+
@writer_method_name ||= :"#{@name}="
|
28
|
+
end
|
29
|
+
|
30
|
+
def attributes_writer_method_name
|
31
|
+
@attributes_writer_method_name ||= :"#{@name}_attributes="
|
32
|
+
end
|
33
|
+
|
34
|
+
def validation_method_name
|
35
|
+
@validation_method_name ||= :"validate_associated_records_for_#{@name}"
|
36
|
+
end
|
37
|
+
|
38
|
+
def define_methods
|
39
|
+
define_writer_method
|
40
|
+
define_attributes_writer_method
|
41
|
+
define_validation_method
|
42
|
+
end
|
43
|
+
|
44
|
+
class HasMany < self
|
45
|
+
def initialize(*)
|
46
|
+
super
|
47
|
+
if (attributes_writer_opts = @options[:attributes_writer]).is_a? Hash
|
48
|
+
@reject_if_proc = attributes_writer_opts[:reject_if]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def type
|
53
|
+
@type ||= NestedRecord::Type::HasMany.new(self)
|
54
|
+
end
|
55
|
+
|
56
|
+
def collection_class_name
|
57
|
+
@collection_class_name ||= :"NestedRecord_Many#{@name.to_s.camelize}"
|
58
|
+
end
|
59
|
+
|
60
|
+
def collection_class
|
61
|
+
return @owner.const_get(collection_class_name, false) if @owner.const_defined?(collection_class_name, false)
|
62
|
+
extension = @extension
|
63
|
+
collection_superclass = record_class.collection_class
|
64
|
+
@owner.const_set(
|
65
|
+
collection_class_name,
|
66
|
+
Class.new(collection_superclass) do
|
67
|
+
@record_class = collection_superclass.record_class
|
68
|
+
include Module.new(&extension) if extension
|
69
|
+
end
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
def reject?(attributes)
|
74
|
+
@reject_if_proc&.call(attributes)
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
def default_value
|
80
|
+
[]
|
81
|
+
end
|
82
|
+
|
83
|
+
def define_writer_method
|
84
|
+
setup = self
|
85
|
+
@owner.define_method(writer_method_name) do |records|
|
86
|
+
collection_class = setup.collection_class
|
87
|
+
return super(records.dup) if records.is_a? collection_class
|
88
|
+
collection = collection_class.new
|
89
|
+
records.each do |obj|
|
90
|
+
collection << obj
|
91
|
+
end
|
92
|
+
super(collection)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def define_attributes_writer_method
|
97
|
+
return unless @options.fetch(:attributes_writer) { true }
|
98
|
+
setup = self
|
99
|
+
writer_method = writer_method_name
|
100
|
+
@owner.define_method(attributes_writer_method_name) do |data|
|
101
|
+
attributes_collection =
|
102
|
+
if data.is_a? Hash
|
103
|
+
data.values
|
104
|
+
else
|
105
|
+
data
|
106
|
+
end
|
107
|
+
collection = setup.collection_class.new
|
108
|
+
attributes_collection.each do |attributes|
|
109
|
+
attributes = attributes.stringify_keys
|
110
|
+
next if setup.reject?(attributes)
|
111
|
+
collection.build(attributes)
|
112
|
+
end
|
113
|
+
public_send(writer_method, collection)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def define_validation_method
|
118
|
+
setup = self
|
119
|
+
name = @name
|
120
|
+
@owner.define_method(validation_method_name) do
|
121
|
+
collection = public_send(name)
|
122
|
+
collection.map do |record|
|
123
|
+
next true if record.valid?
|
124
|
+
record.errors.each do |attribute, message|
|
125
|
+
error_attribute = "#{name}.#{attribute}"
|
126
|
+
errors[error_attribute] << message
|
127
|
+
errors[error_attribute].uniq!
|
128
|
+
end
|
129
|
+
record.errors.details.each_key do |attribute|
|
130
|
+
error_attribute = "#{name}.#{attribute}"
|
131
|
+
record.errors.details[attribute].each do |error|
|
132
|
+
errors.details[error_attribute] << error
|
133
|
+
errors.details[error_attribute].uniq!
|
134
|
+
end
|
135
|
+
end
|
136
|
+
false
|
137
|
+
end.all?
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
class HasOne < self
|
143
|
+
def define_methods
|
144
|
+
define_writer_method
|
145
|
+
define_build_method
|
146
|
+
define_attributes_writer_method
|
147
|
+
define_validation_method
|
148
|
+
define_bang_method
|
149
|
+
end
|
150
|
+
|
151
|
+
def type
|
152
|
+
@type ||= NestedRecord::Type::HasOne.new(self)
|
153
|
+
end
|
154
|
+
|
155
|
+
private
|
156
|
+
|
157
|
+
def default_value
|
158
|
+
nil
|
159
|
+
end
|
160
|
+
|
161
|
+
def build_method_name
|
162
|
+
:"build_#{@name}"
|
163
|
+
end
|
164
|
+
|
165
|
+
def bang_method_name
|
166
|
+
:"#{@name}!"
|
167
|
+
end
|
168
|
+
|
169
|
+
def define_writer_method
|
170
|
+
setup = self
|
171
|
+
@owner.define_method(writer_method_name) do |record|
|
172
|
+
unless record.nil? || record.kind_of?(setup.record_class)
|
173
|
+
raise NestedRecord::TypeMismatchError, "#{record.inspect} should be a #{setup.record_class}"
|
174
|
+
end
|
175
|
+
super(record)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def define_attributes_writer_method
|
180
|
+
return unless @options.fetch(:attributes_writer) { true }
|
181
|
+
@owner.alias_method attributes_writer_method_name, build_method_name
|
182
|
+
end
|
183
|
+
|
184
|
+
def define_validation_method
|
185
|
+
setup = self
|
186
|
+
name = @name
|
187
|
+
@owner.define_method(validation_method_name) do
|
188
|
+
record = public_send(name)
|
189
|
+
return true unless record
|
190
|
+
return true if record.valid?
|
191
|
+
|
192
|
+
record.errors.each do |attribute, message|
|
193
|
+
error_attribute = "#{name}.#{attribute}"
|
194
|
+
errors.details[error_attribute] << message
|
195
|
+
errors.details[error_attribute].uniq!
|
196
|
+
end
|
197
|
+
record.errors.details.each_key do |attribute|
|
198
|
+
error_attribute = "#{name}.#{attribute}"
|
199
|
+
record.errors.details[attribute].each do |error|
|
200
|
+
errors.details[error_attribute] << error
|
201
|
+
errors.details[error_attribute].uniq!
|
202
|
+
end
|
203
|
+
end
|
204
|
+
false
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def define_build_method
|
209
|
+
setup = self
|
210
|
+
writer_method = writer_method_name
|
211
|
+
@owner.define_method(build_method_name) do |attributes = {}|
|
212
|
+
record = setup.record_class.new(attributes)
|
213
|
+
public_send(writer_method, record)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
def define_bang_method
|
218
|
+
@owner.class_eval <<~RUBY
|
219
|
+
def #{bang_method_name}
|
220
|
+
#{@name} || #{build_method_name}
|
221
|
+
end
|
222
|
+
RUBY
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class NestedRecord::Type < ActiveRecord::Type::Json
|
4
|
+
def initialize(setup)
|
5
|
+
@setup = setup
|
6
|
+
end
|
7
|
+
|
8
|
+
def cast(data)
|
9
|
+
cast_value(data)
|
10
|
+
end
|
11
|
+
|
12
|
+
def deserialize(value)
|
13
|
+
cast_value(super)
|
14
|
+
end
|
15
|
+
|
16
|
+
def serialize(obj)
|
17
|
+
super(obj&.as_json)
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def record_class
|
23
|
+
@setup.record_class
|
24
|
+
end
|
25
|
+
|
26
|
+
class HasMany < self
|
27
|
+
private
|
28
|
+
|
29
|
+
def collection_class
|
30
|
+
@setup.collection_class
|
31
|
+
end
|
32
|
+
|
33
|
+
def cast_value(data)
|
34
|
+
return unless data
|
35
|
+
collection = collection_class.new
|
36
|
+
data.each do |obj|
|
37
|
+
if obj.is_a? Hash
|
38
|
+
collection << record_class.instantiate(obj)
|
39
|
+
elsif obj.kind_of?(record_class)
|
40
|
+
collection << obj
|
41
|
+
else
|
42
|
+
raise "Cannot cast #{obj.inspect}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
collection
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
class HasOne < self
|
50
|
+
private
|
51
|
+
|
52
|
+
def cast_value(obj)
|
53
|
+
return unless obj
|
54
|
+
|
55
|
+
if obj.is_a? Hash
|
56
|
+
record_class.instantiate(obj)
|
57
|
+
elsif obj.kind_of?(record_class)
|
58
|
+
obj
|
59
|
+
else
|
60
|
+
raise "Cannot cast #{obj.inspect}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "nested_record/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "nested_record"
|
8
|
+
spec.version = NestedRecord::VERSION
|
9
|
+
spec.authors = ["Vladimir Kochnev"]
|
10
|
+
spec.email = ["hashtable@yandex.ru"]
|
11
|
+
|
12
|
+
spec.summary = %q{ActiveModel mapper for JSON fields}
|
13
|
+
spec.homepage = "https://github.com/marshall-lee/nested_record"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
# Specify which files should be added to the gem when it is released.
|
17
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
18
|
+
spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
|
19
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test)/}) }
|
20
|
+
end
|
21
|
+
spec.bindir = "exe"
|
22
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
23
|
+
spec.require_paths = ["lib"]
|
24
|
+
|
25
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
26
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
27
|
+
spec.add_development_dependency "rspec", "~> 3.0"
|
28
|
+
spec.add_development_dependency "pry"
|
29
|
+
spec.add_development_dependency "pry-byebug"
|
30
|
+
|
31
|
+
spec.add_dependency "rails", "~> 5.2"
|
32
|
+
end
|