Class: Delineate::AttributeMap

Inherits:
Object
  • Object
show all
Includes:
Schema, Serialization
Defined in:
lib/delineate/attribute_map.rb

Overview

Attribute Maps

The AttributeMap class provides the ability to expose an ActiveRecord model’s attributes and associations in a customized way. By specifying an attribute map, the model’s internal attributes and associations can be de-coupled from its presentation or interface, allowing a consumer’s interaction with the model to remain consistent even if the model implementation or schema changes.

Note: Although this description contemplates usage of an attribute map in terms of defining an API, multiple attribute maps can be constructed exposing different model interfaces for various use cases.

To define an attribute map in an ActiveRecord model, do the following:

class Account < ActiveRecord::Base
  map_attributes :api do
    attribute :name
    attribute :path, :access => :ro
    attribute :active, :access => :rw, :using => :active_flag
  end
end

The map_attributes class method establishes an attribute map that will be used by the model’s <map-name>_attributes and <map-name>_attributes= methods. This map specifies the attribute names, access permissions, and other options as viewed by a user of the model’s public API. In the example above, 3 of the the model’s attributes are exposed through the API.

Mapping Model Attributes

To declare a model attribute be included in the map, you use the attribute method on the AttributeMap instance:

attribute :public_name, :access => :rw, :using => :internal_name

The first parameter is required and is the map-specific public name for the attribute. If the :using parameter is not provided, the external name and internal name are assumed to be identical. If :using is specified, the name provided must be either an existing model attribute, or a method that will be called when reading/writing the attribute. In the example above, if internal_name is not a model attribute, you must define methods internal_name() and internal_name=(value) in the ActiveRecord class, the latter being required if the attribute is not read-only.

The :access parameter can take the following values:

:rw This value, which is the default, means that the attribute is read-write. :ro The :ro value designates the attribute as read-only. Attempts to set the

attribute's value will be ignored.

:w The attribute value can be set, but does not appear when the attributes

read.

:none Use this option when merging in a map to ignore the attribute defined in

the other map.

The :optional parameter affects the reading of a model attribute:

attribute :balance, :access => :ro, :optional => true

Optional attributes are not accessed/included when retrieving the mapped attributes, unless explicitly requested. This can be useful when there are performance implications for getting an attribute’s value, for example. You can specify a symbol as the value for :optional instead of true. The symbol then groups together all attributes with that option group. For example, if you specify:

attribute :balance, :access => :ro, :optional => :compute_balances
attribute :total_balance, :access => :ro, :optional => :compute_balances

you then get:

acct.api_attributes(:include => :balance)           # :balance attribute is included in result
acct.api_attributes(:include => :compute_balances)  # Both :balance and :total_balance attributes are returned

The :read and :write parameters are used to define accessor methods for the attribute. The specified lambda will be defined as a method named by the :model_attr parameter. For example:

attribute :parent,
          :read => lambda {|a| a.parent ? a.parent.path : nil},
          :write => lambda {|a, v| a.parent = {:path => v}}

Two methods, parent_<map-name>() and parent_<map_name>=(value) will be defined on the ActiveRecord model.

Mapping Model Associations

In addition to model attributes, you can specify a model’s associations in an attribute map. For example:

class Account < ActiveRecord::Base
  :belongs_to :account_type
  map_attributes :api do
    attribute :name
    attribute :path, :access => :ro
    association :type, :using => :account_type
  end
end

The first parameter in the association specification is its mapped name, and the optional :using parameter is the internal association name. In the the example above the account_type association is exposed as a nested object named ‘type’.

When specifying an association mapping, by default the attribute map in the association’s model class is used to define its attributes and nested associations. If you include an attribute defininiton in the association map, it will override the spec in the association model:

class Account < ActiveRecord::Base
  :belongs_to :account_type
  map_attributes :api do
    association :type, :using => :account_type do
      attribute :name, :access => :ro
      attribute :description, :access => :ro
    end
  end
end

In this example, if the AccountType attribute map declared :name as read-write, the association map in the Account model overrides that to make :name read-only when accessed as a nested object from an Account model. If the :description attribute of AccountType had not been specified in the AccountType attribute map, the inclusion of it here lets that attribute be exposed in the Account attribute map. Note that when overriding an association’s attribute, the override must completely re-define the attribute’s options.

If you want to fully specify an association’s attributes, use the :override option as follows:

class Account < ActiveRecord::Base
  :belongs_to :account_type
  map_api_attributes :account do
    association :type, :using => :account_type, :override => :replace do
      attribute :name, :access => :ro
      attribute :description, :access => :ro
      association :category, :access => :ro :using => :account_category
        attribute :name
      end
    end
  end
end

which re-defines the mapped association as viewed by Account; no merging is done with the attribute map defined in the AccountType model. In the example above, note the ability to nest associations. For this to work, account_category must be declared as an ActiveRecord association in the AccountType class.

Other parameters for mapping associations:

:access As with attributes, an association can be declared :ro or :rw (the

default). An association that is writeable will be automatically
specified in an accepts_nested_attributes_for, which allows
attribute writes to contain a nested hash for the association
(except for individual association attributes that are read-only).

:optional When set to true, the association is not included by default when

retrieving/returning the model's mapped attributes.

:polymorphic Affects reading only and is relevant when the association class

is an STI base class. When set to true, the attribute map of
each association record (as defined by its class) is used to
specify its included attributes and associations. This means that
in a collection association, the returned attribute hashes may be
heterogeneous, i.e. vary according to each retrieved record's
class. NOTE: when using :polymorphic, you cannot merge/override
the association class attribute map.

STI Attribute Maps

ActiveRecord STI subclasses inherit the attribute maps from their superclass. If you want to include additional subclass attributes, just invoke map_attributes in the subclass and define the extra attributes and associations. If the subclass wants to completely override/replace the superclass map, do:

class MySubclass < MyBase
  map_attributes :api, :override => :replace do
    .
    .
  end
end

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from Schema

#schema

Methods included from Serialization

#association_attribute_map, #attribute_value, #map_attributes_for_write, #model_association, #serializable_association_names, #serializable_attribute_names

Constructor Details

#initialize(klass_name, name, options = {}) ⇒ AttributeMap

The klass constructor parameter is the ActiveRecord model class for the map being created.



198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/delineate/attribute_map.rb', line 198

def initialize(klass_name, name, options = {})
  @klass_name = klass_name
  @name = name
  @options = options
  validate_map_options(options)

  @attributes = {}
  @associations = {}
  @write_attributes = {:_destroy => :_destroy}
  @resolved = false
  @sti_baseclass_merged = false
end

Instance Attribute Details

#associationsObject

Returns the value of attribute associations.



194
195
196
# File 'lib/delineate/attribute_map.rb', line 194

def associations
  @associations
end

#attributesObject

Returns the value of attribute attributes.



193
194
195
# File 'lib/delineate/attribute_map.rb', line 193

def attributes
  @attributes
end

#klass_nameObject (readonly)

Returns the value of attribute klass_name.



191
192
193
# File 'lib/delineate/attribute_map.rb', line 191

def klass_name
  @klass_name
end

#nameObject (readonly)

Returns the value of attribute name.



192
193
194
# File 'lib/delineate/attribute_map.rb', line 192

def name
  @name
end

Instance Method Details

#association(name, options = {}, &blk) ⇒ Object

Declare an association to be included in the map.



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/delineate/attribute_map.rb', line 238

def association(name, options = {}, &blk)
  validate_association_options(options, block_given?)

  model_attr = (options[:model_attr] || name).to_sym
  reflection = model_association_reflection(model_attr)

  attr_map = options.delete(:attr_map) || AttributeMap.new(reflection.class_name, @name)
  attr_map.instance_variable_set(:@options, {:override => options[:override]}) if options[:override]

  attr_map.instance_eval(&blk) if block_given?

  if !merge_option?(options) && attr_map.empty?
    raise ArgumentError, "Map association '#{name}' in class #{@klass_name} specifies :replace but has empty block"
  end
  if options[:access] != :ro and !klass.accessible_attributes.include?(model_attr.to_s+'_attributes')
    raise "Expected attr_accessible and/or accepts_nested_attributes_for :#{model_attr} in #{@klass_name} model"
  end

  @associations[name] = {:klass_name => reflection.class_name, :options => options,
                         :attr_map => attr_map.empty? ? nil : attr_map,
                         :collection => (reflection.macro == :has_many || reflection.macro == :has_and_belongs_to_many)}
end

#attribute(*args) ⇒ Object

Declare a single attribute to be included in the map. You can declare a list, but the attribute options are limited to :access and :optional.



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/delineate/attribute_map.rb', line 213

def attribute(*args)
  options = args.extract_options!
  validate_attribute_options(options, args.size)

  args.each do |name|
    if options[:access] == :none
      @attributes.delete(name)
      @write_attributes.delete(name)
    else
      @attributes[name] = options

      model_attr = (options[:model_attr] || name).to_sym
      model_attr = define_attr_methods(name, model_attr, options) unless is_model_attr?(model_attr)

      if options[:access] != :ro
        if model_attr.to_s != klass.primary_key && !klass.accessible_attributes.detect { |a| a == model_attr.to_s }
          raise "Expected 'attr_accessible :#{model_attr}' in #{@klass_name}"
        end
        @write_attributes[name] = model_attr
      end
    end
  end
end

#copy(other_map) ⇒ Object



289
290
291
292
293
294
295
296
297
298
# File 'lib/delineate/attribute_map.rb', line 289

def copy(other_map)
  @attributes = other_map.attributes.deep_dup
  @write_attributes = other_map.instance_variable_get(:@write_attributes).deep_dup
  @associations = other_map.associations.deep_dup

  @resolved = other_map.instance_variable_get(:@resolved)
  @sti_baseclass_merged = other_map.instance_variable_get(:@sti_baseclass_merged)

  self
end

#dupObject

Returns a new copy of this AttributeMap instance



278
279
280
281
282
283
284
285
286
287
# File 'lib/delineate/attribute_map.rb', line 278

def dup
  returning self.class.new(@klass_name, @name) do |map|
    map.attributes = @attributes.dup
    map.instance_variable_set(:@write_attributes, @write_attributes.dup)
    map.associations = @associations.dup

    map.instance_variable_set(:@resolved, @resolved)
    map.instance_variable_set(:@sti_baseclass_merged, @sti_baseclass_merged)
  end
end

#merge!(other_attr_map, merge_opts = {}) ⇒ Object

Merges another AttributeMap instance into this instance.



262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/delineate/attribute_map.rb', line 262

def merge!(other_attr_map, merge_opts = {})
  return if other_attr_map.nil?

  @attributes = @attributes.deep_merge(other_attr_map.attributes)
  @associations.deep_merge!(other_attr_map.associations)

  @write_attributes = {:_destroy => :_destroy}
  @attributes.each {|k, v| @write_attributes[k] = (v[:model_attr] || k) unless v[:access] == :ro}

  @options = other_attr_map.instance_variable_get(:@options).dup if merge_opts[:with_options]
  @resolved = other_attr_map.resolved? if merge_opts[:with_state]

  self
end

#resolve(must_resolve = false, resolving = []) ⇒ Object

Attempts to resolve the map and the maps it depends on. If must_resolve is truthy, will raise an exception if map cannot be resolved.



312
313
314
315
316
317
318
319
320
321
322
323
# File 'lib/delineate/attribute_map.rb', line 312

def resolve(must_resolve = false, resolving = [])
  return true if @resolved
  return true if resolving.include?(@klass_name)    # prevent infinite recursion

  resolving.push(@klass_name)

  result = resolve_associations(must_resolve, resolving)
  result = false unless resolve_sti_baseclass(must_resolve, resolving)

  resolving.pop
  @resolved = result
end

#resolve!Object

Will raise an exception of the map cannot be fully resolved



305
306
307
308
# File 'lib/delineate/attribute_map.rb', line 305

def resolve!
  resolve(:must_resolve)
  self
end

#resolved?Boolean

Returns:

  • (Boolean)


300
301
302
# File 'lib/delineate/attribute_map.rb', line 300

def resolved?
  @resolved
end