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 +4 -4
- data/README.rdoc +8 -9
- data/VERSION +1 -1
- data/delineate.gemspec +10 -8
- data/lib/core_extensions.rb +4 -0
- data/lib/delineate.rb +4 -4
- data/lib/delineate/{attribute_map/attribute_map.rb → attribute_map.rb} +79 -249
- data/lib/delineate/map_attributes.rb +22 -16
- data/lib/delineate/map_serializer.rb +173 -0
- data/lib/delineate/schema.rb +69 -0
- data/lib/delineate/serialization.rb +131 -0
- data/lib/delineate/{attribute_map → serializers}/csv_serializer.rb +1 -1
- data/lib/delineate/{attribute_map → serializers}/json_serializer.rb +1 -1
- data/lib/delineate/{attribute_map → serializers}/xml_serializer.rb +1 -1
- data/spec/delineate_spec.rb +15 -1
- data/spec/spec_helper.rb +2 -4
- metadata +9 -7
- data/lib/delineate/attribute_map/map_serializer.rb +0 -170
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7fcb6ebbc0458a87120f829033d853357317bfa1
|
4
|
+
data.tar.gz: a7ee9aef7c42dd4c1e794935ac6e71c6ada2c7af
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 80fd619678b2808735873628329e4b8d6b7103e586ce3f70c12b9b44e0d2b65fec6f593cac7e625ae9101d65c39f9fe15f2ba24843b5ce42dfaeaa8e97b3e3ef
|
7
|
+
data.tar.gz: e2c886ce454cf4782be0bf957d38a64dd677ee2133960a9c8b0ca8dbbcb6c00a55bad653070b315bb5ff498c01d363ae9b6f9485c512ffd3593b7e5f2d2fa90c
|
data/README.rdoc
CHANGED
@@ -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
|
9
|
-
The functionality is similar in concept to that provided
|
10
|
-
|
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.
|
1
|
+
0.6.1
|
data/delineate.gemspec
CHANGED
@@ -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.
|
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.
|
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-
|
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
|
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",
|
data/lib/core_extensions.rb
CHANGED
data/lib/delineate.rb
CHANGED
@@ -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/
|
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
|
-
|
3
|
+
require 'delineate/serialization'
|
4
|
+
require 'delineate/schema'
|
4
5
|
|
5
|
-
|
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
|
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
|
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
|
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 =
|
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
|
-
|
468
|
-
|
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
|
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
|
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 &&
|
525
|
-
raise ArgumentError, "Cannot specify :override => :replace in map_attributes for #{@klass_name} unless it is
|
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 !
|
411
|
+
raise ArgumentError, 'Invalid value for :access option' if opt and !VALID_ACCESS_OPTIONS.include?(opt)
|
531
412
|
end
|
532
413
|
|
533
|
-
def
|
534
|
-
|
535
|
-
|
536
|
-
|
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
|
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
|
-
|
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
|
-
|
682
|
-
|
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
|
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
|
-
|
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
|
-
|
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::
|
187
|
+
Delineate::MapSerializer
|
182
188
|
else
|
183
|
-
"Delineate::
|
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
|
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
|
data/spec/delineate_spec.rb
CHANGED
@@ -481,7 +481,7 @@ describe "AttributeMap Serializer" do
|
|
481
481
|
end
|
482
482
|
|
483
483
|
|
484
|
-
describe "AttributeMap
|
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
|
data/spec/spec_helper.rb
CHANGED
@@ -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
|
-
|
9
|
-
|
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.
|
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-
|
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
|
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
|