miscellany 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 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