delineate 0.6.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.
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.6.0
data/delineate.gemspec ADDED
@@ -0,0 +1,84 @@
1
+ # Generated by jeweler
2
+ # DO NOT EDIT THIS FILE DIRECTLY
3
+ # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
+ # -*- encoding: utf-8 -*-
5
+ # stub: delineate 0.6.0 ruby lib
6
+
7
+ Gem::Specification.new do |s|
8
+ s.name = "delineate"
9
+ s.version = "0.6.0"
10
+
11
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
+ s.require_paths = ["lib"]
13
+ s.authors = ["Tom Smith"]
14
+ s.date = "2014-02-21"
15
+ s.description = "ActiveRecord serializer DSL for mapping model attributes and associations. Similar to ActiveModel Serializers with many enhancements including bi-directional support, i.e. deserialization."
16
+ s.email = "tsmith@landfall.com"
17
+ s.extra_rdoc_files = [
18
+ "LICENSE.txt",
19
+ "README.rdoc"
20
+ ]
21
+ s.files = [
22
+ ".document",
23
+ ".rspec",
24
+ "Gemfile",
25
+ "Gemfile.lock",
26
+ "LICENSE.txt",
27
+ "README.rdoc",
28
+ "Rakefile",
29
+ "VERSION",
30
+ "delineate.gemspec",
31
+ "lib/class_inheritable_attributes.rb",
32
+ "lib/core_extensions.rb",
33
+ "lib/delineate.rb",
34
+ "lib/delineate/attribute_map/attribute_map.rb",
35
+ "lib/delineate/attribute_map/csv_serializer.rb",
36
+ "lib/delineate/attribute_map/json_serializer.rb",
37
+ "lib/delineate/attribute_map/map_serializer.rb",
38
+ "lib/delineate/attribute_map/xml_serializer.rb",
39
+ "lib/delineate/map_attributes.rb",
40
+ "spec/database.yml",
41
+ "spec/delineate_spec.rb",
42
+ "spec/spec_helper.rb",
43
+ "spec/support/models.rb",
44
+ "spec/support/schema.rb"
45
+ ]
46
+ s.homepage = "http://github.com/rtomsmith/delineate"
47
+ s.licenses = ["MIT"]
48
+ s.rubygems_version = "2.2.2"
49
+ s.summary = "ActiveRecord serializer DSL for mapping model attributes and associations"
50
+
51
+ if s.respond_to? :specification_version then
52
+ s.specification_version = 4
53
+
54
+ if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
55
+ s.add_runtime_dependency(%q<activerecord>, ["~> 3.2"])
56
+ s.add_runtime_dependency(%q<activesupport>, ["~> 3.2"])
57
+ s.add_development_dependency(%q<rspec>, ["~> 2.14"])
58
+ s.add_development_dependency(%q<rdoc>, ["~> 3.12"])
59
+ s.add_development_dependency(%q<bundler>, ["~> 1"])
60
+ s.add_development_dependency(%q<jeweler>, ["~> 2.0"])
61
+ s.add_development_dependency(%q<simplecov>, ["~> 0"])
62
+ s.add_development_dependency(%q<sqlite3>, ["~> 1"])
63
+ else
64
+ s.add_dependency(%q<activerecord>, ["~> 3.2"])
65
+ s.add_dependency(%q<activesupport>, ["~> 3.2"])
66
+ s.add_dependency(%q<rspec>, ["~> 2.14"])
67
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
68
+ s.add_dependency(%q<bundler>, ["~> 1"])
69
+ s.add_dependency(%q<jeweler>, ["~> 2.0"])
70
+ s.add_dependency(%q<simplecov>, ["~> 0"])
71
+ s.add_dependency(%q<sqlite3>, ["~> 1"])
72
+ end
73
+ else
74
+ s.add_dependency(%q<activerecord>, ["~> 3.2"])
75
+ s.add_dependency(%q<activesupport>, ["~> 3.2"])
76
+ s.add_dependency(%q<rspec>, ["~> 2.14"])
77
+ s.add_dependency(%q<rdoc>, ["~> 3.12"])
78
+ s.add_dependency(%q<bundler>, ["~> 1"])
79
+ s.add_dependency(%q<jeweler>, ["~> 2.0"])
80
+ s.add_dependency(%q<simplecov>, ["~> 0"])
81
+ s.add_dependency(%q<sqlite3>, ["~> 1"])
82
+ end
83
+ end
84
+
@@ -0,0 +1,141 @@
1
+ # Allows attributes to be shared within an inheritance hierarchy, but where each descendant gets a copy of
2
+ # their parents' attributes, instead of just a pointer to the same. This means that the child can add elements
3
+ # to, for example, an array without those additions being shared with either their parent, siblings, or
4
+ # children, which is unlike the regular class-level attributes that are shared across the entire hierarchy.
5
+ class Class # :nodoc:
6
+ def class_inheritable_reader(*syms)
7
+ syms.each do |sym|
8
+ next if sym.is_a?(Hash)
9
+ class_eval <<-EOS
10
+ def self.#{sym} # def self.before_add_for_comments
11
+ read_inheritable_attribute(:#{sym}) # read_inheritable_attribute(:before_add_for_comments)
12
+ end # end
13
+ #
14
+ def #{sym} # def before_add_for_comments
15
+ self.class.#{sym} # self.class.before_add_for_comments
16
+ end # end
17
+ EOS
18
+ end
19
+ end
20
+
21
+ def class_inheritable_writer(*syms)
22
+ options = syms.extract_options!
23
+ syms.each do |sym|
24
+ class_eval <<-EOS
25
+ def self.#{sym}=(obj) # def self.color=(obj)
26
+ write_inheritable_attribute(:#{sym}, obj) # write_inheritable_attribute(:color, obj)
27
+ end # end
28
+ #
29
+ #{" #
30
+ def #{sym}=(obj) # def color=(obj)
31
+ self.class.#{sym} = obj # self.class.color = obj
32
+ end # end
33
+ " unless options[:instance_writer] == false } # # the writer above is generated unless options[:instance_writer] == false
34
+ EOS
35
+ end
36
+ end
37
+
38
+ def class_inheritable_array_writer(*syms)
39
+ options = syms.extract_options!
40
+ syms.each do |sym|
41
+ class_eval <<-EOS
42
+ def self.#{sym}=(obj) # def self.levels=(obj)
43
+ write_inheritable_array(:#{sym}, obj) # write_inheritable_array(:levels, obj)
44
+ end # end
45
+ #
46
+ #{" #
47
+ def #{sym}=(obj) # def levels=(obj)
48
+ self.class.#{sym} = obj # self.class.levels = obj
49
+ end # end
50
+ " unless options[:instance_writer] == false } # # the writer above is generated unless options[:instance_writer] == false
51
+ EOS
52
+ end
53
+ end
54
+
55
+ def class_inheritable_hash_writer(*syms)
56
+ options = syms.extract_options!
57
+ syms.each do |sym|
58
+ class_eval <<-EOS
59
+ def self.#{sym}=(obj) # def self.nicknames=(obj)
60
+ write_inheritable_hash(:#{sym}, obj) # write_inheritable_hash(:nicknames, obj)
61
+ end # end
62
+ #
63
+ #{" #
64
+ def #{sym}=(obj) # def nicknames=(obj)
65
+ self.class.#{sym} = obj # self.class.nicknames = obj
66
+ end # end
67
+ " unless options[:instance_writer] == false } # # the writer above is generated unless options[:instance_writer] == false
68
+ EOS
69
+ end
70
+ end
71
+
72
+ def class_inheritable_accessor(*syms)
73
+ class_inheritable_reader(*syms)
74
+ class_inheritable_writer(*syms)
75
+ end
76
+
77
+ def class_inheritable_array(*syms)
78
+ class_inheritable_reader(*syms)
79
+ class_inheritable_array_writer(*syms)
80
+ end
81
+
82
+ def class_inheritable_hash(*syms)
83
+ class_inheritable_reader(*syms)
84
+ class_inheritable_hash_writer(*syms)
85
+ end
86
+
87
+ def inheritable_attributes
88
+ @inheritable_attributes ||= EMPTY_INHERITABLE_ATTRIBUTES
89
+ end
90
+
91
+ def write_inheritable_attribute(key, value)
92
+ if inheritable_attributes.equal?(EMPTY_INHERITABLE_ATTRIBUTES)
93
+ @inheritable_attributes = {}
94
+ end
95
+ inheritable_attributes[key] = value
96
+ end
97
+
98
+ def write_inheritable_array(key, elements)
99
+ write_inheritable_attribute(key, []) if read_inheritable_attribute(key).nil?
100
+ write_inheritable_attribute(key, read_inheritable_attribute(key) + elements)
101
+ end
102
+
103
+ def write_inheritable_hash(key, hash)
104
+ write_inheritable_attribute(key, {}) if read_inheritable_attribute(key).nil?
105
+ write_inheritable_attribute(key, read_inheritable_attribute(key).merge(hash))
106
+ end
107
+
108
+ def write_inheritable_hiwa(key, hash)
109
+ write_inheritable_attribute(key, {}.with_indifferent_access) if read_inheritable_attribute(key).nil?
110
+ write_inheritable_attribute(key, read_inheritable_attribute(key).merge(hash))
111
+ end
112
+
113
+ def read_inheritable_attribute(key)
114
+ inheritable_attributes[key]
115
+ end
116
+
117
+ def reset_inheritable_attributes
118
+ @inheritable_attributes = EMPTY_INHERITABLE_ATTRIBUTES
119
+ end
120
+
121
+ private
122
+ # Prevent this constant from being created multiple times
123
+ EMPTY_INHERITABLE_ATTRIBUTES = {}.freeze unless const_defined?(:EMPTY_INHERITABLE_ATTRIBUTES)
124
+
125
+ def inherited_with_inheritable_attributes(child)
126
+ inherited_without_inheritable_attributes(child) if respond_to?(:inherited_without_inheritable_attributes)
127
+
128
+ if inheritable_attributes.equal?(EMPTY_INHERITABLE_ATTRIBUTES)
129
+ new_inheritable_attributes = EMPTY_INHERITABLE_ATTRIBUTES
130
+ else
131
+ new_inheritable_attributes = inheritable_attributes.inject({}) do |memo, (key, value)|
132
+ memo.update(key => value.duplicable? ? value.dup : value)
133
+ end
134
+ end
135
+
136
+ child.instance_variable_set('@inheritable_attributes', new_inheritable_attributes)
137
+ end
138
+
139
+ alias inherited_without_inheritable_attributes inherited
140
+ alias inherited inherited_with_inheritable_attributes
141
+ end
@@ -0,0 +1,14 @@
1
+ class Object
2
+ # I like +returning+ in some contexts, despite what ActiveSupport 3+ thinks :)
3
+ def returning(value)
4
+ yield(value)
5
+ value
6
+ end
7
+ end
8
+
9
+ class Hash
10
+ # Renames the specified key to the new value
11
+ def rename_key!(old_key, new_key)
12
+ self[new_key] = self.delete(old_key)
13
+ end
14
+ end
data/lib/delineate.rb ADDED
@@ -0,0 +1,11 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+
3
+ require 'core_extensions'
4
+ require 'active_record'
5
+
6
+ require 'delineate/map_attributes'
7
+ require 'delineate/attribute_map/csv_serializer'
8
+ require 'delineate/attribute_map/xml_serializer'
9
+ require 'delineate/attribute_map/json_serializer'
10
+
11
+ $LOAD_PATH.shift
@@ -0,0 +1,714 @@
1
+ require 'active_support/core_ext/hash/deep_dup.rb'
2
+
3
+ module Delineate
4
+
5
+ module AttributeMap
6
+
7
+ # == Attribute Maps
8
+ #
9
+ # The AttributeMap class provides the ability to expose an ActiveRecord model's
10
+ # attributes and associations in a customized way. By speciying an attribute map,
11
+ # the model's internal attributes and associations can be de-coupled from its
12
+ # presentation or interface, allowing a consumer's interaction with the model to
13
+ # remain consistent even if the model implementation or schema changes.
14
+ #
15
+ # Note: Although this description contemplates usage of an attribute map in
16
+ # terms of defining an API, multiple attribute maps can be constructed
17
+ # exposing different model interfaces for various use cases.
18
+ #
19
+ # To define an attribute map in an ActiveRecord model, do the following:
20
+ #
21
+ # class Account < ActiveRecord::Base
22
+ # map_attributes :api do
23
+ # attribute :name
24
+ # attribute :path, :access => :ro
25
+ # attribute :active, :access => :rw, :using => :active_flag
26
+ # end
27
+ # end
28
+ #
29
+ # The map_attributes class method establishes an attribute map that
30
+ # will be used by the model's <map-name>_attributes and <map-name>_attributes= methods.
31
+ # This map specifies the atrribute names, access permissions, and other options
32
+ # as viewed by a user of the model's public API. In the example above, 3 of the
33
+ # the model's attributes are exposed through the API.
34
+ #
35
+ # === Mapping Model Attributes
36
+ #
37
+ # To declare a model attribute be included in the map, you use the attribute
38
+ # method on the AttributeMap instance:
39
+ #
40
+ # attribute :public_name, :access => :rw, :using => :internal_name
41
+ #
42
+ # The first parameter is required and is the map-specific public name for the
43
+ # attribute. If the :using parameter is not provided, the external name
44
+ # and internal name are assumed to be identical. If :using is specified,
45
+ # the name provided must be either an existing model attribute, or a method
46
+ # that will be called when reading/writing the attribute. In the example above,
47
+ # if internal_name is not a model attribute, you must define methods
48
+ # internal_name() and internal_name=(value) in the ActiveRecord class, the
49
+ # latter being required if the attribute is not read-only.
50
+ #
51
+ # The :access parameter can take the following values:
52
+ #
53
+ # :rw This value, which is the default, means that the attribute is read-write.
54
+ # :ro The :ro value designates the attribute as read-only. Attempts to set the
55
+ # attribute's value will silently fail.
56
+ # :w The attribute value can be set, but does not appear when the attributes
57
+ # read.
58
+ # :none Use this option when merging in a map to ignore the attribute defined in
59
+ # the other map.
60
+ #
61
+ # The :optional parameter affects the reading of a model attribute:
62
+ #
63
+ # attribute :balance, :access => :ro, :optional => true
64
+ #
65
+ # Optional attributes are not accessed/included when retrieving the mapped
66
+ # attributes, unless explicitly requested. This can be useful when there are
67
+ # performance implications for getting an attribute's value, for example. You
68
+ # can specify a symbol as the value for :optional instead of true. The symbol
69
+ # then groups together all attributes with that option group. For example, if
70
+ # you specify:
71
+ #
72
+ # attribute :balance, :access => :ro, :optional => :compute_balances
73
+ # attribute :total_balance, :access => :ro, :optional => :compute_balances
74
+ #
75
+ # you then get:
76
+ #
77
+ # acct.api_attributes(:include => :balance) # :balance attribute is included in result
78
+ # acct.api_attributes(:include => :compute_balances) # Both :balance and :total_balance attributes are returned
79
+ #
80
+ # The :read and :write parameters are used to define accessor methods for
81
+ # the attribute. The specified lambda will be defined as a method named
82
+ # by the :model_attr parameter. For example:
83
+ #
84
+ # attribute :parent,
85
+ # :read => lambda {|a| a.parent ? a.parent.path : nil},
86
+ # :write => lambda {|a, v| a.parent = {:path => v}}
87
+ #
88
+ # Two methods, parent_<map-name>() and parent_<map_name>=(value) will be defined
89
+ # on the ActiveRecord model.
90
+ #
91
+ # === Mapping Model Associations
92
+ #
93
+ # In addition to model attributes, you can specify a model's associations in
94
+ # an attribute map. For example:
95
+ #
96
+ # class Account < ActiveRecord::Base
97
+ # :belongs_to :account_type
98
+ # map_attributes :api do
99
+ # attribute :name
100
+ # attribute :path, :access => :ro
101
+ # association :type, :using => :account_type
102
+ # end
103
+ # end
104
+ #
105
+ # The first parameter in the association specification is its mapped name, and
106
+ # the optional :using parameter is the internal association name. In the
107
+ # the example above the account_type association is exposed as a nested object
108
+ # named 'type'.
109
+ #
110
+ # When specifying an association mapping, by default the attribute map in
111
+ # the association's model class is used to define its attributes and nested
112
+ # associations. If you include an attribute defininiton in the association map,
113
+ # it will override the spec in the association model:
114
+ #
115
+ # class Account < ActiveRecord::Base
116
+ # :belongs_to :account_type
117
+ # map_attributes :api do
118
+ # association :type, :using => :account_type do
119
+ # attribute :name, :access => :ro
120
+ # attribute :description, :access => :ro
121
+ # end
122
+ # end
123
+ # end
124
+ #
125
+ # In this example, if the AccountType attribute map declared :name as
126
+ # read-write, the association map in the Account model overrides that to make :name
127
+ # read-only when accessed as a nested object from an Account model. If the
128
+ # :description attribute of AccountType had not been specified in the AccountType
129
+ # attribute map, the inclusion of it here lets that attribute be exposed in the
130
+ # Account attribute map. Note that when overriding an association's attribute, the
131
+ # override must completely re-define the attribute's options.
132
+ #
133
+ # If you want to fully specify an association's attributes, use the
134
+ # :override option as follows:
135
+ #
136
+ # class Account < ActiveRecord::Base
137
+ # :belongs_to :account_type
138
+ # map_api_attributes :account do
139
+ # association :type, :using => :account_type, :override => :replace do
140
+ # attribute :name, :access => :ro
141
+ # attribute :description, :access => :ro
142
+ # association :category, :access => :ro :using => :account_category
143
+ # attribute :name
144
+ # end
145
+ # end
146
+ # end
147
+ # end
148
+ #
149
+ # which re-defines the mapped association as viewed by Account; no merging is
150
+ # done with the attribute map defined in the AccountType model. In the example
151
+ # above, note the ability to nest associations. For this to work, account_category
152
+ # must be declared as an ActiveRecord association in the AccountType class.
153
+ #
154
+ # Other parameters for mapping associations:
155
+ #
156
+ # :access As with attributes, an association can be declared :ro or :rw (the
157
+ # default). An association that is writeable will be automatically
158
+ # specified in an accepts_nested_attributes_for, which allows
159
+ # attribute writes to contain a nested hash for the association
160
+ # (except for individual association attributes that are read-only).
161
+ #
162
+ # :optional When set to true, the association is not included by default when
163
+ # retrieving/returning the model's mapped attributes.
164
+ #
165
+ # :polymorphic Affects reading only and is relevant when the association class
166
+ # is an STI base class. When set to true, the attribute map of
167
+ # each association record (as defined by its class) is used to
168
+ # specify its included attributes and associations. This means that
169
+ # in a collection association, the returned attribute hashes may be
170
+ # heterogeneous, i.e. vary according to each retrieved record's
171
+ # class. NOTE: when using :polymorphic, you cannot merge/override
172
+ # the association class attribute map.
173
+ #
174
+ # === STI Attribute Maps
175
+ #
176
+ # ActiveRecord STI subclasses inherit the attribute maps from their superclass.
177
+ # If you want to include additional subclass attributes, just invoke
178
+ # map_attributes in the subclass and define the extra attributes and
179
+ # associations. If the subclass wants to completely override/replace the
180
+ # superclass map, do:
181
+ #
182
+ # class MySubclass < MyBase
183
+ # map_attributes :api, :override => :replace do
184
+ # .
185
+ # .
186
+ # end
187
+ # end
188
+ #
189
+ class AttributeMap
190
+ attr_reader :klass_name
191
+ attr_reader :name
192
+ attr_accessor :attributes
193
+ attr_accessor :associations
194
+
195
+ # The klass constructor parameter is the ActiveRecord model class for
196
+ # the map being created.
197
+ def initialize(klass_name, name, options = {})
198
+ @klass_name = klass_name
199
+ @name = name
200
+ @options = options
201
+ validate_map_options(options)
202
+
203
+ @attributes = {}
204
+ @associations = {}
205
+ @write_attributes = {:_destroy => :_destroy}
206
+ @resolved = false
207
+ @sti_baseclass_merged = false
208
+ end
209
+
210
+ # Declare a single attribute to be included in the map. You can declare a list,
211
+ # but the attribute options are limited to :access and :optional.
212
+ def attribute(*args)
213
+ options = args.extract_options!
214
+ validate_attribute_options(options, args.size)
215
+
216
+ args.each do |name|
217
+ if options[:access] == :none
218
+ @attributes.delete(name)
219
+ @write_attributes.delete(name)
220
+ else
221
+ @attributes[name] = options
222
+
223
+ model_attr = (options[:model_attr] || name).to_sym
224
+ model_attr = define_attr_methods(name, model_attr, options) unless is_model_attr?(model_attr)
225
+
226
+ if options[:access] != :ro
227
+ if model_attr.to_s != klass.primary_key && !klass.accessible_attributes.detect { |a| a == model_attr.to_s }
228
+ raise "Expected 'attr_accessible :#{model_attr}' in #{@klass_name}"
229
+ end
230
+ @write_attributes[name] = model_attr
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ # Declare an association to be included in the map.
237
+ def association(name, options = {}, &blk)
238
+ validate_association_options(options, block_given?)
239
+
240
+ model_attr = (options[:model_attr] || name).to_sym
241
+ reflection = get_model_association(model_attr)
242
+
243
+ attr_map = options.delete(:attr_map) || AttributeMap.new(reflection.class_name, @name)
244
+ attr_map.instance_variable_set(:@options, {:override => options[:override]}) if options[:override]
245
+
246
+ attr_map.instance_eval(&blk) if block_given?
247
+
248
+ if !merge_option?(options) && attr_map.empty?
249
+ raise ArgumentError, "Map association '#{name}' in class #{@klass_name} specifies :replace but has empty block"
250
+ end
251
+ if options[:access] != :ro and !klass.accessible_attributes.include?(model_attr.to_s+'_attributes')
252
+ raise "Expected attr_accessible and/or accepts_nested_attributes_for :#{model_attr} in #{@klass_name} model"
253
+ end
254
+
255
+ @associations[name] = {:klass_name => reflection.class_name, :options => options,
256
+ :attr_map => attr_map.empty? ? nil : attr_map,
257
+ :collection => (reflection.macro == :has_many || reflection.macro == :has_and_belongs_to_many)}
258
+ end
259
+
260
+ # Returns a schema hash according to the attribute map. This information
261
+ # could be used to generate clients.
262
+ #
263
+ # The schema hash has two keys: +attributes+ and +associations+. The content
264
+ # for each varies depeding on the +access+ parameter which can take values
265
+ # of :read, :write, or nil. The +attributes+ hash looks like this:
266
+ #
267
+ # :read or :write { :name => :string, :age => :integer }
268
+ # :nil { :name => {:type => :string, :access => :rw}, :age => { :type => :integer, :access => :rw} }
269
+ #
270
+ # The +associations+ hash looks like this:
271
+ #
272
+ # :read or :write { :posts => {}, :comments => {:optional => true} }
273
+ # nil { :posts => {:access => :rw}, :comments => {:optional => true, :access=>:ro} }
274
+ #
275
+ # This method uses the +columns_hash+ provided by ActiveRecord. You can implement
276
+ # that method in your custom models if you want to customize the schema output.
277
+ #
278
+ def schema(access = nil, schemas = [])
279
+ schemas.push(@klass_name)
280
+ resolve
281
+
282
+ columns = (klass_cti_subclass? ? klass.cti_base_class.columns_hash : {}).merge klass.columns_hash
283
+ attrs = {}
284
+ @attributes.each do |attr, opts|
285
+ attr_type = (column = columns[model_attribute(attr).to_s]) ? column.type : nil
286
+ if (access == :read && opts[:access] != :w) or (access == :write && opts[:access] != :ro)
287
+ attrs[attr] = attr_type
288
+ elsif access.nil?
289
+ attrs[attr] = {:type => attr_type, :access => opts[:access] || :rw}
290
+ end
291
+ end
292
+
293
+ associations = {}
294
+ @associations.each do |assoc_name, assoc|
295
+ include_assoc = (access == :read && assoc[:options][:access] != :w) || (access == :write && assoc[:options][:access] != :ro) || access.nil?
296
+ if include_assoc
297
+ associations[assoc_name] = {}
298
+ associations[assoc_name][:optional] = true if assoc[:options][:optional]
299
+ end
300
+
301
+ associations[assoc_name][:access] = (assoc[:options][:access] || :rw) if access.nil?
302
+
303
+ if include_assoc && assoc[:attr_map] && assoc[:attr_map] != assoc[:klass_name].to_s.constantize.attribute_map(@name)
304
+ associations[assoc_name].merge! assoc[:attr_map].schema(access, schemas) unless schemas.include?(assoc[:klass_name])
305
+ end
306
+ end
307
+
308
+ schemas.pop
309
+ {:attributes => attrs, :associations => associations}
310
+ end
311
+
312
+ def resolved?
313
+ @resolved
314
+ end
315
+
316
+ # Will raise an exception of the map cannot be fully resolved
317
+ def resolve!
318
+ resolve(:must_resolve)
319
+ self
320
+ end
321
+
322
+ # Attempts to resolve the map and the maps it depends on. If must_resolve is truthy, will
323
+ # raise an exception if map cannot be resolved.
324
+ def resolve(must_resolve = false, resolving = [])
325
+ return true if @resolved
326
+ return true if resolving.include?(@klass_name) # prevent infinite recursion
327
+
328
+ resolving.push(@klass_name)
329
+
330
+ result = resolve_associations(must_resolve, resolving)
331
+ result = false unless resolve_sti_baseclass(must_resolve, resolving)
332
+
333
+ resolving.pop
334
+ @resolved = result
335
+ end
336
+
337
+ # Values for includes param:
338
+ # nil = include all attributes
339
+ # [] = do not include optional attributes
340
+ # [...] = include the specified optional attributes
341
+ def serializable_attribute_names(includes = nil)
342
+ attribute_names = @attributes.keys.reject {|k| @attributes[k][:access] == :w}
343
+ return attribute_names if includes.nil?
344
+
345
+ attribute_names.delete_if do |key|
346
+ (option = @attributes[key][:optional]) && !includes.include?(key) && !includes.include?(option)
347
+ end
348
+ end
349
+
350
+ def serializable_association_names(includes = nil)
351
+ return @associations.keys if includes.nil?
352
+
353
+ @associations.inject([]) do |assoc_names, assoc|
354
+ assoc_names << assoc.first if !(option = assoc.last[:options][:optional]) || includes.include?(assoc.first) || includes.include?(option)
355
+ assoc_names
356
+ end
357
+ end
358
+
359
+ # Given the specified api attributes hash, translates the attribute names to
360
+ # the corresponding model attribute names. Recursive translation on associations
361
+ # is performed. API attributes that are defined as read-only are removed.
362
+ #
363
+ # Input can be a single hash or an array of hashes.
364
+ def map_attributes_for_write(attrs, options = nil)
365
+ raise "Cannot process map #{@klass_name}:#{@name} for write because it has not been resolved" if !resolve
366
+
367
+ (attrs.is_a?(Array) ? attrs : [attrs]).each do |attr_hash|
368
+ raise ArgumentError, "Expected attributes hash but received #{attr_hash.inspect}" if !attr_hash.is_a?(Hash)
369
+
370
+ attr_hash.dup.symbolize_keys.each do |k, v|
371
+ if assoc = @associations[k]
372
+ map_association_attributes_for_write(assoc, attr_hash, k)
373
+ else
374
+ if @write_attributes.has_key?(k)
375
+ attr_hash.rename_key!(k, @write_attributes[k]) if @write_attributes[k] != k
376
+ else
377
+ attr_hash.delete(k)
378
+ end
379
+ end
380
+ end
381
+ end
382
+
383
+ attrs
384
+ end
385
+
386
+ def attribute_value(record, name)
387
+ model_attr = model_attribute(name)
388
+ model_attr == :type ? record.read_attribute(:type) : record.send(model_attr)
389
+ end
390
+
391
+ def model_association(name)
392
+ @associations[name][:options][:model_attr] || name
393
+ end
394
+
395
+ # Access the map of an association defined in this map. Will throw an
396
+ # error if the map cannot be found and resolved.
397
+ def association_attribute_map(association)
398
+ assoc = @associations[association]
399
+ validate(assoc_attr_map(assoc), assoc[:klass_name])
400
+ assoc_attr_map(assoc)
401
+ end
402
+
403
+ def validate(map, class_name)
404
+ raise(NameError, "Expected attribute map :#{@name} to be defined for class '#{class_name}'") if map.nil?
405
+ map.resolve! unless map.resolved?
406
+ map
407
+ end
408
+
409
+ # Merges another AttributeMap instance into this instance.
410
+ def merge!(other_attr_map, merge_opts = {})
411
+ return if other_attr_map.nil?
412
+
413
+ @attributes = @attributes.deep_merge(other_attr_map.attributes)
414
+ @associations.deep_merge!(other_attr_map.associations)
415
+
416
+ @write_attributes = {:_destroy => :_destroy}
417
+ @attributes.each {|k, v| @write_attributes[k] = (v[:model_attr] || k) unless v[:access] == :ro}
418
+
419
+ @options = other_attr_map.instance_variable_get(:@options).dup if merge_opts[:with_options]
420
+ @resolved = other_attr_map.resolved? if merge_opts[:with_state]
421
+
422
+ self
423
+ end
424
+
425
+ # Returns a new copy of this AttributeMap instance
426
+ def dup
427
+ returning self.class.new(@klass_name, @name) do |map|
428
+ map.attributes = @attributes.dup
429
+ map.instance_variable_set(:@write_attributes, @write_attributes.dup)
430
+ map.associations = @associations.dup
431
+
432
+ map.instance_variable_set(:@resolved, @resolved)
433
+ map.instance_variable_set(:@sti_baseclass_merged, @sti_baseclass_merged)
434
+ end
435
+ end
436
+
437
+ def copy(other_map)
438
+ @attributes = other_map.attributes.deep_dup
439
+ @write_attributes = other_map.instance_variable_get(:@write_attributes).deep_dup
440
+ @associations = other_map.associations.deep_dup
441
+
442
+ @resolved = other_map.instance_variable_get(:@resolved)
443
+ @sti_baseclass_merged = other_map.instance_variable_get(:@sti_baseclass_merged)
444
+
445
+ self
446
+ end
447
+
448
+
449
+ protected
450
+
451
+ def klass
452
+ @klass ||= @klass_name.constantize
453
+ end
454
+
455
+ def empty?
456
+ @attributes.empty? && @associations.empty?
457
+ end
458
+
459
+ def model_attribute(name)
460
+ @attributes[name][:model_attr] || name
461
+ end
462
+
463
+ def assoc_attr_map(assoc)
464
+ assoc[:attr_map] || assoc[:klass_name].constantize.attribute_map(@name)
465
+ end
466
+
467
+ # Map an association's attributes for writing. Will call
468
+ # map_attributes_for_write (resulting in recursion) on the association
469
+ # if it's a has_one or belongs_to, or calls map_attributes_for_write
470
+ # on each element of a has_many collection.
471
+ def map_association_attributes_for_write(assoc, attr_hash, key)
472
+ if assoc[:options][:access] == :ro
473
+ attr_hash.delete(key) # Writes not allowed
474
+ else
475
+ assoc_attrs = attr_hash[key]
476
+ if assoc[:collection]
477
+ attr_hash[key] = xlate_params_for_nested_attributes_collection(assoc_attrs)
478
+
479
+ # Iterate thru each element in the collection and map its attributes
480
+ attr_hash[key].each do |entry_attrs|
481
+ entry_attrs = entry_attrs[1] if entry_attrs.is_a?(Array)
482
+ assoc_attr_map(assoc).map_attributes_for_write(entry_attrs)
483
+ end
484
+ else
485
+ # Association is a one-to-one; map its attributes
486
+ assoc_attr_map(assoc).map_attributes_for_write(assoc_attrs)
487
+ end
488
+
489
+ model_attr = assoc[:options][:model_attr] || key
490
+ attr_hash[(model_attr.to_s + '_attributes').to_sym] = attr_hash.delete(key)
491
+ end
492
+ end
493
+
494
+ VALID_ASSOC_OPTIONS = [ :model_attr, :using, :override, :polymorphic, :access, :optional, :attr_map ]
495
+
496
+ def validate_association_options(options, blk)
497
+ options.assert_valid_keys(VALID_ASSOC_OPTIONS)
498
+ validate_access_option(options[:access])
499
+ options[:model_attr] = options.delete(:using) if options.key?(:using)
500
+
501
+ raise ArgumentError, 'Cannot specify :override or provide block with :polymorphic' if options[:polymorphic] and (blk or options[:override])
502
+ raise ArgumentError, 'Option :override must be :replace or :merge' unless !options.key?(:override) || [:merge, :replace].include?(options[:override])
503
+ end
504
+
505
+ VALID_ATTR_OPTIONS = [ :model_attr, :access, :optional, :read, :write, :using ]
506
+ VALID_ATTR_OPTIONS_MULTIPLE = [ :access, :optional ]
507
+
508
+ def validate_attribute_options(options, arg_count = 1)
509
+ options.assert_valid_keys(VALID_ATTR_OPTIONS) if arg_count == 1
510
+ options.assert_valid_keys(VALID_ATTR_OPTIONS_MULTIPLE) if arg_count > 1
511
+
512
+ options[:model_attr] = options.delete(:using) if options.key?(:using)
513
+ options[:access] = :rw if !options.key?(:access)
514
+
515
+ validate_access_option(options[:access])
516
+ raise ArgumentError, 'Cannot specify :write option for read-only attribute' if options[:access] == :ro && options[:write]
517
+ end
518
+
519
+ VALID_MAP_OPTIONS = [ :override, :no_primary_key_attr, :no_destroy_attr ]
520
+
521
+ def validate_map_options(options)
522
+ options.assert_valid_keys(VALID_MAP_OPTIONS)
523
+ raise ArgumentError, 'Option :override must be :replace or :merge' unless !options.key?(:override) || [:merge, :replace].include?(options[:override])
524
+ if options[:override] == :replace && klass.descends_from_active_record? && !klass_cti_subclass?
525
+ raise ArgumentError, "Cannot specify :override => :replace in map_attributes for #{@klass_name} unless it is a CTI or STI subclass"
526
+ end
527
+ end
528
+
529
+ def validate_access_option(opt)
530
+ raise ArgumentError, 'Invalid value for :access option' if opt and ![:ro, :rw, :w, :none].include?(opt)
531
+ end
532
+
533
+ def get_model_association(association)
534
+ returning association_reflection(association) do |reflection|
535
+ raise ArgumentError, "Association '#{association}' in model #{@klass_name} is not defined yet" if reflection.nil?
536
+ begin
537
+ reflection.klass
538
+ rescue
539
+ raise NameError, "Cannot resolve association class '#{reflection.class_name}' from model '#{@klass_name}'"
540
+ end
541
+ end
542
+ end
543
+
544
+ def association_reflection(model_assoc)
545
+ reflection = klass.reflect_on_association(model_assoc)
546
+ reflection || (klass.cti_base_class.reflect_on_association(model_assoc) if klass_cti_subclass?)
547
+ end
548
+
549
+ def is_model_attr?(name)
550
+ klass.column_names.include?(name.to_s)
551
+ end
552
+
553
+ def merge_option?(options)
554
+ options[:override] != :replace
555
+ end
556
+
557
+ def resolve_associations(must_resolve, resolving)
558
+ result = true
559
+
560
+ @associations.each do |assoc_name, assoc|
561
+ if detect_circular_merge(assoc)
562
+ raise "Detected attribute map circular merge references: class=#{@klass_name}, association=#{assoc_name}"
563
+ end
564
+
565
+ assoc_map = assoc[:attr_map] || assoc[:klass_name].constantize.attribute_maps.try(:fetch, @name, nil)
566
+ if assoc_map && !assoc_map.resolved?
567
+ if assoc_map.resolve(must_resolve, resolving) && merge_option?(assoc[:options]) && assoc[:attr_map]
568
+ merge_map = assoc[:klass_name].constantize.attribute_maps[@name]
569
+ assoc_map = merge_map.dup.merge!(assoc_map, :with_options => true, :with_state => true)
570
+ end
571
+ end
572
+ assoc[:attr_map] = assoc_map
573
+
574
+ if assoc_map.nil? or !assoc_map.resolve(false, resolving)
575
+ result = false
576
+ raise "Cannot resolve map for association :#{assoc_name} in #{@klass_name}:#{@name} map" if must_resolve
577
+ end
578
+ end
579
+
580
+ result
581
+ end
582
+
583
+ # If this is the map of an STI subclass, inherit/merge the map from the base class
584
+ def resolve_sti_baseclass(must_resolve, resolving)
585
+ result = true
586
+
587
+ if !klass.descends_from_active_record? && !@sti_baseclass_merged && result && @options[:override] != :replace
588
+ if klass.superclass.attribute_maps.try(:fetch, @name, nil).try(:resolve, must_resolve, resolving)
589
+ @resolved = @sti_baseclass_merged = true
590
+ self.copy(klass.superclass.attribute_maps[@name].dup.merge!(self))
591
+ else
592
+ result = false
593
+ raise "Can't resolve base class map for #{@klass_name}:#{@name} map" if must_resolve
594
+ end
595
+ end
596
+
597
+ result
598
+ end
599
+
600
+ def klass_cti_subclass?
601
+ klass.respond_to?(:is_cti_subclass) && klass.is_cti_subclass?
602
+ end
603
+
604
+ # Checks to see if an assocation specifies a merge, and the association class's
605
+ # attribute map attempts to merge the association parent attribute map.
606
+ def detect_circular_merge(assoc)
607
+ return if assoc.nil? || assoc[:attr_map].nil? || !merge_option?(assoc[:options])
608
+ return unless (map = assoc[:klass_name].constantize.attribute_maps.try(:fetch, @name, nil))
609
+
610
+ map.associations.each_value do |a|
611
+ return true if a[:klass_name] == @klass_name && merge_option?(a[:options]) && a[:attr_map]
612
+ end
613
+
614
+ false
615
+ end
616
+
617
+ # Defines custom read/write attribute methods
618
+ def define_attr_methods(name, model_attr, options)
619
+ read_model_attr = define_attr_reader_method(name, model_attr, options)
620
+ write_model_attr = define_attr_writer_method(name, model_attr, options)
621
+
622
+ if read_model_attr || write_model_attr
623
+ options[:model_attr] = read_model_attr || write_model_attr
624
+ else
625
+ model_attr
626
+ end
627
+ end
628
+
629
+ def define_attr_reader_method(name, model_attr, options)
630
+ return unless (reader = options[:read])
631
+ raise ArgumentError, 'Invalid parameter for :read' unless (reader.is_a?(Symbol) || reader.is_a?(Proc))
632
+
633
+ returning(model_attr == name ? "#{name}_#{@name}" : model_attr) do |method_name|
634
+ if reader.is_a?(Symbol)
635
+ klass.class_eval %(
636
+ def #{method_name}
637
+ if @serializer_context
638
+ @serializer_context.send(attribute_map(:#{@name}).attributes[:#{name}][:read], self)
639
+ else
640
+ self.send(attribute_map(:#{@name}).attributes[:#{name}][:read])
641
+ end
642
+ end
643
+ ), __FILE__, __LINE__
644
+ else
645
+ klass.class_eval %(
646
+ def #{method_name}
647
+ attribute_map(:#{@name}).attributes[:#{name}][:read].call(self)
648
+ end
649
+ ), __FILE__, __LINE__
650
+ end
651
+ end
652
+ end
653
+
654
+ def define_attr_writer_method(name, model_attr, options)
655
+ return unless (writer = options[:write])
656
+ raise ArgumentError, 'Invalid parameter for :write' unless (writer.is_a?(Symbol) || writer.is_a?(Proc))
657
+
658
+ returning(model_attr == name ? "#{name}_#{@name}" : model_attr) do |method_name|
659
+ if writer.is_a?(Symbol)
660
+ klass.class_eval %(
661
+ def #{method_name}=(value)
662
+ if @serializer_context
663
+ @serializer_context.send(attribute_map(:#{@name}).attributes[:#{name}][:write], self, value)
664
+ else
665
+ self.send(attribute_map(:#{@name}).attributes[:#{name}][:write], value)
666
+ end
667
+ end
668
+ ), __FILE__, __LINE__
669
+ else
670
+ klass.class_eval %(
671
+ def #{method_name}=(value)
672
+ attribute_map(:#{@name}).attributes[:#{name}][:write].call(self, value)
673
+ end
674
+ ), __FILE__, __LINE__
675
+ end
676
+
677
+ klass.attr_accessible method_name
678
+ end
679
+ end
680
+
681
+ # The params hash generated from XML/JSON needs to be translated to a form
682
+ # compatible with ActiveRecord nested attributes, specifically with respect
683
+ # to association collections. For example, when the XML input is:
684
+ #
685
+ # <entries>
686
+ # <entry>
687
+ # ... entry 1 stuff ...
688
+ # </entry>
689
+ # <entry>
690
+ # ... entry 2 stuff ...
691
+ # </entry>
692
+ # </entries>
693
+ #
694
+ # Rails constructs the resulting params hash as:
695
+ #
696
+ # {"entries"=>{"entry"=>[{... entry 1 stuff...}, {... entry 2 stuff...}]}}
697
+ #
698
+ # which is incompatible with ActiveRecord nested attrributes. So this method
699
+ # detects that pattern, and translates the above to:
700
+ #
701
+ # {"entries"=> [{... entry 1 stuff...}, {... entry 2 stuff...}]}
702
+ #
703
+ def xlate_params_for_nested_attributes_collection(assoc_attrs)
704
+ if assoc_attrs.is_a?(Hash) and assoc_attrs.keys.size == 1 and assoc_attrs[assoc_attrs.keys.first].is_a?(Array)
705
+ assoc_attrs[assoc_attrs.keys.first]
706
+ else
707
+ assoc_attrs
708
+ end
709
+ end
710
+
711
+ end
712
+
713
+ end
714
+ end