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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/app/controllers/dbwatcher/base_controller.rb +95 -0
- data/app/controllers/dbwatcher/dashboard_controller.rb +12 -0
- data/app/controllers/dbwatcher/queries_controller.rb +24 -0
- data/app/controllers/dbwatcher/sessions_controller.rb +15 -20
- data/app/controllers/dbwatcher/tables_controller.rb +38 -0
- data/app/helpers/dbwatcher/application_helper.rb +103 -0
- data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
- data/app/helpers/dbwatcher/session_helper.rb +27 -0
- data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
- data/app/views/dbwatcher/queries/index.html.erb +240 -0
- data/app/views/dbwatcher/sessions/index.html.erb +120 -27
- data/app/views/dbwatcher/sessions/show.html.erb +326 -129
- data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
- data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
- data/app/views/dbwatcher/shared/_header.html.erb +7 -0
- data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
- data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
- data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
- data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
- data/app/views/dbwatcher/tables/changes.html.erb +225 -0
- data/app/views/dbwatcher/tables/index.html.erb +123 -0
- data/app/views/dbwatcher/tables/show.html.erb +86 -0
- data/app/views/layouts/dbwatcher/application.html.erb +375 -26
- data/config/routes.rb +17 -3
- data/lib/dbwatcher/configuration.rb +9 -1
- data/lib/dbwatcher/engine.rb +12 -7
- data/lib/dbwatcher/logging.rb +72 -0
- data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
- data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
- data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
- data/lib/dbwatcher/sql_logger.rb +107 -0
- data/lib/dbwatcher/storage/api/base_api.rb +134 -0
- data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +172 -0
- data/lib/dbwatcher/storage/api/query_api.rb +95 -0
- data/lib/dbwatcher/storage/api/session_api.rb +134 -0
- data/lib/dbwatcher/storage/api/table_api.rb +86 -0
- data/lib/dbwatcher/storage/base_storage.rb +113 -0
- data/lib/dbwatcher/storage/change_processor.rb +65 -0
- data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
- data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
- data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
- data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
- data/lib/dbwatcher/storage/date_helper.rb +21 -0
- data/lib/dbwatcher/storage/errors.rb +86 -0
- data/lib/dbwatcher/storage/file_manager.rb +122 -0
- data/lib/dbwatcher/storage/null_session.rb +39 -0
- data/lib/dbwatcher/storage/query_storage.rb +338 -0
- data/lib/dbwatcher/storage/query_validator.rb +24 -0
- data/lib/dbwatcher/storage/session.rb +58 -0
- data/lib/dbwatcher/storage/session_operations.rb +37 -0
- data/lib/dbwatcher/storage/session_query.rb +71 -0
- data/lib/dbwatcher/storage/session_storage.rb +322 -0
- data/lib/dbwatcher/storage/table_storage.rb +237 -0
- data/lib/dbwatcher/storage.rb +112 -85
- data/lib/dbwatcher/tracker.rb +4 -55
- data/lib/dbwatcher/version.rb +1 -1
- data/lib/dbwatcher.rb +12 -2
- 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
|