foreign_key_checker 0.4.1 → 0.5.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 +4 -4
- data/lib/foreign_key_checker.rb +5 -0
- data/lib/foreign_key_checker/railtie.rb +1 -0
- data/lib/foreign_key_checker/utils.rb +8 -2
- data/lib/foreign_key_checker/utils/belongs_to.rb +594 -0
- data/lib/foreign_key_checker/version.rb +1 -1
- data/lib/generators/foreign_key_checker/models_generator.rb +21 -0
- data/lib/generators/foreign_key_checker/templates/models/model.rb.erb +9 -0
- metadata +8 -19
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 14d08201b2ec53461b6eeb2da97e21b0713c8dc1f62ebeb8f15644caa1ef7484
|
4
|
+
data.tar.gz: 78454098ee4372ca7c6076bb312f5e9ebdb90074f81c88871e2903898d6e1e27
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1afaaeb393ab9ea52ca22ce080074298d13164b5f730e1fb34ff643b5c736351b433c60faed578ae46f26319361d95c9d603de9b4f862142fb9895b2b16f4515
|
7
|
+
data.tar.gz: f20849ecc864ff36304cf0b54baad81df7b805cca8a7a101e1eb5865eb71ab7951265950a1ec3c562f5b6716950b7f3272856fe000ee346b657a724a90e46c24
|
data/lib/foreign_key_checker.rb
CHANGED
@@ -4,6 +4,7 @@ module ForeignKeyChecker::Checkers
|
|
4
4
|
end
|
5
5
|
require 'foreign_key_checker/checkers/relations'
|
6
6
|
require 'foreign_key_checker/checkers/tables'
|
7
|
+
require 'foreign_key_checker/utils/belongs_to'
|
7
8
|
|
8
9
|
module ForeignKeyChecker
|
9
10
|
class TypeMismatch < StandardError; end
|
@@ -255,5 +256,9 @@ module ForeignKeyChecker
|
|
255
256
|
def check(options = {})
|
256
257
|
Checker.new(options).check
|
257
258
|
end
|
259
|
+
|
260
|
+
def hm_tree(model, **args)
|
261
|
+
ForeignKeyChecker::Utils::BelongsTo.build_hm_tree(model, **args)
|
262
|
+
end
|
258
263
|
end
|
259
264
|
end
|
@@ -8,7 +8,7 @@ module ForeignKeyChecker
|
|
8
8
|
end
|
9
9
|
class UnsupportedConnectionAdapter < StandardError; end
|
10
10
|
def self.get_foreign_keys(model = ActiveRecord::Base)
|
11
|
-
adapter = model.
|
11
|
+
adapter = model.connection_db_config.configuration_hash[:adapter]
|
12
12
|
raise(UnsupportedConnectionAdapter, adapter) unless %w[postgresql mysql2 sqlserver sqlite3].include?(adapter)
|
13
13
|
|
14
14
|
connection = model.connection
|
@@ -100,7 +100,7 @@ module ForeignKeyChecker
|
|
100
100
|
end
|
101
101
|
|
102
102
|
def self.get_tables(model = ActiveRecord::Base)
|
103
|
-
adapter = model.
|
103
|
+
adapter = model.connection_db_config.configuration_hash[:adapter]
|
104
104
|
raise(UnsupportedConnectionAdapter, adapter) unless %w[postgresql mysql2 sqlite3 sqlserver].include?(adapter)
|
105
105
|
|
106
106
|
connection = model.connection
|
@@ -122,5 +122,11 @@ module ForeignKeyChecker
|
|
122
122
|
def self.get_sqlserver_tables(connection)
|
123
123
|
connection.tables
|
124
124
|
end
|
125
|
+
|
126
|
+
def self.get_columns(connection)
|
127
|
+
get_tables.each_with_object({}) do |table, object|
|
128
|
+
object[table] = connection.columns(table)
|
129
|
+
end
|
130
|
+
end
|
125
131
|
end
|
126
132
|
end
|
@@ -0,0 +1,594 @@
|
|
1
|
+
module ForeignKeyChecker
|
2
|
+
module Utils
|
3
|
+
module BelongsTo
|
4
|
+
CONST_REGEXP = /\A[A-Z]\p{Alnum}*(::\p{Alnum}+)*\z/
|
5
|
+
class Result
|
6
|
+
attr_reader :primary_table, :dependant_table, :foreign_key, :foreign_type, :types, :polymorphic, :inverse_of, :name, :connection
|
7
|
+
attr_writer :build_class_name
|
8
|
+
def initialize(**args)
|
9
|
+
args.each do |key, value|
|
10
|
+
instance_variable_set("@#{key}", value)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def association_code
|
15
|
+
if inverse_of
|
16
|
+
has_many_code
|
17
|
+
else
|
18
|
+
belongs_to_code
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def inversed_association(table)
|
23
|
+
@inversed_associations ||= {}
|
24
|
+
@inversed_associations[table] = build_inversed_association(table)
|
25
|
+
@inversed_associations[table].build_class_name = @build_class_name if @build_class_name
|
26
|
+
@inversed_associations[table]
|
27
|
+
end
|
28
|
+
|
29
|
+
def class_name
|
30
|
+
build_class_name
|
31
|
+
end
|
32
|
+
|
33
|
+
def model
|
34
|
+
return class_name.constantize if class_name.is_a?(String)
|
35
|
+
|
36
|
+
class_name
|
37
|
+
end
|
38
|
+
|
39
|
+
def primary_model
|
40
|
+
kls = build_primary_class_name
|
41
|
+
return kls.constantize if kls.is_a?(String)
|
42
|
+
|
43
|
+
kls
|
44
|
+
end
|
45
|
+
|
46
|
+
def dependant_model
|
47
|
+
kls = build_dependant_class_name
|
48
|
+
return kls.constantize if kls.is_a?(String)
|
49
|
+
|
50
|
+
kls
|
51
|
+
rescue NameError => e
|
52
|
+
puts "#{e.class.to_s} #{e.message} #{e.backtrace.first}"
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
def delete_sql(ids)
|
57
|
+
dependent_sql(ids, prefix: 'DELETE FROM')
|
58
|
+
end
|
59
|
+
|
60
|
+
def select_sql_with_fk(ids)
|
61
|
+
dependent_sql(ids, prefix: "SELECT id, #{connection.quote_column_name(foreign_key)} FROM")
|
62
|
+
end
|
63
|
+
|
64
|
+
def select_sql(ids)
|
65
|
+
dependent_sql(ids)
|
66
|
+
end
|
67
|
+
|
68
|
+
def nullify_sql(ids)
|
69
|
+
dependent_sql(ids, prefix: 'UPDATE', suffix: nullify_suffix)
|
70
|
+
end
|
71
|
+
|
72
|
+
def has_many?
|
73
|
+
!belongs_to?
|
74
|
+
end
|
75
|
+
|
76
|
+
def belongs_to?
|
77
|
+
!inverse_of
|
78
|
+
end
|
79
|
+
|
80
|
+
def inspect
|
81
|
+
"#{class_name}.#{association_code}"
|
82
|
+
end
|
83
|
+
|
84
|
+
def conflict_with_fk?(fk)
|
85
|
+
return false if polymorphic
|
86
|
+
|
87
|
+
fk.from_column == foreign_key && fk.from_table == dependant_table && (fk.to_table != primary_table || fk.to_column != 'id')
|
88
|
+
end
|
89
|
+
|
90
|
+
private
|
91
|
+
def dependent_sql(ids, prefix: 'SELECT id FROM', suffix: '')
|
92
|
+
return nil if belongs_to?
|
93
|
+
return nil if ids.empty?
|
94
|
+
return if class_name.is_a?(ActiveRecord::Base) && class_name.primary_key.nil?
|
95
|
+
|
96
|
+
conn = connection
|
97
|
+
if polymorphic
|
98
|
+
[
|
99
|
+
"#{prefix} #{conn.quote_table_name(dependant_table.to_s)} #{suffix} WHERE #{conn.quote_column_name(foreign_key)} IN (#{ids.map(&:to_i).join(',')}) AND #{conn.quote_column_name(foreign_type)} = $1",
|
100
|
+
nil,
|
101
|
+
[class_name.to_s],
|
102
|
+
]
|
103
|
+
else
|
104
|
+
["#{prefix} #{conn.quote_table_name(dependant_table.to_s)} #{suffix} WHERE #{conn.quote_column_name(foreign_key)} IN (#{ids.map(&:to_i).join(',')})"]
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def nullify_suffix
|
109
|
+
conn = connection
|
110
|
+
if polymorphic
|
111
|
+
"SET #{conn.quote_column_name(foreign_key)} = NULL, #{conn.quote_column_name(foreign_type)} = NULL"
|
112
|
+
else
|
113
|
+
"SET #{conn.quote_column_name(foreign_key)} = NULL"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def build_inversed_association(table)
|
118
|
+
table = table.table_name if table.is_a?(ActiveRecord::Base)
|
119
|
+
if polymorphic
|
120
|
+
self.class.new(primary_table: table, dependant_table: dependant_table, foreign_key: foreign_key, foreign_type: foreign_type, polymorphic: true, inverse_of: self, connection: connection)
|
121
|
+
else
|
122
|
+
self.class.new(primary_table: primary_table, connection: connection, dependant_table: dependant_table, foreign_key: foreign_key, inverse_of: self)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def has_many_code
|
127
|
+
if polymorphic
|
128
|
+
has_many_polymorphic_code
|
129
|
+
else
|
130
|
+
has_many_simple_code
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def belongs_to_code
|
135
|
+
if polymorphic
|
136
|
+
"belongs_to :#{belongs_to_relation_name}, polymorphic: true, foreign_key: :#{foreign_key}, foreign_type: :#{foreign_type}"
|
137
|
+
else
|
138
|
+
"belongs_to :#{belongs_to_relation_name}, class_name: '#{build_primary_class_name}', foreign_key: :#{foreign_key}"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
def has_many_polymorphic_code
|
143
|
+
"has_many :#{has_many_relation_name}, class_name: '#{build_dependant_class_name}', polymorphic: true, foreign_key: :#{foreign_key}, foreign_type: :#{foreign_type}, dependent: :destroy"
|
144
|
+
end
|
145
|
+
|
146
|
+
def has_many_simple_code
|
147
|
+
"has_many :#{has_many_relation_name}, class_name: '#{build_dependant_class_name}', foreign_key: :#{foreign_key}, dependent: :destroy"
|
148
|
+
end
|
149
|
+
|
150
|
+
def has_many_relation_name
|
151
|
+
dependant_table.tr('.', '_').underscore.pluralize
|
152
|
+
end
|
153
|
+
|
154
|
+
def belongs_to_relation_name
|
155
|
+
foreign_key.delete_suffix('_id')
|
156
|
+
#return polymorphic_belongs_to_relation_name if polymorphic
|
157
|
+
#primary_table.tr('.', '_').underscore.singularize
|
158
|
+
end
|
159
|
+
|
160
|
+
def polymorphic_belongs_to_relation_name
|
161
|
+
@name
|
162
|
+
end
|
163
|
+
|
164
|
+
def build_class_name(arg = _table)
|
165
|
+
@build_class_name.call(arg)
|
166
|
+
end
|
167
|
+
|
168
|
+
def build_primary_class_name
|
169
|
+
build_class_name(primary_table)
|
170
|
+
end
|
171
|
+
|
172
|
+
def build_dependant_class_name
|
173
|
+
build_class_name(dependant_table)
|
174
|
+
end
|
175
|
+
|
176
|
+
def type_to_table(type)
|
177
|
+
@type_to_table.is_a?(Proc) ? @type_to_table.call(type) : type.underscore.pluralize
|
178
|
+
end
|
179
|
+
|
180
|
+
def _table
|
181
|
+
return dependant_table if belongs_to?
|
182
|
+
|
183
|
+
primary_table
|
184
|
+
end
|
185
|
+
end
|
186
|
+
class TableLevel
|
187
|
+
attr_reader :connection, :table, :columns, :columns_of_table, :polymorphic_suffixes, :fks
|
188
|
+
def initialize(connection, table, columns_of_table, polymorphic_suffixes, fks:, on_error: proc { } )
|
189
|
+
@connection = connection
|
190
|
+
@table = table
|
191
|
+
@columns = columns_of_table[table]
|
192
|
+
@columns_of_table = columns_of_table
|
193
|
+
@polymorphic_suffixes = polymorphic_suffixes
|
194
|
+
@on_error = on_error
|
195
|
+
@fks = fks
|
196
|
+
end
|
197
|
+
|
198
|
+
def foreign_key_suffix
|
199
|
+
'_id'
|
200
|
+
end
|
201
|
+
|
202
|
+
def candidates
|
203
|
+
columns.select { |column| column.name.ends_with?(foreign_key_suffix) }.each_with_object([]) do |column, object|
|
204
|
+
level = ColumnLevel.new(self, column)
|
205
|
+
level.errors.each(&@on_error)
|
206
|
+
[level.association].compact.each do |ass|
|
207
|
+
next if fks.find { |fk| ass.conflict_with_fk?(fk) }
|
208
|
+
|
209
|
+
object.push(ass)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
class ColumnLevel
|
215
|
+
attr_reader :table_level, :column
|
216
|
+
def initialize(table_level, column)
|
217
|
+
@table_level = table_level
|
218
|
+
@column = column
|
219
|
+
@errors = []
|
220
|
+
@association_name = column.name.delete_suffix(table_level.foreign_key_suffix)
|
221
|
+
@polymorphic_column_names = polymorphic_column_names
|
222
|
+
end
|
223
|
+
|
224
|
+
def association
|
225
|
+
perform if !@done
|
226
|
+
@association
|
227
|
+
end
|
228
|
+
|
229
|
+
def errors
|
230
|
+
perform if !@done
|
231
|
+
@errors
|
232
|
+
end
|
233
|
+
|
234
|
+
def polymorphic?
|
235
|
+
perform if !@done
|
236
|
+
@polymorphic
|
237
|
+
end
|
238
|
+
|
239
|
+
private
|
240
|
+
|
241
|
+
def perform
|
242
|
+
check_polymorphic!
|
243
|
+
check_common!
|
244
|
+
@done = true
|
245
|
+
end
|
246
|
+
|
247
|
+
def check_polymorphic!
|
248
|
+
if @polymorphic_column_names.size > 1
|
249
|
+
@errors.push("Can't determine polymorphic column for association #{@association_name}; candidates: #{@polymorphic_columns.join(', ')}")
|
250
|
+
elsif @polymorphic_column_names.size == 1
|
251
|
+
conn = table_level.connection
|
252
|
+
foreign_type = @polymorphic_column_names.first
|
253
|
+
types = conn.select_all("SELECT DISTINCT #{conn.quote_column_name(foreign_type)} FROM #{conn.quote_table_name(table_level.table)}").rows.map(&:first).compact.select { |tp| tp.match(CONST_REGEXP) }
|
254
|
+
@association = Result.new(polymorphic: true, foreign_key: column.name, foreign_type: foreign_type, types: types, name: @association_name, dependant_table: table_level.table, connection: conn)
|
255
|
+
@polymorphic = true
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
def check_common!
|
260
|
+
if @polymorphic_column_names.size != 0
|
261
|
+
return
|
262
|
+
end
|
263
|
+
matched_table = table_level.columns_of_table.keys.find do |table1|
|
264
|
+
next false if table1.singularize != @association_name
|
265
|
+
next false if table_level.columns_of_table[table1].find { |col| col.name == 'id' }&.type != column.type
|
266
|
+
true
|
267
|
+
end
|
268
|
+
if matched_table
|
269
|
+
@association = Result.new(polymorphic: false, foreign_key: column.name, primary_table: matched_table, dependant_table: table_level.table, connection: table_level.connection)
|
270
|
+
@polymorphic = false
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def polymorphic_column_names
|
275
|
+
table_level.polymorphic_suffixes.map do |polymorphic_suffix|
|
276
|
+
column_candidate_name = "#{@association_name}#{polymorphic_suffix}"
|
277
|
+
next unless table_level.columns.find { |col| col.name == column_candidate_name && col.type == :string }
|
278
|
+
|
279
|
+
column_candidate_name
|
280
|
+
end.compact
|
281
|
+
end
|
282
|
+
|
283
|
+
end
|
284
|
+
|
285
|
+
def self.build_table_mapping(model: ActiveRecord::Base, module_if_gt: 3)
|
286
|
+
Rails.application.eager_load!
|
287
|
+
base_class = model
|
288
|
+
loop do
|
289
|
+
if !base_class.superclass.respond_to?(:connection) || base_class.superclass.connection != base_class.connection
|
290
|
+
break
|
291
|
+
end
|
292
|
+
base_class = base_class.superclass
|
293
|
+
end
|
294
|
+
@table_mappings ||= {}
|
295
|
+
@table_mappings[base_class] ||= begin
|
296
|
+
start_hash = base_class.descendants.reject do |_model|
|
297
|
+
_model.abstract_class || (_model.connection != model.connection)
|
298
|
+
end.group_by(&:table_name).transform_values do |models|
|
299
|
+
next models.first if models.size == 1
|
300
|
+
|
301
|
+
root_models = models.map do |_model|
|
302
|
+
if _model.to_s.demodulize.starts_with?("HABTM_")
|
303
|
+
next
|
304
|
+
end
|
305
|
+
loop do
|
306
|
+
break if !_model.superclass.respond_to?(:abstract_class?) || _model.superclass.table_name.nil?
|
307
|
+
|
308
|
+
_model = _model.superclass
|
309
|
+
end
|
310
|
+
_model
|
311
|
+
end.compact.uniq
|
312
|
+
if root_models.size > 1
|
313
|
+
raise "More than one root model for table #{models.first.table_name}: #{root_models.inspect}"
|
314
|
+
end
|
315
|
+
root_models.first
|
316
|
+
end
|
317
|
+
all_tables = ForeignKeyChecker::Utils.get_tables(model)
|
318
|
+
#group_tables(all_tables)
|
319
|
+
all_tables.each_with_object(start_hash) do |table, object|
|
320
|
+
object[table] ||= table.tr('.', '_').singularize.camelize
|
321
|
+
end
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
def self.group_tables(tables, level: 1)
|
326
|
+
if tables.size < 2
|
327
|
+
return tables
|
328
|
+
end
|
329
|
+
tables.group_by do |table|
|
330
|
+
table.split(/[\._]/).first(level).join('_')
|
331
|
+
end.transform_values do |children|
|
332
|
+
group_tables(children, level: level + 1)
|
333
|
+
end
|
334
|
+
end
|
335
|
+
def self.column_name_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {})
|
336
|
+
columns_of_table = ForeignKeyChecker::Utils.get_columns(connection)
|
337
|
+
columns_of_table.each_with_object({}) do |(table, columns), object|
|
338
|
+
object[table] = TableLevel.new(connection, table, columns_of_table, polymorphic_suffixes, on_error: on_error, fks: fks).candidates
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
def self.all_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {})
|
343
|
+
merge_candidates(
|
344
|
+
fk_candidates(connection, polymorphic_suffixes: polymorphic_suffixes, on_error: on_error),
|
345
|
+
column_name_candidates(connection, polymorphic_suffixes: polymorphic_suffixes, on_error: on_error)
|
346
|
+
)
|
347
|
+
end
|
348
|
+
|
349
|
+
def self.all_association_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {})
|
350
|
+
belongs_to = all_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {})
|
351
|
+
has_many = inverse(belongs_to)
|
352
|
+
belongs_to.each_with_object({}) do |(table, bt_associations), object|
|
353
|
+
object[table] = {belongs_to: bt_associations, has_many: has_many[table] || []}
|
354
|
+
end
|
355
|
+
end
|
356
|
+
|
357
|
+
def self.inverse(hash)
|
358
|
+
hash.each_with_object({}) do |(table, bt_associations), object|
|
359
|
+
bt_associations.each do |association|
|
360
|
+
object[association.primary_table] ||= []
|
361
|
+
object[association.primary_table].push(association.inversed_association(table))
|
362
|
+
end
|
363
|
+
end.transform_values do |associations|
|
364
|
+
associations.uniq do |result|
|
365
|
+
"#{result.polymorphic} #{result.foreign_key} #{result.primary_table} #{result.dependant_table} #{result.name} #{result.types&.join(',')}"
|
366
|
+
end
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
def self.fks
|
371
|
+
@fks ||= ForeignKeyChecker::Utils.get_foreign_keys
|
372
|
+
end
|
373
|
+
|
374
|
+
def self.fk_candidates(connection, polymorphic_suffixes: ['_type'], on_error: proc {})
|
375
|
+
fks.to_a.each_with_object({}) do |datum, obj|
|
376
|
+
obj[datum.from_table] ||= []
|
377
|
+
obj[datum.from_table].push(Result.new(polymorphic: false, foreign_key: datum.from_column, dependant_table: datum.from_table, primary_table: datum.to_table, connection: connection))
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
def self.merge_candidates(*hashes)
|
382
|
+
hashes.each_with_object({}) do |hash, object|
|
383
|
+
hash.each do |table, candidates|
|
384
|
+
object[table] ||= []
|
385
|
+
object[table] = (object[table] + candidates).uniq do |result|
|
386
|
+
"#{result.polymorphic} #{result.foreign_key} #{result.primary_table} #{result.dependant_table} #{result.name} #{result.types&.join(',')}"
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
391
|
+
|
392
|
+
# ForeignKeyChecker::Utils::BelongsTo.build_classes(User)
|
393
|
+
def self.build_classes(model, polymorphic_suffixes: ['_type'], on_error: proc {})
|
394
|
+
@build_classes ||= begin
|
395
|
+
mapping = build_table_mapping(model: model)
|
396
|
+
build_class_name = proc { |table| mapping[table] }
|
397
|
+
all_association_candidates(model.connection, polymorphic_suffixes: polymorphic_suffixes, on_error: on_error).map do |table, hash|
|
398
|
+
hash.transform_values { |results| results.map { |result| result.build_class_name = build_class_name; result }}.merge(
|
399
|
+
table_name: table,
|
400
|
+
class_name: mapping[table],
|
401
|
+
)
|
402
|
+
end
|
403
|
+
end
|
404
|
+
end
|
405
|
+
|
406
|
+
# ForeignKeyChecker::Utils::BelongsTo.build_delete_chain(City.where(slug: 'paris'), {countries: :capital_city_id, cities: %i[curator_id main_city_id]})
|
407
|
+
# ForeignKeyChecker::Utils::BelongsTo.build_delete_chain(City.where(slug: 'paris'), {countries: :capital_city_id, cities: %i[curator_id main_city_id], local_profiles: :published_edition_id, hint_places: :place_image_id}, {})
|
408
|
+
def self.build_delete_chain(scope, nullify = {}, **args)
|
409
|
+
tree = build_hm_tree(scope.model, **args)
|
410
|
+
all_ids = []
|
411
|
+
dependencies = {}
|
412
|
+
triggers = {}
|
413
|
+
cmds = [proc { |*args| __ids = scope.pluck(:id); all_ids << [scope.model.table_name, __ids, []]; __ids}]
|
414
|
+
process_tree = proc do |_tree, way|
|
415
|
+
_tree.each do |table, data|
|
416
|
+
cmds << proc do |*args|
|
417
|
+
primary_table = data[:associations].first.primary_table
|
418
|
+
ids = all_ids.select { |tn, __ids| tn == primary_table }.map(&:second).reduce(&:+).uniq
|
419
|
+
_id_fks = []
|
420
|
+
data[:associations].each do |association|
|
421
|
+
association.select_sql_with_fk(ids.dup).try do |sql|
|
422
|
+
begin
|
423
|
+
if Array.wrap(nullify[association.dependant_table.to_sym]).include?(association.foreign_key.to_sym)
|
424
|
+
ids.in_groups_of(1000, false).each do |group|
|
425
|
+
all_ids << [";sql;", association.nullify_sql(group), way]
|
426
|
+
end
|
427
|
+
next
|
428
|
+
end
|
429
|
+
|
430
|
+
p way
|
431
|
+
_id_fks += association.connection.select_all(*sql).rows
|
432
|
+
rescue => e
|
433
|
+
raise e unless e.message.tr('"', '').include?("column id does not exist")
|
434
|
+
ids.in_groups_of(1000, false).each do |group|
|
435
|
+
all_ids << [";sql;", association.delete_sql(group), way]
|
436
|
+
end
|
437
|
+
puts e.message
|
438
|
+
end
|
439
|
+
end
|
440
|
+
end
|
441
|
+
_id_fks.each do |_id, _fk|
|
442
|
+
dependencies[primary_table] ||= {}
|
443
|
+
dependencies[primary_table][_fk] ||= Set.new
|
444
|
+
dependencies[primary_table][_fk] << [table, _id]
|
445
|
+
|
446
|
+
triggers[table] ||= {}
|
447
|
+
triggers[table][_id] ||= Set.new
|
448
|
+
triggers[table][_id] << [primary_table, _fk]
|
449
|
+
end
|
450
|
+
_ids = _id_fks.map(&:first).uniq - (all_ids.select { |tn, __ids| tn == table }.map(&:second).reduce([], &:+).uniq)
|
451
|
+
if _ids.any?
|
452
|
+
all_ids << [table, _ids, way]
|
453
|
+
data[:children].presence.try { |ch| process_tree.call(ch, way + [table]) }
|
454
|
+
end
|
455
|
+
_ids
|
456
|
+
end
|
457
|
+
end
|
458
|
+
end
|
459
|
+
process_tree.call(tree, [scope.model.table_name])
|
460
|
+
arg = nil
|
461
|
+
cmds.each do |cmd|
|
462
|
+
arg = cmd.call(arg)
|
463
|
+
end
|
464
|
+
sql_queries = all_ids.select { |a, b, c| a == ';sql;' }.map(&:second)
|
465
|
+
t_ids = all_ids.reject { |a, b, c| a == ';sql;' }.group_by(&:first).transform_values { |vs| vs.map(&:second).reduce([], &:+).uniq }.to_a
|
466
|
+
loop do
|
467
|
+
break unless t_ids.map(&:second).any?(&:any?)
|
468
|
+
any = false
|
469
|
+
t_ids.each do |table, ids|
|
470
|
+
_ids = ids.select { |id| dependencies.dig(table, id).blank? }
|
471
|
+
ids.reject! { |id| dependencies.dig(table, id).blank? }
|
472
|
+
if _ids.present?
|
473
|
+
any = true
|
474
|
+
_ids.in_groups_of(1000, false).each do |ids_group|
|
475
|
+
sql_queries << ["DELETE FROM #{scope.model.connection.quote_table_name(table)} WHERE id IN (#{ids_group.map(&:to_i).join(',')})"]
|
476
|
+
end
|
477
|
+
_ids.each do |id|
|
478
|
+
triggers.dig(table, id)&.each do |keys|
|
479
|
+
dependencies.dig(*keys).delete([table, id])
|
480
|
+
end
|
481
|
+
end
|
482
|
+
end
|
483
|
+
end
|
484
|
+
unless any
|
485
|
+
binding.pry
|
486
|
+
puts "Cannot destroy these objects. Check cyclic relation chains:\n#{find_cycles(tree, [scope.model.table_name]).inspect}"
|
487
|
+
return []
|
488
|
+
end
|
489
|
+
end
|
490
|
+
sql_queries
|
491
|
+
end
|
492
|
+
class WayPoint
|
493
|
+
attr_reader :table
|
494
|
+
def initialize(foreign_key, table)
|
495
|
+
@foreign_key = foreign_key
|
496
|
+
@table = table
|
497
|
+
end
|
498
|
+
|
499
|
+
def inspect
|
500
|
+
return @table if @foreign_key.blank?
|
501
|
+
|
502
|
+
"->#{@foreign_key}(#{@table})"
|
503
|
+
end
|
504
|
+
|
505
|
+
def ==(value)
|
506
|
+
return @table == value if value.is_a?(String)
|
507
|
+
|
508
|
+
value.table == @table
|
509
|
+
end
|
510
|
+
end
|
511
|
+
|
512
|
+
def self.find_ways(node, way)
|
513
|
+
way = way.map { |wp| next WayPoint.new(nil, wp) if wp.is_a?(String); wp}
|
514
|
+
return [way] if node.nil?
|
515
|
+
node.each_with_object([]) do |(key, item), obj|
|
516
|
+
tails = if way.include?(key)
|
517
|
+
[way + [key]]
|
518
|
+
else
|
519
|
+
find_ways(item[:children], way + [key])
|
520
|
+
end
|
521
|
+
item[:associations].each do |ass|
|
522
|
+
tails.each do |tail|
|
523
|
+
obj.push((tail[0...way.size]) + [WayPoint.new(ass.foreign_key, ass.dependant_table)] + (tail[(way.size + 1)..-1] || []))
|
524
|
+
end
|
525
|
+
end
|
526
|
+
end
|
527
|
+
end
|
528
|
+
|
529
|
+
def self.find_cycles(tree, start)
|
530
|
+
find_ways(tree, start).each_with_object([]) do |way, obj|
|
531
|
+
way[0..-2].index(way.last).try do |idx|
|
532
|
+
obj.push ([WayPoint.new(nil, way[idx].table)] + way[idx+1..-1])
|
533
|
+
end
|
534
|
+
end.uniq(&:inspect)
|
535
|
+
end
|
536
|
+
|
537
|
+
# ForeignKeyChecker::Utils::BelongsTo.ways_for(User)
|
538
|
+
def self.ways_for(model)
|
539
|
+
find_ways(build_hm_tree(model), [model.table_name])
|
540
|
+
end
|
541
|
+
|
542
|
+
# ForeignKeyChecker::Utils::BelongsTo.ways_for(User)
|
543
|
+
def self.cycles_for(model)
|
544
|
+
find_cycles(build_hm_tree(model), [model.table_name])
|
545
|
+
end
|
546
|
+
|
547
|
+
# ForeignKeyChecker::Utils::BelongsTo.build_hm_tree(User)
|
548
|
+
# построит дерево зависимостей от модели User
|
549
|
+
#find_cycle = proc { |node, way| puts way.join('->'); next [way] if node.nil?; node.each_with_object([]) { |(key, item), obj| ways = way.include?(key) ? [way + [key]] : find_cycle.call(item[:children], way + [key]); ways.each { |w| obj.push(w) } } v}
|
550
|
+
def self.build_hm_tree(model, **args)
|
551
|
+
mapping = build_table_mapping(model: model)
|
552
|
+
hash = build_classes(model, **args).group_by do |result|
|
553
|
+
result[:table_name]
|
554
|
+
end
|
555
|
+
found = {}
|
556
|
+
processing = {}
|
557
|
+
should_fill = []
|
558
|
+
find_children = proc do |c_table_name|
|
559
|
+
processing[c_table_name] = true
|
560
|
+
hash[c_table_name].each_with_object({}) do |results, object|
|
561
|
+
perform_result = proc do |result|
|
562
|
+
key = result.dependant_table
|
563
|
+
object[key] ||= {associations: []}
|
564
|
+
object[key][:associations].push(result)
|
565
|
+
|
566
|
+
if processing[key]
|
567
|
+
should_fill.push([key, object[key]])
|
568
|
+
next
|
569
|
+
end
|
570
|
+
|
571
|
+
found[key] ||= find_children.call(key)
|
572
|
+
object[key][:children] ||= found[key]
|
573
|
+
end
|
574
|
+
results[:has_many].each(&perform_result)
|
575
|
+
hash.each_value do |hs|
|
576
|
+
hs.each do |h|
|
577
|
+
h[:belongs_to].each do |r|
|
578
|
+
next unless r.polymorphic && r.types.include?(mapping[c_table_name])
|
579
|
+
|
580
|
+
perform_result.call(r.inversed_association(c_table_name))
|
581
|
+
end
|
582
|
+
end
|
583
|
+
end
|
584
|
+
end
|
585
|
+
end
|
586
|
+
ret = find_children.call(model.table_name)
|
587
|
+
should_fill.each do |table_name, item|
|
588
|
+
item[:children] = found[table_name]
|
589
|
+
end
|
590
|
+
ret
|
591
|
+
end
|
592
|
+
end
|
593
|
+
end
|
594
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'rails/generators/active_record'
|
2
|
+
module ForeignKeyChecker
|
3
|
+
module Generators
|
4
|
+
class ModelsGenerator < ActiveRecord::Generators::Base
|
5
|
+
desc "generates models for all tables in development database"
|
6
|
+
argument :name, type: :string, default: 'FixForeignKeys'
|
7
|
+
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
9
|
+
|
10
|
+
def install
|
11
|
+
ForeignKeyChecker::Utils::BelongsTo.build_classes(ActiveRecord::Base.connection).each do |object|
|
12
|
+
file_path = "app/models/#{object[:class_name].underscore}.rb"
|
13
|
+
@object = object
|
14
|
+
template 'models/model.rb.erb', file_path
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
class <%= @object[:class_name] %> < ApplicationRecord
|
2
|
+
self.table_name = '<%= @object[:table_name] %>'
|
3
|
+
<% @object[:belongs_to].each do |bt| %>
|
4
|
+
<%= bt.association_code %>
|
5
|
+
<% end %>
|
6
|
+
<% @object[:has_many].each do |bt| %>
|
7
|
+
<%= bt.association_code %>
|
8
|
+
<% end %>
|
9
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: foreign_key_checker
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- AnatolyShirykalov
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-03-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
19
|
+
version: 6.1.0
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
26
|
+
version: 6.1.0
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: sqlite3
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,20 +80,6 @@ dependencies:
|
|
80
80
|
- - ">="
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
|
-
- !ruby/object:Gem::Dependency
|
84
|
-
name: activerecord-sqlserver-adapter
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
86
|
-
requirements:
|
87
|
-
- - ">="
|
88
|
-
- !ruby/object:Gem::Version
|
89
|
-
version: '0'
|
90
|
-
type: :development
|
91
|
-
prerelease: false
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
93
|
-
requirements:
|
94
|
-
- - ">="
|
95
|
-
- !ruby/object:Gem::Version
|
96
|
-
version: '0'
|
97
83
|
- !ruby/object:Gem::Dependency
|
98
84
|
name: irb
|
99
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -151,9 +137,12 @@ files:
|
|
151
137
|
- lib/foreign_key_checker/checkers/tables.rb
|
152
138
|
- lib/foreign_key_checker/railtie.rb
|
153
139
|
- lib/foreign_key_checker/utils.rb
|
140
|
+
- lib/foreign_key_checker/utils/belongs_to.rb
|
154
141
|
- lib/foreign_key_checker/version.rb
|
155
142
|
- lib/generators/foreign_key_checker/migration/migration_generator.rb
|
156
143
|
- lib/generators/foreign_key_checker/migration/templates/migrations/fix_foreign_keys.rb.erb
|
144
|
+
- lib/generators/foreign_key_checker/models_generator.rb
|
145
|
+
- lib/generators/foreign_key_checker/templates/models/model.rb.erb
|
157
146
|
- lib/tasks/foreign_key_checker_tasks.rake
|
158
147
|
homepage: https://gitlab.com/pipocavsobake/foreign_key_checker
|
159
148
|
licenses:
|
@@ -175,7 +164,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
175
164
|
- !ruby/object:Gem::Version
|
176
165
|
version: '0'
|
177
166
|
requirements: []
|
178
|
-
rubygems_version: 3.1.
|
167
|
+
rubygems_version: 3.1.4
|
179
168
|
signing_key:
|
180
169
|
specification_version: 4
|
181
170
|
summary: Find problems with relations in active_record models
|