sourced_attributes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 60d367ee4b743bbcc4ee67e653cafe5b53b9ffc0
4
+ data.tar.gz: 771bf3f0feb097ea84927bc7a0583c83ecf9197f
5
+ SHA512:
6
+ metadata.gz: e8fe5020e6977c061132b683f01d76366dd89776d8428e35e3c9dda7e9167c035bbd695ae389c49281ece5636dbe618dcc13160ad834bece8031c90f339852bf
7
+ data.tar.gz: ba2d5172d0e37c1e342940452fb425ff4e4b6cfb061810eeff59194fb8aa3ebbbbb8a4c4caa2873b8011f4b7f1c53678c4e9ac767b4406dfe117173eaaebf9e6
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2016 Jon Egeland
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -0,0 +1,3 @@
1
+ require 'sourced_attributes/sourced_attributes'
2
+ require 'sourced_attributes/dsl'
3
+ require 'sourced_atributes//source'
@@ -0,0 +1,52 @@
1
+ module SourcedAttributes
2
+ module DSL
3
+ # Set options specific to this Source instance.
4
+ def configure options={}
5
+ @config.merge! options
6
+ end
7
+
8
+ # Set the primary key that this Source will use to find records to update.
9
+ def primary_key local, opts={}
10
+ @primary_key = { local: local, source: opts[:source] || local }
11
+ end
12
+
13
+ # Short-hand for defining attributes whose local names map directly to
14
+ # field names in the source data.
15
+ def attributes *args
16
+ args.each{ |arg| @attribute_map[arg] = arg }
17
+ end
18
+
19
+ # Define an attribute whose local name is different from its name in the
20
+ # source data.
21
+ def aliased_attribute local_name, source_name
22
+ @attribute_map[local_name] = source_name
23
+ end
24
+
25
+ def complex_attribute local_name, &block
26
+ attributes local_name
27
+ @complex_attributes[local_name] = block
28
+ end
29
+
30
+ # Conditional attributes only get updated when the block is true. If no
31
+ # block is given, a default block checking for the presence of the
32
+ # attribute in the source data will be used
33
+ def conditional_attribute local_name, &block
34
+ attributes local_name
35
+ if block_given?
36
+ @conditional_attributes[local_name] = block
37
+ else
38
+ @conditional_attributes[local_name] = ->(record) { record[local_name] }
39
+ end
40
+ end
41
+
42
+ # Define an association whose value comes from the source data.
43
+ # `primary_key` here is the primary key to use for the associated table.
44
+ # `source_key` is the key to pick out of the source data.
45
+ def association name, options={}
46
+ options[:name] ||= name
47
+ options[:source_key] ||= options[:name]
48
+ options[:preload] ||= false
49
+ @associations << options
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,206 @@
1
+ module SourcedAttributes
2
+ class Source
3
+ include ::SourcedAttributes::DSL
4
+
5
+ # A map of all aliases for concrete Source objects for use by the factory
6
+ # methods.
7
+ @@subclasses = {}
8
+
9
+ class << self
10
+ # A factory for creating instances of Source subclasses based on the
11
+ # parameterized name passed in and the list of registered subclasses.
12
+ def create name, opts, klass
13
+ (@@subclasses[name] or Source).new klass, opts
14
+ end
15
+
16
+ # Subclasses of Source should register aliases with the factory through
17
+ # this method.
18
+ def register_source name
19
+ @@subclasses[name] = self
20
+ end
21
+ end
22
+
23
+ # The default values for source-agnostic options that can be overridden by
24
+ # including new values in the Hash argument to `sources_attributes_from`.
25
+ DEFAULT_OPTIONS = {
26
+ save: true,
27
+ create_new: true,
28
+ batch_size: nil
29
+ }
30
+
31
+ def initialize klass, opts={}
32
+ # The model this source is working on.
33
+ @klass = klass
34
+ # A configuration hash for source-agnostic options, passed in as a Hash
35
+ # from the arguments given to the `sources_attributes_from` helper.
36
+ @options = DEFAULT_OPTIONS.merge(opts)
37
+ # A generic configuration hash for source-specific options, managed
38
+ # through the `configure` helper.
39
+ @config = {}
40
+ # The primary key that this source will use to find records to update.
41
+ # `local` is the alias of the primary key in the locally, while `source`
42
+ # is the alias of the primary key in the source data.
43
+ @primary_key = { local: :id, source: :id }
44
+ # A mapping of attributes on the model to field names from the source.
45
+ @attribute_map = {}
46
+ # A mapping of attributes which require special preparation to Procs
47
+ # which can perform that preparation, provided by the configuration.
48
+ @complex_attributes = {}
49
+ # A mapping of attributes which are only to be updated when a given
50
+ # condition is met to Procs which represent that condition.
51
+ @conditional_attributes = {}
52
+ # A list of associations that this source will update. Each entry is a
53
+ # hash, containing the keys :name, :primary_key, :preload.
54
+ @associations = []
55
+ # The most recent set of data from the source, formatted as a Hash using
56
+ # the :primary_key values as keys. Updated by `refresh` and used by
57
+ # `apply` to update records.
58
+ @source_data = []
59
+ # The last-retrieved set of data from the source, formatted the same way
60
+ # as @source_data.
61
+ @previous_data = []
62
+ # The records that that the source data will affect. Updated by
63
+ # `refresh_affected_records`.
64
+ @affected_records = {}
65
+ end
66
+
67
+ # Given an attribute name and a primary key, resolve the value to be given
68
+ # to that attribute using the configuration supplied through the DSL.
69
+ def resolve_attribute_for_datum attribute, datum
70
+ # The alias of this attribute in the source data
71
+ source_name = @attribute_map[attribute]
72
+ # Complex Attributes are evaluated with the datum as a parameter
73
+ if @complex_attributes.has_key?(attribute)
74
+ @complex_attributes[attribute].call(datum)
75
+ else
76
+ datum[source_name]
77
+ end
78
+ end
79
+
80
+ # Create a Hash from the @source_data array, keyed by @primary_key. If
81
+ # @source_data is already a Hash, assume it has already been indexed and do
82
+ # nothing.
83
+ def ensure_indexed_source_data
84
+ return if @source_data.is_a?(Hash)
85
+ @source_data = @source_data.inject({}) do |hash, datum|
86
+ hash[datum[@primary_key[:source]]] = datum
87
+ hash
88
+ end
89
+ end
90
+
91
+ # Create new instances of @klass for every key that does not already have
92
+ # an instance associated with it.
93
+ def create_new_records
94
+ @source_data.keys.each do |pk|
95
+ # TODO: Add an option for creating/not creating new records
96
+ @affected_records[pk] ||= @klass.new(@primary_key[:local] => pk)
97
+ end
98
+ end
99
+
100
+ # Fill @affected_records with all records affected by the current set of
101
+ # source data, creating new records for any keys which do not yet exist.
102
+ def refresh_affected_records
103
+ # Ensure that the source data is indexed by primary key...
104
+ ensure_indexed_source_data
105
+ # ...so that it can be skimmed to find existing records.
106
+ @affected_records = @klass \
107
+ .where(@primary_key[:local] => @source_data.keys) \
108
+ .index_by(&@primary_key[:local])
109
+ # Then create new objects for the remaining data
110
+ create_new_records if @options[:create_new]
111
+ end
112
+
113
+ # Apply the attribute map to the source data for the given record
114
+ def mapped_attributes_for pk
115
+ source = @source_data[pk]
116
+ @attribute_map.inject({}) do |hash, (attribute,_)|
117
+ # Only apply conditional attributes if they're condition is met
118
+ if @conditional_attributes.has_key?(attribute)
119
+ next hash unless @conditional_attributes[attribute].call(source)
120
+ end
121
+ hash[attribute] = resolve_attribute_for_datum(attribute, source)
122
+ hash
123
+ end
124
+ end
125
+
126
+ # Load into memory all key-value pairs needed to fully associate all
127
+ # records that a source handles.
128
+ def preload_association_data
129
+ @associations.each do |assoc|
130
+ # Get the model that this association references.
131
+ reflection = @klass.reflect_on_association(assoc[:name])
132
+ # Determine which key-value pairs should be preloaded.
133
+ values = @source_data.map{ |key, datum| datum[assoc[:source_key]] }
134
+ # Load the data, keyed by the local primary key
135
+ assoc[:data] = reflection.klass \
136
+ .where(assoc[:primary_key] => values) \
137
+ .select(assoc[:primary_key], reflection.klass.primary_key) \
138
+ .index_by(&assoc[:primary_key])
139
+ end
140
+ end
141
+
142
+ # Apply the current set of source data to the attributes for the given
143
+ # primary key.
144
+ def apply_attributes_to pk, record
145
+ # Map the attributes from the source data to their local counterparts
146
+ # and apply it to the record
147
+ record.assign_attributes(mapped_attributes_for(pk))
148
+ end
149
+
150
+ # Apply the current set of source data to the associations for the given
151
+ # primary key.
152
+ def apply_associations_to pk, record
153
+ @associations.each do |config|
154
+ # Get the model that this association references
155
+ reflection = @klass.reflect_on_association(config[:name])
156
+ # The associated records are already loaded, but need to be plucked out
157
+ # of their containing hash
158
+ associated_records = config[:data][@source_data[pk][config[:source_key]]]
159
+ # Apply the updated association to the record
160
+ record.assign_attributes(config[:name] => associated_records)
161
+ end
162
+ end
163
+
164
+ # Perform all of the operations related to updating a sourced record
165
+ def update_record pk, record
166
+ # Update attributes
167
+ apply_attributes_to(pk, record)
168
+ # Update associations
169
+ apply_associations_to(pk, record)
170
+ # Save the record if it should be
171
+ record.save if (@options[:save] && !@options[:batch_size])
172
+ end
173
+
174
+ # Apply the current set of source data to the records it affects
175
+ def apply
176
+ # Make sure the source data is up-to-date
177
+ refresh
178
+ # Make sure the source data is indexed by the primary key
179
+ ensure_indexed_source_data
180
+ # Make sure that all of the affected records are loaded
181
+ refresh_affected_records
182
+ # Preload all key-value pairs needed to fully associate this record.
183
+ preload_association_data
184
+ # Wrap all of the updates in a single transaction
185
+ @klass.transaction do
186
+ if @options[:batch_size]
187
+ @affected_records.each_slice(@options[:batch_size]) do |batch|
188
+ @klass.import batch.map{ |pk, record| update_record(pk, record); record }
189
+ end
190
+ else
191
+ @affected_records.each do |pk, record|
192
+ update_record pk, record
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+
199
+ # #
200
+ # Abstract Methods for Subclasses
201
+ # #
202
+
203
+ # Talk to the data source to refresh the contents of @source_data.
204
+ def refresh; raise :subclass_responsiblity; end
205
+ end
206
+ end
@@ -0,0 +1,25 @@
1
+ module SourcedAttributes
2
+ def self.included base
3
+ base.extend ClassMethods
4
+ end
5
+
6
+ module ClassMethods
7
+ # Configure a new Source for a model.
8
+ def sources_attributes_from source_name, **opts, &block
9
+ @sources ||= {}
10
+ @sources[source_name] ||= Source.create(source_name, opts, self)
11
+ @sources[source_name].instance_eval(&block)
12
+ @sources[source_name]
13
+ end
14
+
15
+ # Apply all Sources to the model. If `source_name` is specified, only apply
16
+ # changes from that Source.
17
+ def update_sourced_attributes source_name=nil
18
+ if source_name
19
+ @sources[source_name].apply
20
+ else
21
+ @sources.values.each(&:apply)
22
+ end
23
+ end
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sourced_attributes
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jon Egeland
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-05-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A DSL for aggregating data from multiple sources into ActiveRecord models.
14
+ Includes options for aliases, predicates, associations, and complex attributes.
15
+ email: audiobahn404@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - lib/sourced_attributes.rb
22
+ - lib/sourced_attributes/dsl.rb
23
+ - lib/sourced_attributes/source.rb
24
+ - lib/sourced_attributes/sourced_attributes.rb
25
+ homepage: http://github.com/audiobahn404/sourced_attributes
26
+ licenses:
27
+ - MIT
28
+ metadata: {}
29
+ post_install_message:
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: 2.2.0
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubyforge_project:
45
+ rubygems_version: 2.4.8
46
+ signing_key:
47
+ specification_version: 4
48
+ summary: A DSL for aggregating data from multiple sources into ActiveRecord models.
49
+ test_files: []