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 +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
|