dbwatcher 1.0.0 → 1.1.1

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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +8 -2
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
  33. data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
  34. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  35. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  36. data/app/helpers/dbwatcher/session_helper.rb +3 -2
  37. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  38. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  39. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  40. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  41. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  42. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  43. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  44. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  45. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  46. data/app/views/dbwatcher/sessions/index.html.erb +14 -10
  47. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  48. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  49. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  50. data/app/views/dbwatcher/sessions/show.html.erb +3 -346
  51. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  52. data/app/views/layouts/dbwatcher/application.html.erb +125 -247
  53. data/bin/compile_scss +49 -0
  54. data/config/routes.rb +26 -0
  55. data/lib/dbwatcher/configuration.rb +102 -8
  56. data/lib/dbwatcher/engine.rb +17 -7
  57. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  58. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  59. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  60. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  61. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  62. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  63. data/lib/dbwatcher/services/base_service.rb +64 -0
  64. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  65. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  66. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  67. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +603 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +280 -0
  70. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  71. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  72. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  73. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  74. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  75. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  76. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  77. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  78. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  79. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  80. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  81. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  82. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  83. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  84. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +140 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +48 -0
  86. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  87. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  88. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +118 -0
  89. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  90. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
  91. data/lib/dbwatcher/storage/api/session_api.rb +47 -0
  92. data/lib/dbwatcher/storage/base_storage.rb +7 -0
  93. data/lib/dbwatcher/version.rb +1 -1
  94. data/lib/dbwatcher.rb +58 -1
  95. metadata +94 -2
@@ -0,0 +1,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections" if defined?(ActiveSupport)
4
+
5
+ module Dbwatcher
6
+ module Services
7
+ module DiagramAnalyzers
8
+ # Analyzes relationships based on naming conventions and data patterns
9
+ #
10
+ # This service infers relationships between tables when explicit foreign keys
11
+ # are not present, using naming conventions, column patterns, and junction table detection.
12
+ #
13
+ # @example
14
+ # analyzer = InferredRelationshipAnalyzer.new(session)
15
+ # dataset = analyzer.call
16
+ class InferredRelationshipAnalyzer < BaseAnalyzer
17
+ # Initialize with session
18
+ #
19
+ # @param session [Session] session to analyze (optional for global analysis)
20
+ def initialize(session = nil)
21
+ @session = session
22
+ @connection = ActiveRecord::Base.connection if defined?(ActiveRecord::Base)
23
+ @session_tables = session ? extract_session_tables : []
24
+ super()
25
+ end
26
+
27
+ # Analyze inferred relationships
28
+ #
29
+ # @param context [Hash] analysis context
30
+ # @return [Array<Hash>] array of inferred relationship data
31
+ def analyze(_context)
32
+ return [] unless schema_available?
33
+
34
+ Rails.logger.debug "InferredRelationshipAnalyzer: Starting analysis with #{tables_to_analyze.length} tables"
35
+ relationships = []
36
+
37
+ # Analyze naming convention relationships
38
+ relationships.concat(analyze_naming_conventions)
39
+
40
+ # Analyze junction tables
41
+ relationships.concat(analyze_junction_tables)
42
+
43
+ # Analyze column patterns
44
+ relationships.concat(analyze_column_patterns)
45
+
46
+ Rails.logger.info "InferredRelationshipAnalyzer: Found #{relationships.length} inferred relationships"
47
+ relationships
48
+ end
49
+
50
+ # Transform raw relationship data to Dataset
51
+ #
52
+ # @param raw_data [Array<Hash>] raw relationship data
53
+ # @return [DiagramData::Dataset] standardized dataset
54
+ def transform_to_dataset(raw_data)
55
+ dataset = create_empty_dataset
56
+ dataset.metadata.merge!({
57
+ total_relationships: raw_data.length,
58
+ tables_analyzed: tables_to_analyze.length,
59
+ inference_types: raw_data.map { |r| r[:inference_type] }.uniq
60
+ })
61
+
62
+ # Create entities for each unique table
63
+ table_entities = {}
64
+
65
+ # First, collect all unique tables from the relationships
66
+ tables = []
67
+ raw_data.each do |relationship|
68
+ tables << relationship[:from_table] if relationship[:from_table]
69
+ tables << relationship[:to_table] if relationship[:to_table]
70
+ end
71
+ tables.uniq!
72
+
73
+ # Create entities for all tables
74
+ tables.each do |table_name|
75
+ entity = create_entity(
76
+ id: table_name,
77
+ name: table_name,
78
+ type: "table",
79
+ metadata: {
80
+ table_name: table_name,
81
+ source: "inferred_analysis"
82
+ }
83
+ )
84
+ dataset.add_entity(entity)
85
+ table_entities[table_name] = entity
86
+ end
87
+
88
+ # Create relationships in a separate loop
89
+ raw_data.each do |relationship|
90
+ next unless relationship[:from_table] && relationship[:to_table]
91
+
92
+ # Include self-referential relationships (source and target are the same)
93
+ # but log them for debugging
94
+ if relationship[:from_table] == relationship[:to_table]
95
+ Rails.logger.info "InferredRelationshipAnalyzer: Including self-referential relationship for " \
96
+ "#{relationship[:from_table]} " \
97
+ "(#{relationship[:from_column]} -> #{relationship[:to_column]})"
98
+ end
99
+
100
+ relationship_obj = create_relationship({
101
+ source_id: relationship[:from_table],
102
+ target_id: relationship[:to_table],
103
+ type: relationship[:type],
104
+ label: relationship[:label],
105
+ metadata: {
106
+ inference_type: relationship[:inference_type],
107
+ confidence: relationship[:confidence],
108
+ from_column: relationship[:from_column],
109
+ to_column: relationship[:to_column],
110
+ original_type: relationship[:type],
111
+ self_referential: relationship[:from_table] ==
112
+ relationship[:to_table]
113
+ }
114
+ })
115
+
116
+ dataset.add_relationship(relationship_obj)
117
+ end
118
+
119
+ dataset
120
+ end
121
+
122
+ # Get analyzer type
123
+ #
124
+ # @return [String] analyzer type identifier
125
+ def analyzer_type
126
+ "inferred_relationship"
127
+ end
128
+
129
+ protected
130
+
131
+ # Build analysis context for this analyzer
132
+ #
133
+ # @return [Hash] analysis context
134
+ def analysis_context
135
+ {
136
+ session: session,
137
+ session_tables: session_tables,
138
+ tables_to_analyze: tables_to_analyze
139
+ }
140
+ end
141
+
142
+ # Get the database connection
143
+ #
144
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter] database connection
145
+ attr_reader :connection
146
+
147
+ private
148
+
149
+ attr_reader :session, :session_tables
150
+
151
+ # Check if schema analysis is available
152
+ #
153
+ # @return [Boolean] true if schema can be analyzed
154
+ def schema_available?
155
+ defined?(ActiveRecord::Base) &&
156
+ connection.respond_to?(:tables) &&
157
+ connection.respond_to?(:columns)
158
+ end
159
+
160
+ # Extract tables that were involved in the session
161
+ #
162
+ # @return [Array<String>] unique table names
163
+ def extract_session_tables
164
+ return [] unless session&.changes
165
+
166
+ session.changes.map do |change|
167
+ change[:table_name] || change["table_name"]
168
+ end.compact.uniq
169
+ end
170
+
171
+ # Get tables to analyze (session tables or all tables if no session)
172
+ #
173
+ # @return [Array<String>] table names to analyze
174
+ def tables_to_analyze
175
+ session_tables.any? ? session_tables : connection.tables
176
+ end
177
+
178
+ # Analyze naming convention relationships (e.g., user_id -> users)
179
+ #
180
+ # @return [Array<Hash>] naming convention relationships
181
+ def analyze_naming_conventions
182
+ relationships = []
183
+
184
+ tables_to_analyze.each do |table_name|
185
+ next unless table_exists?(table_name)
186
+
187
+ columns = get_table_columns(table_name)
188
+
189
+ columns.each do |column|
190
+ # Look for _id columns that might reference other tables
191
+ next unless column.name.end_with?("_id") && column.name != "id"
192
+
193
+ # Check for common self-referential patterns
194
+ if self_referential_column?(column.name, table_name)
195
+ relationships << {
196
+ from_table: table_name,
197
+ to_table: table_name,
198
+ type: "inferred_belongs_to",
199
+ inference_type: "self_referential",
200
+ confidence: 0.9,
201
+ from_column: column.name,
202
+ to_column: "id",
203
+ label: "inferred (#{column.name})"
204
+ }
205
+ next
206
+ end
207
+
208
+ referenced_table = infer_table_from_column(column.name)
209
+
210
+ next unless referenced_table && tables_to_analyze.include?(referenced_table)
211
+
212
+ relationships << {
213
+ from_table: table_name,
214
+ to_table: referenced_table,
215
+ type: "inferred_belongs_to",
216
+ inference_type: "naming_convention",
217
+ confidence: 0.8,
218
+ from_column: column.name,
219
+ to_column: "id",
220
+ label: "inferred (#{column.name})"
221
+ }
222
+ end
223
+ end
224
+
225
+ relationships
226
+ end
227
+
228
+ # Check if a column name suggests a self-referential relationship
229
+ #
230
+ # @param column_name [String] column name to check
231
+ # @param table_name [String] current table name
232
+ # @param primary_key [String, nil] optional primary key for testing
233
+ # @return [Boolean] true if likely self-referential
234
+ def self_referential_column?(column_name, table_name, primary_key = nil)
235
+ # Common self-referential patterns
236
+ self_ref_patterns = %w[
237
+ parent_id
238
+ ancestor_id
239
+ child_id
240
+ reply_to_id
241
+ reference_id
242
+ original_id
243
+ source_id
244
+ target_id
245
+ superior_id
246
+ manager_id
247
+ supervisor_id
248
+ predecessor_id
249
+ successor_id
250
+ previous_id
251
+ next_id
252
+ related_id
253
+ duplicate_id
254
+ clone_id
255
+ copy_id
256
+ forwarded_id
257
+ replied_to_id
258
+ ]
259
+
260
+ # Check for exact matches with common patterns
261
+ return true if self_ref_patterns.include?(column_name)
262
+
263
+ # Get the singular form of the table name
264
+ base_name = singularize(table_name)
265
+
266
+ # Special case for post_id in posts table - not a self-reference
267
+ return false if column_name == "#{base_name}_id" && table_name == "posts" && base_name == "post"
268
+
269
+ # Check for table-specific self-references (e.g., comment_id in comments table)
270
+ if column_name == "#{base_name}_id"
271
+ # Check if this is not the primary key column
272
+ if primary_key.nil?
273
+ begin
274
+ primary_key = connection.primary_key(table_name)
275
+ rescue StandardError
276
+ return false
277
+ end
278
+ end
279
+
280
+ return column_name != primary_key
281
+ end
282
+
283
+ # Check for hierarchy patterns with table name
284
+ hierarchy_prefixes = %w[parent child ancestor descendant superior subordinate manager supervisor]
285
+ hierarchy_prefixes.each do |prefix|
286
+ # Check for patterns like parent_comment_id in comments table
287
+ return true if column_name.start_with?("#{prefix}_#{base_name}_id")
288
+
289
+ # Check for patterns like parent_of_id in any table
290
+ return true if column_name.start_with?("#{prefix}_of_id")
291
+ end
292
+
293
+ # Check for relationship patterns
294
+ relationship_patterns = %w[related linked connected associated referenced]
295
+ relationship_patterns.each do |pattern|
296
+ return true if column_name.start_with?("#{pattern}_")
297
+ end
298
+
299
+ # Check for directional patterns
300
+ directional_patterns = %w[previous next original copy source target]
301
+ directional_patterns.each do |pattern|
302
+ return true if column_name.start_with?("#{pattern}_")
303
+ end
304
+
305
+ false
306
+ end
307
+
308
+ # Analyze junction tables (many-to-many relationships)
309
+ #
310
+ # @return [Array<Hash>] junction table relationships
311
+ def analyze_junction_tables
312
+ relationships = []
313
+
314
+ tables_to_analyze.each do |table_name|
315
+ next unless junction_table?(table_name)
316
+
317
+ junction_relationships = analyze_junction_table(table_name)
318
+ relationships.concat(junction_relationships)
319
+ end
320
+
321
+ relationships
322
+ end
323
+
324
+ # Analyze column patterns for relationships
325
+ #
326
+ # @return [Array<Hash>] column pattern relationships
327
+ def analyze_column_patterns
328
+ relationships = []
329
+
330
+ # Look for common patterns like created_by_id, updated_by_id, etc.
331
+ tables_to_analyze.each do |table_name|
332
+ next unless table_exists?(table_name)
333
+
334
+ columns = get_table_columns(table_name)
335
+
336
+ columns.each do |column|
337
+ # Look for audit columns that might reference users
338
+ next unless audit_column?(column.name)
339
+
340
+ user_table = find_user_table
341
+
342
+ next unless user_table && tables_to_analyze.include?(user_table)
343
+
344
+ relationships << {
345
+ from_table: table_name,
346
+ to_table: user_table,
347
+ type: "inferred_audit",
348
+ inference_type: "audit_pattern",
349
+ confidence: 0.6,
350
+ from_column: column.name,
351
+ to_column: "id",
352
+ label: "audit (#{column.name})"
353
+ }
354
+ end
355
+ end
356
+
357
+ relationships
358
+ end
359
+
360
+ # Check if table exists in database
361
+ #
362
+ # @param table_name [String] table name
363
+ # @return [Boolean] true if table exists
364
+ def table_exists?(table_name)
365
+ connection.table_exists?(table_name)
366
+ rescue StandardError
367
+ false
368
+ end
369
+
370
+ # Get columns for a table
371
+ #
372
+ # @param table_name [String] table name
373
+ # @return [Array] column objects
374
+ def get_table_columns(table_name)
375
+ connection.columns(table_name)
376
+ rescue StandardError => e
377
+ Rails.logger.warn "InferredRelationshipAnalyzer: Could not get columns for #{table_name}: #{e.message}"
378
+ []
379
+ end
380
+
381
+ # Infer table name from column name (e.g., user_id -> users)
382
+ #
383
+ # @param column_name [String] column name ending with _id
384
+ # @return [String, nil] inferred table name
385
+ def infer_table_from_column(column_name)
386
+ base_name = column_name.gsub(/_id$/, "")
387
+
388
+ # Try pluralized version first
389
+ plural_table = pluralize(base_name)
390
+ return plural_table if connection.table_exists?(plural_table)
391
+
392
+ # Try singular version
393
+ return base_name if connection.table_exists?(base_name)
394
+
395
+ nil
396
+ end
397
+
398
+ # Get the plural form of a table name
399
+ #
400
+ # @param table_name [String] table name
401
+ # @return [String] plural form of table name
402
+ def pluralize(table_name)
403
+ return table_name if table_name.nil? || table_name.empty?
404
+
405
+ # Use ActiveSupport if available
406
+ return table_name.pluralize if table_name.respond_to?(:pluralize)
407
+
408
+ # Simple fallback pluralization rules
409
+ if table_name.end_with?("y") && !table_name.end_with?("ay", "ey", "iy", "oy", "uy")
410
+ "#{table_name[0...-1]}ies"
411
+ elsif table_name.end_with?("s", "x", "z", "ch", "sh")
412
+ "#{table_name}es"
413
+ else
414
+ "#{table_name}s"
415
+ end
416
+ end
417
+
418
+ # Check if table is likely a junction table
419
+ #
420
+ # @param table_name [String] table name
421
+ # @return [Boolean] true if likely junction table
422
+ def junction_table?(table_name)
423
+ # Common junction table patterns
424
+ return true if table_name.include?("_")
425
+
426
+ columns = get_table_columns(table_name)
427
+ id_columns = columns.select { |c| c.name.end_with?("_id") && c.name != "id" }
428
+
429
+ # Junction tables typically have 2+ foreign key columns and few other columns
430
+ id_columns.length >= 2 && columns.length <= (id_columns.length + 3)
431
+ end
432
+
433
+ # Analyze a junction table for relationships
434
+ #
435
+ # @param table_name [String] junction table name
436
+ # @return [Array<Hash>] junction relationships
437
+ def analyze_junction_table(table_name)
438
+ relationships = []
439
+ columns = get_table_columns(table_name)
440
+ id_columns = columns.select { |c| c.name.end_with?("_id") && c.name != "id" }
441
+
442
+ # Create many-to-many relationships between the referenced tables
443
+ id_columns.combination(2).each do |col1, col2|
444
+ table1 = infer_table_from_column(col1.name)
445
+ table2 = infer_table_from_column(col2.name)
446
+
447
+ next unless table1 && table2 && tables_to_analyze.include?(table1) && tables_to_analyze.include?(table2)
448
+
449
+ relationships << {
450
+ from_table: table1,
451
+ to_table: table2,
452
+ type: "inferred_many_to_many",
453
+ inference_type: "junction_table",
454
+ confidence: 0.9,
455
+ from_column: "id",
456
+ to_column: "id",
457
+ label: "many-to-many via #{table_name}"
458
+ }
459
+ end
460
+
461
+ relationships
462
+ end
463
+
464
+ # Check if column is an audit column
465
+ #
466
+ # @param column_name [String] column name
467
+ # @return [Boolean] true if audit column
468
+ def audit_column?(column_name)
469
+ %w[created_by_id updated_by_id deleted_by_id author_id modifier_id].include?(column_name)
470
+ end
471
+
472
+ # Find the user table in available tables
473
+ #
474
+ # @return [String, nil] user table name
475
+ def find_user_table
476
+ user_tables = %w[users user accounts account people person]
477
+ user_tables.find { |table| tables_to_analyze.include?(table) }
478
+ end
479
+
480
+ # Get the singular form of a table name
481
+ #
482
+ # @param table_name [String] table name
483
+ # @return [String] singular form of table name
484
+ def singularize(table_name)
485
+ return table_name if table_name.nil? || table_name.empty?
486
+
487
+ # Use ActiveSupport if available
488
+ return table_name.singularize if table_name.respond_to?(:singularize)
489
+
490
+ # Simple fallback singularization rules
491
+ if table_name.end_with?("ies")
492
+ "#{table_name[0...-3]}y"
493
+ elsif table_name.end_with?("s")
494
+ table_name[0...-1]
495
+ else
496
+ table_name
497
+ end
498
+ end
499
+ end
500
+ end
501
+ end
502
+ end