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 +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: []
|