sourced_attributes 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +19 -0
- data/lib/sourced_attributes.rb +3 -0
- data/lib/sourced_attributes/dsl.rb +52 -0
- data/lib/sourced_attributes/source.rb +206 -0
- data/lib/sourced_attributes/sourced_attributes.rb +25 -0
- metadata +49 -0
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,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: []
|