advanced_ar 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,3 @@
1
+ # AdvancedAR
2
+
3
+ Gem for adding advanced features into ActiveRecord.
@@ -0,0 +1,2 @@
1
+ # This Preloader needs to run before any that will load Models
2
+ AdvancedAR::CustomPreloaders.install
@@ -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
@@ -0,0 +1,6 @@
1
+
2
+ Dir[File.dirname(__FILE__) + "/advanced_ar/*.rb"].each { |file| require file }
3
+
4
+ module AdvancedAR
5
+
6
+ end
@@ -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