delineate 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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