database-model-generator 0.6.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 +7 -0
- checksums.yaml.gz.sig +3 -0
- data/CHANGES.md +59 -0
- data/Gemfile +2 -0
- data/LICENSE +177 -0
- data/MANIFEST.md +10 -0
- data/README.md +186 -0
- data/Rakefile +32 -0
- data/bin/dmg +831 -0
- data/certs/djberg96_pub.pem +26 -0
- data/database-model-generator.gemspec +31 -0
- data/docker/README.md +238 -0
- data/docker/oracle/Dockerfile +87 -0
- data/docker/oracle/README.md +140 -0
- data/docker/oracle/docker-compose.yml +41 -0
- data/docker/oracle/test.sh +45 -0
- data/docker/sqlserver/DOCKER.md +152 -0
- data/docker/sqlserver/Dockerfile +29 -0
- data/docker/sqlserver/SUPPORT.md +477 -0
- data/docker/sqlserver/TESTING.md +194 -0
- data/docker/sqlserver/docker-compose.yml +52 -0
- data/docker/sqlserver/init-db.sql +158 -0
- data/docker/sqlserver/run_tests.sh +154 -0
- data/docker/sqlserver/setup-db.sh +9 -0
- data/docker/sqlserver/test-Dockerfile +36 -0
- data/docker/sqlserver/test.sh +56 -0
- data/lib/database_model_generator.rb +652 -0
- data/lib/oracle/model/generator.rb +287 -0
- data/lib/sqlserver/model/generator.rb +281 -0
- data/spec/oracle_model_generator_spec.rb +176 -0
- data/spec/spec_helper.rb +30 -0
- data/spec/support/oracle_connection.rb +126 -0
- data.tar.gz.sig +0 -0
- metadata +162 -0
- metadata.gz.sig +0 -0
@@ -0,0 +1,652 @@
|
|
1
|
+
begin
|
2
|
+
require 'oci8'
|
3
|
+
rescue LoadError
|
4
|
+
# OCI8 not available - Oracle support will be disabled
|
5
|
+
end
|
6
|
+
|
7
|
+
begin
|
8
|
+
require 'tiny_tds'
|
9
|
+
rescue LoadError
|
10
|
+
# TinyTDS not available - SQL Server support will be disabled
|
11
|
+
end
|
12
|
+
|
13
|
+
module DatabaseModel
|
14
|
+
module Generator
|
15
|
+
# The version of the database-model-generator library
|
16
|
+
VERSION = '0.6.0'
|
17
|
+
|
18
|
+
# Factory method to create the appropriate generator based on connection type
|
19
|
+
def self.new(connection, options = {})
|
20
|
+
database_type = detect_database_type(connection, options)
|
21
|
+
|
22
|
+
case database_type
|
23
|
+
when :oracle
|
24
|
+
require_relative 'oracle/model/generator'
|
25
|
+
Oracle::Model::Generator.new(connection)
|
26
|
+
when :sqlserver
|
27
|
+
require_relative 'sqlserver/model/generator'
|
28
|
+
SqlServer::Model::Generator.new(connection)
|
29
|
+
else
|
30
|
+
raise ArgumentError, "Unsupported database type: #{database_type}"
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def self.detect_database_type(connection, options = {})
|
37
|
+
return options[:type].to_sym if options[:type]
|
38
|
+
|
39
|
+
case connection.class.name
|
40
|
+
when 'OCI8'
|
41
|
+
:oracle
|
42
|
+
when 'TinyTds::Client'
|
43
|
+
:sqlserver
|
44
|
+
else
|
45
|
+
raise ArgumentError, "Cannot detect database type from connection: #{connection.class}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Base generator class with common functionality
|
52
|
+
module DatabaseModel
|
53
|
+
module Generator
|
54
|
+
class Base
|
55
|
+
attr_reader :connection, :constraints, :foreign_keys, :belongs_to
|
56
|
+
attr_reader :table, :model, :view, :dependencies, :column_info, :primary_keys
|
57
|
+
attr_reader :polymorphic_associations, :enum_columns
|
58
|
+
|
59
|
+
def initialize(connection)
|
60
|
+
raise ArgumentError, "Connection cannot be nil" if connection.nil?
|
61
|
+
validate_connection(connection)
|
62
|
+
|
63
|
+
@connection = connection
|
64
|
+
@constraints = []
|
65
|
+
@primary_keys = []
|
66
|
+
@foreign_keys = []
|
67
|
+
@dependencies = []
|
68
|
+
@belongs_to = []
|
69
|
+
@polymorphic_associations = []
|
70
|
+
@enum_columns = []
|
71
|
+
@column_info = []
|
72
|
+
@table = nil
|
73
|
+
@model = nil
|
74
|
+
end
|
75
|
+
|
76
|
+
def generate(table, view = false)
|
77
|
+
raise ArgumentError, "Table name cannot be nil or empty" if table.nil? || table.strip.empty?
|
78
|
+
|
79
|
+
@table = normalize_table_name(table)
|
80
|
+
@model = generate_model_name(table)
|
81
|
+
@view = view
|
82
|
+
|
83
|
+
reset_state
|
84
|
+
get_column_info
|
85
|
+
get_primary_keys
|
86
|
+
get_foreign_keys unless view
|
87
|
+
get_belongs_to
|
88
|
+
get_constraints unless view
|
89
|
+
get_polymorphic_associations unless view
|
90
|
+
get_enum_columns unless view
|
91
|
+
get_dependencies unless view
|
92
|
+
|
93
|
+
self
|
94
|
+
end
|
95
|
+
|
96
|
+
def generated?
|
97
|
+
!@table.nil? && !@column_info.empty?
|
98
|
+
end
|
99
|
+
|
100
|
+
def column_names
|
101
|
+
return [] unless generated?
|
102
|
+
@column_info.map(&:name)
|
103
|
+
end
|
104
|
+
|
105
|
+
def table_exists?
|
106
|
+
return false unless @table
|
107
|
+
check_table_exists(@table)
|
108
|
+
end
|
109
|
+
|
110
|
+
def disconnect
|
111
|
+
# Default implementation - subclasses should override
|
112
|
+
@connection = nil
|
113
|
+
end
|
114
|
+
|
115
|
+
def constraint_summary
|
116
|
+
return {} unless generated?
|
117
|
+
|
118
|
+
summary = Hash.new { |h, k| h[k] = [] }
|
119
|
+
@constraints.each do |constraint|
|
120
|
+
type = format_constraint_type(constraint)
|
121
|
+
column_name = get_constraint_column_name(constraint)
|
122
|
+
summary[column_name.downcase] << type
|
123
|
+
end
|
124
|
+
summary
|
125
|
+
end
|
126
|
+
|
127
|
+
def index_recommendations
|
128
|
+
return {} unless generated?
|
129
|
+
|
130
|
+
recommendations = {
|
131
|
+
foreign_keys: [],
|
132
|
+
unique_constraints: [],
|
133
|
+
date_queries: [],
|
134
|
+
status_enum: [],
|
135
|
+
composite: [],
|
136
|
+
full_text: []
|
137
|
+
}
|
138
|
+
|
139
|
+
build_foreign_key_recommendations(recommendations)
|
140
|
+
build_unique_constraint_recommendations(recommendations)
|
141
|
+
build_date_recommendations(recommendations)
|
142
|
+
build_status_recommendations(recommendations)
|
143
|
+
build_composite_recommendations(recommendations)
|
144
|
+
build_full_text_recommendations(recommendations)
|
145
|
+
|
146
|
+
recommendations
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# Abstract methods to be implemented by database-specific subclasses
|
152
|
+
def validate_connection(connection)
|
153
|
+
raise NotImplementedError, "Subclasses must implement validate_connection"
|
154
|
+
end
|
155
|
+
|
156
|
+
def normalize_table_name(table)
|
157
|
+
raise NotImplementedError, "Subclasses must implement normalize_table_name"
|
158
|
+
end
|
159
|
+
|
160
|
+
def check_table_exists(table)
|
161
|
+
raise NotImplementedError, "Subclasses must implement check_table_exists"
|
162
|
+
end
|
163
|
+
|
164
|
+
def get_column_info
|
165
|
+
raise NotImplementedError, "Subclasses must implement get_column_info"
|
166
|
+
end
|
167
|
+
|
168
|
+
def get_primary_keys
|
169
|
+
raise NotImplementedError, "Subclasses must implement get_primary_keys"
|
170
|
+
end
|
171
|
+
|
172
|
+
def get_foreign_keys
|
173
|
+
raise NotImplementedError, "Subclasses must implement get_foreign_keys"
|
174
|
+
end
|
175
|
+
|
176
|
+
def get_constraints
|
177
|
+
raise NotImplementedError, "Subclasses must implement get_constraints"
|
178
|
+
end
|
179
|
+
|
180
|
+
def get_dependencies
|
181
|
+
raise NotImplementedError, "Subclasses must implement get_dependencies"
|
182
|
+
end
|
183
|
+
|
184
|
+
def format_constraint_type(constraint)
|
185
|
+
raise NotImplementedError, "Subclasses must implement format_constraint_type"
|
186
|
+
end
|
187
|
+
|
188
|
+
def get_constraint_column_name(constraint)
|
189
|
+
raise NotImplementedError, "Subclasses must implement get_constraint_column_name"
|
190
|
+
end
|
191
|
+
|
192
|
+
# Common implementation methods
|
193
|
+
def reset_state
|
194
|
+
@constraints.clear
|
195
|
+
@primary_keys.clear
|
196
|
+
@foreign_keys.clear
|
197
|
+
@dependencies.clear
|
198
|
+
@belongs_to.clear
|
199
|
+
@polymorphic_associations.clear
|
200
|
+
@enum_columns.clear
|
201
|
+
@column_info.clear
|
202
|
+
end
|
203
|
+
|
204
|
+
def generate_model_name(table)
|
205
|
+
model = table.dup
|
206
|
+
model.downcase!
|
207
|
+
model.chop! if model[-1].chr.downcase == 's'
|
208
|
+
model.split('_').map(&:capitalize).join
|
209
|
+
end
|
210
|
+
|
211
|
+
def get_belongs_to
|
212
|
+
@belongs_to = @foreign_keys.map { |fk| find_fk_table(fk) }.compact
|
213
|
+
end
|
214
|
+
|
215
|
+
def get_polymorphic_associations
|
216
|
+
@polymorphic_associations = detect_polymorphic_associations
|
217
|
+
end
|
218
|
+
|
219
|
+
def get_enum_columns
|
220
|
+
@enum_columns = detect_enum_columns
|
221
|
+
end
|
222
|
+
|
223
|
+
def detect_enum_columns
|
224
|
+
enum_columns = []
|
225
|
+
|
226
|
+
@column_info.each do |col|
|
227
|
+
next unless is_string_type?(col)
|
228
|
+
|
229
|
+
column_name = col.name.downcase
|
230
|
+
|
231
|
+
# Check for common enum-like column names
|
232
|
+
if enum_candidate?(column_name)
|
233
|
+
enum_info = {
|
234
|
+
name: col.name,
|
235
|
+
column_name: column_name,
|
236
|
+
suggested_values: suggest_enum_values(column_name),
|
237
|
+
type: determine_enum_type(column_name)
|
238
|
+
}
|
239
|
+
|
240
|
+
# Try to get actual values from constraints if available
|
241
|
+
constraint_values = extract_constraint_values(col.name)
|
242
|
+
if constraint_values.any?
|
243
|
+
enum_info[:values] = constraint_values
|
244
|
+
enum_info[:source] = 'check_constraint'
|
245
|
+
else
|
246
|
+
enum_info[:values] = enum_info[:suggested_values]
|
247
|
+
enum_info[:source] = 'pattern_matching'
|
248
|
+
end
|
249
|
+
|
250
|
+
enum_columns << enum_info
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
enum_columns
|
255
|
+
end
|
256
|
+
|
257
|
+
private
|
258
|
+
|
259
|
+
def enum_candidate?(column_name)
|
260
|
+
# Common enum column patterns
|
261
|
+
enum_patterns = [
|
262
|
+
/^status$/,
|
263
|
+
/^state$/,
|
264
|
+
/^type$/,
|
265
|
+
/^role$/,
|
266
|
+
/^priority$/,
|
267
|
+
/^level$/,
|
268
|
+
/^category$/,
|
269
|
+
/^kind$/,
|
270
|
+
/^mode$/,
|
271
|
+
/^visibility$/,
|
272
|
+
/_status$/,
|
273
|
+
/_state$/,
|
274
|
+
/_type$/,
|
275
|
+
/_role$/,
|
276
|
+
/_priority$/,
|
277
|
+
/_level$/,
|
278
|
+
/_category$/,
|
279
|
+
/_kind$/,
|
280
|
+
/_mode$/
|
281
|
+
]
|
282
|
+
|
283
|
+
enum_patterns.any? { |pattern| column_name =~ pattern }
|
284
|
+
end
|
285
|
+
|
286
|
+
def suggest_enum_values(column_name)
|
287
|
+
# Suggest common enum values based on column name patterns
|
288
|
+
case column_name
|
289
|
+
when /status/
|
290
|
+
%w[active inactive pending approved rejected]
|
291
|
+
when /state/
|
292
|
+
%w[draft published archived]
|
293
|
+
when /priority/
|
294
|
+
%w[low medium high critical]
|
295
|
+
when /level/
|
296
|
+
%w[beginner intermediate advanced expert]
|
297
|
+
when /role/
|
298
|
+
%w[user admin moderator]
|
299
|
+
when /visibility/
|
300
|
+
%w[public private protected]
|
301
|
+
when /category/
|
302
|
+
%w[general news updates]
|
303
|
+
when /type/
|
304
|
+
%w[standard premium basic]
|
305
|
+
when /mode/
|
306
|
+
%w[automatic manual]
|
307
|
+
else
|
308
|
+
%w[option1 option2 option3]
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def determine_enum_type(column_name)
|
313
|
+
# Determine if enum should use string or integer values
|
314
|
+
case column_name
|
315
|
+
when /status|state|priority|level|role|visibility/
|
316
|
+
:string # These are better as string enums for readability
|
317
|
+
when /type|category|kind|mode/
|
318
|
+
:string # These are also better as strings
|
319
|
+
else
|
320
|
+
:integer # Default to integer for performance
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
def extract_constraint_values(column_name)
|
325
|
+
# Try to extract enum values from CHECK constraints
|
326
|
+
values = []
|
327
|
+
|
328
|
+
puts "DEBUG: Looking for constraints for column: #{column_name}" if ENV['DEBUG']
|
329
|
+
puts "DEBUG: Available constraints: #{@constraints.length}" if ENV['DEBUG']
|
330
|
+
|
331
|
+
@constraints.each do |constraint|
|
332
|
+
puts "DEBUG: Checking constraint: #{constraint.inspect}" if ENV['DEBUG']
|
333
|
+
if constraint_applies_to_column?(constraint, column_name)
|
334
|
+
puts "DEBUG: Constraint applies to column #{column_name}" if ENV['DEBUG']
|
335
|
+
constraint_values = parse_check_constraint_values(constraint)
|
336
|
+
values.concat(constraint_values) if constraint_values.any?
|
337
|
+
end
|
338
|
+
end
|
339
|
+
|
340
|
+
puts "DEBUG: Final extracted values for #{column_name}: #{values.inspect}" if ENV['DEBUG']
|
341
|
+
values.uniq
|
342
|
+
end
|
343
|
+
|
344
|
+
def constraint_applies_to_column?(constraint, column_name)
|
345
|
+
# Check if constraint applies to the specific column
|
346
|
+
constraint_column = get_constraint_column_name(constraint)
|
347
|
+
return false unless constraint_column
|
348
|
+
|
349
|
+
constraint_column.downcase == column_name.downcase
|
350
|
+
end
|
351
|
+
|
352
|
+
def parse_check_constraint_values(constraint)
|
353
|
+
# Parse CHECK constraint to extract possible enum values
|
354
|
+
# This is database-specific and may need to be overridden
|
355
|
+
values = []
|
356
|
+
|
357
|
+
# Look for patterns like: column IN ('value1', 'value2', 'value3')
|
358
|
+
# or: column = 'value1' OR column = 'value2'
|
359
|
+
# or SQL Server: [column] IS NOT DISTINCT FROM 'value1' OR [column] IS NOT DISTINCT FROM 'value2'
|
360
|
+
constraint_text = get_constraint_text(constraint)
|
361
|
+
return values unless constraint_text
|
362
|
+
|
363
|
+
puts "DEBUG: Parsing constraint: #{constraint_text}" if ENV['DEBUG']
|
364
|
+
|
365
|
+
# Extract values from IN clause
|
366
|
+
in_match = constraint_text.match(/IN\s*\(\s*([^)]+)\s*\)/i)
|
367
|
+
if in_match
|
368
|
+
values_text = in_match[1]
|
369
|
+
# Extract quoted strings
|
370
|
+
values = values_text.scan(/'([^']+)'/).flatten
|
371
|
+
puts "DEBUG: Found IN values: #{values.inspect}" if ENV['DEBUG']
|
372
|
+
else
|
373
|
+
# Extract values from OR conditions (standard format)
|
374
|
+
or_matches = constraint_text.scan(/=\s*'([^']+)'/i)
|
375
|
+
if or_matches.any?
|
376
|
+
values = or_matches.flatten
|
377
|
+
puts "DEBUG: Found OR values: #{values.inspect}" if ENV['DEBUG']
|
378
|
+
else
|
379
|
+
# Extract values from SQL Server IS NOT DISTINCT FROM format
|
380
|
+
distinct_matches = constraint_text.scan(/IS NOT DISTINCT FROM\s+'([^']+)'/i)
|
381
|
+
if distinct_matches.any?
|
382
|
+
values = distinct_matches.flatten
|
383
|
+
puts "DEBUG: Found DISTINCT values: #{values.inspect}" if ENV['DEBUG']
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
values
|
389
|
+
end
|
390
|
+
|
391
|
+
def get_constraint_text(constraint)
|
392
|
+
# This should be overridden by database-specific implementations
|
393
|
+
# to return the actual constraint text/condition
|
394
|
+
nil
|
395
|
+
end
|
396
|
+
|
397
|
+
public
|
398
|
+
|
399
|
+
def detect_polymorphic_associations
|
400
|
+
polymorphic_assocs = []
|
401
|
+
column_names = @column_info.map { |col| col.name.downcase }
|
402
|
+
|
403
|
+
# Look for patterns like: commentable_type + commentable_id
|
404
|
+
# or imageable_type + imageable_id, etc.
|
405
|
+
type_columns = column_names.select { |name| name.end_with?('_type') }
|
406
|
+
|
407
|
+
type_columns.each do |type_col|
|
408
|
+
base_name = type_col.gsub(/_type$/, '')
|
409
|
+
id_col = "#{base_name}_id"
|
410
|
+
|
411
|
+
if column_names.include?(id_col)
|
412
|
+
# Check if this isn't already a regular foreign key
|
413
|
+
unless @foreign_keys.map(&:downcase).include?(id_col)
|
414
|
+
polymorphic_assocs << {
|
415
|
+
name: base_name,
|
416
|
+
foreign_key: id_col,
|
417
|
+
foreign_type: type_col,
|
418
|
+
association_name: base_name
|
419
|
+
}
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
polymorphic_assocs
|
425
|
+
end
|
426
|
+
|
427
|
+
# Make sure these polymorphic methods are public
|
428
|
+
public
|
429
|
+
|
430
|
+
def has_polymorphic_associations?
|
431
|
+
!@polymorphic_associations.empty?
|
432
|
+
end
|
433
|
+
|
434
|
+
def has_enum_columns?
|
435
|
+
!@enum_columns.empty?
|
436
|
+
end
|
437
|
+
|
438
|
+
def enum_column_names
|
439
|
+
@enum_columns.map { |enum_col| enum_col[:name] }
|
440
|
+
end
|
441
|
+
|
442
|
+
def enum_definitions
|
443
|
+
# Generate Rails enum definitions
|
444
|
+
definitions = []
|
445
|
+
@enum_columns.each do |enum_col|
|
446
|
+
if enum_col[:type] == :integer
|
447
|
+
# Integer enum: { draft: 0, published: 1, archived: 2 }
|
448
|
+
values = enum_col[:values].each_with_index.map { |val, idx| "#{val}: #{idx}" }.join(', ')
|
449
|
+
definitions << "enum #{enum_col[:column_name]}: { #{values} }"
|
450
|
+
else
|
451
|
+
# String enum: { low: 'low', medium: 'medium', high: 'high' }
|
452
|
+
values = enum_col[:values].map { |val| "#{val}: '#{val}'" }.join(', ')
|
453
|
+
definitions << "enum #{enum_col[:column_name]}: { #{values} }"
|
454
|
+
end
|
455
|
+
end
|
456
|
+
definitions
|
457
|
+
end
|
458
|
+
|
459
|
+
def enum_validation_suggestions
|
460
|
+
# Suggest validations for enum columns
|
461
|
+
suggestions = []
|
462
|
+
@enum_columns.each do |enum_col|
|
463
|
+
suggestions << {
|
464
|
+
column: enum_col[:column_name],
|
465
|
+
validation: "validates :#{enum_col[:column_name]}, inclusion: { in: #{enum_col[:column_name].pluralize}.keys }",
|
466
|
+
description: "Validates #{enum_col[:column_name]} is a valid enum value"
|
467
|
+
}
|
468
|
+
end
|
469
|
+
suggestions
|
470
|
+
end
|
471
|
+
|
472
|
+
def polymorphic_association_names
|
473
|
+
@polymorphic_associations.map { |assoc| assoc[:name] }
|
474
|
+
end
|
475
|
+
|
476
|
+
def polymorphic_has_many_suggestions
|
477
|
+
# Suggest has_many associations for models that could be polymorphic parents
|
478
|
+
suggestions = []
|
479
|
+
@polymorphic_associations.each do |assoc|
|
480
|
+
# For a 'commentable' polymorphic association, suggest:
|
481
|
+
# has_many :comments, as: :commentable, dependent: :destroy
|
482
|
+
child_model = pluralize_for_has_many(assoc[:name])
|
483
|
+
suggestions << {
|
484
|
+
association: "has_many :#{child_model}, as: :#{assoc[:name]}, dependent: :destroy",
|
485
|
+
description: "For models that can have #{child_model} (polymorphic)"
|
486
|
+
}
|
487
|
+
end
|
488
|
+
suggestions
|
489
|
+
end
|
490
|
+
|
491
|
+
def find_fk_table(fk)
|
492
|
+
# Default implementation - may be overridden by subclasses
|
493
|
+
fk.gsub(/_id$/i, '').pluralize rescue "#{fk.gsub(/_id$/i, '')}s"
|
494
|
+
end
|
495
|
+
|
496
|
+
private
|
497
|
+
|
498
|
+
def pluralize_for_has_many(singular_name)
|
499
|
+
# Simple pluralization - can be enhanced
|
500
|
+
case singular_name
|
501
|
+
when /able$/
|
502
|
+
# commentable -> comments, imageable -> images
|
503
|
+
base = singular_name.gsub(/able$/, '')
|
504
|
+
case base
|
505
|
+
when 'comment' then 'comments'
|
506
|
+
when 'image' then 'images'
|
507
|
+
when 'tag' then 'tags'
|
508
|
+
when 'like' then 'likes'
|
509
|
+
when 'favorite' then 'favorites'
|
510
|
+
else "#{base}s"
|
511
|
+
end
|
512
|
+
else
|
513
|
+
"#{singular_name}s"
|
514
|
+
end
|
515
|
+
end
|
516
|
+
|
517
|
+
public
|
518
|
+
|
519
|
+
def find_fk_table(fk)
|
520
|
+
# Default implementation - may be overridden by subclasses
|
521
|
+
fk.gsub(/_id$/i, '').pluralize rescue "#{fk.gsub(/_id$/i, '')}s"
|
522
|
+
end
|
523
|
+
|
524
|
+
def build_foreign_key_recommendations(recommendations)
|
525
|
+
@belongs_to.each do |table_ref|
|
526
|
+
fk_column = "#{table_ref.downcase.gsub(/s$/, '')}_id"
|
527
|
+
col = @column_info.find { |c| c.name.downcase == fk_column }
|
528
|
+
if col
|
529
|
+
recommendations[:foreign_keys] << {
|
530
|
+
column: col.name.downcase,
|
531
|
+
sql: "add_index :#{@table.downcase}, :#{col.name.downcase}",
|
532
|
+
reason: "Foreign key index for #{col.name}"
|
533
|
+
}
|
534
|
+
end
|
535
|
+
end
|
536
|
+
end
|
537
|
+
|
538
|
+
def build_unique_constraint_recommendations(recommendations)
|
539
|
+
unique_columns = @column_info.select do |col|
|
540
|
+
col.name.downcase =~ /(email|username|code|slug|uuid|token)/ ||
|
541
|
+
(!col.nullable? && col.name.downcase =~ /(name|title)$/ && is_string_type?(col))
|
542
|
+
end
|
543
|
+
|
544
|
+
unique_columns.each do |col|
|
545
|
+
recommendations[:unique_constraints] << {
|
546
|
+
column: col.name.downcase,
|
547
|
+
sql: "add_index :#{@table.downcase}, :#{col.name.downcase}, unique: true",
|
548
|
+
reason: "Unique constraint for #{col.name}"
|
549
|
+
}
|
550
|
+
end
|
551
|
+
end
|
552
|
+
|
553
|
+
def build_date_recommendations(recommendations)
|
554
|
+
date_columns = @column_info.select do |col|
|
555
|
+
is_date_type?(col) ||
|
556
|
+
col.name.downcase =~ /(created_at|updated_at|modified_date|start_date|end_date|due_date)/
|
557
|
+
end
|
558
|
+
|
559
|
+
date_columns.each do |col|
|
560
|
+
recommendations[:date_queries] << {
|
561
|
+
column: col.name.downcase,
|
562
|
+
sql: "add_index :#{@table.downcase}, :#{col.name.downcase}",
|
563
|
+
reason: "Date queries for #{col.name}"
|
564
|
+
}
|
565
|
+
end
|
566
|
+
end
|
567
|
+
|
568
|
+
def build_status_recommendations(recommendations)
|
569
|
+
status_columns = @column_info.select do |col|
|
570
|
+
col.name.downcase =~ /(status|state|type|role|priority|level|category)$/ &&
|
571
|
+
is_string_type?(col)
|
572
|
+
end
|
573
|
+
|
574
|
+
status_columns.each do |col|
|
575
|
+
recommendations[:status_enum] << {
|
576
|
+
column: col.name.downcase,
|
577
|
+
sql: "add_index :#{@table.downcase}, :#{col.name.downcase}",
|
578
|
+
reason: "Status/enum queries for #{col.name}"
|
579
|
+
}
|
580
|
+
end
|
581
|
+
end
|
582
|
+
|
583
|
+
def build_composite_recommendations(recommendations)
|
584
|
+
foreign_key_columns = recommendations[:foreign_keys].map { |fk| fk[:column] }
|
585
|
+
date_column_names = recommendations[:date_queries].map { |d| d[:column] }
|
586
|
+
status_column_names = recommendations[:status_enum].map { |s| s[:column] }
|
587
|
+
|
588
|
+
if foreign_key_columns.any? && date_column_names.any?
|
589
|
+
fk_col = foreign_key_columns.first
|
590
|
+
date_col = date_column_names.find { |col| col =~ /created/ } || date_column_names.first
|
591
|
+
recommendations[:composite] << {
|
592
|
+
columns: [fk_col, date_col],
|
593
|
+
sql: "add_index :#{@table.downcase}, [:#{fk_col}, :#{date_col}]",
|
594
|
+
reason: "Composite index for filtering by #{fk_col} and #{date_col}"
|
595
|
+
}
|
596
|
+
end
|
597
|
+
|
598
|
+
if status_column_names.any? && date_column_names.any?
|
599
|
+
status_col = status_column_names.first
|
600
|
+
date_col = date_column_names.find { |col| col =~ /created/ } || date_column_names.first
|
601
|
+
recommendations[:composite] << {
|
602
|
+
columns: [status_col, date_col],
|
603
|
+
sql: "add_index :#{@table.downcase}, [:#{status_col}, :#{date_col}]",
|
604
|
+
reason: "Composite index for filtering by #{status_col} and #{date_col}"
|
605
|
+
}
|
606
|
+
end
|
607
|
+
end
|
608
|
+
|
609
|
+
def build_full_text_recommendations(recommendations)
|
610
|
+
text_search_columns = @column_info.select do |col|
|
611
|
+
is_text_type?(col) &&
|
612
|
+
col.name.downcase =~ /(name|title|description|content|text|search)/ &&
|
613
|
+
(get_column_size(col).nil? || get_column_size(col) > 50)
|
614
|
+
end
|
615
|
+
|
616
|
+
text_search_columns.each do |col|
|
617
|
+
recommendations[:full_text] << {
|
618
|
+
column: col.name.downcase,
|
619
|
+
sql: build_full_text_index_sql(col),
|
620
|
+
reason: "Full-text search for #{col.name}",
|
621
|
+
type: get_full_text_index_type
|
622
|
+
}
|
623
|
+
end
|
624
|
+
end
|
625
|
+
|
626
|
+
# Abstract helper methods for database-specific type checking
|
627
|
+
def is_string_type?(column)
|
628
|
+
raise NotImplementedError, "Subclasses must implement is_string_type?"
|
629
|
+
end
|
630
|
+
|
631
|
+
def is_date_type?(column)
|
632
|
+
raise NotImplementedError, "Subclasses must implement is_date_type?"
|
633
|
+
end
|
634
|
+
|
635
|
+
def is_text_type?(column)
|
636
|
+
raise NotImplementedError, "Subclasses must implement is_text_type?"
|
637
|
+
end
|
638
|
+
|
639
|
+
def get_column_size(column)
|
640
|
+
raise NotImplementedError, "Subclasses must implement get_column_size"
|
641
|
+
end
|
642
|
+
|
643
|
+
def build_full_text_index_sql(column)
|
644
|
+
raise NotImplementedError, "Subclasses must implement build_full_text_index_sql"
|
645
|
+
end
|
646
|
+
|
647
|
+
def get_full_text_index_type
|
648
|
+
raise NotImplementedError, "Subclasses must implement get_full_text_index_type"
|
649
|
+
end
|
650
|
+
end
|
651
|
+
end
|
652
|
+
end
|