delineate 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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