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.
- checksums.yaml +4 -4
- data/README.md +81 -210
- data/app/assets/config/dbwatcher_manifest.js +15 -0
- data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
- data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
- data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
- data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
- data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
- data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
- data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
- data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
- data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
- data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
- data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
- data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
- data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
- data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
- data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
- data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
- data/app/assets/stylesheets/dbwatcher/application.css +423 -0
- data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
- data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
- data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
- data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
- data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
- data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
- data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
- data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
- data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
- data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
- data/app/controllers/dbwatcher/base_controller.rb +8 -2
- data/app/controllers/dbwatcher/dashboard_controller.rb +8 -0
- data/app/controllers/dbwatcher/sessions_controller.rb +25 -10
- data/app/helpers/dbwatcher/component_helper.rb +29 -0
- data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
- data/app/helpers/dbwatcher/session_helper.rb +3 -2
- data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
- data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
- data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
- data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
- data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
- data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
- data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
- data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
- data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
- data/app/views/dbwatcher/sessions/index.html.erb +14 -10
- data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
- data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
- data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
- data/app/views/dbwatcher/sessions/show.html.erb +3 -346
- data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
- data/app/views/layouts/dbwatcher/application.html.erb +125 -247
- data/bin/compile_scss +49 -0
- data/config/routes.rb +26 -0
- data/lib/dbwatcher/configuration.rb +102 -8
- data/lib/dbwatcher/engine.rb +17 -7
- data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
- data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
- data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
- data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
- data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
- data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
- data/lib/dbwatcher/services/base_service.rb +64 -0
- data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
- data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
- data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
- data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
- data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
- data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
- data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
- data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
- data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
- data/lib/dbwatcher/services/diagram_data.rb +65 -0
- data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
- data/lib/dbwatcher/services/diagram_generator.rb +154 -0
- data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
- data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
- data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
- data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
- data/lib/dbwatcher/services/diagram_system.rb +69 -0
- data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
- data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
- data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
- data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
- data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
- data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
- data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
- data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
- data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
- data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +15 -128
- data/lib/dbwatcher/storage/api/session_api.rb +47 -0
- data/lib/dbwatcher/storage/base_storage.rb +7 -0
- data/lib/dbwatcher/version.rb +1 -1
- data/lib/dbwatcher.rb +58 -1
- 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
|