nested_record 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 +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
|