gorillib-model 0.0.1
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.
- data/.gitignore +4 -0
- data/Gemfile +12 -0
- data/README.md +21 -0
- data/Rakefile +15 -0
- data/gorillib-model.gemspec +27 -0
- data/lib/gorillib/builder.rb +239 -0
- data/lib/gorillib/core_ext/datetime.rb +23 -0
- data/lib/gorillib/core_ext/exception.rb +153 -0
- data/lib/gorillib/core_ext/module.rb +10 -0
- data/lib/gorillib/core_ext/object.rb +14 -0
- data/lib/gorillib/model/base.rb +273 -0
- data/lib/gorillib/model/collection/model_collection.rb +157 -0
- data/lib/gorillib/model/collection.rb +200 -0
- data/lib/gorillib/model/defaults.rb +115 -0
- data/lib/gorillib/model/errors.rb +24 -0
- data/lib/gorillib/model/factories.rb +555 -0
- data/lib/gorillib/model/field.rb +168 -0
- data/lib/gorillib/model/lint.rb +24 -0
- data/lib/gorillib/model/named_schema.rb +53 -0
- data/lib/gorillib/model/positional_fields.rb +35 -0
- data/lib/gorillib/model/schema_magic.rb +163 -0
- data/lib/gorillib/model/serialization/csv.rb +60 -0
- data/lib/gorillib/model/serialization/json.rb +44 -0
- data/lib/gorillib/model/serialization/lines.rb +30 -0
- data/lib/gorillib/model/serialization/to_wire.rb +54 -0
- data/lib/gorillib/model/serialization/tsv.rb +53 -0
- data/lib/gorillib/model/serialization.rb +41 -0
- data/lib/gorillib/model/type/extended.rb +83 -0
- data/lib/gorillib/model/type/ip_address.rb +153 -0
- data/lib/gorillib/model/type/url.rb +11 -0
- data/lib/gorillib/model/validate.rb +22 -0
- data/lib/gorillib/model/version.rb +5 -0
- data/lib/gorillib/model.rb +34 -0
- data/spec/builder_spec.rb +193 -0
- data/spec/core_ext/datetime_spec.rb +41 -0
- data/spec/core_ext/exception.rb +98 -0
- data/spec/core_ext/object.rb +45 -0
- data/spec/model/collection_spec.rb +290 -0
- data/spec/model/defaults_spec.rb +104 -0
- data/spec/model/factories_spec.rb +323 -0
- data/spec/model/lint_spec.rb +28 -0
- data/spec/model/serialization/csv_spec.rb +30 -0
- data/spec/model/serialization/tsv_spec.rb +28 -0
- data/spec/model/serialization_spec.rb +41 -0
- data/spec/model/type/extended_spec.rb +166 -0
- data/spec/model/type/ip_address_spec.rb +141 -0
- data/spec/model_spec.rb +261 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/capture_output.rb +28 -0
- data/spec/support/nuke_constants.rb +9 -0
- data/spec/support/shared_context_for_builders.rb +59 -0
- data/spec/support/shared_context_for_models.rb +55 -0
- data/spec/support/shared_examples_for_factories.rb +71 -0
- data/spec/support/shared_examples_for_model_fields.rb +62 -0
- data/spec/support/shared_examples_for_models.rb +87 -0
- metadata +193 -0
@@ -0,0 +1,273 @@
|
|
1
|
+
module Gorillib
|
2
|
+
|
3
|
+
# Provides a set of class methods for defining a field schema and instance
|
4
|
+
# methods for reading and writing attributes.
|
5
|
+
#
|
6
|
+
# @example Usage
|
7
|
+
# class Person
|
8
|
+
# include Gorillib::Model
|
9
|
+
#
|
10
|
+
# field :name, String, :doc => 'Full name of person'
|
11
|
+
# field :height, Float, :doc => 'Height in meters'
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# person = Person.new
|
15
|
+
# person.name = "Bob Dobbs, Jr"
|
16
|
+
# puts person #=> #<Person name="Bob Dobbs, Jr">
|
17
|
+
#
|
18
|
+
module Model
|
19
|
+
extend ActiveSupport::Concern
|
20
|
+
|
21
|
+
def initialize(*args, &block)
|
22
|
+
attrs = self.class.attrs_hash_from_args(args)
|
23
|
+
receive!(attrs, &block)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns a Hash of all attributes
|
27
|
+
#
|
28
|
+
# @example Get attributes
|
29
|
+
# person.attributes # => { :name => "Emmet Brown", :title => "Dr" }
|
30
|
+
#
|
31
|
+
# @return [{Symbol => Object}] The Hash of all attributes
|
32
|
+
def attributes
|
33
|
+
self.class.field_names.inject(Hash.new) do |hsh, fn|
|
34
|
+
# hsh[fn] = attribute_set?(fn) ? read_attribute(fn) : nil
|
35
|
+
hsh[fn] = read_attribute(fn)
|
36
|
+
hsh
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [Array[Object]] all the attributes, in field order, with `nil` where unset
|
41
|
+
def attribute_values
|
42
|
+
self.class.field_names.map{|fn| read_attribute(fn) }
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns a Hash of all attributes *that have been set*
|
46
|
+
#
|
47
|
+
# @example Get attributes (smurfette is unarmed)
|
48
|
+
# smurfette.attributes # => { :name => "Smurfette", :weapon => nil }
|
49
|
+
# smurfette.compact_attributes # => { :name => "Smurfette" }
|
50
|
+
#
|
51
|
+
# @return [{Symbol => Object}] The Hash of all *set* attributes
|
52
|
+
def compact_attributes
|
53
|
+
self.class.field_names.inject(Hash.new) do |hsh, fn|
|
54
|
+
hsh[fn] = read_attribute(fn) if attribute_set?(fn)
|
55
|
+
hsh
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
#
|
60
|
+
# Accept the given attributes, converting each value to the appropriate
|
61
|
+
# type, constructing included models and collections, and other triggers as
|
62
|
+
# defined.
|
63
|
+
#
|
64
|
+
# Use `#receive!` to accept 'dirty' data -- from JSON, from a nested hash,
|
65
|
+
# or some such. Use `#update_attributes` if your data is already type safe.
|
66
|
+
#
|
67
|
+
# @param [{Symbol => Object}] hsh The values to receive
|
68
|
+
# @return [nil] nothing
|
69
|
+
def receive!(hsh={})
|
70
|
+
if hsh.respond_to?(:attributes)
|
71
|
+
hsh = hsh.compact_attributes
|
72
|
+
else
|
73
|
+
Gorillib::Model::Validate.hashlike!(hsh){ "attributes for #{self.inspect}" }
|
74
|
+
hsh = hsh.dup
|
75
|
+
end
|
76
|
+
self.class.field_names.each do |field_name|
|
77
|
+
if hsh.has_key?(field_name) then val = hsh.delete(field_name)
|
78
|
+
elsif hsh.has_key?(field_name.to_s) then val = hsh.delete(field_name.to_s)
|
79
|
+
else next ; end
|
80
|
+
self.send("receive_#{field_name}", val)
|
81
|
+
end
|
82
|
+
handle_extra_attributes(hsh)
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def handle_extra_attributes(attrs)
|
87
|
+
@_extra_attributes ||= Hash.new
|
88
|
+
@_extra_attributes.merge!(attrs)
|
89
|
+
end
|
90
|
+
|
91
|
+
#
|
92
|
+
# Accept the given attributes, adopting each value directly.
|
93
|
+
#
|
94
|
+
# Use `#receive!` to accept 'dirty' data -- from JSON, from a nested hash,
|
95
|
+
# or some such. Use `#update_attributes` if your data is already type safe.
|
96
|
+
#
|
97
|
+
# @param [{Symbol => Object}] hsh The values to update with
|
98
|
+
# @return [Gorillib::Model] the object itself
|
99
|
+
def update_attributes(hsh)
|
100
|
+
if hsh.respond_to?(:attributes) then hsh = hsh.attributes ; end
|
101
|
+
Gorillib::Model::Validate.hashlike!(hsh){ "attributes for #{self.inspect}" }
|
102
|
+
self.class.field_names.each do |field_name|
|
103
|
+
if hsh.has_key?(field_name) then val = hsh[field_name]
|
104
|
+
elsif hsh.has_key?(field_name.to_s) then val = hsh[field_name.to_s]
|
105
|
+
else next ; end
|
106
|
+
write_attribute(field_name, val)
|
107
|
+
end
|
108
|
+
self
|
109
|
+
end
|
110
|
+
|
111
|
+
# Read a value from the model's attributes.
|
112
|
+
#
|
113
|
+
# @example Reading an attribute
|
114
|
+
# person.read_attribute(:name)
|
115
|
+
#
|
116
|
+
# @param [String, Symbol, #to_s] field_name Name of the attribute to get.
|
117
|
+
#
|
118
|
+
# @raise [UnknownAttributeError] if the attribute is unknown
|
119
|
+
# @return [Object] The value of the attribute, or nil if it is unset
|
120
|
+
def read_attribute(field_name)
|
121
|
+
attr_name = "@#{field_name}"
|
122
|
+
if instance_variable_defined?(attr_name)
|
123
|
+
instance_variable_get(attr_name)
|
124
|
+
else
|
125
|
+
read_unset_attribute(field_name)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Write the value of a single attribute.
|
130
|
+
#
|
131
|
+
# @example Writing an attribute
|
132
|
+
# person.write_attribute(:name, "Benjamin")
|
133
|
+
#
|
134
|
+
# @param [String, Symbol, #to_s] field_name Name of the attribute to update.
|
135
|
+
# @param [Object] val The value to set for the attribute.
|
136
|
+
#
|
137
|
+
# @raise [UnknownAttributeError] if the attribute is unknown
|
138
|
+
# @return [Object] the attribute's value
|
139
|
+
def write_attribute(field_name, val)
|
140
|
+
instance_variable_set("@#{field_name}", val)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Unset an attribute. Subsequent reads of the attribute will return `nil`,
|
144
|
+
# and `attribute_set?` for that field will return false.
|
145
|
+
#
|
146
|
+
# @example Unsetting an attribute
|
147
|
+
# obj.write_attribute(:foo, nil)
|
148
|
+
# [ obj.read_attribute(:foo), obj.attribute_set?(:foo) ] # => [ nil, true ]
|
149
|
+
# person.unset_attribute(:height)
|
150
|
+
# [ obj.read_attribute(:foo), obj.attribute_set?(:foo) ] # => [ nil, false ]
|
151
|
+
#
|
152
|
+
# @param [String, Symbol, #to_s] field_name Name of the attribute to unset.
|
153
|
+
#
|
154
|
+
# @raise [UnknownAttributeError] if the attribute is unknown
|
155
|
+
# @return [Object] the former value if it was set, nil if it was unset
|
156
|
+
def unset_attribute(field_name)
|
157
|
+
if instance_variable_defined?("@#{field_name}")
|
158
|
+
val = instance_variable_get("@#{field_name}")
|
159
|
+
remove_instance_variable("@#{field_name}")
|
160
|
+
return val
|
161
|
+
else
|
162
|
+
return nil
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# True if the attribute is set.
|
167
|
+
#
|
168
|
+
# Note that an attribute can have the value nil but be set.
|
169
|
+
#
|
170
|
+
# @param [String, Symbol, #to_s] field_name Name of the attribute to check.
|
171
|
+
#
|
172
|
+
# @raise [UnknownAttributeError] if the attribute is unknown
|
173
|
+
# @return [true, false]
|
174
|
+
def attribute_set?(field_name)
|
175
|
+
instance_variable_defined?("@#{field_name}")
|
176
|
+
end
|
177
|
+
|
178
|
+
# Two models are equal if they have the same class and their attributes
|
179
|
+
# are equal.
|
180
|
+
#
|
181
|
+
# @example Compare for equality.
|
182
|
+
# model == other
|
183
|
+
#
|
184
|
+
# @param [Gorillib::Model, Object] other The other model to compare
|
185
|
+
#
|
186
|
+
# @return [true, false] True if attributes are equal and other is instance of the same Class
|
187
|
+
def ==(other)
|
188
|
+
return false unless other.instance_of?(self.class)
|
189
|
+
attributes == other.attributes
|
190
|
+
end
|
191
|
+
|
192
|
+
# override to_inspectable (not this) in your descendant class
|
193
|
+
# @return [String] Human-readable presentation of the attributes
|
194
|
+
def inspect
|
195
|
+
str = '#<' << self.class.name.to_s
|
196
|
+
attrs = to_inspectable
|
197
|
+
if attrs.present?
|
198
|
+
str << '(' << attrs.map{|attr, val| "#{attr}=#{val.respond_to?(:inspect_compact) ? val.inspect_compact : val.inspect}" }.join(", ") << ')'
|
199
|
+
end
|
200
|
+
str << '>'
|
201
|
+
end
|
202
|
+
|
203
|
+
def to_s
|
204
|
+
inspect
|
205
|
+
end
|
206
|
+
|
207
|
+
def inspect_compact
|
208
|
+
str = "#<#{self.class.name.to_s}>"
|
209
|
+
end
|
210
|
+
|
211
|
+
# assembles just the given attributes into the inspect string.
|
212
|
+
# @return [String] Human-readable presentation of the attributes
|
213
|
+
def to_inspectable
|
214
|
+
compact_attributes
|
215
|
+
end
|
216
|
+
|
217
|
+
protected
|
218
|
+
|
219
|
+
module ClassMethods
|
220
|
+
|
221
|
+
#
|
222
|
+
# A readable handle for this field
|
223
|
+
#
|
224
|
+
def typename
|
225
|
+
@typename ||= ActiveSupport::Inflector.underscore(self.name||'anon').gsub(%r{/}, '.')
|
226
|
+
end
|
227
|
+
|
228
|
+
#
|
229
|
+
# Receive external data, type-converting and creating contained models as necessary
|
230
|
+
#
|
231
|
+
# @return [Gorillib::Model] the new object
|
232
|
+
def receive(attrs={}, &block)
|
233
|
+
return nil if attrs.nil?
|
234
|
+
return attrs if native?(attrs)
|
235
|
+
#
|
236
|
+
Gorillib::Model::Validate.hashlike!(attrs){ "attributes for #{self.inspect}" }
|
237
|
+
klass = attrs.has_key?(:_type) ? Gorillib::Factory(attrs[:_type]) : self
|
238
|
+
warn "factory #{klass} is not a type of #{self} as specified in #{attrs}" unless klass <= self
|
239
|
+
#
|
240
|
+
klass.new(attrs, &block)
|
241
|
+
end
|
242
|
+
|
243
|
+
# A `native` object does not need any transformation; it is accepted directly.
|
244
|
+
# By default, an object is native if it `is_a?` this class
|
245
|
+
#
|
246
|
+
# @param obj [Object] the object that will be received
|
247
|
+
# @return [true, false] true if the item does not need conversion
|
248
|
+
def native?(obj)
|
249
|
+
obj.is_a?(self)
|
250
|
+
end
|
251
|
+
|
252
|
+
# @return Class name and its attributes
|
253
|
+
#
|
254
|
+
# @example Inspect the model's definition.
|
255
|
+
# Person.inspect #=> Person[first_name, last_name]
|
256
|
+
def inspect
|
257
|
+
"#{self.name || 'anon'}[#{ field_names.join(",") }]"
|
258
|
+
end
|
259
|
+
def inspect_compact() self.name || inspect ; end
|
260
|
+
|
261
|
+
end
|
262
|
+
|
263
|
+
self.included do |base|
|
264
|
+
base.instance_eval do
|
265
|
+
extend Gorillib::Model::NamedSchema
|
266
|
+
extend Gorillib::Model::ClassMethods
|
267
|
+
self.meta_module
|
268
|
+
@_own_fields ||= {}
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
end
|
273
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
module Gorillib
|
2
|
+
|
3
|
+
#
|
4
|
+
# A collection of Models
|
5
|
+
#
|
6
|
+
# ### Item Type
|
7
|
+
#
|
8
|
+
# `item_type` is a class attribute -- you can make a "collection of Foo's" by
|
9
|
+
# subclassing ModelCollection and set the item item_type at the class level:
|
10
|
+
#
|
11
|
+
# class ClusterCollection < ModelCollection
|
12
|
+
# self.item_type = Cluster
|
13
|
+
# end
|
14
|
+
#
|
15
|
+
#
|
16
|
+
#
|
17
|
+
# A model collection serializes as an array does, but indexes labelled objects
|
18
|
+
# as a hash does.
|
19
|
+
#
|
20
|
+
#
|
21
|
+
class ModelCollection < Gorillib::Collection
|
22
|
+
# [Class, #receive] Factory for generating a new collection item.
|
23
|
+
class_attribute :item_type, :instance_writer => false
|
24
|
+
singleton_class.send(:protected, :item_type=)
|
25
|
+
|
26
|
+
def initialize(options={})
|
27
|
+
@item_type = Gorillib::Factory(options[:item_type]) if options[:item_type]
|
28
|
+
super
|
29
|
+
end
|
30
|
+
|
31
|
+
def receive_item(label, *args, &block)
|
32
|
+
item = item_type.receive(*args, &block)
|
33
|
+
super(label, item)
|
34
|
+
rescue StandardError => err ; err.polish("#{item_type} #{label} as #{args.inspect} to #{self}") rescue nil ; raise
|
35
|
+
end
|
36
|
+
|
37
|
+
def update_or_add(label, attrs, &block)
|
38
|
+
if label && include?(label)
|
39
|
+
item = fetch(label)
|
40
|
+
item.receive!(attrs, &block)
|
41
|
+
item
|
42
|
+
else
|
43
|
+
attrs = attrs.attributes if attrs.is_a? Gorillib::Model
|
44
|
+
attrs = attrs.merge(key_method => label) if key_method && label
|
45
|
+
receive_item(label, attrs, &block)
|
46
|
+
end
|
47
|
+
rescue StandardError => err ; err.polish("#{item_type} #{label} as #{attrs} to #{self}") rescue nil ; raise
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [Array] serializable array representation of the collection
|
51
|
+
def to_wire(options={})
|
52
|
+
to_a.map{|el| el.respond_to?(:to_wire) ? el.to_wire(options) : el }
|
53
|
+
end
|
54
|
+
# same as #to_wire
|
55
|
+
def as_json(*args) to_wire(*args) ; end
|
56
|
+
# @return [String] JSON serialization of the collection's array representation
|
57
|
+
def to_json(*args) to_wire(*args).to_json(*args) ; end
|
58
|
+
end
|
59
|
+
|
60
|
+
class Collection
|
61
|
+
|
62
|
+
#
|
63
|
+
#
|
64
|
+
# class ClusterCollection < ModelCollection
|
65
|
+
# self.item_type = Cluster
|
66
|
+
# end
|
67
|
+
# class Organization
|
68
|
+
# field :clusters, ClusterCollection, default: ->{ ClusterCollection.new(common_attrs: { organization: self }) }
|
69
|
+
# end
|
70
|
+
#
|
71
|
+
module CommonAttrs
|
72
|
+
extend ActiveSupport::Concern
|
73
|
+
|
74
|
+
included do
|
75
|
+
# [Class, #receive] Attributes to mix in to each added item
|
76
|
+
class_attribute :common_attrs, :instance_writer => false
|
77
|
+
singleton_class.send(:protected, :common_attrs=)
|
78
|
+
self.common_attrs = Hash.new
|
79
|
+
end
|
80
|
+
|
81
|
+
def initialize(options={})
|
82
|
+
super
|
83
|
+
@common_attrs = self.common_attrs.merge(options[:common_attrs]) if options.include?(:common_attrs)
|
84
|
+
end
|
85
|
+
|
86
|
+
#
|
87
|
+
# * a factory-native object: item is updated with common_attrs, then added
|
88
|
+
# * raw materials for the object: item is constructed (from the merged attrs and common_attrs), then added
|
89
|
+
#
|
90
|
+
def receive_item(label, *args, &block)
|
91
|
+
attrs = args.extract_options!.merge(common_attrs)
|
92
|
+
super(label, *args, attrs, &block)
|
93
|
+
end
|
94
|
+
|
95
|
+
def update_or_add(label, *args, &block)
|
96
|
+
attrs = args.extract_options!.merge(common_attrs)
|
97
|
+
super(label, *args, attrs, &block)
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
|
102
|
+
#
|
103
|
+
# @example
|
104
|
+
# class Smurf
|
105
|
+
# include Gorillib::Model
|
106
|
+
# end
|
107
|
+
#
|
108
|
+
# # Sets the 'village' attribute on each item it receives to the object
|
109
|
+
# # this collection belongs to.
|
110
|
+
# class SmurfCollection < ModelCollection
|
111
|
+
# include Gorillib::Collection::ItemsBelongTo
|
112
|
+
# self.item_type = Smurf
|
113
|
+
# self.parentage_method = :village
|
114
|
+
# end
|
115
|
+
#
|
116
|
+
# # SmurfVillage makes sure its SmurfCollection knows that it `belongs_to` the village
|
117
|
+
# class SmurfVillage
|
118
|
+
# include Gorillib::Model
|
119
|
+
# field :name, Symbol
|
120
|
+
# field :smurfs, SmurfCollection, default: ->{ SmurfCollection.new(belongs_to: self) }
|
121
|
+
# end
|
122
|
+
#
|
123
|
+
# # all the normal stuff works as you'd expect
|
124
|
+
# smurf_town = SmurfVillage.new('smurf_town') # #<SmurfVillage name=smurf_town>
|
125
|
+
# smurf_town.smurfs # c{ }
|
126
|
+
# smurf_town.smurfs.belongs_to # #<SmurfVillage name=smurf_town>
|
127
|
+
#
|
128
|
+
# # when a new smurf moves to town, it knows what village it belongs_to
|
129
|
+
# smurf_town.smurfs.receive_item(:novel_smurf, smurfiness: 10)
|
130
|
+
# # => #<Smurf name=:novel_smurf smurfiness=10 village=#<SmurfVillage name=smurf_town>>
|
131
|
+
#
|
132
|
+
module ItemsBelongTo
|
133
|
+
extend ActiveSupport::Concern
|
134
|
+
include Gorillib::Collection::CommonAttrs
|
135
|
+
|
136
|
+
included do
|
137
|
+
# [Class, #receive] Name of the attribute to set on
|
138
|
+
class_attribute :parentage_method, :instance_writer => false
|
139
|
+
singleton_class.send(:protected, :common_attrs=)
|
140
|
+
end
|
141
|
+
|
142
|
+
# add this collection's belongs_to to the common attrs, so that a
|
143
|
+
# newly-created object knows its parentage from birth.
|
144
|
+
def initialize(*args)
|
145
|
+
super
|
146
|
+
@common_attrs = self.common_attrs.merge(parentage_method => self.belongs_to)
|
147
|
+
end
|
148
|
+
|
149
|
+
def add(item, *args)
|
150
|
+
item.send("#{parentage_method}=", belongs_to)
|
151
|
+
super
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,200 @@
|
|
1
|
+
module Gorillib
|
2
|
+
|
3
|
+
#
|
4
|
+
# The Collection class encapsulates the minimum functionality to let you:
|
5
|
+
#
|
6
|
+
# * store items uniquely, in order added
|
7
|
+
# * retrieve items by label
|
8
|
+
# * iterate over its values
|
9
|
+
#
|
10
|
+
# A collection is best used for representing 'plural' properties of models; it
|
11
|
+
# is *not* intended to be some radical reimagining of a generic array or
|
12
|
+
# hash. We've found its locked-down capabilities to particularly useful for
|
13
|
+
# constructing DSLs (Domain-Specific Languages). Collections are *not*
|
14
|
+
# intended to be performant: its abstraction layer comes at the price of
|
15
|
+
# additional method calls.
|
16
|
+
#
|
17
|
+
# ### Gated admission
|
18
|
+
#
|
19
|
+
# Collection provides a well-defended perimeter. Every item added to the
|
20
|
+
# collection (whether sent to the initializer, the passes through `add` method
|
21
|
+
#
|
22
|
+
# ### Familiarity with its contents
|
23
|
+
#
|
24
|
+
# Typically your model will have a familiar (but not intimate) relationship
|
25
|
+
# with its plural property:
|
26
|
+
#
|
27
|
+
# * items may have some intrinsic, uniquely-identifying feature: a `name`,
|
28
|
+
# `id`, or normalized representation. You'd like to be able to add an
|
29
|
+
# retrieve them by that intrinsic feature without having to manually juggle
|
30
|
+
# the correspondence of labels to intrinsic features.
|
31
|
+
#
|
32
|
+
# In the case of a ModelCollection,
|
33
|
+
#
|
34
|
+
# * all its items may share a common type: "a post has many `Comment`s".
|
35
|
+
#
|
36
|
+
# * a model may want items to hold a reference back to the containing model,
|
37
|
+
# or otherwise to share some common attributes. As an example, a `Graph` may
|
38
|
+
# have many `Stage` objects; the collection can inform newly-added stages
|
39
|
+
# which graph they belong to.
|
40
|
+
#
|
41
|
+
# ### Barebones enumerable methods
|
42
|
+
#
|
43
|
+
# The set of methods is purposefully sparse. If you want to use `select`,
|
44
|
+
# `invert`, etc, just invoke `to_hash` or `to_a` and work with the copy it
|
45
|
+
# gives you.
|
46
|
+
#
|
47
|
+
# Collection responds to:
|
48
|
+
# - receive!, values, to_a, each and each_value;
|
49
|
+
# - length, size, empty?, blank?
|
50
|
+
# - [], []=, include?, fetch, delete, each_pair, to_hash.
|
51
|
+
# - `key_method`: called on items to get their key; `to_key` by default.
|
52
|
+
# - `<<`: adds item under its `key_method` key
|
53
|
+
# - `receive!`s an array by auto-keying the elements, or a hash by trusting what you give it
|
54
|
+
#
|
55
|
+
# A ModelCollection adds:
|
56
|
+
# - `factory`: generates new items, converts received items
|
57
|
+
# - `update_or_create: if absent, creates item with given attributes and
|
58
|
+
# `key_method => key`; if present, updates with given attributes.
|
59
|
+
#
|
60
|
+
#
|
61
|
+
class Collection
|
62
|
+
# [{Symbol => Object}] The actual store of items -- not for you to mess with
|
63
|
+
attr_reader :clxn
|
64
|
+
protected :clxn
|
65
|
+
|
66
|
+
# Object that owns this collection, if any
|
67
|
+
attr_reader :belongs_to
|
68
|
+
|
69
|
+
# [String, Symbol] Method invoked on a new item to generate its collection key; :to_key by default
|
70
|
+
class_attribute :key_method, :instance_writer => false
|
71
|
+
singleton_class.send(:protected, :key_method=)
|
72
|
+
|
73
|
+
# include Gorillib::Model
|
74
|
+
def initialize(options={})
|
75
|
+
@clxn = Hash.new
|
76
|
+
@key_method = options[:key_method] if options[:key_method]
|
77
|
+
@belongs_to = options[:belongs_to] if options[:belongs_to]
|
78
|
+
end
|
79
|
+
|
80
|
+
# Adds an item in-place. Items added to the collection (via `add`, `[]=`,
|
81
|
+
# `initialize`, etc) all pass through the `add` method: you should override
|
82
|
+
# this in subclasses to add any gatekeeper behavior.
|
83
|
+
#
|
84
|
+
# If no label is supplied, we use the result of invoking `key_method` on the
|
85
|
+
# item (or raise an error if no label *and* no key_method).
|
86
|
+
#
|
87
|
+
# It's up to you to ensure that labels make sense; this method doesn't
|
88
|
+
# demand the item's key_method match its label.
|
89
|
+
#
|
90
|
+
# @return [Object] the item
|
91
|
+
def add(item, label=nil)
|
92
|
+
label ||= label_for(item)
|
93
|
+
@clxn[label] = item
|
94
|
+
end
|
95
|
+
|
96
|
+
def label_for(item)
|
97
|
+
if key_method.nil? then
|
98
|
+
raise ArgumentError, "Can't add things to a #{self.class} without some sort of label: use foo[label] = obj, or set the collection's key_method" ;
|
99
|
+
end
|
100
|
+
item.public_send(key_method)
|
101
|
+
end
|
102
|
+
|
103
|
+
#
|
104
|
+
# Barebones enumerable methods
|
105
|
+
#
|
106
|
+
# This set of methods is purposefully sparse. If you want to use `select`,
|
107
|
+
# `invert`, etc, just invoke `to_hash` or `to_a` and work with the copy it
|
108
|
+
# gives you.
|
109
|
+
#
|
110
|
+
|
111
|
+
delegate :[], :fetch, :delete, :include?, :to => :clxn
|
112
|
+
delegate :keys, :values, :each_pair, :each_value, :to => :clxn
|
113
|
+
delegate :length, :size, :empty?, :blank?, :to => :clxn
|
114
|
+
|
115
|
+
# @return [Array] an array holding the items
|
116
|
+
def to_a ; values ; end
|
117
|
+
# @return [{Symbol => Object}] a hash of key=>item pairs
|
118
|
+
def to_hash ; clxn.dup ; end
|
119
|
+
|
120
|
+
# iterate over each value in the collection
|
121
|
+
def each(&block); each_value(&block) ; end
|
122
|
+
|
123
|
+
# Adds item, returning the collection itself.
|
124
|
+
# @return [Gorillib::Collection] the collection
|
125
|
+
def <<(item)
|
126
|
+
add(item)
|
127
|
+
self
|
128
|
+
end
|
129
|
+
|
130
|
+
# add item with given label
|
131
|
+
def []=(label, item)
|
132
|
+
add(item, label)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Receive items in-place, replacing any existing item with that label.
|
136
|
+
#
|
137
|
+
# Individual items are added using #receive_item -- if you'd like to perform
|
138
|
+
# any conversion or modification to items, do it there
|
139
|
+
#
|
140
|
+
# @param other [{Symbol => Object}, Array<Object>] a hash of key=>item pairs or a list of items
|
141
|
+
# @return [Gorillib::Collection] the collection
|
142
|
+
def receive!(other)
|
143
|
+
if other.respond_to?(:each_pair)
|
144
|
+
other.each_pair{|label, item| receive_item(label, item) }
|
145
|
+
elsif other.respond_to?(:each)
|
146
|
+
other.each{|item| receive_item(nil, item) }
|
147
|
+
else
|
148
|
+
raise "A collection can only receive something that is enumerable: got #{other.inspect}"
|
149
|
+
end
|
150
|
+
self
|
151
|
+
end
|
152
|
+
|
153
|
+
# items arriving from the outside world should pass through receive_item,
|
154
|
+
# not directly to add.
|
155
|
+
def receive_item(label, item)
|
156
|
+
add(item, label)
|
157
|
+
end
|
158
|
+
|
159
|
+
# Create a new collection and add the given items to it
|
160
|
+
# (if given an existing collection, just returns it directly)
|
161
|
+
def self.receive(items, *args)
|
162
|
+
return items if native?(items)
|
163
|
+
coll = new(*args)
|
164
|
+
coll.receive!(items)
|
165
|
+
coll
|
166
|
+
end
|
167
|
+
|
168
|
+
# A `native` object does not need any transformation; it is accepted directly.
|
169
|
+
# By default, an object is native if it `is_a?` this class
|
170
|
+
#
|
171
|
+
# @param obj [Object] the object that will be received
|
172
|
+
# @return [true, false] true if the item does not need conversion
|
173
|
+
def self.native?(obj)
|
174
|
+
obj.is_a?(self)
|
175
|
+
end
|
176
|
+
|
177
|
+
# Two collections are equal if they have the same class and their contents are equal
|
178
|
+
#
|
179
|
+
# @param [Gorillib::Collection, Object] other The other collection to compare
|
180
|
+
# @return [true, false] True if attributes are equal and other is instance of the same Class
|
181
|
+
def ==(other)
|
182
|
+
return false unless other.instance_of?(self.class)
|
183
|
+
clxn == other.send(:clxn)
|
184
|
+
end
|
185
|
+
|
186
|
+
# @return [String] string describing the collection's array representation
|
187
|
+
def to_s ; to_a.to_s ; end
|
188
|
+
# @return [String] string describing the collection's array representation
|
189
|
+
def inspect
|
190
|
+
key_width = [keys.map{|key| key.to_s.length + 1 }.max.to_i, 45].min
|
191
|
+
guts = clxn.map{|key, val| "%-#{key_width}s %s" % ["#{key}:", val.inspect] }.join(",\n ")
|
192
|
+
['c{ ', guts, ' }'].join
|
193
|
+
end
|
194
|
+
|
195
|
+
def inspect_compact
|
196
|
+
['c{ ', keys.join(", "), ' }'].join
|
197
|
+
end
|
198
|
+
|
199
|
+
end
|
200
|
+
end
|