Class: SourcedAttributes::Source

Inherits:
Object
  • Object
show all
Includes:
DSL
Defined in:
lib/sourced_attributes/source.rb

Constant Summary collapse

DEFAULT_OPTIONS =

The default values for source-agnostic options that can be overridden by including new values in the Hash argument to sources_attributes_from.

{
  save: true,
  create_new: true,
  batch_size: nil
}
@@subclasses =

A map of all aliases for concrete Source objects for use by the factory methods.

{}

Class Method Summary collapse

Instance Method Summary collapse

Methods included from DSL

#aliased_attribute, #association, #attributes, #complex_attribute, #conditional_attribute, #configure, #primary_key

Constructor Details

#initialize(klass, opts = {}) ⇒ Source

Returns a new instance of Source.



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/sourced_attributes/source.rb', line 31

def initialize klass, opts={}
  # The model this source is working on.
  @klass = klass
  # A configuration hash for source-agnostic options, passed in as a Hash
  # from the arguments given to the `sources_attributes_from` helper.
  @options = DEFAULT_OPTIONS.merge(opts)
  # A generic configuration hash for source-specific options, managed
  # through the `configure` helper.
  @config = {}
  # The primary key that this source will use to find records to update.
  # `local` is the alias of the primary key in the locally, while `source`
  # is the alias of the primary key in the source data.
  @primary_key = { local: :id, source: :id }
  # A mapping of attributes on the model to field names from the source.
  @attribute_map = {}
  # A mapping of attributes which require special preparation to Procs
  # which can perform that preparation, provided by the configuration.
  @complex_attributes = {}
  # A mapping of attributes which are only to be updated when a given
  # condition is met to Procs which represent that condition.
  @conditional_attributes = {}
  # A list of associations that this source will update. Each entry is a
  # hash, containing the keys :name, :primary_key, :preload.
  @associations = []
  # The most recent set of data from the source, formatted as a Hash using
  # the :primary_key values as keys. Updated by `refresh` and used by
  # `apply` to update records.
  @source_data = []
  # The last-retrieved set of data from the source, formatted the same way
  # as @source_data.
  @previous_data = []
  # The records that that the source data will affect. Updated by
  # `refresh_affected_records`.
  @affected_records = {}
end

Class Method Details

.create(name, opts, klass) ⇒ Object

A factory for creating instances of Source subclasses based on the parameterized name passed in and the list of registered subclasses.



12
13
14
# File 'lib/sourced_attributes/source.rb', line 12

def create name, opts, klass
  (@@subclasses[name] or Source).new klass, opts
end

.register_source(name) ⇒ Object

Subclasses of Source should register aliases with the factory through this method.



18
19
20
# File 'lib/sourced_attributes/source.rb', line 18

def register_source name
  @@subclasses[name] = self
end

Instance Method Details

#applyObject

Apply the current set of source data to the records it affects



175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/sourced_attributes/source.rb', line 175

def apply
  # Make sure the source data is up-to-date
  refresh
  # Make sure the source data is indexed by the primary key
  ensure_indexed_source_data
  # Make sure that all of the affected records are loaded
  refresh_affected_records
  # Preload all key-value pairs needed to fully associate this record.
  preload_association_data
  # Wrap all of the updates in a single transaction
  @klass.transaction do
    if @options[:batch_size]
      @affected_records.each_slice(@options[:batch_size]) do |batch|
        @klass.import batch.map{ |pk, record| update_record(pk, record); record }
      end
    else
      @affected_records.each do |pk, record|
        update_record pk, record
      end
    end
  end
end

#apply_associations_to(pk, record) ⇒ Object

Apply the current set of source data to the associations for the given primary key.



152
153
154
155
156
157
158
159
160
161
162
# File 'lib/sourced_attributes/source.rb', line 152

def apply_associations_to pk, record
  @associations.each do |config|
    # Get the model that this association references
    reflection = @klass.reflect_on_association(config[:name])
    # The associated records are already loaded, but need to be plucked out
    # of their containing hash
    associated_records = config[:data][@source_data[pk][config[:source_key]]]
    # Apply the updated association to the record
    record.assign_attributes(config[:name] => associated_records)
  end
end

#apply_attributes_to(pk, record) ⇒ Object

Apply the current set of source data to the attributes for the given primary key.



144
145
146
147
148
# File 'lib/sourced_attributes/source.rb', line 144

def apply_attributes_to pk, record
  # Map the attributes from the source data to their local counterparts
  # and apply it to the record
  record.assign_attributes(mapped_attributes_for(pk))
end

#create_new_recordsObject

Create new instances of @klass for every key that does not already have an instance associated with it.



93
94
95
96
97
98
# File 'lib/sourced_attributes/source.rb', line 93

def create_new_records
  @source_data.keys.each do |pk|
    # TODO: Add an option for creating/not creating new records
    @affected_records[pk] ||= @klass.new(@primary_key[:local] => pk)
  end
end

#ensure_indexed_source_dataObject

Create a Hash from the @source_data array, keyed by @primary_key. If nothing.



83
84
85
86
87
88
89
# File 'lib/sourced_attributes/source.rb', line 83

def ensure_indexed_source_data
  return if @source_data.is_a?(Hash)
  @source_data = @source_data.inject({}) do |hash, datum|
    hash[datum[@primary_key[:source]]] = datum
    hash
  end
end

#mapped_attributes_for(pk) ⇒ Object

Apply the attribute map to the source data for the given record



114
115
116
117
118
119
120
121
122
123
124
# File 'lib/sourced_attributes/source.rb', line 114

def mapped_attributes_for pk
  source = @source_data[pk]
  @attribute_map.inject({}) do |hash, (attribute,_)|
    # Only apply conditional attributes if they're condition is met
    if @conditional_attributes.has_key?(attribute)
      next hash unless @conditional_attributes[attribute].call(source)
    end
    hash[attribute] = resolve_attribute_for_datum(attribute, source)
    hash
  end
end

#preload_association_dataObject

Load into memory all key-value pairs needed to fully associate all records that a source handles.



128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/sourced_attributes/source.rb', line 128

def preload_association_data
  @associations.each do |assoc|
    # Get the model that this association references.
    reflection = @klass.reflect_on_association(assoc[:name])
    # Determine which key-value pairs should be preloaded.
    values = @source_data.map{ |key, datum| datum[assoc[:source_key]] }
    # Load the data, keyed by the local primary key
    assoc[:data] = reflection.klass \
      .where(assoc[:primary_key] => values) \
      .select(assoc[:primary_key], reflection.klass.primary_key) \
      .index_by(&assoc[:primary_key])
  end
end

#refreshObject

Talk to the data source to refresh the contents of @source_data.



204
# File 'lib/sourced_attributes/source.rb', line 204

def refresh; raise :subclass_responsiblity; end

#refresh_affected_recordsObject

Fill @affected_records with all records affected by the current set of source data, creating new records for any keys which do not yet exist.



102
103
104
105
106
107
108
109
110
111
# File 'lib/sourced_attributes/source.rb', line 102

def refresh_affected_records
  # Ensure that the source data is indexed by primary key...
  ensure_indexed_source_data
  # ...so that it can be skimmed to find existing records.
  @affected_records = @klass \
    .where(@primary_key[:local] => @source_data.keys) \
    .index_by(&@primary_key[:local])
  # Then create new objects for the remaining data
  create_new_records if @options[:create_new]
end

#resolve_attribute_for_datum(attribute, datum) ⇒ Object

Given an attribute name and a primary key, resolve the value to be given to that attribute using the configuration supplied through the DSL.



69
70
71
72
73
74
75
76
77
78
# File 'lib/sourced_attributes/source.rb', line 69

def resolve_attribute_for_datum attribute, datum
  # The alias of this attribute in the source data
  source_name = @attribute_map[attribute]
  # Complex Attributes are evaluated with the datum as a parameter
  if @complex_attributes.has_key?(attribute)
    @complex_attributes[attribute].call(datum)
  else
    datum[source_name]
  end
end

#update_record(pk, record) ⇒ Object

Perform all of the operations related to updating a sourced record



165
166
167
168
169
170
171
172
# File 'lib/sourced_attributes/source.rb', line 165

def update_record pk, record
  # Update attributes
  apply_attributes_to(pk, record)
  # Update associations
  apply_associations_to(pk, record)
  # Save the record if it should be
  record.save if (@options[:save] && !@options[:batch_size])
end