delineate 0.6.0 → 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c80a9bfec2ba77cc6cdb8ae3f60451cecb42b09e
4
- data.tar.gz: 7c18f5138e3661086b03e9b3894296bbc09fc86f
3
+ metadata.gz: 7fcb6ebbc0458a87120f829033d853357317bfa1
4
+ data.tar.gz: a7ee9aef7c42dd4c1e794935ac6e71c6ada2c7af
5
5
  SHA512:
6
- metadata.gz: 15b35ff7187adb21b2117e9204ec033a80c70f1616541aab7d410e672682747ab3609e6c61b9bdd1230fdbe015f738817984278cc84c3c2d0d5177582c4bc853
7
- data.tar.gz: d00ed37e44867047b35ace1747be62bb5901c65e8aaf9e5b1532f6e7b3becc15d8f57ace996bcad158c5e01178ac5b8b8e4ce6ccb67d8b26536c9518bda06c96
6
+ metadata.gz: 80fd619678b2808735873628329e4b8d6b7103e586ce3f70c12b9b44e0d2b65fec6f593cac7e625ae9101d65c39f9fe15f2ba24843b5ce42dfaeaa8e97b3e3ef
7
+ data.tar.gz: e2c886ce454cf4782be0bf957d38a64dd677ee2133960a9c8b0ca8dbbcb6c00a55bad653070b315bb5ff498c01d363ae9b6f9485c512ffd3593b7e5f2d2fa90c
@@ -1,14 +1,10 @@
1
- *NOTE*: This code is published for the purpose of exposing some of my work, and is
2
- not yet intended to be a fully productized library. Please consider this an
3
- implementation sample only. The library will be packaged as a full gem real soon now.
4
-
5
-
6
1
  = Delineate
7
2
 
8
- The delineate gem provides ActiveRecord serialization DSL for mapping model attributes and associations.
9
- The functionality is similar in concept to that provided by ActiveModel Serializers with many enhancements,
10
- including built in bi-directional support, i.e. deserialization (parsing) of attributes and nested
11
- associations.
3
+ The delineate gem provides ActiveRecord serialization/deserialization DSL for mapping
4
+ model attributes and associations. The functionality is similar in concept to that provided
5
+ by ActiveModel Serializers with several enhancements including bi-directional support
6
+ (i.e. parsing of input attributes and nested associations) and multiple maps for
7
+ different use cases.
12
8
 
13
9
  == About Attribute Maps
14
10
 
@@ -264,8 +260,11 @@ superclass map, do:
264
260
 
265
261
  === Serializng Out
266
262
 
263
+ tbd
264
+
267
265
  === Serializing In
268
266
 
267
+ tbd
269
268
 
270
269
  == Roadmap
271
270
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.0
1
+ 0.6.1
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: delineate 0.6.0 ruby lib
5
+ # stub: delineate 0.6.1 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "delineate"
9
- s.version = "0.6.0"
9
+ s.version = "0.6.1"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib"]
13
13
  s.authors = ["Tom Smith"]
14
- s.date = "2014-02-21"
14
+ s.date = "2014-02-23"
15
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
16
  s.email = "tsmith@landfall.com"
17
17
  s.extra_rdoc_files = [
@@ -31,12 +31,14 @@ Gem::Specification.new do |s|
31
31
  "lib/class_inheritable_attributes.rb",
32
32
  "lib/core_extensions.rb",
33
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",
34
+ "lib/delineate/attribute_map.rb",
39
35
  "lib/delineate/map_attributes.rb",
36
+ "lib/delineate/map_serializer.rb",
37
+ "lib/delineate/schema.rb",
38
+ "lib/delineate/serialization.rb",
39
+ "lib/delineate/serializers/csv_serializer.rb",
40
+ "lib/delineate/serializers/json_serializer.rb",
41
+ "lib/delineate/serializers/xml_serializer.rb",
40
42
  "spec/database.yml",
41
43
  "spec/delineate_spec.rb",
42
44
  "spec/spec_helper.rb",
@@ -4,6 +4,10 @@ class Object
4
4
  yield(value)
5
5
  value
6
6
  end
7
+
8
+ def is_not_a?(klass)
9
+ !self.is_a?(klass)
10
+ end
7
11
  end
8
12
 
9
13
  class Hash
@@ -1,11 +1,11 @@
1
+ require 'active_record'
2
+
1
3
  $LOAD_PATH.unshift(File.dirname(__FILE__))
2
4
 
3
5
  require 'core_extensions'
4
- require 'active_record'
5
6
 
7
+ require 'delineate/attribute_map'
6
8
  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'
9
+ require 'delineate/map_serializer'
10
10
 
11
11
  $LOAD_PATH.shift
@@ -1,13 +1,14 @@
1
1
  require 'active_support/core_ext/hash/deep_dup.rb'
2
2
 
3
- module Delineate
3
+ require 'delineate/serialization'
4
+ require 'delineate/schema'
4
5
 
5
- module AttributeMap
6
+ module Delineate
6
7
 
7
8
  # == Attribute Maps
8
9
  #
9
10
  # 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
+ # attributes and associations in a customized way. By specifying an attribute map,
11
12
  # the model's internal attributes and associations can be de-coupled from its
12
13
  # presentation or interface, allowing a consumer's interaction with the model to
13
14
  # remain consistent even if the model implementation or schema changes.
@@ -28,7 +29,7 @@ module Delineate
28
29
  #
29
30
  # The map_attributes class method establishes an attribute map that
30
31
  # 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
+ # This map specifies the attribute names, access permissions, and other options
32
33
  # as viewed by a user of the model's public API. In the example above, 3 of the
33
34
  # the model's attributes are exposed through the API.
34
35
  #
@@ -52,7 +53,7 @@ module Delineate
52
53
  #
53
54
  # :rw This value, which is the default, means that the attribute is read-write.
54
55
  # :ro The :ro value designates the attribute as read-only. Attempts to set the
55
- # attribute's value will silently fail.
56
+ # attribute's value will be ignored.
56
57
  # :w The attribute value can be set, but does not appear when the attributes
57
58
  # read.
58
59
  # :none Use this option when merging in a map to ignore the attribute defined in
@@ -238,7 +239,7 @@ module Delineate
238
239
  validate_association_options(options, block_given?)
239
240
 
240
241
  model_attr = (options[:model_attr] || name).to_sym
241
- reflection = get_model_association(model_attr)
242
+ reflection = model_association_reflection(model_attr)
242
243
 
243
244
  attr_map = options.delete(:attr_map) || AttributeMap.new(reflection.class_name, @name)
244
245
  attr_map.instance_variable_set(:@options, {:override => options[:override]}) if options[:override]
@@ -257,155 +258,6 @@ module Delineate
257
258
  :collection => (reflection.macro == :has_many || reflection.macro == :has_and_belongs_to_many)}
258
259
  end
259
260
 
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
261
  # Merges another AttributeMap instance into this instance.
410
262
  def merge!(other_attr_map, merge_opts = {})
411
263
  return if other_attr_map.nil?
@@ -445,6 +297,31 @@ module Delineate
445
297
  self
446
298
  end
447
299
 
300
+ def resolved?
301
+ @resolved
302
+ end
303
+
304
+ # Will raise an exception of the map cannot be fully resolved
305
+ def resolve!
306
+ resolve(:must_resolve)
307
+ self
308
+ end
309
+
310
+ # Attempts to resolve the map and the maps it depends on. If must_resolve is truthy, will
311
+ # raise an exception if map cannot be resolved.
312
+ def resolve(must_resolve = false, resolving = [])
313
+ return true if @resolved
314
+ return true if resolving.include?(@klass_name) # prevent infinite recursion
315
+
316
+ resolving.push(@klass_name)
317
+
318
+ result = resolve_associations(must_resolve, resolving)
319
+ result = false unless resolve_sti_baseclass(must_resolve, resolving)
320
+
321
+ resolving.pop
322
+ @resolved = result
323
+ end
324
+
448
325
 
449
326
  protected
450
327
 
@@ -452,46 +329,55 @@ module Delineate
452
329
  @klass ||= @klass_name.constantize
453
330
  end
454
331
 
332
+ def klass_sti_subclass?
333
+ !klass.descends_from_active_record?
334
+ end
335
+
336
+ def klass_cti_subclass?
337
+ klass.respond_to?(:is_cti_subclass?) && klass.is_cti_subclass?
338
+ end
339
+
455
340
  def empty?
456
341
  @attributes.empty? && @associations.empty?
457
342
  end
458
343
 
344
+ def is_model_attr?(name)
345
+ klass.column_names.include?(name.to_s)
346
+ end
347
+
459
348
  def model_attribute(name)
460
349
  @attributes[name][:model_attr] || name
461
350
  end
462
351
 
352
+ def model_association_reflection(association)
353
+ returning association_reflection(association) do |reflection|
354
+ raise ArgumentError, "Association '#{association}' in model #{@klass_name} is not defined" if reflection.nil?
355
+ begin
356
+ reflection.klass
357
+ rescue
358
+ raise NameError, "Cannot resolve association class '#{reflection.class_name}' from model '#{@klass_name}'"
359
+ end
360
+ end
361
+ end
362
+
363
+ def association_reflection(model_assoc)
364
+ reflection = klass.reflect_on_association(model_assoc)
365
+ reflection || (klass.cti_base_class.reflect_on_association(model_assoc) if klass_cti_subclass?)
366
+ end
367
+
463
368
  def assoc_attr_map(assoc)
464
369
  assoc[:attr_map] || assoc[:klass_name].constantize.attribute_map(@name)
465
370
  end
466
371
 
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
372
+ def merge_option?(options)
373
+ options[:override] != :replace
492
374
  end
493
375
 
376
+ VALID_MAP_OPTIONS = [ :override, :no_primary_key_attr, :no_destroy_attr ]
494
377
  VALID_ASSOC_OPTIONS = [ :model_attr, :using, :override, :polymorphic, :access, :optional, :attr_map ]
378
+ VALID_ATTR_OPTIONS = [ :model_attr, :access, :optional, :read, :write, :using ]
379
+ VALID_ATTR_OPTIONS_MULTIPLE = [ :access, :optional ]
380
+ VALID_ACCESS_OPTIONS = [:ro, :rw, :w, :none]
495
381
 
496
382
  def validate_association_options(options, blk)
497
383
  options.assert_valid_keys(VALID_ASSOC_OPTIONS)
@@ -499,59 +385,36 @@ module Delineate
499
385
  options[:model_attr] = options.delete(:using) if options.key?(:using)
500
386
 
501
387
  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])
388
+ raise ArgumentError, 'Option :override must = :replace or :merge' unless !options.key?(:override) || [:merge, :replace].include?(options[:override])
503
389
  end
504
390
 
505
- VALID_ATTR_OPTIONS = [ :model_attr, :access, :optional, :read, :write, :using ]
506
- VALID_ATTR_OPTIONS_MULTIPLE = [ :access, :optional ]
507
-
508
391
  def validate_attribute_options(options, arg_count = 1)
509
392
  options.assert_valid_keys(VALID_ATTR_OPTIONS) if arg_count == 1
510
393
  options.assert_valid_keys(VALID_ATTR_OPTIONS_MULTIPLE) if arg_count > 1
511
394
 
512
395
  options[:model_attr] = options.delete(:using) if options.key?(:using)
513
- options[:access] = :rw if !options.key?(:access)
396
+ options[:access] = :rw unless options.key?(:access)
514
397
 
515
398
  validate_access_option(options[:access])
516
399
  raise ArgumentError, 'Cannot specify :write option for read-only attribute' if options[:access] == :ro && options[:write]
517
400
  end
518
401
 
519
- VALID_MAP_OPTIONS = [ :override, :no_primary_key_attr, :no_destroy_attr ]
520
-
521
402
  def validate_map_options(options)
522
403
  options.assert_valid_keys(VALID_MAP_OPTIONS)
523
404
  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"
405
+ if options[:override] == :replace && !klass_sti_subclass? && !klass_cti_subclass?
406
+ raise ArgumentError, "Cannot specify :override => :replace in map_attributes for #{@klass_name} unless it is an STI or CTI subclass"
526
407
  end
527
408
  end
528
409
 
529
410
  def validate_access_option(opt)
530
- raise ArgumentError, 'Invalid value for :access option' if opt and ![:ro, :rw, :w, :none].include?(opt)
411
+ raise ArgumentError, 'Invalid value for :access option' if opt and !VALID_ACCESS_OPTIONS.include?(opt)
531
412
  end
532
413
 
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
414
+ def validate(map, class_name)
415
+ raise(NameError, "Expected attribute map :#{@name} to be defined for class '#{class_name}'") if map.nil?
416
+ map.resolve! unless map.resolved?
417
+ map
555
418
  end
556
419
 
557
420
  def resolve_associations(must_resolve, resolving)
@@ -584,7 +447,7 @@ module Delineate
584
447
  def resolve_sti_baseclass(must_resolve, resolving)
585
448
  result = true
586
449
 
587
- if !klass.descends_from_active_record? && !@sti_baseclass_merged && result && @options[:override] != :replace
450
+ if klass_sti_subclass? && !@sti_baseclass_merged && merge_option?(@options)
588
451
  if klass.superclass.attribute_maps.try(:fetch, @name, nil).try(:resolve, must_resolve, resolving)
589
452
  @resolved = @sti_baseclass_merged = true
590
453
  self.copy(klass.superclass.attribute_maps[@name].dup.merge!(self))
@@ -597,11 +460,7 @@ module Delineate
597
460
  result
598
461
  end
599
462
 
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
463
+ # Checks to see if an association specifies a merge, and the association class's
605
464
  # attribute map attempts to merge the association parent attribute map.
606
465
  def detect_circular_merge(assoc)
607
466
  return if assoc.nil? || assoc[:attr_map].nil? || !merge_option?(assoc[:options])
@@ -678,37 +537,8 @@ module Delineate
678
537
  end
679
538
  end
680
539
 
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
540
+ include Serialization
541
+ include Schema
712
542
 
713
543
  end
714
544
  end
@@ -1,5 +1,3 @@
1
- require 'delineate/attribute_map/attribute_map'
2
- require 'delineate/attribute_map/map_serializer'
3
1
  require 'class_inheritable_attributes'
4
2
 
5
3
  module ActiveRecord
@@ -95,23 +93,17 @@ module ActiveRecord
95
93
  # especially after the model class's associations and accepts_nested_attributes_for.
96
94
  #
97
95
  def self.map_attributes(map_name, options = {}, &blk)
98
- map = Delineate::AttributeMap::AttributeMap.new(self.name, map_name, options)
96
+ map = Delineate::AttributeMap.new(self.name, map_name, options)
99
97
 
100
98
  # If this is a CTI subclass, init this map with its base class attributes and associations
101
- if respond_to?(:is_cti_subclass) and is_cti_subclass? and options[:override] != :replace
102
- base_class_map = cti_base_class.attribute_map(map_name)
103
- raise "Base class for CTI subclass #{self.name} must specify attribute map #{map_name}" if base_class_map.nil?
104
-
105
- base_class_map.attributes.each { |attr, opts| map.attribute(attr, opts.dup) }
106
- base_class_map.associations.each do |name, assoc|
107
- map.association(name, assoc[:options].merge({:attr_map => assoc[:attr_map].try(:dup)})) unless assoc[:klass_name] == self.name
108
- end
109
- end
99
+ inherit_cti_base_class(map, options) if cti_subclass? && options[:override] != :replace
110
100
 
111
101
  # Parse the map specification DSL
112
102
  map.instance_eval(&blk)
113
103
 
114
- define_attribute_map_methods(map_name) # define map accessor methods
104
+ # Define the map accessor methods on this class
105
+ define_attribute_map_methods(map_name)
106
+
115
107
  attribute_maps[map_name] = map
116
108
  end
117
109
 
@@ -176,11 +168,25 @@ module ActiveRecord
176
168
  end
177
169
  end
178
170
 
171
+ # Returns true if this AR class is a CTI subclass.
172
+ def self.cti_subclass?
173
+ respond_to?(:is_cti_subclass?) and is_cti_subclass?
174
+ end
175
+
176
+ # Initialize this map with its CTI base class attributes and associations
177
+ def self.inherit_cti_base_class(map, options)
178
+ base_class_map = cti_base_class.attribute_map(map.name)
179
+ raise "Base class for CTI subclass #{self.name} must specify attribute map #{map.name}" unless base_class_map
180
+
181
+ map.copy(base_class_map)
182
+ map.instance_variable_set(:@resolved, false)
183
+ end
184
+
179
185
  def serializer_class(format)
180
186
  if format == :hash
181
- Delineate::AttributeMap::MapSerializer
187
+ Delineate::MapSerializer
182
188
  else
183
- "Delineate::AttributeMap::#{format.to_s.camelize}Serializer".constantize
189
+ "Delineate::Serializers::#{format.to_s.camelize}Serializer".constantize
184
190
  end
185
191
  end
186
192
 
@@ -191,7 +197,7 @@ module ActiveRecord
191
197
  raise ArgumentError, "Missing attribute map :#{map_name} for class #{self.class.name}" if map.nil?
192
198
  end
193
199
 
194
- raise ArgumentError, "The map parameter :#{map_name} for class #{self.class.name} is invalid" if !map.is_a?(Delineate::AttributeMap::AttributeMap)
200
+ raise ArgumentError, "The map parameter :#{map_name} for class #{self.class.name} is invalid" if !map.is_a?(Delineate::AttributeMap)
195
201
  raise ArgumentError, 'Invalid format parameter' unless [:hash, :csv, :xml, :json].include?(format)
196
202
  map
197
203
  end
@@ -0,0 +1,173 @@
1
+ module Delineate #:nodoc:
2
+
3
+ # The MapSerializer class serves as the base class for processing the
4
+ # reading and writing of ActiveRecord model attributes through an
5
+ # attribute map. Each serializer class supports its own external format
6
+ # for the input/output of the attributes. The format handled by MapSerializer
7
+ # is a hash.
8
+ class MapSerializer
9
+
10
+ # Creates a serializer for a single record.
11
+ #
12
+ # The +attribute_map+ parameter can be an AttributeMap instance or the
13
+ # name of the record's attribute map. The +options+ hash is used to
14
+ # filter which attributes and associations are to be serialized for
15
+ # output, and can have the following keys:
16
+ #
17
+ # :include Specifies which optional attributes and associations to output.
18
+ # :only Restricts the attributes and associations to only those specified.
19
+ # :except Processes attributes and associations except those specified.
20
+ #
21
+ # See the description for +mapped_attributes+ for more info about options.
22
+ #
23
+ def initialize(record, attribute_map, options = nil)
24
+ @record = record
25
+ attribute_map = record.send(:attribute_map, attribute_map) if attribute_map.is_a?(Symbol)
26
+ @attribute_map = attribute_map
27
+ @options = options ? options.dup : {}
28
+ end
29
+
30
+ # Returns the record's mapped attributes in the serializer's intrinsic format.
31
+ #
32
+ # For the MapSerializer class the attributes are returned as a hash,
33
+ # and the +options+ parameter is ignored.
34
+ def serialize(options = {})
35
+ @attribute_map.resolve! unless @attribute_map.resolved?
36
+ serializable_record
37
+ end
38
+
39
+ # Takes a record's attributes in the serializer's intrinsic format, and
40
+ # returns a hash suitable for direct assignment to the record's collection
41
+ # of attributes. For example:
42
+ #
43
+ # s = ActiveRecord::AttributeMap::MapSerializer.new(record, :api)
44
+ # record.attributes = s.serialize_in(attrs_hash)
45
+ #
46
+ def serialize_in(attributes, options = {})
47
+ @attribute_map.resolve! unless @attribute_map.resolved?
48
+ @attribute_map.map_attributes_for_write(attributes, options)
49
+ end
50
+
51
+ # Returns the record's mapped attributes in the serializer's "internal"
52
+ # format, usually this is a hash.
53
+ def serializable_record
54
+ returning(serializable_record = Hash.new) do
55
+ serializable_attribute_names.each do |name|
56
+ serializable_record[name] = @attribute_map.attribute_value(@record, name)
57
+ end
58
+
59
+ add_includes do |association, records, opts|
60
+ polymorphic = @attribute_map.associations[association][:options][:polymorphic]
61
+ assoc_map = association_attribute_map(association)
62
+
63
+ if records.is_a?(Enumerable)
64
+ serializable_record[association] = records.collect do |r|
65
+ assoc_map = attribute_map_for_record(r) if polymorphic
66
+ self.class.new(r, assoc_map, opts).serializable_record
67
+ end
68
+ else
69
+ assoc_map = attribute_map_for_record(records) if polymorphic
70
+ serializable_record[association] = self.class.new(records, assoc_map, opts).serializable_record
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ protected
77
+
78
+ # Returns the list of mapped attribute names that are to be output
79
+ # by applying the serializer's +:include+, +:only+, and +:except+
80
+ # options to the attribute map.
81
+ def serializable_attribute_names
82
+ includes = @options[:include] || []
83
+ includes = [] if includes.is_a?(Hash)
84
+ attribute_names = @attribute_map.serializable_attribute_names(Array(includes))
85
+
86
+ if @options[:only]
87
+ @options.delete(:except)
88
+ attribute_names & Array(@options[:only])
89
+ else
90
+ @options[:except] = Array(@options[:except])
91
+ attribute_names - @options[:except]
92
+ end
93
+ end
94
+
95
+ # Returns the list of mapped association names that are to be output
96
+ # by applying the serializer's +:include+ to the attribute map.
97
+ def serializable_association_names(includes)
98
+ assoc_includes = includes
99
+ if assoc_includes.is_a?(Array)
100
+ if (h = includes.detect {|i| i.is_a?(Hash)})
101
+ assoc_includes = h.dup
102
+ includes.each { |i| assoc_includes[i] = {} unless i.is_a?(Hash) }
103
+ end
104
+ end
105
+
106
+ include_has_options = assoc_includes.is_a?(Hash)
107
+ include_associations = include_has_options ? assoc_includes.keys : Array(assoc_includes)
108
+ associations = @attribute_map.serializable_association_names(include_associations)
109
+
110
+ if @options[:only]
111
+ @options.delete(:except)
112
+ associations = associations & Array(@options[:only])
113
+ else
114
+ @options[:except] = Array(@options[:except])
115
+ associations = associations - @options[:except]
116
+ end
117
+
118
+ [assoc_includes, associations]
119
+ end
120
+
121
+ # Helper for serializing nested models
122
+ def add_includes(&block)
123
+ includes = @options.delete(:include)
124
+ assoc_includes, associations = serializable_association_names(includes)
125
+
126
+ for association in associations
127
+ model_assoc = @attribute_map.model_association(association)
128
+
129
+ records = case reflection(model_assoc).macro
130
+ when :has_many, :has_and_belongs_to_many
131
+ @record.send(model_assoc).to_a
132
+ when :has_one, :belongs_to
133
+ @record.send(model_assoc)
134
+ end
135
+
136
+ yield(association, records, assoc_includes.is_a?(Hash) ? assoc_includes[association] : {}) if records
137
+ end
138
+
139
+ @options[:include] = includes if includes
140
+ end
141
+
142
+ # Returns an association's attribute map - argument is external name
143
+ def association_attribute_map(association)
144
+ @attribute_map.association_attribute_map(association)
145
+ end
146
+
147
+ # Returns the attribute map for the specified record - ensures it
148
+ # is resolved and valid.
149
+ def attribute_map_for_record(record)
150
+ map = record.attribute_map(@attribute_map.name)
151
+ raise(NameError, "Expected attribute map :#{@attribute_map.name} to be defined for class '#{record.class.name}'") if map.nil?
152
+ map.resolve!
153
+ end
154
+
155
+ # Gets association reflection
156
+ def reflection(model_assoc)
157
+ klass = @record.class
158
+ reflection = klass.reflect_on_association(model_assoc)
159
+ reflection || (klass.cti_base_class.reflect_on_association(model_assoc) if klass.is_cti_subclass?)
160
+ end
161
+
162
+ SERIALIZER_CLASS_OPTIONS = [:include, :only, :except, :context]
163
+
164
+ def remove_serializer_class_options(options)
165
+ options.reject {|k,v| SERIALIZER_CLASS_OPTIONS.include?(k)}
166
+ end
167
+
168
+ end
169
+ end
170
+
171
+ require 'delineate/serializers/csv_serializer'
172
+ require 'delineate/serializers/json_serializer'
173
+ require 'delineate/serializers/xml_serializer'
@@ -0,0 +1,69 @@
1
+ module Delineate
2
+ module Schema
3
+ extend ActiveSupport::Concern
4
+
5
+ # Returns a schema hash according to the attribute map. This information
6
+ # could be used to generate clients.
7
+ #
8
+ # The schema hash has two keys: +attributes+ and +associations+. The content
9
+ # for each varies depending on the +access+ parameter which can take values
10
+ # of :read, :write, or nil. The +attributes+ hash looks like this:
11
+ #
12
+ # :read or :write { :name => :string, :age => :integer }
13
+ # :nil { :name => {:type => :string, :access => :rw}, :age => { :type => :integer, :access => :rw} }
14
+ #
15
+ # The +associations+ hash looks like this:
16
+ #
17
+ # :read or :write { :posts => {}, :comments => {:optional => true} }
18
+ # nil { :posts => {:access => :rw}, :comments => {:optional => true, :access=>:ro} }
19
+ #
20
+ # This method uses the +columns_hash+ provided by ActiveRecord. You can implement
21
+ # that method in your models if you want to customize the schema output.
22
+ #
23
+ def schema(access = nil, schema_classes = [])
24
+ schema_classes.push(@klass_name)
25
+ resolve
26
+
27
+ attrs = schema_attributes(access)
28
+ associations = schema_associations(access, schema_classes)
29
+
30
+ schema_classes.pop
31
+ {:attributes => attrs, :associations => associations}
32
+ end
33
+
34
+ private
35
+
36
+ def schema_attributes(access)
37
+ columns = (klass_cti_subclass? ? klass.cti_base_class.columns_hash : {}).merge klass.columns_hash
38
+
39
+ returning({}) do |attrs|
40
+ @attributes.each do |attr, opts|
41
+ attr_type = (column = columns[model_attribute(attr).to_s]) ? column.type : nil
42
+ if (access == :read && opts[:access] != :w) or (access == :write && opts[:access] != :ro)
43
+ attrs[attr] = attr_type
44
+ elsif access.nil?
45
+ attrs[attr] = {:type => attr_type, :access => opts[:access] || :rw}
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def schema_associations(access, schema_classes)
52
+ returning({}) do |associations|
53
+ @associations.each do |assoc_name, assoc|
54
+ include_assoc = (access == :read && assoc[:options][:access] != :w) || (access == :write && assoc[:options][:access] != :ro) || access.nil?
55
+ next unless include_assoc
56
+
57
+ associations[assoc_name] = {}
58
+ associations[assoc_name][:optional] = true if assoc[:options][:optional]
59
+ associations[assoc_name][:access] = (assoc[:options][:access] || :rw) if access.nil?
60
+
61
+ if assoc[:attr_map] && assoc[:attr_map] != assoc[:klass_name].to_s.constantize.attribute_map(@name)
62
+ associations[assoc_name].merge!(assoc[:attr_map].schema(access, schema_classes)) unless schema_classes.include?(assoc[:klass_name])
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ end
69
+ end
@@ -0,0 +1,131 @@
1
+ module Delineate
2
+ module Serialization
3
+ extend ActiveSupport::Concern
4
+
5
+ # Values for includes param:
6
+ # nil = include all attributes
7
+ # [] = do not include optional attributes
8
+ # [...] = include the specified optional attributes
9
+ def serializable_attribute_names(includes = nil)
10
+ attribute_names = @attributes.keys.reject {|k| @attributes[k][:access] == :w}
11
+ return attribute_names if includes.nil?
12
+
13
+ attribute_names.delete_if do |key|
14
+ (option = @attributes[key][:optional]) && !includes.include?(key) && !includes.include?(option)
15
+ end
16
+ end
17
+
18
+ def serializable_association_names(includes = nil)
19
+ return @associations.keys if includes.nil?
20
+
21
+ @associations.inject([]) do |assoc_names, assoc|
22
+ assoc_names << assoc.first if !(option = assoc.last[:options][:optional]) || includes.include?(assoc.first) || includes.include?(option)
23
+ assoc_names
24
+ end
25
+ end
26
+
27
+ def attribute_value(record, name)
28
+ model_attr = model_attribute(name)
29
+ model_attr == :type ? record.read_attribute(:type) : record.send(model_attr)
30
+ end
31
+
32
+ def model_association(name)
33
+ @associations[name][:options][:model_attr] || name
34
+ end
35
+
36
+ # Access the map of an association defined in this map. Will throw an
37
+ # error if the map cannot be found and resolved.
38
+ def association_attribute_map(association)
39
+ assoc = @associations[association]
40
+ validate(assoc_attr_map(assoc), assoc[:klass_name])
41
+ assoc_attr_map(assoc)
42
+ end
43
+
44
+ # Given the specified api attributes hash, translates the attribute names to
45
+ # the corresponding model attribute names. Recursive translation on associations
46
+ # is performed. API attributes that are defined as read-only are removed.
47
+ #
48
+ # Input can be a single hash or an array of hashes.
49
+ def map_attributes_for_write(attrs, options = nil)
50
+ raise "Cannot process map #{@klass_name}:#{@name} for write because it has not been resolved" if !resolve
51
+
52
+ (attrs.is_a?(Array) ? attrs : [attrs]).each do |attr_hash|
53
+ raise ArgumentError, "Expected attributes hash but received #{attr_hash.inspect}" if attr_hash.is_not_a?(Hash)
54
+
55
+ attr_hash.dup.symbolize_keys.each do |k, v|
56
+ if (assoc = @associations[k])
57
+ map_association_attributes_for_write(assoc, attr_hash, k)
58
+ else
59
+ if @write_attributes.has_key?(k)
60
+ attr_hash.rename_key!(k, @write_attributes[k]) if @write_attributes[k] != k
61
+ else
62
+ attr_hash.delete(k)
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ attrs
69
+ end
70
+
71
+ private
72
+
73
+ # Map an association's attributes for writing. Will call
74
+ # map_attributes_for_write (resulting in recursion) on the association
75
+ # if it's a has_one or belongs_to, or calls map_attributes_for_write
76
+ # on each element of a has_many collection.
77
+ def map_association_attributes_for_write(assoc, attr_hash, key)
78
+ if assoc[:options][:access] == :ro
79
+ attr_hash.delete(key) # Writes not allowed
80
+ else
81
+ assoc_attrs = attr_hash[key]
82
+ if assoc[:collection]
83
+ attr_hash[key] = xlate_params_for_nested_attributes_collection(assoc_attrs)
84
+
85
+ # Iterate thru each element in the collection and map its attributes
86
+ attr_hash[key].each do |entry_attrs|
87
+ entry_attrs = entry_attrs[1] if entry_attrs.is_a?(Array)
88
+ assoc_attr_map(assoc).map_attributes_for_write(entry_attrs)
89
+ end
90
+ else
91
+ # Association is a one-to-one; map its attributes
92
+ assoc_attr_map(assoc).map_attributes_for_write(assoc_attrs)
93
+ end
94
+
95
+ model_attr = assoc[:options][:model_attr] || key
96
+ attr_hash[(model_attr.to_s + '_attributes').to_sym] = attr_hash.delete(key)
97
+ end
98
+ end
99
+
100
+ # The Rails params hash generated from XML/JSON needs to be translated to a form
101
+ # compatible with ActiveRecord nested attributes, specifically with respect
102
+ # to association collections. For example, when the XML input is:
103
+ #
104
+ # <entries>
105
+ # <entry>
106
+ # ... entry 1 stuff ...
107
+ # </entry>
108
+ # <entry>
109
+ # ... entry 2 stuff ...
110
+ # </entry>
111
+ # </entries>
112
+ #
113
+ # Rails constructs the resulting params hash as:
114
+ #
115
+ # {"entries"=>{"entry"=>[{... entry 1 stuff...}, {... entry 2 stuff...}]}}
116
+ #
117
+ # which is incompatible with ActiveRecord nested attributes. So this method
118
+ # detects that pattern, and translates the above to:
119
+ #
120
+ # {"entries"=> [{... entry 1 stuff...}, {... entry 2 stuff...}]}
121
+ #
122
+ def xlate_params_for_nested_attributes_collection(assoc_attrs)
123
+ if assoc_attrs.is_a?(Hash) and assoc_attrs.keys.size == 1 and assoc_attrs[assoc_attrs.keys.first].is_a?(Array)
124
+ assoc_attrs[assoc_attrs.keys.first]
125
+ else
126
+ assoc_attrs
127
+ end
128
+ end
129
+
130
+ end
131
+ end
@@ -1,7 +1,7 @@
1
1
  require 'csv'
2
2
 
3
3
  module Delineate
4
- module AttributeMap
4
+ module Serializers
5
5
 
6
6
  # AttributeMap serializer that handles CSV as the external data format.
7
7
  class CsvSerializer < MapSerializer
@@ -1,5 +1,5 @@
1
1
  module Delineate
2
- module AttributeMap
2
+ module Serializers
3
3
 
4
4
  # AttributeMap serializer that handles JSON as the external data format.
5
5
  class JsonSerializer < MapSerializer
@@ -1,5 +1,5 @@
1
1
  module Delineate
2
- module AttributeMap
2
+ module Serializers
3
3
 
4
4
  # AttributeMap serializer that handles XML as the external data format.
5
5
  class XmlSerializer < MapSerializer
@@ -481,7 +481,7 @@ describe "AttributeMap Serializer" do
481
481
  end
482
482
 
483
483
 
484
- describe "AttributeMap Deerializer" do
484
+ describe "AttributeMap Deserializer" do
485
485
  it "handles inputting attributes from a hash for new objects" do
486
486
  h = {:title => "New title", :content => "New content"}
487
487
 
@@ -660,3 +660,17 @@ describe "AttributeMap Deerializer" do
660
660
  end
661
661
 
662
662
  end
663
+
664
+ describe "AttributeMap Schema" do
665
+ it "shows a map schema" do
666
+ expected_schema = {:attributes=>
667
+ {:id=>{:type=>:integer, :access=>:rw}, :title=>{:type=>:string, :access=>:rw}, :content=>{:type=>:text, :access=>:rw}, :created_at=>{:type=>:datetime, :access=>:ro}, :heavy_attr=>{:type=>nil, :access=>:ro}},
668
+ :associations=>
669
+ {:author=>{:access=>:rw, :attributes=>{:id=>{:type=>:integer, :access=>:rw}, :first_name=>{:type=>:string, :access=>:rw}, :last_name=>{:type=>:string, :access=>:rw}, :name=>{:type=>nil, :access=>:ro}}, :associations=>{:email=>{:access=>:rw}, :posts=>{:optional=>true, :access=>:rw}, :author_group=>{:optional=>true, :access=>:rw}}},
670
+ :topics=>{:access=>:rw}, :comments=>{:optional=>true, :access=>:rw}
671
+ }
672
+ }
673
+ expect(Post.attribute_map(:api).schema).to eql(expected_schema)
674
+ end
675
+
676
+ end
@@ -2,11 +2,9 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
2
  $LOAD_PATH.unshift(File.dirname(__FILE__))
3
3
 
4
4
  require 'rubygems'
5
- #require 'bundler/setup'
6
- #Bundler.require
7
5
 
8
- #require 'simplecov'
9
- #SimpleCov.start
6
+ require 'simplecov'
7
+ SimpleCov.start
10
8
 
11
9
  ENV['RAILS_ENV'] = 'test'
12
10
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: delineate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Smith
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-02-21 00:00:00.000000000 Z
11
+ date: 2014-02-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  version_requirements: !ruby/object:Gem::Requirement
@@ -144,12 +144,14 @@ files:
144
144
  - lib/class_inheritable_attributes.rb
145
145
  - lib/core_extensions.rb
146
146
  - lib/delineate.rb
147
- - lib/delineate/attribute_map/attribute_map.rb
148
- - lib/delineate/attribute_map/csv_serializer.rb
149
- - lib/delineate/attribute_map/json_serializer.rb
150
- - lib/delineate/attribute_map/map_serializer.rb
151
- - lib/delineate/attribute_map/xml_serializer.rb
147
+ - lib/delineate/attribute_map.rb
152
148
  - lib/delineate/map_attributes.rb
149
+ - lib/delineate/map_serializer.rb
150
+ - lib/delineate/schema.rb
151
+ - lib/delineate/serialization.rb
152
+ - lib/delineate/serializers/csv_serializer.rb
153
+ - lib/delineate/serializers/json_serializer.rb
154
+ - lib/delineate/serializers/xml_serializer.rb
153
155
  - spec/database.yml
154
156
  - spec/delineate_spec.rb
155
157
  - spec/spec_helper.rb
@@ -1,170 +0,0 @@
1
- module Delineate #:nodoc:
2
- module AttributeMap
3
-
4
- # The MapSerializer class serves as the base class for processing the
5
- # reading and writing of ActiveRecord model attributes through an
6
- # attribute map. Each serializer class supports its own external format
7
- # for the input/output of the attributes. The format handled by MapSerializer
8
- # is a hash.
9
- class MapSerializer
10
-
11
- # Creates a serializer for a single record.
12
- #
13
- # The +attribute_map+ parameter can be an AttributeMap instance or the
14
- # name of the record's attribute map. The +options+ hash is used to
15
- # filter which attributes and associations are to be serialized for
16
- # output, and can have the following keys:
17
- #
18
- # :include Specifies which optional attributes and associations to output.
19
- # :only Restricts the attributes and associations to only those specified.
20
- # :except Processes attributes and associations except those specified.
21
- #
22
- # See the description for +mapped_attributes+ for more info about options.
23
- #
24
- def initialize(record, attribute_map, options = nil)
25
- @record = record
26
- attribute_map = record.send(:attribute_map, attribute_map) if attribute_map.is_a?(Symbol)
27
- @attribute_map = attribute_map
28
- @options = options ? options.dup : {}
29
- end
30
-
31
- # Returns the record's mapped attributes in the serializer's intrinsic format.
32
- #
33
- # For the MapSerializer class the attributes are returned as a hash,
34
- # and the +options+ parameter is ignored.
35
- def serialize(options = {})
36
- @attribute_map.resolve! unless @attribute_map.resolved?
37
- serializable_record
38
- end
39
-
40
- # Takes a record's attributes in the serializer's intrinsic format, and
41
- # returns a hash suitable for direct assignment to the record's collection
42
- # of attributes. For example:
43
- #
44
- # s = ActiveRecord::AttributeMap::MapSerializer.new(record, :api)
45
- # record.attributes = s.serialize_in(attrs_hash)
46
- #
47
- def serialize_in(attributes, options = {})
48
- @attribute_map.resolve! unless @attribute_map.resolved?
49
- @attribute_map.map_attributes_for_write(attributes, options)
50
- end
51
-
52
- # Returns the record's mapped attributes in the serializer's "internal"
53
- # format, usually this is a hash.
54
- def serializable_record
55
- returning(serializable_record = Hash.new) do
56
- serializable_attribute_names.each do |name|
57
- serializable_record[name] = @attribute_map.attribute_value(@record, name)
58
- end
59
-
60
- add_includes do |association, records, opts|
61
- polymorphic = @attribute_map.associations[association][:options][:polymorphic]
62
- assoc_map = association_attribute_map(association)
63
-
64
- if records.is_a?(Enumerable)
65
- serializable_record[association] = records.collect do |r|
66
- assoc_map = attribute_map_for_record(r) if polymorphic
67
- self.class.new(r, assoc_map, opts).serializable_record
68
- end
69
- else
70
- assoc_map = attribute_map_for_record(records) if polymorphic
71
- serializable_record[association] = self.class.new(records, assoc_map, opts).serializable_record
72
- end
73
- end
74
- end
75
- end
76
-
77
- protected
78
-
79
- # Returns the list of mapped attribute names that are to be output
80
- # by applying the serializer's +:include+, +:only+, and +:except+
81
- # options to the attribute map.
82
- def serializable_attribute_names
83
- includes = @options[:include] || []
84
- includes = [] if includes.is_a?(Hash)
85
- attribute_names = @attribute_map.serializable_attribute_names(Array(includes))
86
-
87
- if @options[:only]
88
- @options.delete(:except)
89
- attribute_names & Array(@options[:only])
90
- else
91
- @options[:except] = Array(@options[:except])
92
- attribute_names - @options[:except]
93
- end
94
- end
95
-
96
- # Returns the list of mapped association names that are to be output
97
- # by applying the serializer's +:include+ to the attribute map.
98
- def serializable_association_names(includes)
99
- assoc_includes = includes
100
- if assoc_includes.is_a?(Array)
101
- if (h = includes.detect {|i| i.is_a?(Hash)})
102
- assoc_includes = h.dup
103
- includes.each { |i| assoc_includes[i] = {} unless i.is_a?(Hash) }
104
- end
105
- end
106
-
107
- include_has_options = assoc_includes.is_a?(Hash)
108
- include_associations = include_has_options ? assoc_includes.keys : Array(assoc_includes)
109
- associations = @attribute_map.serializable_association_names(include_associations)
110
-
111
- if @options[:only]
112
- @options.delete(:except)
113
- associations = associations & Array(@options[:only])
114
- else
115
- @options[:except] = Array(@options[:except])
116
- associations = associations - @options[:except]
117
- end
118
-
119
- [assoc_includes, associations]
120
- end
121
-
122
- # Helper for serializing nested models
123
- def add_includes(&block)
124
- includes = @options.delete(:include)
125
- assoc_includes, associations = serializable_association_names(includes)
126
-
127
- for association in associations
128
- model_assoc = @attribute_map.model_association(association)
129
-
130
- records = case reflection(model_assoc).macro
131
- when :has_many, :has_and_belongs_to_many
132
- @record.send(model_assoc).to_a
133
- when :has_one, :belongs_to
134
- @record.send(model_assoc)
135
- end
136
-
137
- yield(association, records, assoc_includes.is_a?(Hash) ? assoc_includes[association] : {}) if records
138
- end
139
-
140
- @options[:include] = includes if includes
141
- end
142
-
143
- # Returns an association's attribute map - argument is external name
144
- def association_attribute_map(association)
145
- @attribute_map.association_attribute_map(association)
146
- end
147
-
148
- # Returns the attribute map for the specified record - ensures it
149
- # is resolved and valid.
150
- def attribute_map_for_record(record)
151
- @attribute_map.validate(record.attribute_map(@attribute_map.name), record.class.name)
152
- end
153
-
154
- # Gets association reflection
155
- def reflection(model_assoc)
156
- klass = @record.class
157
- reflection = klass.reflect_on_association(model_assoc)
158
- reflection || (klass.cti_base_class.reflect_on_association(model_assoc) if klass.is_cti_subclass?)
159
- end
160
-
161
- SERIALIZER_CLASS_OPTIONS = [:include, :only, :except, :context]
162
-
163
- def remove_serializer_class_options(options)
164
- options.reject {|k,v| SERIALIZER_CLASS_OPTIONS.include?(k)}
165
- end
166
-
167
- end
168
-
169
- end
170
- end