dbwatcher 1.0.0 → 1.1.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.
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 +564 -0
  68. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  69. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -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 +136 -0
  85. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -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 +102 -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,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Services
5
+ module DiagramAnalyzers
6
+ # Analyzes relationships based on database schema foreign keys
7
+ #
8
+ # This service examines the actual database schema to detect foreign key
9
+ # relationships between tables that were involved in a session.
10
+ #
11
+ # @example
12
+ # analyzer = ForeignKeyAnalyzer.new(session)
13
+ # dataset = analyzer.call
14
+ class ForeignKeyAnalyzer < BaseAnalyzer
15
+ # Initialize with session
16
+ #
17
+ # @param session [Session] session to analyze (optional for global analysis)
18
+ def initialize(session = nil)
19
+ @session = session
20
+ @connection = ActiveRecord::Base.connection if defined?(ActiveRecord::Base)
21
+ @session_tables = session ? extract_session_tables : []
22
+ super()
23
+ end
24
+
25
+ # Analyze schema relationships
26
+ #
27
+ # @param context [Hash] analysis context
28
+ # @return [Array<Hash>] array of relationship data
29
+ def analyze(_context)
30
+ return [] unless schema_available?
31
+
32
+ Rails.logger.debug "ForeignKeyAnalyzer: Starting analysis with #{tables_to_analyze.length} tables"
33
+ relationships = extract_foreign_key_relationships
34
+
35
+ # Log some sample data to help with debugging
36
+ if relationships.any?
37
+ sample_relationship = relationships.first
38
+ Rails.logger.debug "ForeignKeyAnalyzer: Sample relationship - " \
39
+ "from_table: #{sample_relationship[:from_table]}, " \
40
+ "to_table: #{sample_relationship[:to_table]}, " \
41
+ "type: #{sample_relationship[:type]}"
42
+ else
43
+ Rails.logger.info "ForeignKeyAnalyzer: No relationships found"
44
+ end
45
+
46
+ relationships
47
+ end
48
+
49
+ # Transform raw relationship data to Dataset
50
+ #
51
+ # @param raw_data [Array<Hash>] raw relationship data
52
+ # @return [DiagramData::Dataset] standardized dataset
53
+ def transform_to_dataset(raw_data)
54
+ dataset = create_empty_dataset
55
+ dataset.metadata.merge!({
56
+ total_relationships: raw_data.length,
57
+ tables_analyzed: tables_to_analyze.length
58
+ })
59
+
60
+ # Create entities for each unique table
61
+ table_entities = {}
62
+
63
+ # First, collect all unique tables from the relationships
64
+ tables = []
65
+ raw_data.each do |relationship|
66
+ tables << relationship[:from_table] if relationship[:from_table]
67
+ tables << relationship[:to_table] if relationship[:to_table]
68
+ end
69
+ tables.uniq!
70
+
71
+ # Create entities for all tables
72
+ tables.each do |table_name|
73
+ entity = create_entity_with_columns(table_name)
74
+ dataset.add_entity(entity)
75
+ table_entities[table_name] = entity
76
+ end
77
+
78
+ # Create relationships in a separate loop
79
+ raw_data.each do |relationship|
80
+ next unless relationship[:from_table] && relationship[:to_table]
81
+
82
+ # Include self-referential relationships (source and target are the same)
83
+ # but log them for debugging
84
+ if relationship[:from_table] == relationship[:to_table]
85
+ Rails.logger.info "ForeignKeyAnalyzer: Including self-referential relationship for " \
86
+ "#{relationship[:from_table]} " \
87
+ "(#{relationship[:from_column]} -> #{relationship[:to_column]})"
88
+ end
89
+
90
+ cardinality = determine_cardinality(relationship)
91
+
92
+ relationship_obj = create_relationship({
93
+ source_id: relationship[:from_table],
94
+ target_id: relationship[:to_table],
95
+ type: relationship[:type],
96
+ label: relationship[:constraint_name] ||
97
+ relationship[:from_column],
98
+ cardinality: cardinality,
99
+ metadata: {
100
+ constraint_name: relationship[:constraint_name],
101
+ from_column: relationship[:from_column],
102
+ to_column: relationship[:to_column],
103
+ on_delete: relationship[:on_delete],
104
+ on_update: relationship[:on_update],
105
+ original_type: relationship[:type],
106
+ self_referential: relationship[:from_table] ==
107
+ relationship[:to_table]
108
+ }
109
+ })
110
+
111
+ dataset.add_relationship(relationship_obj)
112
+ end
113
+
114
+ dataset
115
+ end
116
+
117
+ # Get analyzer type
118
+ #
119
+ # @return [String] analyzer type identifier
120
+ def analyzer_type
121
+ "foreign_key"
122
+ end
123
+
124
+ protected
125
+
126
+ # Build analysis context for this analyzer
127
+ #
128
+ # @return [Hash] analysis context
129
+ def analysis_context
130
+ {
131
+ session: session,
132
+ session_tables: session_tables,
133
+ tables_to_analyze: tables_to_analyze
134
+ }
135
+ end
136
+
137
+ # Get the database connection
138
+ #
139
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter] database connection
140
+ attr_reader :connection
141
+
142
+ private
143
+
144
+ attr_reader :session, :session_tables
145
+
146
+ # Create entity with table columns
147
+ #
148
+ # @param table_name [String] table name
149
+ # @return [DiagramData::Entity] entity with columns as attributes
150
+ def create_entity_with_columns(table_name)
151
+ return nil unless table_exists?(table_name)
152
+
153
+ attributes = []
154
+
155
+ # Extract columns from table
156
+ if connection.respond_to?(:columns)
157
+ begin
158
+ columns = connection.columns(table_name)
159
+
160
+ # Convert columns to attributes
161
+ attributes = columns.map do |column|
162
+ primary_key = column.name == connection.primary_key(table_name)
163
+ foreign_key = column.name.end_with?("_id") ||
164
+ foreign_key_columns(table_name).include?(column.name)
165
+
166
+ create_attribute(
167
+ name: column.name,
168
+ type: column.type.to_s,
169
+ nullable: column.null,
170
+ default: column.default,
171
+ metadata: {
172
+ primary_key: primary_key,
173
+ foreign_key: foreign_key,
174
+ limit: column.limit,
175
+ precision: column.precision,
176
+ scale: column.scale,
177
+ visibility: "+" # Public visibility for all columns
178
+ }
179
+ )
180
+ end
181
+ rescue StandardError => e
182
+ Rails.logger.warn "ForeignKeyAnalyzer: Could not get columns for #{table_name}: #{e.message}"
183
+ end
184
+ end
185
+
186
+ create_entity(
187
+ id: table_name,
188
+ name: table_name,
189
+ type: "table",
190
+ attributes: attributes,
191
+ metadata: {
192
+ table_name: table_name,
193
+ source: "database_schema"
194
+ }
195
+ )
196
+ end
197
+
198
+ # Get foreign key column names for a table
199
+ #
200
+ # @param table_name [String] table name
201
+ # @return [Array<String>] foreign key column names
202
+ def foreign_key_columns(table_name)
203
+ return [] unless connection.respond_to?(:foreign_keys)
204
+
205
+ begin
206
+ connection.foreign_keys(table_name).map(&:column)
207
+ rescue StandardError => e
208
+ Rails.logger.warn "ForeignKeyAnalyzer: Could not get foreign keys for #{table_name}: #{e.message}"
209
+ []
210
+ end
211
+ end
212
+
213
+ # Determine relationship cardinality
214
+ #
215
+ # @param relationship [Hash] relationship data
216
+ # @return [String] cardinality type
217
+ def determine_cardinality(relationship)
218
+ # For foreign keys, we can determine cardinality based on constraints
219
+ if relationship[:from_column] && relationship[:to_column]
220
+ # If the foreign key column is part of a unique constraint or primary key,
221
+ # it's likely a one-to-one relationship
222
+ return "one_to_one" if column_has_unique_constraint?(relationship[:from_table], relationship[:from_column])
223
+
224
+ # Default to one-to-many for standard foreign keys
225
+ # (many records in source table can reference one record in target table)
226
+ return "many_to_one"
227
+ end
228
+
229
+ # Default to one-to-many if we can't determine
230
+ "one_to_many"
231
+ end
232
+
233
+ # Check if column has a unique constraint
234
+ #
235
+ # @param table_name [String] table name
236
+ # @param column_name [String] column name
237
+ # @return [Boolean] true if column has unique constraint
238
+ def column_has_unique_constraint?(table_name, column_name)
239
+ # Check if column is primary key
240
+ return true if column_name == connection.primary_key(table_name)
241
+
242
+ # Check for unique indexes if supported
243
+ if connection.respond_to?(:indexes)
244
+ begin
245
+ indexes = connection.indexes(table_name)
246
+ return indexes.any? { |idx| idx.columns == [column_name] && idx.unique }
247
+ rescue StandardError => e
248
+ Rails.logger.warn "ForeignKeyAnalyzer: Could not check unique constraints for " \
249
+ "#{table_name}.#{column_name}: #{e.message}"
250
+ end
251
+ end
252
+
253
+ false
254
+ end
255
+
256
+ # Check if schema analysis is available
257
+ #
258
+ # @return [Boolean] true if schema can be analyzed
259
+ def schema_available?
260
+ defined?(ActiveRecord::Base) &&
261
+ connection.respond_to?(:foreign_keys) &&
262
+ connection.respond_to?(:tables)
263
+ end
264
+
265
+ # Extract tables that were involved in the session
266
+ #
267
+ # @return [Array<String>] unique table names
268
+ def extract_session_tables
269
+ return [] unless session&.changes
270
+
271
+ session.changes.map do |change|
272
+ change[:table_name] || change["table_name"]
273
+ end.compact.uniq
274
+ end
275
+
276
+ # Get tables to analyze (session tables or all tables if no session)
277
+ #
278
+ # @return [Array<String>] table names to analyze
279
+ def tables_to_analyze
280
+ session_tables.any? ? session_tables : connection.tables
281
+ end
282
+
283
+ # Extract foreign key relationships from schema
284
+ #
285
+ # @return [Array<Hash>] relationships array
286
+ def extract_foreign_key_relationships
287
+ relationships = []
288
+
289
+ tables_to_analyze.each do |table_name|
290
+ next unless table_exists?(table_name)
291
+
292
+ foreign_keys = get_foreign_keys(table_name)
293
+
294
+ foreign_keys.each do |fk|
295
+ # Only include if target table is also in scope
296
+ relationships << build_schema_relationship(table_name, fk) if target_table_in_scope?(fk.to_table)
297
+ end
298
+ end
299
+
300
+ relationships
301
+ end
302
+
303
+ # Check if table exists in database
304
+ #
305
+ # @param table_name [String] table name
306
+ # @return [Boolean] true if table exists
307
+ def table_exists?(table_name)
308
+ connection.table_exists?(table_name)
309
+ rescue StandardError
310
+ false
311
+ end
312
+
313
+ # Get foreign keys for a table
314
+ #
315
+ # @param table_name [String] table name
316
+ # @return [Array] foreign key objects
317
+ def get_foreign_keys(table_name)
318
+ connection.foreign_keys(table_name)
319
+ rescue StandardError => e
320
+ Rails.logger.warn "ForeignKeyAnalyzer: Could not get foreign keys for #{table_name}: #{e.message}"
321
+ []
322
+ end
323
+
324
+ # Check if target table is in analysis scope
325
+ #
326
+ # @param target_table [String] target table name
327
+ # @return [Boolean] true if target table should be included
328
+ def target_table_in_scope?(target_table)
329
+ # If analyzing session, both tables must be in session
330
+ # If analyzing globally, include all
331
+ session_tables.empty? || session_tables.include?(target_table)
332
+ end
333
+
334
+ # Build relationship hash from foreign key
335
+ #
336
+ # @param table_name [String] source table name
337
+ # @param foreign_key [Object] foreign key object
338
+ # @return [Hash] relationship data
339
+ def build_schema_relationship(table_name, foreign_key)
340
+ {
341
+ from_table: table_name,
342
+ to_table: foreign_key.to_table,
343
+ type: "foreign_key",
344
+ constraint_name: foreign_key.name,
345
+ from_column: foreign_key.column,
346
+ to_column: foreign_key.primary_key,
347
+ on_delete: foreign_key.on_delete,
348
+ on_update: foreign_key.on_update
349
+ }
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end