dbwatcher 0.1.5 → 1.0.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/app/controllers/dbwatcher/base_controller.rb +95 -0
  4. data/app/controllers/dbwatcher/dashboard_controller.rb +12 -0
  5. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  6. data/app/controllers/dbwatcher/sessions_controller.rb +15 -20
  7. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  8. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  9. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  10. data/app/helpers/dbwatcher/session_helper.rb +27 -0
  11. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  12. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  13. data/app/views/dbwatcher/sessions/index.html.erb +120 -27
  14. data/app/views/dbwatcher/sessions/show.html.erb +326 -129
  15. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  16. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  17. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  18. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  19. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  20. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  21. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  22. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  23. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  24. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  25. data/app/views/layouts/dbwatcher/application.html.erb +375 -26
  26. data/config/routes.rb +17 -3
  27. data/lib/dbwatcher/configuration.rb +9 -1
  28. data/lib/dbwatcher/engine.rb +12 -7
  29. data/lib/dbwatcher/logging.rb +72 -0
  30. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  31. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  32. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  33. data/lib/dbwatcher/sql_logger.rb +107 -0
  34. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  35. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +172 -0
  36. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  37. data/lib/dbwatcher/storage/api/session_api.rb +134 -0
  38. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  39. data/lib/dbwatcher/storage/base_storage.rb +113 -0
  40. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  41. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  42. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  43. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  44. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  45. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  46. data/lib/dbwatcher/storage/errors.rb +86 -0
  47. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  48. data/lib/dbwatcher/storage/null_session.rb +39 -0
  49. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  50. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  51. data/lib/dbwatcher/storage/session.rb +58 -0
  52. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  53. data/lib/dbwatcher/storage/session_query.rb +71 -0
  54. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  55. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  56. data/lib/dbwatcher/storage.rb +112 -85
  57. data/lib/dbwatcher/tracker.rb +4 -55
  58. data/lib/dbwatcher/version.rb +1 -1
  59. data/lib/dbwatcher.rb +12 -2
  60. metadata +47 -1
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength
4
+
5
+ require_relative "session_operations"
6
+ require_relative "null_session"
7
+
8
+ module Dbwatcher
9
+ module Storage
10
+ # Handles persistence and retrieval of database monitoring sessions
11
+ #
12
+ # This class manages the storage of session data including metadata,
13
+ # timestamps, and associated database changes. Sessions are stored
14
+ # as individual JSON files with an index for efficient querying.
15
+ # Follows Ruby style guide patterns for storage class organization.
16
+ #
17
+ # @example Basic usage
18
+ # storage = SessionStorage.new
19
+ # session = Session.new(id: "123", name: "Test Session")
20
+ # storage.save(session)
21
+ # loaded_session = storage.find("123")
22
+ #
23
+ # @see Session
24
+ # @see NullSession
25
+ class SessionStorage < BaseStorage
26
+ # Include shared concerns
27
+ include Concerns::Validatable
28
+ include Concerns::DataNormalizer
29
+
30
+ # Configuration constants
31
+ DEFAULT_INDEX_FILENAME = "index.json"
32
+ SESSIONS_DIRECTORY = "sessions"
33
+
34
+ # Validation rules
35
+ validates_presence_of :id
36
+
37
+ # @return [String] path to sessions directory
38
+ attr_reader :sessions_path
39
+
40
+ # @return [String] path to index file
41
+ attr_reader :index_file
42
+
43
+ # @return [SessionOperations] operations helper
44
+ attr_reader :operations
45
+
46
+ # @return [Mutex] thread safety mutex
47
+ attr_reader :mutex
48
+
49
+ # Initializes a new SessionStorage instance
50
+ #
51
+ # Sets up the necessary directories and index files for session storage.
52
+ # Creates the sessions directory and index file if they don't exist.
53
+ # Includes thread safety for concurrent operations.
54
+ #
55
+ # @param storage_path [String, nil] custom storage path (optional)
56
+ def initialize(storage_path = nil)
57
+ super
58
+ @sessions_path = File.join(self.storage_path, SESSIONS_DIRECTORY)
59
+ @index_file = File.join(self.storage_path, DEFAULT_INDEX_FILENAME)
60
+ @operations = SessionOperations.new(@sessions_path, @index_file)
61
+ @mutex = Mutex.new
62
+
63
+ setup_directories
64
+ end
65
+
66
+ # Persists a session to storage
67
+ #
68
+ # Saves the session data to a JSON file and updates the session index.
69
+ # Automatically triggers cleanup of old sessions after successful save.
70
+ # Uses thread safety to prevent concurrent write conflicts.
71
+ #
72
+ # @param session [Session, Hash] the session object or hash to save
73
+ # @return [Boolean] true if saved successfully, false otherwise
74
+ # @raise [ValidationError] if session data is invalid
75
+ #
76
+ # @example
77
+ # session = Session.new(id: "123", name: "Test")
78
+ # storage.save(session) # => true
79
+ # rubocop:disable Naming/PredicateMethod
80
+ def save(session)
81
+ session_data = normalize_session_data(session)
82
+ validate_session_data!(session_data)
83
+
84
+ mutex.synchronize do
85
+ persist_session_file(session_data)
86
+ update_session_index(session_data)
87
+ trigger_cleanup
88
+ end
89
+
90
+ true
91
+ end
92
+ # rubocop:enable Naming/PredicateMethod
93
+
94
+ # Alternative save method that raises on failure
95
+ #
96
+ # @param session [Session, Hash] the session object or hash to save
97
+ # @return [Boolean] true if saved successfully
98
+ # @raise [ValidationError] if session data is invalid
99
+ # @raise [StorageError] if save operation fails
100
+ def save!(session)
101
+ with_error_handling("save session") do
102
+ save(session) or raise StorageError, "Failed to save session"
103
+ end
104
+ end
105
+
106
+ # Finds a session by ID
107
+ #
108
+ # Retrieves session data from storage and constructs a Session object.
109
+ # Returns nil if the session is not found.
110
+ #
111
+ # @param id [String, Integer] the session ID to find
112
+ # @return [Session, nil] the loaded session or nil if not found
113
+ #
114
+ # @example
115
+ # session = storage.find("123")
116
+ # puts session.name if session
117
+ def find(id)
118
+ return nil unless valid_id?(id)
119
+
120
+ session_data = load_session_data(id)
121
+ return nil if session_data.empty?
122
+
123
+ build_session_from_data(session_data)
124
+ end
125
+
126
+ # Finds a session by ID or raises an exception
127
+ #
128
+ # @param id [String, Integer] the session ID to find
129
+ # @return [Session] the loaded session
130
+ # @raise [SessionNotFoundError] if session is not found
131
+ def find!(id)
132
+ find(id) or raise SessionNotFoundError, "Session with id '#{id}' not found"
133
+ end
134
+
135
+ # Loads a session by ID (legacy method, use find instead)
136
+ #
137
+ # @deprecated Use {#find} instead
138
+ # @param id [String, Integer] the session ID to load
139
+ # @return [Session, NullSession] the loaded session or null object
140
+ def load(id)
141
+ find(id) || NullSession.instance
142
+ end
143
+
144
+ # Returns all session summaries from the index
145
+ #
146
+ # @return [Array<Hash>] array of session summary hashes
147
+ def all
148
+ safe_read_json(index_file)
149
+ end
150
+
151
+ # Checks if a session exists
152
+ #
153
+ # @param id [String, Integer] the session ID to check
154
+ # @return [Boolean] true if session exists
155
+ def exists?(id)
156
+ return false unless valid_id?(id)
157
+
158
+ session_file = operations.session_file_path(id)
159
+ file_manager.file_exists?(session_file)
160
+ end
161
+
162
+ # Counts total number of sessions
163
+ #
164
+ # @return [Integer] number of sessions
165
+ def count
166
+ all.size
167
+ end
168
+
169
+ # Clears all session storage
170
+ #
171
+ # Removes all session files and reinitializes the storage structure.
172
+ # This operation cannot be undone.
173
+ #
174
+ # @return [Integer] number of files removed
175
+ def clear_all
176
+ with_error_handling("clear all sessions") do
177
+ # Count files before deleting
178
+ file_count = count_session_files
179
+
180
+ safe_delete_directory(sessions_path)
181
+ safe_write_json(index_file, [])
182
+ setup_directories
183
+ touch_updated_at
184
+
185
+ file_count
186
+ end
187
+ end
188
+
189
+ # Removes old session files based on configuration
190
+ #
191
+ # Automatically called after each save operation to maintain
192
+ # storage size within configured limits.
193
+ #
194
+ # @return [void]
195
+ def cleanup_old_sessions
196
+ return unless cleanup_enabled?
197
+
198
+ cutoff_date = calculate_cleanup_cutoff
199
+ remove_old_session_files(cutoff_date)
200
+ end
201
+
202
+ private
203
+
204
+ # Sets up required directories and files
205
+ #
206
+ # @return [void]
207
+ def setup_directories
208
+ file_manager.ensure_directory(sessions_path)
209
+ file_manager.write_json(index_file, []) unless File.exist?(index_file)
210
+ end
211
+
212
+ # Validates session data
213
+ #
214
+ # @param session_data [Hash] session data to validate
215
+ # @return [void]
216
+ # @raise [ValidationError] if data is invalid
217
+ def validate_session_data!(session_data)
218
+ validate_presence!(session_data, :id)
219
+ validate_id!(session_data[:id])
220
+ end
221
+
222
+ # Persists session data to file
223
+ #
224
+ # @param session_data [Hash] session data to persist
225
+ # @return [void]
226
+ def persist_session_file(session_data)
227
+ session_file = operations.session_file_path(session_data[:id])
228
+ safe_write_json(session_file, session_data)
229
+ end
230
+
231
+ # Updates the session index
232
+ #
233
+ # @param session_data [Hash] session data for index update
234
+ # @return [void]
235
+ def update_session_index(session_data)
236
+ index = safe_read_json(index_file)
237
+ session_summary = operations.build_session_summary(session_data)
238
+
239
+ updated_index = [session_summary] + index
240
+ limited_index = operations.apply_session_limits(updated_index)
241
+
242
+ safe_write_json(index_file, limited_index)
243
+ end
244
+
245
+ # Triggers cleanup of old sessions
246
+ #
247
+ # @return [void]
248
+ def trigger_cleanup
249
+ cleanup_old_sessions
250
+ end
251
+
252
+ # Loads session data from file
253
+ #
254
+ # @param id [String] session ID
255
+ # @return [Hash] session data or empty hash
256
+ def load_session_data(id)
257
+ session_file = operations.session_file_path(id)
258
+ safe_read_json(session_file, {})
259
+ end
260
+
261
+ # Builds session object from data
262
+ #
263
+ # @param data [Hash] session data
264
+ # @return [Session] session object
265
+ def build_session_from_data(data)
266
+ Storage::Session.new(data)
267
+ rescue StandardError => e
268
+ log_error("Failed to build session from data", e)
269
+ raise CorruptedDataError, "Session data is corrupted: #{e.message}"
270
+ end
271
+
272
+ # Counts the number of session files
273
+ #
274
+ # @return [Integer] number of session files
275
+ def count_session_files
276
+ return 0 unless Dir.exist?(sessions_path)
277
+
278
+ Dir.glob(File.join(sessions_path, "*.json")).count
279
+ end
280
+
281
+ # Checks if cleanup is enabled
282
+ #
283
+ # @return [Boolean] true if cleanup is enabled
284
+ def cleanup_enabled?
285
+ Dbwatcher.configuration.auto_clean_after_days&.positive?
286
+ end
287
+
288
+ # Calculates cleanup cutoff date
289
+ #
290
+ # @return [Time] cutoff date for cleanup
291
+ def calculate_cleanup_cutoff
292
+ days = Dbwatcher.configuration.auto_clean_after_days
293
+ current_time - (days * 24 * 60 * 60)
294
+ end
295
+
296
+ # Removes old session files
297
+ #
298
+ # @param cutoff_date [Time] files older than this date are removed
299
+ # @return [void]
300
+ def remove_old_session_files(cutoff_date)
301
+ safe_operation("cleanup old sessions") do
302
+ Dir.glob(File.join(sessions_path, "*.json")).each do |file|
303
+ File.delete(file) if File.mtime(file) < cutoff_date
304
+ end
305
+ end
306
+ end
307
+
308
+ # Returns current time (compatible with and without Rails)
309
+ #
310
+ # @return [Time] current time
311
+ def current_time
312
+ if defined?(Time.current)
313
+ Time.current
314
+ else
315
+ Time.now
316
+ end
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "change_processor"
4
+
5
+ module Dbwatcher
6
+ module Storage
7
+ # Handles retrieval and processing of table change data
8
+ #
9
+ # This class provides access to database table changes by coordinating
10
+ # with the change processor to aggregate and filter table modifications
11
+ # from stored session data. Follows Ruby style guide patterns for
12
+ # storage class organization.
13
+ #
14
+ # @example Basic usage
15
+ # storage = TableStorage.new(session_storage)
16
+ # changes = storage.find_changes("users")
17
+ # changes.each { |change| puts "#{change[:operation]} on #{change[:table]}" }
18
+ #
19
+ # @example Advanced filtering
20
+ # recent_changes = storage.find_recent_changes("users", limit: 10)
21
+ # filtered_changes = storage.find_changes_by_operation("users", "INSERT")
22
+ class TableStorage < BaseStorage
23
+ # Include validation capabilities
24
+ include Concerns::Validatable
25
+
26
+ # Configuration constants
27
+ DEFAULT_CHANGE_LIMIT = 100
28
+ SUPPORTED_OPERATIONS = %w[INSERT UPDATE DELETE].freeze
29
+
30
+ # @return [ChangeProcessor] processor for handling table changes
31
+ attr_reader :change_processor
32
+
33
+ # @return [SessionStorage] session storage dependency
34
+ attr_reader :session_storage
35
+
36
+ # Initializes table storage with session storage dependency
37
+ #
38
+ # @param session_storage [SessionStorage] storage instance for session data
39
+ # @param storage_path [String, nil] custom storage path (optional)
40
+ def initialize(session_storage, storage_path = nil)
41
+ super(storage_path)
42
+ @session_storage = session_storage
43
+ @change_processor = ChangeProcessor.new(session_storage)
44
+ end
45
+
46
+ # Finds all changes for a specific table
47
+ #
48
+ # Retrieves and processes all database changes related to the specified
49
+ # table from stored session data. Returns an empty array if the table
50
+ # name is invalid or no changes are found.
51
+ #
52
+ # @param table_name [String] name of the table to load changes for
53
+ # @param options [Hash] filtering options
54
+ # @option options [Integer] :limit maximum number of changes to return
55
+ # @option options [String] :operation filter by operation type (INSERT, UPDATE, DELETE)
56
+ # @option options [Time] :since only return changes after this time
57
+ # @return [Array<Hash>] array of change records for the table
58
+ #
59
+ # @example
60
+ # changes = storage.find_changes("users")
61
+ # puts "Found #{changes.length} changes for users table"
62
+ #
63
+ # @example With filtering
64
+ # recent_inserts = storage.find_changes("users", operation: "INSERT", limit: 50)
65
+ def find_changes(table_name, **options)
66
+ validate_table_name!(table_name)
67
+ validate_operation!(options[:operation]) if options[:operation]
68
+
69
+ changes = change_processor.process_table_changes(table_name)
70
+ apply_filters(changes, **options)
71
+ rescue StandardError => e
72
+ log_error("Failed to load changes for table #{table_name}", e)
73
+ []
74
+ end
75
+
76
+ # Finds changes for a table with a specific operation
77
+ #
78
+ # @param table_name [String] name of the table
79
+ # @param operation [String] operation type (INSERT, UPDATE, DELETE)
80
+ # @param limit [Integer] maximum number of changes to return
81
+ # @return [Array<Hash>] filtered change records
82
+ def find_changes_by_operation(table_name, operation, limit: DEFAULT_CHANGE_LIMIT)
83
+ find_changes(table_name, operation: operation, limit: limit)
84
+ end
85
+
86
+ # Finds recent changes for a table
87
+ #
88
+ # @param table_name [String] name of the table
89
+ # @param limit [Integer] maximum number of changes to return
90
+ # @param since [Time] only return changes after this time
91
+ # @return [Array<Hash>] recent change records
92
+ def find_recent_changes(table_name, limit: DEFAULT_CHANGE_LIMIT, since: 1.day.ago)
93
+ find_changes(table_name, limit: limit, since: since)
94
+ end
95
+
96
+ # Counts total changes for a table
97
+ #
98
+ # @param table_name [String] name of the table
99
+ # @return [Integer] number of changes for the table
100
+ def count_changes(table_name)
101
+ find_changes(table_name).size
102
+ end
103
+
104
+ # Counts changes by operation type
105
+ #
106
+ # @param table_name [String] name of the table
107
+ # @return [Hash] hash with operation types as keys and counts as values
108
+ def count_changes_by_operation(table_name)
109
+ changes = find_changes(table_name)
110
+
111
+ SUPPORTED_OPERATIONS.each_with_object({}) do |operation, counts|
112
+ counts[operation] = changes.count { |change| change[:operation] == operation }
113
+ end
114
+ end
115
+
116
+ # Lists all tables that have changes
117
+ #
118
+ # @return [Array<String>] array of table names with changes
119
+ def tables_with_changes
120
+ change_processor.tables_with_changes
121
+ rescue StandardError => e
122
+ log_error("Failed to load tables with changes", e)
123
+ []
124
+ end
125
+
126
+ # Checks if a table has any changes
127
+ #
128
+ # @param table_name [String] name of the table to check
129
+ # @return [Boolean] true if table has changes
130
+ def changes?(table_name)
131
+ return false unless valid_table_name?(table_name)
132
+
133
+ count_changes(table_name).positive?
134
+ end
135
+
136
+ # Legacy method for backward compatibility
137
+ #
138
+ # @deprecated Use {#find_changes} instead
139
+ # @param table_name [String] name of the table to load changes for
140
+ # @return [Array<Hash>] array of change records
141
+ def load_changes(table_name)
142
+ find_changes(table_name)
143
+ end
144
+
145
+ private
146
+
147
+ # Validates table name for presence and format
148
+ #
149
+ # @param table_name [String] table name to validate
150
+ # @return [void]
151
+ # @raise [ValidationError] if table name is invalid
152
+ def validate_table_name!(table_name)
153
+ raise ValidationError, "Table name cannot be nil or empty" if table_name.nil? || table_name.to_s.strip.empty?
154
+
155
+ return unless table_name.to_s.include?(" ")
156
+
157
+ raise ValidationError, "Table name cannot contain spaces"
158
+ end
159
+
160
+ # Validates operation type
161
+ #
162
+ # @param operation [String] operation to validate
163
+ # @return [void]
164
+ # @raise [ValidationError] if operation is invalid
165
+ def validate_operation!(operation)
166
+ return if SUPPORTED_OPERATIONS.include?(operation.to_s.upcase)
167
+
168
+ raise ValidationError,
169
+ "Unsupported operation: #{operation}. Must be one of: #{SUPPORTED_OPERATIONS.join(", ")}"
170
+ end
171
+
172
+ # Checks if table name is valid
173
+ #
174
+ # @param table_name [String] table name to check
175
+ # @return [Boolean] true if table name is valid
176
+ def valid_table_name?(table_name)
177
+ !table_name.nil? && !table_name.to_s.strip.empty?
178
+ end
179
+
180
+ # Applies filtering options to changes
181
+ #
182
+ # @param changes [Array<Hash>] changes to filter
183
+ # @param options [Hash] filtering options
184
+ # @return [Array<Hash>] filtered changes
185
+ def apply_filters(changes, **options)
186
+ filtered_changes = changes
187
+ filtered_changes = filter_by_operation(filtered_changes, options[:operation]) if options[:operation]
188
+ filtered_changes = filter_by_time(filtered_changes, options[:since]) if options[:since]
189
+ filtered_changes = apply_limit(filtered_changes, options[:limit]) if options[:limit]
190
+ filtered_changes
191
+ end
192
+
193
+ # Filters changes by operation type
194
+ #
195
+ # @param changes [Array<Hash>] changes to filter
196
+ # @param operation [String, Symbol] operation to filter by
197
+ # @return [Array<Hash>] filtered changes
198
+ def filter_by_operation(changes, operation)
199
+ operation_str = operation.to_s.upcase
200
+ changes.select { |change| change[:operation] == operation_str }
201
+ end
202
+
203
+ # Filters changes by timestamp
204
+ #
205
+ # @param changes [Array<Hash>] changes to filter
206
+ # @param since_time [Time] minimum timestamp
207
+ # @return [Array<Hash>] filtered changes
208
+ def filter_by_time(changes, since_time)
209
+ changes.select do |change|
210
+ change_time = parse_timestamp(change[:timestamp])
211
+ change_time && change_time >= since_time
212
+ end
213
+ end
214
+
215
+ # Applies limit to changes (returns most recent)
216
+ #
217
+ # @param changes [Array<Hash>] changes to limit
218
+ # @param limit [Integer] maximum number of changes to return
219
+ # @return [Array<Hash>] limited changes
220
+ def apply_limit(changes, limit)
221
+ changes.sort_by { |change| change[:timestamp] }
222
+ .reverse
223
+ .first(limit)
224
+ end
225
+
226
+ # Parses timestamp safely
227
+ #
228
+ # @param timestamp [String, Time] timestamp to parse
229
+ # @return [Time, nil] parsed time or nil if invalid
230
+ def parse_timestamp(timestamp)
231
+ Time.parse(timestamp.to_s)
232
+ rescue StandardError
233
+ nil
234
+ end
235
+ end
236
+ end
237
+ end