Class: BulkDependencyEraser::QuerySchemaParser

Inherits:
Base
  • Object
show all
Defined in:
lib/bulk_dependency_eraser/query_schema_parser.rb

Constant Summary collapse

DEFAULT_OPTS =
{
  verbose: false,
  # Some associations scopes take parameters.
  # - We would have to instantiate if we wanted to apply that scope filter.
  instantiate_if_assoc_scope_with_arity: false,
  force_destroy_restricted: false,
}

Constants inherited from Base

Base::DEFAULT_DB_BLANK_WRAPPER, Base::DEFAULT_DB_READ_WRAPPER, Base::DEFAULT_DB_WRITE_WRAPPER, Base::DEFAULT_KLASS_MAPPED_SCOPE_WRAPPER, Base::DEFAULT_SCOPE_WRAPPER, Base::DEPENDENCY_DESTROY, Base::DEPENDENCY_DESTROY_IGNORE_REFLECTION_TYPES, Base::DEPENDENCY_NULLIFY, Base::DEPENDENCY_RESTRICT, Base::POLY_KLASS_NAME

Instance Attribute Summary collapse

Attributes inherited from Base

#errors

Instance Method Summary collapse

Methods inherited from Base

#merge_errors, #report_error

Methods included from Utils::Methods

#deep_freeze

Constructor Details

#initialize(query:, opts: {}) ⇒ QuerySchemaParser

Returns a new instance of QuerySchemaParser.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 19

def initialize query:, opts: {}
  if query.is_a?(ActiveRecord::Relation)
    @initial_class      = query.klass
  else
    # current_query is a normal rails class
    @initial_class      = query
  end
  # @dependencies_per_klass Structure
  # {
  #   <ActiveRecord::Base> => {
  #     <ActiveRecord::Reflection::AssociationReflection> => <ActiveRecord::Base> 
  #   }
  # }
  @dependencies_per_klass = {}
  # @circular_dependency_klasses Structure
  # {
  #   <ActiveRecord::Base> => [
  #     # Path of dependencies that start and end with the key class
  #     <ActiveRecord::Base>,
  #     <ActiveRecord::Base>,
  #     <ActiveRecord::Base>,
  #   ]
  # }
  @circular_dependency_klasses = {}
  @full_schema_parser = BulkDependencyEraser::FullSchemaParser.new(opts:)
  super(opts:)
end

Instance Attribute Details

#circular_dependency_klassesObject (readonly)

Returns the value of attribute circular_dependency_klasses.



14
15
16
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 14

def circular_dependency_klasses
  @circular_dependency_klasses
end

#dependencies_per_klassObject (readonly)

Returns the value of attribute dependencies_per_klass.



13
14
15
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 13

def dependencies_per_klass
  @dependencies_per_klass
end

#full_schema_parserObject (readonly)

Returns the value of attribute full_schema_parser.



15
16
17
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 15

def full_schema_parser
  @full_schema_parser
end

#initial_classObject (readonly)

attr_accessor :deletion_list, :nullification_list



12
13
14
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 12

def initial_class
  @initial_class
end

Instance Method Details

#association_parser(parent_class, association_name, dependency_type, dependency_path) ⇒ Object

Used to iterate through each destroyable association, and recursively call ‘deletion_query_parser’.

Parameters:

  • parent_class (ApplicationRecord)
  • association_name (Symbol)
    • The association name from the parent_class



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 125

def association_parser(parent_class, association_name, dependency_type, dependency_path)
  reflection = parent_class.reflect_on_association(association_name)
  reflection_type = reflection.class.name

  raise "No dependency set for #{parent_class} and it's association: #{association_name}" unless dependency_type

  case reflection_type
  when 'ActiveRecord::Reflection::HasManyReflection'
    association_parser_has_many(parent_class, association_name, dependency_type, dependency_path)
  when 'ActiveRecord::Reflection::HasOneReflection'
    association_parser_has_many(parent_class, association_name, dependency_type, dependency_path)
  when 'ActiveRecord::Reflection::BelongsToReflection'
    association_parser_belongs_to(parent_class, association_name, dependency_type, dependency_path)
  else
    report_message("Unsupported association type for #{parent_class.name}'s association '#{association_name}': #{reflection_type}")
  end
end

#association_parser_belongs_to(parent_class, association_name, dependency_type, dependency_path) ⇒ Object



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 210

def association_parser_belongs_to(parent_class, association_name, dependency_type, dependency_path)
  raise "parent_class missing" unless parent_class
  raise "#{parent_class} - association_name: missing" unless association_name
  raise "#{parent_class} - dependency_type: missing"  unless dependency_type
  raise "#{parent_class} - dependency_path: nil"      if dependency_path.nil?

  reflection = parent_class.reflect_on_association(association_name)
  reflection_type = reflection.class.name

  is_polymorphic = reflection.options[:polymorphic]
  if is_polymorphic
    assoc_klass = find_klasses_from_polymorphic_dependency(parent_class).map(&:constantize)
    @dependencies_per_klass[parent_class.name] += assoc_klass.map(&:name)
  else
    assoc_klass = reflection.klass
    @dependencies_per_klass[parent_class.name] << assoc_klass.name
  end

  specified_primary_key = reflection.options[:primary_key] || 'id'
  specified_foreign_key = reflection.options[:foreign_key] || "#{association_name}_id"

  # Check to see if foreign_key exists in our parent table
  unless parent_class.column_names.include?(specified_foreign_key)
    report_error(
      "
      For #{parent_class.name}'s association '#{association_name}': Could not determine the assoc's foreign key.
      Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{parent_class.table_name} table.
      "
    )
    return
  end

  if (
    DEPENDENCY_DESTROY.include?(dependency_type) ||
    DEPENDENCY_NULLIFY.include?(dependency_type) && traverse_restricted_dependency?(parent_class, reflection)
  )
    klass_dependencies_parser(assoc_klass, klass_action: dependency_type, dependency_path: dependency_path.dup << parent_class.name)
  end
end

#association_parser_has_many(parent_class, association_name, dependency_type, dependency_path) ⇒ Object

Handles the :has_many association type

  • handles it’s polymorphic associations internally (easier on the has_many)



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 145

def association_parser_has_many(parent_class, association_name, dependency_type, dependency_path)
  raise "parent_class missing" unless parent_class
  raise "#{parent_class} - association_name: missing" unless association_name
  raise "#{parent_class} - dependency_type: missing"  unless dependency_type
  raise "#{parent_class} - dependency_path: nil"      if dependency_path.nil?

  reflection = parent_class.reflect_on_association(association_name)
  reflection_type = reflection.class.name

  assoc_klass = reflection.klass
  assoc_klass_name = assoc_klass.name
  @dependencies_per_klass[parent_class.name] << assoc_klass.name

  # If there is an association scope present, check to see how many parameters it's using
  # - if there's any parameter, we have to either skip it or instantiate it to find it's dependencies.
  if reflection.scope&.arity&.nonzero? && opts_c.instantiate_if_assoc_scope_with_arity == false
    report_error(
      "#{parent_class.name} and '#{association_name}' - scope has instance parameters. Use :instantiate_if_assoc_scope_with_arity option?"
    )
    return
  end

  # Look for manually specified keys in the assocation first
  specified_primary_key = reflection.options[:primary_key]&.to_s
  specified_foreign_key = reflection.options[:foreign_key]&.to_s
  # For polymorphic_associations
  specified_foreign_type = nil

  # handle foreign_key edge cases
  if specified_foreign_key.nil?
    if reflection.options[:as]
      specified_foreign_type = "#{reflection.options[:as]}_type"
      specified_foreign_key = "#{reflection.options[:as]}_id"
    else
      specified_foreign_key = parent_class.table_name.singularize + "_id"
    end
  end

  # Check to see if foreign_key exists in association class's table
  unless assoc_klass.column_names.include?(specified_foreign_key)
    report_error(
      "
      For '#{assoc_klass.name}': Could not determine the assoc's foreign key.
      Foreign key should have been '#{specified_foreign_key}', but did not exist on the #{assoc_klass.table_name} table.
      "
    )
    return
  end

  unless specified_foreign_type.nil? || assoc_klass.column_names.include?(specified_foreign_type)
    report_error(
      "
      For '#{assoc_klass.name}': Could not determine the assoc's foreign key type.
      Foreign key type should have been '#{specified_foreign_type}', but did not exist on the #{assoc_klass.table_name} table.
      "
    )
  end

  if DEPENDENCY_RESTRICT.include?(dependency_type) && traverse_restricted_dependency?(parent_class, reflection)
    klass_dependencies_parser(assoc_klass, klass_action: dependency_type, dependency_path: dependency_path.dup << parent_class.name)
  else
    klass_dependencies_parser(assoc_klass, klass_action: dependency_type, dependency_path: dependency_path.dup << parent_class.name)
  end
end

#executeObject



47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 47

def execute
  unless full_schema_parser.execute
    merge_errors(full_schema_parser.errors, 'FullSchemaParser: ')
    return false
  end
  klass_dependencies_parser(initial_class, klass_action: :destroy)

  @dependencies_per_klass.each do |key, values|
    @dependencies_per_klass[key] = values.uniq
  end

  return true
end

#find_klasses_from_polymorphic_dependency(klass) ⇒ Object

In this example the klass would be the polymorphic klass

  • i.e. Attachment belongs_to: :attachable, dependent: :destroy

We’re looking for klasses in the flat map that have a has_many :attachments, as: :attachable



253
254
255
256
257
258
259
260
261
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 253

def find_klasses_from_polymorphic_dependency(klass)
  found_klasses = []
  flat_dependencies_per_klass.each do |flat_klass_name, klass_dependencies|
    if klass_dependencies[:has_many].values.include?(klass.name)
      found_klasses << flat_klass_name
    end
  end
  found_klasses
end

#klass_dependencies_parser(klass, klass_action:, dependency_path: []) ⇒ Object

  • if was a dependency from a polymophic class, then iterate through the klasses.

Parameters:

  • klass (ActiveRecord::Base, Array<ActiveRecord::Base>)
  • dependency_path (Array<ActiveRecord::Base>) (defaults to: [])
    • previously parsed klasses



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 64

def klass_dependencies_parser klass, klass_action:, dependency_path: []
  if klass.is_a?(Array)
    klass.each do |klass_subset|
      klass_dependencies_parser(klass_subset, klass_action:, dependency_path:)
    end
    return
  end

  unless DEPENDENCY_DESTROY.include?(klass_action) || DEPENDENCY_NULLIFY.include?(klass_action)
    raise "invalid klass action: #{klass_action}"
  end
  raise "invalid klass: #{klass}" unless klass < ActiveRecord::Base

  # Not a circular dependency if the repetitious klass has a nullify action.
  if DEPENDENCY_DESTROY.include?(klass_action) && dependency_path.include?(klass.name)
    index = dependency_path.index(klass.name)
    circular_dependency = dependency_path[index..] + [klass.name]
    circular_dependency_klasses[klass.name] = circular_dependency
    return
  end

  # We don't need to consider dependencies for a klass that is being nullified.
  return if DEPENDENCY_NULLIFY.include?(klass_action)

  # already parsed, doesn't need to be parsed again.
  return if dependencies_per_klass.include?(klass.name)

  @dependencies_per_klass[klass.name] = []

  # We're including :restricted dependencies
  destroy_associations = klass.reflect_on_all_associations.select do |reflection|
    dependency_type = reflection.options&.dig(:dependent)&.to_sym
    DEPENDENCY_DESTROY.include?(dependency_type)
  end

  nullify_associations = klass.reflect_on_all_associations.select do |reflection|
    dependency_type = reflection.options&.dig(:dependent)&.to_sym
    DEPENDENCY_NULLIFY.include?(dependency_type)
  end

  # Iterate through the assoc names, if there are any :through assocs, then rename the association
  # - Rails interpretation of any dependencies of a :through association is to apply it to
  #   the leaf association at the end of the :through chain(s)
  association_dependencies = {}
  (
    destroy_associations.map(&:name) +
    nullify_associations.map(&:name)
  ).collect do |assoc_name|
    root_association_name = find_root_association_from_through_assocs(klass, assoc_name)
    association_dependencies[root_association_name] = klass.reflect_on_association(assoc_name).options.dig(:dependent)
  end

  # Using association names as keys helps remove duplicates - from dependent options on through associations and root associations.
  association_dependencies.each do |association_name, dependency_type|
    association_parser(klass, association_name, dependency_type, dependency_path)
  end
end

#traverse_restricted_dependency?(parent_class, reflection) ⇒ Boolean

return [Boolean]

  • true if valid

  • false if not valid

Returns:

  • (Boolean)


266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/bulk_dependency_eraser/query_schema_parser.rb', line 266

def traverse_restricted_dependency? parent_class, reflection
  # Return true if we're going to destroy all restricted
  return true if opts_c.force_destroy_restricted

  report_error(
    "
      #{parent_class.name}'s assoc '#{reflection.name}' has a restricted dependency type.
      If you still wish to destroy, use the 'force_destroy_restricted: true' option
    "
  )

  return false
end