foreign_key_checker 0.4.1 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|