sourced_attributes 0.1.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.
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: []