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.
@@ -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