delineate 0.6.0

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.
@@ -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