miscellany 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 296f82b755fbbff37b7b939e31d30d53c1e1ed3b528df93992460a5c5093e1bb
4
+ data.tar.gz: f42d95c04a728a4b16e9b0286e900d243b18b1385649150fed2b1a0501e7098a
5
+ SHA512:
6
+ metadata.gz: 513b776e716e713413ee7f1c45cb2300c6142ffad6fcd3b550bfa06f5f496b630dea82fb99274853e2f825d47387648d015364d619de3b7572e0565b83160219
7
+ data.tar.gz: 85540e4cca127328d52c1b68a51be2b2366e5bcf5dfef95464703ece61539ba68ed3e0689fd55e22d8c258bbb625efa486b2d5f0b32075bffcf43da866d4e110
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Miscellany
2
+
3
+ Gem for a bunch of random, re-usable Rails Concerns & Helpers.
@@ -0,0 +1,2 @@
1
+ # This Preloader needs to run before any that will load Models
2
+ Miscellany::CustomPreloaders.install
@@ -0,0 +1 @@
1
+ Miscellany::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/miscellany.rb ADDED
@@ -0,0 +1,6 @@
1
+
2
+ Dir[File.dirname(__FILE__) + "/miscellany/**/*.rb"].each { |file| require file }
3
+
4
+ module Miscellany
5
+
6
+ end
@@ -0,0 +1,163 @@
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 Miscellany
18
+ module ArbitraryPrefetch
19
+ class PrefetcherContext
20
+ attr_accessor :model, :target_attribute
21
+ attr_reader :options
22
+
23
+ def initialize(model, opts)
24
+ @options = opts
25
+ @model = model
26
+ @source_key = opts[:relation]
27
+ @target_attribute = opts[:attribute]
28
+ @queryset = opts[:queryset]
29
+ @models = []
30
+ end
31
+
32
+ def link_models(models)
33
+ Array(models).each do |m|
34
+ @models << m
35
+
36
+ # assoc = PrefetchAssociation.new(m, self, reflection)
37
+ assoc = reflection.association_class.new(m, reflection)
38
+ m.send(:association_instance_set, target_attribute, assoc)
39
+
40
+ m.instance_eval <<-CODE, __FILE__, __LINE__ + 1
41
+ def #{target_attribute}
42
+ association(:#{target_attribute}).reader
43
+ end
44
+ CODE
45
+ end
46
+ end
47
+
48
+ def reflection
49
+ @reflection ||= begin
50
+ queryset = @queryset
51
+ source_refl = model.reflections[@source_key.to_s]
52
+ scope = lambda { |*_args|
53
+ qs = queryset
54
+ qs = qs.merge(source_refl.scope_for(model.unscoped)) if source_refl.scope
55
+ qs
56
+ }
57
+ ActiveRecord::Reflection.create(
58
+ options[:type],
59
+ @target_attribute,
60
+ scope,
61
+ source_refl.options.merge(
62
+ class_name: source_refl.class_name,
63
+ inverse_of: nil
64
+ ),
65
+ model
66
+ )
67
+ end
68
+ end
69
+ end
70
+
71
+ module ActiveRecordBasePatch
72
+ extend ActiveSupport::Concern
73
+
74
+ included do
75
+ class << self
76
+ delegate :prefetch, to: :all
77
+ end
78
+ end
79
+ end
80
+
81
+ module ActiveRecordRelationPatch
82
+ def exec_queries
83
+ return super if loaded?
84
+
85
+ records = super
86
+ preloader = nil
87
+ (@values[:prefetches] || {}).each do |_key, opts|
88
+ pfc = PrefetcherContext.new(model, opts)
89
+ pfc.link_models(records)
90
+
91
+ unless defined?(Goldiloader)
92
+ preloader ||= build_preloader
93
+ preloader.preload(records, opts[:attribute])
94
+ end
95
+ end
96
+ records
97
+ end
98
+
99
+ def prefetch(**kwargs)
100
+ spawn.add_prefetches!(kwargs)
101
+ end
102
+
103
+ def add_prefetches!(kwargs)
104
+ return unless kwargs.present?
105
+
106
+ assert_mutability!
107
+ @values[:prefetches] ||= {}
108
+ kwargs.each do |attr, opts|
109
+ @values[:prefetches][attr] = normalize_options(attr, opts)
110
+ end
111
+ self
112
+ end
113
+
114
+ def normalize_options(attr, opts)
115
+ norm = if opts.is_a?(Array)
116
+ { relation: opts[0], queryset: opts[1] }
117
+ elsif opts.is_a?(ActiveRecord::Relation)
118
+ rel_name = opts.model.name.underscore
119
+ rel = (model.reflections[rel_name] || model.reflections[rel_name.pluralize])&.name
120
+ { relation: rel, queryset: opts }
121
+ else
122
+ opts
123
+ end
124
+
125
+ norm[:attribute] = attr
126
+ norm[:type] ||= (attr.to_s.pluralize == attr.to_s) ? :has_many : :has_one
127
+
128
+ norm
129
+ end
130
+ end
131
+
132
+ module ActiveRecordMergerPatch
133
+ def merge
134
+ super.tap do
135
+ merge_prefetches
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def merge_prefetches
142
+ relation.add_prefetches!(other.values[:prefetches])
143
+ end
144
+ end
145
+
146
+ def self.install
147
+ ::ActiveRecord::Base.include(ActiveRecordBasePatch)
148
+ ::ActiveRecord::Relation.prepend(ActiveRecordRelationPatch)
149
+ ::ActiveRecord::Relation::Merger.prepend(ActiveRecordMergerPatch)
150
+
151
+ return unless defined? ::Goldiloader
152
+
153
+ ::Goldiloader::AssociationLoader.module_eval do
154
+ def self.has_association?(model, association_name) # rubocop:disable Naming/PredicateName
155
+ model.association(association_name)
156
+ true
157
+ rescue ::ActiveRecord::AssociationNotFoundError => _err
158
+ false
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,199 @@
1
+ module Miscellany
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,96 @@
1
+ module Miscellany
2
+ module BatchedDestruction
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ define_model_callbacks :bulk_destroy
7
+ define_model_callbacks :destroy_batch
8
+
9
+ before_destroy_batch do
10
+ # TODO Delete Dependant Relations
11
+ model_class.reflections.each do |name, reflection|
12
+ options = reflection.options
13
+ end
14
+ end
15
+ end
16
+
17
+ class_methods do
18
+ def bulk_destroy(**kwargs)
19
+ return to_sql
20
+ bulk_destroy_internal(self, **kwargs)
21
+ end
22
+
23
+ # Hook for performing the actual deletion of items, may be used to facilitate soft-deletion.
24
+ # Must not call destroy().
25
+ # Default implementation is to delete the batch using delete_all(id: batch_ids).
26
+ def destroy_bulk_batch(batch, options)
27
+ delete_ids = batch.map(&:id)
28
+ where(id: delete_ids).delete_all()
29
+ end
30
+
31
+ private
32
+
33
+ def bulk_destroy_internal(items, **kwargs)
34
+ options = {}
35
+ options.merge!(kwargs)
36
+ ClassCallbackExector.run_callbacks(model_class, :bulk_destroy, options: options) do
37
+ if items.respond_to?(:find_in_batches)
38
+ items.find_in_batches do |batch|
39
+ _destroy_batch(batch, options)
40
+ end
41
+ else
42
+ _destroy_batch(items, options)
43
+ end
44
+ end
45
+ end
46
+
47
+ def _destroy_batch(batch, options)
48
+ ClassCallbackExector.run_callbacks(model_class, :destroy_batch, {
49
+ model_class: model_class,
50
+ batch: batch,
51
+ }) do
52
+ model_class.destroy_bulk_batch(batch, options)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def model_class
59
+ try(:model) || self
60
+ end
61
+ end
62
+
63
+ def destroy(*args, legacy: false, **kwargs)
64
+ if legacy
65
+ super(*args)
66
+ else
67
+ self.class.send(:bulk_destroy_internal, [self], **kwargs)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # These classes are some Hackery to allow us to use callbacks against the Model classes instead of Model instances
74
+ class ClassCallbackExector
75
+ include ActiveSupport::Callbacks
76
+
77
+ attr_reader :callback_class
78
+ delegate :__callbacks, to: :callback_class
79
+ delegate_missing_to :callback_class
80
+
81
+ def initialize(cls, env)
82
+ @callback_class = cls
83
+ env.keys.each do |k|
84
+ define_singleton_method(k) do
85
+ env[k]
86
+ end
87
+ end
88
+ @options = options
89
+ end
90
+
91
+ def self.run_callbacks(cls, callback, env={}, &blk)
92
+ new(cls, env).run_callbacks(callback, &blk)
93
+ end
94
+ end
95
+ end
96
+ end