delineate 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,133 @@
1
+ require 'csv'
2
+
3
+ module Delineate
4
+ module AttributeMap
5
+
6
+ # AttributeMap serializer that handles CSV as the external data format.
7
+ class CsvSerializer < MapSerializer
8
+
9
+ # Returns the record's mapped attributes as a CSV string. If
10
+ # you specify a truthy value for the :include_header option,
11
+ # the CSV header is output as the first line.
12
+ #
13
+ # See the description in +serializable_record+ for the order
14
+ # of the attributes.
15
+ def serialize(options = {})
16
+ opts = options[:include_header] ?
17
+ {:write_headers => true, :headers => serializable_header, :encoding => "UTF-8"} :
18
+ {:encoding => "UTF-8"}
19
+
20
+ opts = remove_serializer_class_options(options).merge(opts)
21
+ opts.delete(:include_header)
22
+
23
+ CSV.generate(opts) do |csv|
24
+ serializable_record.each {|r| csv << r}
25
+ end
26
+ end
27
+
28
+ # Returns the header row as a CSV string.
29
+ def serialize_header(options = {})
30
+ opts = {:encoding => "UTF-8"}.merge(remove_serializer_class_options(options))
31
+ CSV.generate_line(serializable_header, opts)
32
+ end
33
+
34
+ # Not implemented yet.
35
+ def serialize_in(csv_string, options = {})
36
+ raise "Serializing from CSV is not supported at this time. You can inherit a class from CsvSerializer to write a custom importer."
37
+ end
38
+
39
+ # Returns the record's mapped attributes in the serializer's "internal"
40
+ # format. For this class the representation is an array of one or more
41
+ # rows, one row for each item in teh record's has_many collections. Each
42
+ # row is an array of values ordered as follows:
43
+ #
44
+ # 1. All the record's mapped attributes in map order.
45
+ # 2. All one-to-one mapped association attributes in map order.
46
+ # 3. All one-to-many mapped association attributes in map order
47
+ #
48
+ # Do not specify any method parameters when calling +serializable_record+.
49
+ def serializable_record(prefix = [], top_level = true)
50
+ new_rows = []
51
+
52
+ prefix += serializable_attribute_names.map do |name|
53
+ @attribute_map.attribute_value(@record, name)
54
+ end
55
+
56
+ add_includes(:one_to_one) do |association, record, opts, nil_record|
57
+ assoc_map = association_attribute_map(association)
58
+ prefix, new_rows = self.class.new(record, assoc_map, opts).serializable_record(prefix, false)
59
+ end
60
+
61
+ add_includes(:one_to_many) do |association, records, opts, nil_record|
62
+ assoc_map = association_attribute_map(association)
63
+ records.each do |r|
64
+ p, next_rows = self.class.new(r, assoc_map, opts).serializable_record(prefix, false)
65
+ new_rows << (next_rows.empty? ? p : next_rows) unless nil_record
66
+ end
67
+ end
68
+
69
+ top_level ? (new_rows << prefix if new_rows.empty?; new_rows) : [prefix, new_rows]
70
+ end
71
+
72
+ # Returns the header row as an array of strings, one for each
73
+ # mapped attribute, including nested assoications. The items
74
+ # appear in the array in the same order as their corresponding
75
+ # attribute values.
76
+ def serializable_header(prefix = '')
77
+ returning(serializable_header = serializable_attribute_names) do
78
+ serializable_header.map! {|h| headerize(prefix + h.to_s)}
79
+
80
+ add_includes(:one_to_one) do |association, record, opts|
81
+ assoc_map = association_attribute_map(association)
82
+ assoc_prefix = prefix + association.to_s + '_'
83
+ serializable_header.concat self.class.new(record, assoc_map, opts).serializable_header(assoc_prefix)
84
+ end
85
+
86
+ add_includes(:one_to_many) do |association, records, opts|
87
+ assoc_map = association_attribute_map(association)
88
+ assoc_prefix = prefix + association.to_s.singularize + '_'
89
+ serializable_header.concat self.class.new(records.first, assoc_map, opts).serializable_header(assoc_prefix)
90
+ end
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ # The diff here is that if the associaton record(s) is empty, we have to generate
97
+ # a new empty record: @record.class.new.build_xxx or @record.class.new.xxx.build
98
+ def add_includes(assoc_type)
99
+ includes = @options.delete(:include)
100
+ assoc_includes, associations = serializable_association_names(includes)
101
+
102
+ for association in associations
103
+ model_assoc = @attribute_map.model_association(association)
104
+
105
+ case reflection(model_assoc).macro
106
+ when :has_many, :has_and_belongs_to_many
107
+ next if assoc_type == :one_to_one
108
+ records = @record.send(model_assoc).to_a
109
+ records = [@record.class.new.send(model_assoc).build] if (nil_record = records.empty?)
110
+ when :has_one, :belongs_to
111
+ next if assoc_type != :one_to_one
112
+ records = @record.send(model_assoc)
113
+ records = @record.class.new.send('build_'+model_assoc.to_s) if (nil_record = records.nil?)
114
+ end
115
+
116
+ yield(association, records, assoc_includes.is_a?(Hash) ? assoc_includes[association] : {}, nil_record)
117
+ end
118
+
119
+ @options[:include] = includes if includes
120
+ end
121
+
122
+ def headerize(attribute_name)
123
+ str = attribute_name.gsub(/_/, " ").gsub(/\b('?[a-z])/) { $1.capitalize }
124
+
125
+ words = str.split(' ')
126
+ str = words[0..-2].join(' ') if words[-2] == words[-1]
127
+ str
128
+ end
129
+
130
+ end
131
+
132
+ end
133
+ end
@@ -0,0 +1,37 @@
1
+ module Delineate
2
+ module AttributeMap
3
+
4
+ # AttributeMap serializer that handles JSON as the external data format.
5
+ class JsonSerializer < MapSerializer
6
+
7
+ # Returns the record's mapped attributes as a JSON string. The specified options
8
+ # are passed to the JSON.encode() function.
9
+ def serialize(options = {})
10
+ hash = super()
11
+
12
+ if options[:root] == true
13
+ hash = { @record.class.model_name.element.to_sym => hash }
14
+ elsif options[:root]
15
+ hash = { options[:root].to_sym => hash }
16
+ end
17
+ opts = remove_serializer_class_options(options)
18
+ opts.delete(:root)
19
+
20
+ hash.to_json(opts)
21
+ end
22
+
23
+ # Takes a record's attributes represented as a JSON string, and returns a
24
+ # hash suitable for direct assignment to the record's collection of attributes.
25
+ # For example:
26
+ #
27
+ # s = ActiveRecord::AttributeMap::JsonSerializer.new(record, :api)
28
+ # record.attributes = s.serialize_in(json_string)
29
+ #
30
+ def serialize_in(json_string, options = {})
31
+ super(ActiveSupport::JSON.decode(json_string), options)
32
+ end
33
+
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,170 @@
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
@@ -0,0 +1,45 @@
1
+ module Delineate
2
+ module AttributeMap
3
+
4
+ # AttributeMap serializer that handles XML as the external data format.
5
+ class XmlSerializer < MapSerializer
6
+
7
+ # Returns the record's mapped attributes as XML. The specified options
8
+ # are passed to the XML builder. Some typical options are:
9
+ #
10
+ # :root
11
+ # :dasherize
12
+ # :skip_types
13
+ # :skip_instruct
14
+ # :indent
15
+ #
16
+ def serialize(options = {})
17
+ hash = super()
18
+
19
+ if options[:root] == true
20
+ root_option = {:root => @record.class.model_name.element}
21
+ elsif options[:root]
22
+ root_option = {:root => options[:root]}
23
+ else
24
+ root_option = {}
25
+ end
26
+ opts = remove_serializer_class_options(options).merge(root_option)
27
+
28
+ hash.to_xml(opts)
29
+ end
30
+
31
+ # Takes a record's attributes represented in XML, and returns a hash
32
+ # suitable for direct assignment to the record's collection of attributes.
33
+ # For example:
34
+ #
35
+ # s = Delineate::AttributeMap::XmlSerializer.new(record, :api)
36
+ # record.attributes = s.serialize_in(xml_string)
37
+ #
38
+ def serialize_in(xml_string, options = {})
39
+ super(Hash.from_xml(xml_string), options)
40
+ end
41
+
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,201 @@
1
+ require 'delineate/attribute_map/attribute_map'
2
+ require 'delineate/attribute_map/map_serializer'
3
+ require 'class_inheritable_attributes'
4
+
5
+ module ActiveRecord
6
+
7
+ # Extend the ActiveRecord::Base class to include methods for defining and
8
+ # reading/writing the model API attributes.
9
+ class Base
10
+
11
+ # Collection of declared attribute maps for the model class
12
+ class_inheritable_accessor :attribute_maps
13
+ self.attribute_maps = {}
14
+
15
+ # The map_attributes method lets an ActiveRecord model class define a set
16
+ # of attributes that are to be exposed through the model's public interface.
17
+ # See the AttributeMap documentation for more information.
18
+ #
19
+ # The map_name parameter names the attribute map, and must be unique within
20
+ # a model class.
21
+ #
22
+ # class Account < ActiveRecord::Base
23
+ # map_attributes :api do
24
+ # .
25
+ # .
26
+ # end
27
+ #
28
+ # map_attributes :csv_export do
29
+ # .
30
+ # .
31
+ # end
32
+ # end
33
+ #
34
+ # ActiveRecord STI subclasses inherit the attribute map of the same name
35
+ # from their superclass. If you want to include additional subclass attributes,
36
+ # just invoke map_attributes in the subclass and define the extra attributes
37
+ # and associations. If the subclass wants to completely override/replace
38
+ # the superclass map, do:
39
+ #
40
+ # map_attributes :api, :override => :replace do
41
+ # .
42
+ # .
43
+ # end
44
+ #
45
+ # To access (read) a model instance's attributes via an attribute map,
46
+ # you invoke a method on the instance named <map-name>_attributes. For
47
+ # example:
48
+ #
49
+ # attrs = post.api_attributes
50
+ #
51
+ # retrieves the attributes as specified in the Post model attribute
52
+ # map named :api. A hash of the API attributes is returned.
53
+ #
54
+ # An optional +options+ parameter lets you include attributes and
55
+ # associations that are defined as :optional in the attribute map.
56
+ # Following are some examples:
57
+ #
58
+ # post.api_attributes(:include => :author)
59
+ # post.api_attributes(:include => [:author, :comments])
60
+ #
61
+ # Include the balance attribute and also the description attribute in the
62
+ # :type association:
63
+ #
64
+ # account.api_attributes(:include => [:balance, {:type => :description}])
65
+ #
66
+ # Another exmpale: in the :type association, include the optional name attribute,
67
+ # the desc attribute of the category association, and all the mapped attributes of
68
+ # the :type association named :assoc2.
69
+ #
70
+ # account.api_attributes(:include => {:type => [:name, {:category => :desc}, :assoc2]})
71
+ #
72
+ # Other include forms:
73
+ # :include => :attr1
74
+ # :include => :assoc1
75
+ # :include => [:attr1, :attr2, :assoc1]
76
+ # :include => {:assoc1 => {}, :assoc2 => {:include => [:attr1, :attr2]}}
77
+ # :include => [:attr1, :attr2, {:assoc1 => {:include => :assoc2}}, :assoc3]
78
+ #
79
+ # In addition to the :include option, you can specify:
80
+ #
81
+ # :only Restricts the attributes and associations to only those specified.
82
+ # :except Processes attributes and associations except those specified.
83
+ #
84
+ # To update/set a model instance's attributes via an attribute map,
85
+ # you invoke a setter method on the instance named <map_name>_attributes=.
86
+ # For example:
87
+ #
88
+ # post.api_attributes = attr_hash
89
+ #
90
+ # The input hash contains name/value pairs, including those for nested
91
+ # models as defined as writeable in the attribute map. The input attribute
92
+ # values are mapped to the appropriate model attributes and associations.
93
+ #
94
+ # NOTE: Maps should pretty much be the last thing defined at the class level, but
95
+ # especially after the model class's associations and accepts_nested_attributes_for.
96
+ #
97
+ def self.map_attributes(map_name, options = {}, &blk)
98
+ map = Delineate::AttributeMap::AttributeMap.new(self.name, map_name, options)
99
+
100
+ # If this is a CTI subclass, init this map with its base class attributes and associations
101
+ if respond_to?(:is_cti_subclass) and is_cti_subclass? and options[:override] != :replace
102
+ base_class_map = cti_base_class.attribute_map(map_name)
103
+ raise "Base class for CTI subclass #{self.name} must specify attribute map #{map_name}" if base_class_map.nil?
104
+
105
+ base_class_map.attributes.each { |attr, opts| map.attribute(attr, opts.dup) }
106
+ base_class_map.associations.each do |name, assoc|
107
+ map.association(name, assoc[:options].merge({:attr_map => assoc[:attr_map].try(:dup)})) unless assoc[:klass_name] == self.name
108
+ end
109
+ end
110
+
111
+ # Parse the map specification DSL
112
+ map.instance_eval(&blk)
113
+
114
+ define_attribute_map_methods(map_name) # define map accessor methods
115
+ attribute_maps[map_name] = map
116
+ end
117
+
118
+ def self.attribute_map(map_name)
119
+ attribute_maps.try(:fetch, map_name, nil)
120
+ end
121
+
122
+ def attribute_map(map_name)
123
+ self.class.attribute_maps[map_name]
124
+ end
125
+
126
+ # Returns the attributes as specified in the attribut map. The +format+ paramater
127
+ # can be one of the following: :hash, :json, :xml, :csv.
128
+ #
129
+ # The supported options hash keys are:
130
+ #
131
+ # :include Specifies which optional attributes and associations to output.
132
+ # :only Restricts the attributes and associations to only those specified.
133
+ # :except Processes attributes and associations except those specified.
134
+ # :context If this option is specified, then attribute readers and writers
135
+ # defined as symbols will be executed as instance methods on the
136
+ # specified context object.
137
+ #
138
+ def mapped_attributes(map_name, format = :hash, options = {})
139
+ map = validate_parameters(map_name, format)
140
+ @serializer_context = options[:context]
141
+
142
+ serializer_class(format).new(self, map, options).serialize(options)
143
+ end
144
+
145
+ # Sets the model object's attributes from the input hash. The hash contains
146
+ # name/value pairs, including those for nested models as defined as writeable
147
+ # in the attribute map. The input attribute names are mapped to the appropriate
148
+ # model attributes and associations.
149
+ #
150
+ def mapped_attributes=(map_name, attrs, format = :hash, options = {})
151
+ map = validate_parameters(map_name, format)
152
+ @serializer_context = options[:context]
153
+
154
+ self.attributes = serializer_class(format).new(self, map).serialize_in(attrs, options)
155
+ end
156
+ alias_method :set_mapped_attributes, :mapped_attributes=
157
+
158
+ private
159
+
160
+ # Defines the attribute map accessor methods:
161
+ #
162
+ # def api_attributes([format = :hash,] options = {}) # returns the mapped attributes as a hash
163
+ # def api_attributes=(attr_hash, options={}) # sets model attributes via the map
164
+ #
165
+ def self.define_attribute_map_methods(map_name)
166
+ class_eval do
167
+ define_method("#{map_name}_attributes") do |*args|
168
+ format = args.first && args.first.is_a?(Symbol) ? args.first : :hash
169
+ options = args.last.is_a?(Hash) ? args.last : {}
170
+ mapped_attributes(map_name, format, options)
171
+ end
172
+
173
+ define_method("#{map_name}_attributes=") do |attr_hash|
174
+ set_mapped_attributes(map_name, attr_hash, :hash)
175
+ end
176
+ end
177
+ end
178
+
179
+ def serializer_class(format)
180
+ if format == :hash
181
+ Delineate::AttributeMap::MapSerializer
182
+ else
183
+ "Delineate::AttributeMap::#{format.to_s.camelize}Serializer".constantize
184
+ end
185
+ end
186
+
187
+ def validate_parameters(map_name, format)
188
+ map = map_name
189
+ if map_name.is_a? Symbol
190
+ map = attribute_map(map_name)
191
+ raise ArgumentError, "Missing attribute map :#{map_name} for class #{self.class.name}" if map.nil?
192
+ end
193
+
194
+ raise ArgumentError, "The map parameter :#{map_name} for class #{self.class.name} is invalid" if !map.is_a?(Delineate::AttributeMap::AttributeMap)
195
+ raise ArgumentError, 'Invalid format parameter' unless [:hash, :csv, :xml, :json].include?(format)
196
+ map
197
+ end
198
+
199
+ end
200
+
201
+ end