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,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength
4
+
5
+ require_relative "query_validator"
6
+ require_relative "date_helper"
7
+
8
+ module Dbwatcher
9
+ module Storage
10
+ # Handles persistence and retrieval of database query logs
11
+ #
12
+ # This class manages the storage of query data organized by date.
13
+ # Queries are stored in daily files with automatic cleanup and
14
+ # size limiting based on configuration. Follows Ruby style guide
15
+ # patterns for storage class organization.
16
+ #
17
+ # @example Basic usage
18
+ # storage = QueryStorage.new
19
+ # query = { sql: "SELECT * FROM users", timestamp: Time.current }
20
+ # storage.save(query)
21
+ # daily_queries = storage.find_by_date(Date.current)
22
+ #
23
+ # @example Advanced filtering
24
+ # queries = storage.find_by_date_range(1.week.ago..Time.current)
25
+ # recent_queries = storage.recent(limit: 50)
26
+ class QueryStorage < BaseStorage
27
+ # Include shared concerns
28
+ include Concerns::Validatable
29
+ include DateHelper
30
+
31
+ # Configuration constants
32
+ DEFAULT_CLEANUP_DAYS = 30
33
+ QUERIES_DIRECTORY = "queries"
34
+ MAX_QUERIES_PER_FILE = 1000
35
+
36
+ # Validation rules
37
+ validates_presence_of :sql, :timestamp
38
+
39
+ # @return [String] path to queries directory
40
+ attr_reader :queries_path
41
+
42
+ # Initializes query storage with queries directory
43
+ #
44
+ # Creates the queries directory if it doesn't exist and sets up
45
+ # the necessary file structure for date-based organization.
46
+ #
47
+ # @param storage_path [String, nil] custom storage path (optional)
48
+ def initialize(storage_path = nil)
49
+ super
50
+ @queries_path = File.join(self.storage_path, QUERIES_DIRECTORY)
51
+ file_manager.ensure_directory(@queries_path)
52
+ end
53
+
54
+ # Saves a query to date-based storage
55
+ #
56
+ # Validates the query and stores it in a daily file. Automatically
57
+ # applies size limits based on configuration to prevent excessive
58
+ # storage usage.
59
+ #
60
+ # @param query [Hash] query data containing at least :sql and :timestamp
61
+ # @return [Boolean] true if saved successfully, false if invalid
62
+ # @raise [ValidationError] if query data is invalid and strict mode enabled
63
+ #
64
+ # @example
65
+ # query = { sql: "SELECT * FROM users", timestamp: Time.current }
66
+ # storage.save(query) # => true
67
+ def save(query)
68
+ query_data = normalize_query_data(query)
69
+ return false unless QueryValidator.valid?(query_data)
70
+
71
+ date = format_date(query_data[:timestamp])
72
+ query_file = date_file_path(@queries_path, date)
73
+
74
+ queries = load_queries_from_file(query_file)
75
+ queries = add_query_with_limits(queries, query_data)
76
+
77
+ safe_write_json(query_file, queries)
78
+ end
79
+
80
+ # Alternative save method that raises on failure
81
+ #
82
+ # @param query [Hash] query data to save
83
+ # @return [Boolean] true if saved successfully
84
+ # @raise [ValidationError] if query data is invalid
85
+ # @raise [StorageError] if save operation fails
86
+ def save!(query)
87
+ with_error_handling("save query") do
88
+ save(query) or raise StorageError, "Failed to save query"
89
+ end
90
+ end
91
+
92
+ # Finds all queries for a specific date
93
+ #
94
+ # @param date [Date, String] the date to load queries for
95
+ # @return [Array<Hash>] array of query data for the specified date
96
+ #
97
+ # @example
98
+ # queries = storage.find_by_date(Date.current)
99
+ # queries.each { |q| puts q[:sql] }
100
+ def find_by_date(date)
101
+ query_file = date_file_path(@queries_path, date)
102
+ safe_read_json(query_file)
103
+ end
104
+
105
+ # Finds queries within a date range
106
+ #
107
+ # @param date_range [Range] range of dates to search
108
+ # @return [Array<Hash>] array of query data within the date range
109
+ #
110
+ # @example
111
+ # queries = storage.find_by_date_range(1.week.ago..Time.current)
112
+ def find_by_date_range(date_range)
113
+ date_range.flat_map do |date|
114
+ find_by_date(date)
115
+ end
116
+ end
117
+
118
+ # Finds recent queries across all dates
119
+ #
120
+ # @param limit [Integer] maximum number of queries to return
121
+ # @return [Array<Hash>] array of recent query data
122
+ def recent(limit: 100)
123
+ all_queries = []
124
+ dates_descending.each do |date|
125
+ daily_queries = find_by_date(date)
126
+ all_queries.concat(daily_queries)
127
+ break if all_queries.size >= limit
128
+ end
129
+
130
+ all_queries
131
+ .sort_by { |q| q[:timestamp] }
132
+ .reverse
133
+ .first(limit)
134
+ end
135
+
136
+ # Counts total number of queries
137
+ #
138
+ # @return [Integer] total number of stored queries
139
+ def count
140
+ query_files.sum do |file|
141
+ safe_read_json(file).size
142
+ end
143
+ end
144
+
145
+ # Counts queries for a specific date
146
+ #
147
+ # @param date [Date, String] date to count queries for
148
+ # @return [Integer] number of queries for the date
149
+ def count_by_date(date)
150
+ find_by_date(date).size
151
+ end
152
+
153
+ # Loads all queries for a specific date (legacy method)
154
+ #
155
+ # @deprecated Use {#find_by_date} instead
156
+ # @param date [Date, String] the date to load queries for
157
+ # @return [Array<Hash>] array of query data
158
+ def load_for_date(date)
159
+ find_by_date(date)
160
+ end
161
+
162
+ # Removes old query files based on retention period
163
+ #
164
+ # @param days_to_keep [Integer] number of days of queries to retain
165
+ # @return [Integer] number of files removed
166
+ def cleanup_old_queries(days_to_keep = DEFAULT_CLEANUP_DAYS)
167
+ cutoff_date = cleanup_cutoff_date(days_to_keep)
168
+ removed_count = 0
169
+
170
+ cleanup_files_older_than(cutoff_date) do
171
+ removed_count += 1
172
+ end
173
+
174
+ removed_count
175
+ end
176
+
177
+ # Optimizes storage by removing duplicate queries
178
+ #
179
+ # @return [Integer] number of duplicates removed
180
+ def optimize_storage
181
+ duplicate_count = 0
182
+
183
+ query_files.each do |file|
184
+ queries = safe_read_json(file)
185
+ unique_queries = queries.uniq { |q| [q[:sql], q[:timestamp]] }
186
+
187
+ if unique_queries.size < queries.size
188
+ duplicate_count += queries.size - unique_queries.size
189
+ safe_write_json(file, unique_queries)
190
+ end
191
+ end
192
+
193
+ duplicate_count
194
+ end
195
+
196
+ # Clears all query logs
197
+ #
198
+ # @return [Integer] number of files removed
199
+ def clear_all
200
+ with_error_handling("clear all queries") do
201
+ # Count files before deleting
202
+ file_count = count_query_files
203
+
204
+ safe_delete_directory(queries_path)
205
+
206
+ file_count
207
+ end
208
+ end
209
+
210
+ # Counts the number of query files
211
+ #
212
+ # @return [Integer] number of query files
213
+ def count_query_files
214
+ return 0 unless Dir.exist?(@queries_path)
215
+
216
+ query_files.count
217
+ end
218
+
219
+ private
220
+
221
+ # Normalizes query data to hash format
222
+ #
223
+ # @param query [Hash, Object] query object or hash
224
+ # @return [Hash] normalized query data
225
+ def normalize_query_data(query)
226
+ case query
227
+ when Hash
228
+ normalize_hash_keys(query)
229
+ when ->(q) { q.respond_to?(:to_h) }
230
+ normalize_hash_keys(query.to_h)
231
+ else
232
+ raise ValidationError, "Query must be a Hash or respond to :to_h"
233
+ end
234
+ end
235
+
236
+ # Normalizes hash keys to symbols (Rails-compatible)
237
+ #
238
+ # @param hash [Hash] hash to normalize
239
+ # @return [Hash] hash with symbolized keys
240
+ def normalize_hash_keys(hash)
241
+ if hash.respond_to?(:with_indifferent_access)
242
+ hash.with_indifferent_access
243
+ else
244
+ hash.transform_keys(&:to_sym)
245
+ end
246
+ end
247
+
248
+ # Loads existing queries from a file
249
+ #
250
+ # @param query_file [String] path to the query file
251
+ # @return [Array<Hash>] existing queries or empty array
252
+ def load_queries_from_file(query_file)
253
+ safe_read_json(query_file)
254
+ end
255
+
256
+ # Adds a new query and applies daily limits
257
+ #
258
+ # @param queries [Array<Hash>] existing queries
259
+ # @param new_query [Hash] new query to add
260
+ # @return [Array<Hash>] updated queries with limits applied
261
+ def add_query_with_limits(queries, new_query)
262
+ updated_queries = queries + [new_query]
263
+ apply_daily_limits(updated_queries)
264
+ end
265
+
266
+ # Applies daily query limits based on configuration
267
+ #
268
+ # @param queries [Array<Hash>] queries to limit
269
+ # @return [Array<Hash>] limited queries (keeps most recent)
270
+ def apply_daily_limits(queries)
271
+ max_queries = Dbwatcher.configuration.max_query_logs_per_day || MAX_QUERIES_PER_FILE
272
+ return queries if max_queries <= 0
273
+
274
+ queries
275
+ .sort_by { |q| normalize_timestamp_for_sorting(q[:timestamp]) }
276
+ .last(max_queries)
277
+ end
278
+
279
+ # Removes query files older than the cutoff date
280
+ #
281
+ # @param cutoff_date [Time] files older than this date are removed
282
+ # @yield [String] called for each file removed
283
+ # @return [void]
284
+ def cleanup_files_older_than(cutoff_date)
285
+ safe_operation("cleanup old queries") do
286
+ query_files.each do |file|
287
+ if File.mtime(file) < cutoff_date
288
+ file_manager.delete_file(file)
289
+ yield file if block_given?
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ # Returns all query files
296
+ #
297
+ # @return [Array<String>] paths to all query files
298
+ def query_files
299
+ file_manager.glob_files(File.join(@queries_path, "*.json"))
300
+ end
301
+
302
+ # Normalizes timestamp for sorting to handle mixed string/Time types
303
+ #
304
+ # @param timestamp [String, Time, nil] timestamp to normalize
305
+ # @return [Time] normalized timestamp
306
+ def normalize_timestamp_for_sorting(timestamp)
307
+ case timestamp
308
+ when Time
309
+ timestamp
310
+ when String
311
+ Time.parse(timestamp)
312
+ else
313
+ Time.at(0) # Fallback for nil or invalid timestamps
314
+ end
315
+ rescue ArgumentError
316
+ Time.at(0) # Fallback for unparseable strings
317
+ end
318
+
319
+ # Returns dates in descending order based on existing files
320
+ #
321
+ # @return [Array<Date>] sorted dates
322
+ def dates_descending
323
+ query_files
324
+ .map { |file| File.basename(file, ".json") }
325
+ .map do |filename|
326
+ Date.parse(filename)
327
+ rescue StandardError
328
+ nil
329
+ end
330
+ .compact
331
+ .sort
332
+ .reverse
333
+ end
334
+ end
335
+ end
336
+ end
337
+
338
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ class QueryValidator
6
+ REQUIRED_FIELDS = [:timestamp].freeze
7
+
8
+ def self.valid?(query)
9
+ return false unless query.is_a?(Hash)
10
+
11
+ REQUIRED_FIELDS.all? { |field| query.key?(field) && !query[field].nil? }
12
+ end
13
+
14
+ def self.validate!(query)
15
+ raise ArgumentError, "Query must be a Hash" unless query.is_a?(Hash)
16
+
17
+ missing_fields = REQUIRED_FIELDS.reject { |field| query.key?(field) }
18
+ return true if missing_fields.empty?
19
+
20
+ raise ArgumentError, "Missing required fields: #{missing_fields.join(", ")}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ class Session
6
+ attr_accessor :id, :name, :metadata, :started_at, :ended_at, :changes
7
+
8
+ def initialize(attrs = {})
9
+ # Set default values
10
+ @changes = []
11
+ @metadata = {}
12
+
13
+ # Set provided attributes
14
+ attrs.each do |key, value|
15
+ setter_method = "#{key}="
16
+ send(setter_method, value) if respond_to?(setter_method)
17
+ end
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ id: id,
23
+ name: name,
24
+ metadata: metadata,
25
+ started_at: started_at,
26
+ ended_at: ended_at,
27
+ changes: changes
28
+ }
29
+ end
30
+
31
+ def summary
32
+ return {} unless changes.is_a?(Array)
33
+
34
+ valid_changes = filter_valid_changes
35
+ group_changes_by_operation(valid_changes)
36
+ rescue StandardError => e
37
+ warn "Failed to calculate session summary: #{e.message}"
38
+ {}
39
+ end
40
+
41
+ private
42
+
43
+ def filter_valid_changes
44
+ changes.select { |change| valid_change?(change) }
45
+ end
46
+
47
+ def valid_change?(change)
48
+ change.is_a?(Hash) && change[:table_name] && change[:operation]
49
+ end
50
+
51
+ def group_changes_by_operation(valid_changes)
52
+ valid_changes
53
+ .group_by { |change| "#{change[:table_name]},#{change[:operation]}" }
54
+ .transform_values(&:count)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module Storage
5
+ class SessionOperations
6
+ # Include data normalization capabilities
7
+ include Concerns::DataNormalizer
8
+ def initialize(sessions_path, index_file)
9
+ @sessions_path = sessions_path
10
+ @index_file = index_file
11
+ end
12
+
13
+ def session_file_path(session_id)
14
+ File.join(@sessions_path, "#{session_id}.json")
15
+ end
16
+
17
+ def build_session_summary(session)
18
+ session_data = normalize_session_data(session)
19
+
20
+ {
21
+ id: session_data[:id],
22
+ name: session_data[:name],
23
+ started_at: session_data[:started_at],
24
+ ended_at: session_data[:ended_at],
25
+ change_count: (session_data[:changes] || []).count
26
+ }
27
+ end
28
+
29
+ def apply_session_limits(sessions)
30
+ max_sessions = Dbwatcher.configuration.max_sessions
31
+ return sessions unless max_sessions&.positive?
32
+
33
+ sessions.first(max_sessions)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Dbwatcher
6
+ module Storage
7
+ class SessionQuery
8
+ def initialize(storage)
9
+ @storage = storage
10
+ @conditions = {}
11
+ @limit_value = nil
12
+ end
13
+
14
+ def find(id)
15
+ @storage.load(id)
16
+ end
17
+
18
+ def all
19
+ apply_filters(@storage.all)
20
+ end
21
+
22
+ def where(conditions)
23
+ @conditions.merge!(conditions)
24
+ self
25
+ end
26
+
27
+ def limit(count)
28
+ @limit_value = count
29
+ self
30
+ end
31
+
32
+ def recent(days: 7)
33
+ cutoff = Time.now - (days * 24 * 60 * 60)
34
+ where(started_after: cutoff)
35
+ end
36
+
37
+ def create(session)
38
+ @storage.save(session)
39
+ end
40
+
41
+ def with_changes
42
+ all.select { |s| @storage.load(s[:id])&.changes&.any? }
43
+ end
44
+
45
+ private
46
+
47
+ def apply_filters(sessions)
48
+ result = sessions
49
+ result = result.select { |s| matches_conditions?(s) } if @conditions.any?
50
+ result = result.first(@limit_value) if @limit_value
51
+ result
52
+ end
53
+
54
+ def matches_conditions?(session)
55
+ @conditions.all? do |key, value|
56
+ case key
57
+ when :started_after
58
+ started_at = session[:started_at]
59
+ started_at && Time.parse(started_at) >= value
60
+ when :name
61
+ session[:name]&.include?(value)
62
+ else
63
+ session[key] == value
64
+ end
65
+ end
66
+ rescue ArgumentError, TypeError
67
+ false
68
+ end
69
+ end
70
+ end
71
+ end