advanced_ar 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/README.md +3 -0
- data/config/initializers/01_custom_preloaders.rb +2 -0
- data/config/initializers/arbitrary_prefetch.rb +1 -0
- data/config/initializers/cancancan.rb +34 -0
- data/lib/advanced_ar.rb +6 -0
- data/lib/advanced_ar/arbitrary_prefetch.rb +161 -0
- data/lib/advanced_ar/batch_matcher.rb +199 -0
- data/lib/advanced_ar/batch_processor.rb +41 -0
- data/lib/advanced_ar/batched_destruction.rb +94 -0
- data/lib/advanced_ar/batching_csv_processor.rb +86 -0
- data/lib/advanced_ar/custom_preloaders.rb +57 -0
- data/lib/advanced_ar/param_validator.rb +421 -0
- data/lib/advanced_ar/version.rb +3 -0
- metadata +293 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 89ee5d16158505c78a2daf264fdbe578f97c81c14be8722d489aede8759d8306
|
4
|
+
data.tar.gz: 87d105eca9cece82a02fabdcc83ea641549fa6243c400561e089a104e380a0b1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9a8fea0f12d6bb2246910c61d6870065b6af7a31300a49fe815c555cdbf0d540a4401911450d0da7e6c52520a514d0e482855f540f68bd526625624cb99df3e8
|
7
|
+
data.tar.gz: 99765c594830908e98a36a870bd7cc75fcb80ea1d3828af496c470459ad6e17c919c2f0fe51e0e50fe4be4835a296dc6ede2595c40d32bd9e3c43013fb6fe045
|
data/README.md
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
AdvancedAR::ArbitraryPrefetch.install
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# This patch fixes an issue where CanCanCan wasn't properly handling
|
2
|
+
# existence checks for AR Scopes - instead of do a DB Query, it loaded the entire relation
|
3
|
+
module CanCan
|
4
|
+
module ConditionsMatcher
|
5
|
+
def condition_match?(attribute, value)
|
6
|
+
case value
|
7
|
+
when Hash
|
8
|
+
hash_condition_match?(attribute, value)
|
9
|
+
when Range
|
10
|
+
value.cover?(attribute)
|
11
|
+
when ActiveRecord::Relation
|
12
|
+
ar_condition_match?(attribute, value)
|
13
|
+
when Enumerable
|
14
|
+
value.include?(attribute)
|
15
|
+
else
|
16
|
+
attribute == value
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def ar_condition_match?(attribute, value)
|
21
|
+
return false if attribute.nil?
|
22
|
+
|
23
|
+
if attribute.is_a?(Array) || (defined?(ActiveRecord) && attribute.is_a?(ActiveRecord::Relation) && attribute.loaded?)
|
24
|
+
value.where(id: attribute.pluck(:id)).exists?
|
25
|
+
elsif (defined?(ActiveRecord) && attribute.is_a?(ActiveRecord::Relation))
|
26
|
+
value.where(id: attribute.select(:id)).exists?
|
27
|
+
elsif (defined?(ActiveRecord) && attribute.is_a?(ActiveRecord::Base))
|
28
|
+
value.where(id: attribute.id).exists?
|
29
|
+
else
|
30
|
+
value.where(id: attribute).exists?
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/advanced_ar.rb
ADDED
@@ -0,0 +1,161 @@
|
|
1
|
+
# Add support for creating arbitrary associations when using ActiveRecord
|
2
|
+
# Adds a `prefetch` method to ActiveRecord Queries.
|
3
|
+
# This method accepts a Hash. The keys of the Hash represent how the Association will be made available.
|
4
|
+
# The values of the Hash may be an array of [Symbol, Relation] or another (filtered) Relation.
|
5
|
+
# Objects are queried from an existing Association on the model. This Association is detemrined
|
6
|
+
# by either the Symbol when an array is passed, or by finding an Assoication for the passed Relation's model
|
7
|
+
#
|
8
|
+
# NOTICE: This implementation is NOT COMPLETE by itself - it depends on Goldiloader
|
9
|
+
# to detect the use of the virtual associations and prevent N+1s. We were already using
|
10
|
+
# Goldiloader, so this made sense. If this module is ever needed stand-alone,
|
11
|
+
# the following options have been identified:
|
12
|
+
# 1. Extend ActiveRecordRelationPatch#exec_queries to execute an ActiveRecord::Associations::Preloader
|
13
|
+
# that will load the related objects
|
14
|
+
# 2. Duplicates the relevant snippets from Goldiloader into this module. See Goldiloader::AutoIncludeContext
|
15
|
+
# The current Goldiloader implementation uses Option 1 internally, but also makes the relations lazy - even
|
16
|
+
# if you define a prefetch, it won't actually be loaded until you attempt to access it on one of the models.
|
17
|
+
module AdvancedAR::ArbitraryPrefetch
|
18
|
+
class PrefetcherContext
|
19
|
+
attr_accessor :model, :target_attribute
|
20
|
+
attr_reader :options
|
21
|
+
|
22
|
+
def initialize(model, opts)
|
23
|
+
@options = opts
|
24
|
+
@model = model
|
25
|
+
@source_key = opts[:relation]
|
26
|
+
@target_attribute = opts[:attribute]
|
27
|
+
@queryset = opts[:queryset]
|
28
|
+
@models = []
|
29
|
+
end
|
30
|
+
|
31
|
+
def link_models(models)
|
32
|
+
Array(models).each do |m|
|
33
|
+
@models << m
|
34
|
+
|
35
|
+
# assoc = PrefetchAssociation.new(m, self, reflection)
|
36
|
+
assoc = reflection.association_class.new(m, reflection)
|
37
|
+
m.send(:association_instance_set, target_attribute, assoc)
|
38
|
+
|
39
|
+
m.instance_eval <<-CODE, __FILE__, __LINE__ + 1
|
40
|
+
def #{target_attribute}
|
41
|
+
association(:#{target_attribute}).reader
|
42
|
+
end
|
43
|
+
CODE
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def reflection
|
48
|
+
@reflection ||= begin
|
49
|
+
queryset = @queryset
|
50
|
+
source_refl = model.reflections[@source_key.to_s]
|
51
|
+
scope = lambda { |*_args|
|
52
|
+
qs = queryset
|
53
|
+
qs = qs.merge(source_refl.scope_for(model.unscoped)) if source_refl.scope
|
54
|
+
qs
|
55
|
+
}
|
56
|
+
ActiveRecord::Reflection.create(
|
57
|
+
options[:type],
|
58
|
+
@target_attribute,
|
59
|
+
scope,
|
60
|
+
source_refl.options.merge(
|
61
|
+
class_name: source_refl.class_name,
|
62
|
+
inverse_of: nil
|
63
|
+
),
|
64
|
+
model
|
65
|
+
)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
module ActiveRecordBasePatch
|
71
|
+
extend ActiveSupport::Concern
|
72
|
+
|
73
|
+
included do
|
74
|
+
class << self
|
75
|
+
delegate :prefetch, to: :all
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
module ActiveRecordRelationPatch
|
81
|
+
def exec_queries
|
82
|
+
return super if loaded?
|
83
|
+
|
84
|
+
records = super
|
85
|
+
preloader = nil
|
86
|
+
(@values[:prefetches] || {}).each do |_key, opts|
|
87
|
+
pfc = PrefetcherContext.new(model, opts)
|
88
|
+
pfc.link_models(records)
|
89
|
+
|
90
|
+
unless defined?(Goldiloader)
|
91
|
+
preloader ||= build_preloader
|
92
|
+
preloader.preload(records, opts[:attribute])
|
93
|
+
end
|
94
|
+
end
|
95
|
+
records
|
96
|
+
end
|
97
|
+
|
98
|
+
def prefetch(**kwargs)
|
99
|
+
spawn.add_prefetches!(kwargs)
|
100
|
+
end
|
101
|
+
|
102
|
+
def add_prefetches!(kwargs)
|
103
|
+
return unless kwargs.present?
|
104
|
+
|
105
|
+
assert_mutability!
|
106
|
+
@values[:prefetches] ||= {}
|
107
|
+
kwargs.each do |attr, opts|
|
108
|
+
@values[:prefetches][attr] = normalize_options(attr, opts)
|
109
|
+
end
|
110
|
+
self
|
111
|
+
end
|
112
|
+
|
113
|
+
def normalize_options(attr, opts)
|
114
|
+
norm = if opts.is_a?(Array)
|
115
|
+
{ relation: opts[0], queryset: opts[1] }
|
116
|
+
elsif opts.is_a?(ActiveRecord::Relation)
|
117
|
+
rel_name = opts.model.name.underscore
|
118
|
+
rel = (model.reflections[rel_name] || model.reflections[rel_name.pluralize])&.name
|
119
|
+
{ relation: rel, queryset: opts }
|
120
|
+
else
|
121
|
+
opts
|
122
|
+
end
|
123
|
+
|
124
|
+
norm[:attribute] = attr
|
125
|
+
norm[:type] ||= (attr.to_s.pluralize == attr.to_s) ? :has_many : :has_one
|
126
|
+
|
127
|
+
norm
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
module ActiveRecordMergerPatch
|
132
|
+
def merge
|
133
|
+
super.tap do
|
134
|
+
merge_prefetches
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def merge_prefetches
|
141
|
+
relation.add_prefetches!(other.values[:prefetches])
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def self.install
|
146
|
+
::ActiveRecord::Base.include(ActiveRecordBasePatch)
|
147
|
+
::ActiveRecord::Relation.prepend(ActiveRecordRelationPatch)
|
148
|
+
::ActiveRecord::Relation::Merger.prepend(ActiveRecordMergerPatch)
|
149
|
+
|
150
|
+
return unless defined? ::Goldiloader
|
151
|
+
|
152
|
+
::Goldiloader::AssociationLoader.module_eval do
|
153
|
+
def self.has_association?(model, association_name) # rubocop:disable Naming/PredicateName
|
154
|
+
model.association(association_name)
|
155
|
+
true
|
156
|
+
rescue ::ActiveRecord::AssociationNotFoundError => _err
|
157
|
+
false
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,199 @@
|
|
1
|
+
module AdvancedAR
|
2
|
+
# Matches a set of rows (eg from a CSV) against Rows in the Database.
|
3
|
+
#
|
4
|
+
# Examples:
|
5
|
+
# rule_matcher = BatchMatcher.new(
|
6
|
+
# Rule, rows,
|
7
|
+
# validate_all: false,
|
8
|
+
# columns: [[:rule_id, :id, 'ID'], [:rule_import_id, :import_id, 'Import ID']]
|
9
|
+
# )
|
10
|
+
# context_matcher = BatchMatcher.new(
|
11
|
+
# [Account, Course], rows,
|
12
|
+
# polymorphic_on: :rule_context,
|
13
|
+
# columns: [[:canvas_context_id, :canvas_id, 'Canvas ID'], [:sis_context_id, :sis_id, 'SIS ID']]
|
14
|
+
# )
|
15
|
+
# role_matcher = BatchMatcher.new(
|
16
|
+
# Role, rows,
|
17
|
+
# columns: [[:canvas_role_id, :canvas_id, 'Canvas Role ID'], [:role_label, :label, 'Role Label']]
|
18
|
+
# )
|
19
|
+
#
|
20
|
+
# Params:
|
21
|
+
# @param clazz - Model or ActiveRecord::Realtion
|
22
|
+
# @param rows - Data to search the DB for matches
|
23
|
+
# @param columns - [[CSV Header Key, Model Key, (Human Name)], ...]
|
24
|
+
class BatchMatcher
|
25
|
+
attr_reader :rows, :maps, :columns, :primary_column
|
26
|
+
|
27
|
+
def initialize(clazz, rows, columns:, polymorphic_on: false, validate_all: true)
|
28
|
+
# columns: [csv_key, db_key, human_name]
|
29
|
+
@options = {
|
30
|
+
mode: :eager,
|
31
|
+
polymorphic_on: polymorphic_on,
|
32
|
+
validate_all: validate_all
|
33
|
+
}
|
34
|
+
@clazz = clazz
|
35
|
+
@columns = columns
|
36
|
+
@primary_column = columns[0]
|
37
|
+
@rows = rows
|
38
|
+
@maps = {}
|
39
|
+
@loaded_columns = {}
|
40
|
+
@mode = :eager
|
41
|
+
end
|
42
|
+
|
43
|
+
def get_for_row!(row)
|
44
|
+
resolve_row_value(row, :get_by_column)
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_for_row(row)
|
48
|
+
get_for_row!(row)
|
49
|
+
rescue ActiveRecord::RecordNotFound
|
50
|
+
nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_primary_for_row!(row)
|
54
|
+
resolve_row_value(row, :column_to_primary_key)
|
55
|
+
end
|
56
|
+
|
57
|
+
def get_primary_for_row(row)
|
58
|
+
get_primary_for_row!(row)
|
59
|
+
rescue ActiveRecord::RecordNotFound
|
60
|
+
nil
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns True if the row has a value for any of this Matcher's Columns
|
64
|
+
def should_match?(row)
|
65
|
+
@columns.any? { |col| row[col[0]].present? }
|
66
|
+
end
|
67
|
+
|
68
|
+
protected
|
69
|
+
|
70
|
+
def get_by_column(column, row)
|
71
|
+
if column == @primary_column
|
72
|
+
column_value(column, row)
|
73
|
+
else
|
74
|
+
mapped_id = column_value(column, row)
|
75
|
+
get_column_map(primary_column, row, via_column: column)[transform_key(mapped_id)]
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def column_value(column, row)
|
80
|
+
get_column_map(column, row)[transform_key(row[column[0]])]
|
81
|
+
end
|
82
|
+
|
83
|
+
def column_to_primary_key(column, row)
|
84
|
+
if column == @primary_column
|
85
|
+
transform_key(row[column[0]])
|
86
|
+
else
|
87
|
+
transform_key(column_value(column, row))
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
92
|
+
def resolve_row_value(row, resolver)
|
93
|
+
found_values = []
|
94
|
+
@columns.each do |c|
|
95
|
+
next unless row[c[0]].present?
|
96
|
+
|
97
|
+
inst = send(resolver, c, row)
|
98
|
+
if inst.nil? && (!found_values.present? || @options[:validate_all])
|
99
|
+
clazz = as_class(get_base_query(row))
|
100
|
+
raise ActiveRecord::RecordNotFound, "could not find #{clazz.name} with #{c[2] || c[0]} #{row[c[0]]}"
|
101
|
+
end
|
102
|
+
found_values << inst
|
103
|
+
end
|
104
|
+
|
105
|
+
if @options[:validate_all] && found_values.uniq.count > 1
|
106
|
+
raise ActiveRecord::RecordNotFound, "multiple of [#{@columns.pluck(2).join(', ')}] were supplied, but resolved to different objects" # rubocop:disable Metrics/LineLength
|
107
|
+
end
|
108
|
+
|
109
|
+
found_values[0]
|
110
|
+
end
|
111
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
112
|
+
|
113
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
114
|
+
def get_column_map(column, row, via_column: nil)
|
115
|
+
base_query = get_base_query(row)
|
116
|
+
clazz = as_class(base_query)
|
117
|
+
raise ActiveRecord::RecordNotFound, "invalid #{@options[:polymorphic_on]}: #{row[@options[:polymorphic_on]]}" if clazz.nil? # rubocop:disable Metrics/LineLength
|
118
|
+
|
119
|
+
load_column(clazz, column, via_column) do
|
120
|
+
relevant_rows = rows
|
121
|
+
relevant_rows = relevant_rows.select { |r| r[@options[:polymorphic_on]] == clazz.name } if @options[:polymorphic_on]
|
122
|
+
row_keys = Set.new(relevant_rows.pluck(column[0]).compact)
|
123
|
+
|
124
|
+
# In :eager mode, requesting the primary column triggers loading of all columns
|
125
|
+
row_keys |= eager_load_columns(base_query, relevant_rows) if @options[:mode] == :eager && column == @primary_column
|
126
|
+
|
127
|
+
# Load data for the requested column
|
128
|
+
loaded_hash = load_column_data(column, base_query, row_keys)
|
129
|
+
|
130
|
+
# In :lazy mode, the corresponding primary_column data is loaded with each column
|
131
|
+
load_column_data(primary_column, base_query, loaded_hash.values) if @options[:mode] == :lazy && column != @primary_column # rubocop:disable Metrics/LineLength
|
132
|
+
end
|
133
|
+
end
|
134
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
135
|
+
|
136
|
+
def load_column_data(column, base_query, keys)
|
137
|
+
data = if column == @primary_column
|
138
|
+
base_query.where(column[1] => keys).index_by do |row|
|
139
|
+
transform_key(row[column[1]])
|
140
|
+
end
|
141
|
+
else
|
142
|
+
base_query.where(column[1] => keys).pluck(column[1], @primary_column[1]).map do |k, v|
|
143
|
+
[transform_key(k), v]
|
144
|
+
end.to_h
|
145
|
+
end
|
146
|
+
add_to_column(column, as_class(base_query), data)
|
147
|
+
data
|
148
|
+
end
|
149
|
+
|
150
|
+
def add_to_column(column, clazz, entries)
|
151
|
+
@maps[column] ||= {}
|
152
|
+
cache = @maps[column][clazz] ||= {}
|
153
|
+
cache.merge! entries
|
154
|
+
cache
|
155
|
+
end
|
156
|
+
|
157
|
+
def as_class(clazz)
|
158
|
+
clazz.is_a?(ActiveRecord::Relation) ? clazz.model : clazz
|
159
|
+
end
|
160
|
+
|
161
|
+
def get_base_query(row)
|
162
|
+
return row if row.is_a?(Class) || row.is_a?(ActiveRecord::Relation)
|
163
|
+
return @clazz unless @options[:polymorphic_on]
|
164
|
+
|
165
|
+
@clazz.find { |cls| cls.name == row[@options[:polymorphic_on]] }
|
166
|
+
end
|
167
|
+
|
168
|
+
def transform_key(key)
|
169
|
+
key.nil? ? key : key.to_s
|
170
|
+
end
|
171
|
+
|
172
|
+
private
|
173
|
+
|
174
|
+
def load_column(clazz, column, request_column)
|
175
|
+
lkey = loaded_key(clazz, column, request_column)
|
176
|
+
unless @loaded_columns[lkey]
|
177
|
+
yield
|
178
|
+
@loaded_columns[lkey] = true
|
179
|
+
end
|
180
|
+
@maps[column][clazz]
|
181
|
+
end
|
182
|
+
|
183
|
+
def loaded_key(clazz, column, request_column)
|
184
|
+
key_column = @options[:mode] == :eager ? column : request_column || column
|
185
|
+
[*key_column, clazz]
|
186
|
+
end
|
187
|
+
|
188
|
+
def eager_load_columns(base_query, relevant_rows)
|
189
|
+
additional_keys = Set.new
|
190
|
+
@columns.each do |column|
|
191
|
+
next if column == primary_column
|
192
|
+
|
193
|
+
loaded_col = load_column_data(column, base_query, relevant_rows.pluck(column[0]).compact.uniq)
|
194
|
+
additional_keys |= loaded_col.values
|
195
|
+
end
|
196
|
+
additional_keys
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module AdvancedAR
|
2
|
+
# An array that "processes" after so many items are added.
|
3
|
+
#
|
4
|
+
# Example Usage:
|
5
|
+
# batches = BatchProcessor.new(of: 1000) do |batch|
|
6
|
+
# # Process the batch somehow
|
7
|
+
# end
|
8
|
+
# enumerator_of_some_kind.each { |item| batches << item }
|
9
|
+
# batches.flush
|
10
|
+
class BatchProcessor
|
11
|
+
attr_reader :batch_size
|
12
|
+
|
13
|
+
def initialize(of: 1000, &blk)
|
14
|
+
@batch_size = of
|
15
|
+
@block = blk
|
16
|
+
@current_batch = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def <<(item)
|
20
|
+
@current_batch << item
|
21
|
+
process_batch if @current_batch.count >= batch_size
|
22
|
+
end
|
23
|
+
|
24
|
+
def add_all(items)
|
25
|
+
items.each do |i|
|
26
|
+
self << i
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def flush
|
31
|
+
process_batch if @current_batch.present?
|
32
|
+
end
|
33
|
+
|
34
|
+
protected
|
35
|
+
|
36
|
+
def process_batch
|
37
|
+
@block.call(@current_batch)
|
38
|
+
@current_batch = []
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|