advanced_ar 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.
@@ -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