dbviewer 0.3.1

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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +250 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/stylesheets/dbviewer/application.css +21 -0
  6. data/app/assets/stylesheets/dbviewer/dbviewer.css +0 -0
  7. data/app/assets/stylesheets/dbviewer/enhanced.css +0 -0
  8. data/app/controllers/concerns/dbviewer/database_operations.rb +354 -0
  9. data/app/controllers/concerns/dbviewer/error_handling.rb +42 -0
  10. data/app/controllers/concerns/dbviewer/pagination_concern.rb +43 -0
  11. data/app/controllers/dbviewer/application_controller.rb +21 -0
  12. data/app/controllers/dbviewer/databases_controller.rb +0 -0
  13. data/app/controllers/dbviewer/entity_relationship_diagrams_controller.rb +24 -0
  14. data/app/controllers/dbviewer/home_controller.rb +10 -0
  15. data/app/controllers/dbviewer/logs_controller.rb +39 -0
  16. data/app/controllers/dbviewer/tables_controller.rb +73 -0
  17. data/app/helpers/dbviewer/application_helper.rb +118 -0
  18. data/app/jobs/dbviewer/application_job.rb +4 -0
  19. data/app/mailers/dbviewer/application_mailer.rb +6 -0
  20. data/app/models/dbviewer/application_record.rb +5 -0
  21. data/app/services/dbviewer/file_storage.rb +0 -0
  22. data/app/services/dbviewer/in_memory_storage.rb +0 -0
  23. data/app/services/dbviewer/query_analyzer.rb +0 -0
  24. data/app/services/dbviewer/query_collection.rb +0 -0
  25. data/app/services/dbviewer/query_logger.rb +0 -0
  26. data/app/services/dbviewer/query_parser.rb +82 -0
  27. data/app/services/dbviewer/query_storage.rb +0 -0
  28. data/app/views/dbviewer/entity_relationship_diagrams/index.html.erb +564 -0
  29. data/app/views/dbviewer/home/index.html.erb +237 -0
  30. data/app/views/dbviewer/logs/index.html.erb +614 -0
  31. data/app/views/dbviewer/shared/_sidebar.html.erb +177 -0
  32. data/app/views/dbviewer/tables/_table_structure.html.erb +102 -0
  33. data/app/views/dbviewer/tables/index.html.erb +128 -0
  34. data/app/views/dbviewer/tables/query.html.erb +600 -0
  35. data/app/views/dbviewer/tables/show.html.erb +271 -0
  36. data/app/views/layouts/dbviewer/application.html.erb +728 -0
  37. data/config/routes.rb +22 -0
  38. data/lib/dbviewer/configuration.rb +79 -0
  39. data/lib/dbviewer/database_manager.rb +450 -0
  40. data/lib/dbviewer/engine.rb +20 -0
  41. data/lib/dbviewer/initializer.rb +23 -0
  42. data/lib/dbviewer/logger.rb +102 -0
  43. data/lib/dbviewer/query_analyzer.rb +109 -0
  44. data/lib/dbviewer/query_collection.rb +41 -0
  45. data/lib/dbviewer/query_parser.rb +82 -0
  46. data/lib/dbviewer/sql_validator.rb +194 -0
  47. data/lib/dbviewer/storage/base.rb +31 -0
  48. data/lib/dbviewer/storage/file_storage.rb +96 -0
  49. data/lib/dbviewer/storage/in_memory_storage.rb +59 -0
  50. data/lib/dbviewer/version.rb +3 -0
  51. data/lib/dbviewer.rb +65 -0
  52. data/lib/tasks/dbviewer_tasks.rake +4 -0
  53. metadata +126 -0
data/config/routes.rb ADDED
@@ -0,0 +1,22 @@
1
+ Dbviewer::Engine.routes.draw do
2
+ resources :tables, only: [ :index, :show ] do
3
+ member do
4
+ get "query"
5
+ post "query"
6
+ get "export_csv"
7
+ end
8
+ end
9
+
10
+ resources :entity_relationship_diagrams, only: [ :index ]
11
+
12
+ resources :logs, only: [ :index ] do
13
+ collection do
14
+ delete :destroy_all
15
+ end
16
+ end
17
+
18
+ # Homepage
19
+ get "dashboard", to: "home#index", as: :dashboard
20
+
21
+ root to: "home#index"
22
+ end
@@ -0,0 +1,79 @@
1
+ module Dbviewer
2
+ # Configuration class for DBViewer engine settings
3
+ class Configuration
4
+ # Default pagination options
5
+ attr_accessor :per_page_options
6
+
7
+ # Default number of records per page
8
+ attr_accessor :default_per_page
9
+
10
+ # Maximum number of records to return in any query
11
+ attr_accessor :max_records
12
+
13
+ # Maximum SQL query length allowed
14
+ attr_accessor :max_query_length
15
+
16
+ # Cache expiration time in seconds
17
+ attr_accessor :cache_expiry
18
+
19
+ # Allow downloading of data in various formats
20
+ attr_accessor :enable_data_export
21
+
22
+ # Timeout for SQL queries in seconds
23
+ attr_accessor :query_timeout
24
+
25
+ # Query logging storage mode (:memory or :file)
26
+ attr_accessor :query_logging_mode
27
+
28
+ # Path for query log file when in :file mode
29
+ attr_accessor :query_log_path
30
+
31
+ # Maximum number of queries to keep in memory
32
+ attr_accessor :max_memory_queries
33
+
34
+ # Admin access credentials (username, password)
35
+ attr_accessor :admin_credentials
36
+
37
+ def initialize
38
+ @per_page_options = [ 10, 20, 50, 100 ]
39
+ @default_per_page = 20
40
+ @max_records = 10000
41
+ @max_query_length = 10000
42
+ @cache_expiry = 300
43
+ @enable_data_export = false
44
+ @query_timeout = 30
45
+ @query_logging_mode = :memory
46
+ @query_log_path = "log/dbviewer.log"
47
+ @max_memory_queries = 1000
48
+ @admin_credentials = nil
49
+ end
50
+ end
51
+
52
+ # Class accessor for configuration
53
+ class << self
54
+ attr_accessor :configuration
55
+ end
56
+
57
+ # Configure the engine with a block
58
+ #
59
+ # @example
60
+ # Dbviewer.configure do |config|
61
+ # config.per_page_options = [10, 25, 50]
62
+ # config.default_per_page = 25
63
+ # end
64
+ def self.configure
65
+ self.configuration ||= Configuration.new
66
+ yield(configuration) if block_given?
67
+ end
68
+
69
+ # Reset configuration to defaults
70
+ def self.reset_configuration
71
+ self.configuration = Configuration.new
72
+ end
73
+
74
+ # Get the current configuration
75
+ # Creates a default configuration if none exists
76
+ def self.configuration
77
+ @configuration ||= Configuration.new
78
+ end
79
+ end
@@ -0,0 +1,450 @@
1
+ module Dbviewer
2
+ # DatabaseManager handles all database interactions for the DBViewer engine
3
+ # It provides methods to access database structure and data
4
+ class DatabaseManager
5
+ attr_reader :connection, :adapter_name
6
+
7
+ # Default number of records per page if not specified
8
+ DEFAULT_PER_PAGE = 20
9
+
10
+ # Max number of records to return in any query for safety
11
+ MAX_RECORDS = 10000
12
+
13
+ # Cache for dynamically created AR models
14
+ @@dynamic_models = {}
15
+
16
+ # Cache for table column info
17
+ @@table_columns_cache = {}
18
+
19
+ # Cache for table metadata
20
+ @@table_metadata_cache = {}
21
+
22
+ # Cache expiration time in seconds (5 minutes)
23
+ CACHE_EXPIRY = 300
24
+
25
+ # Last cache reset time
26
+ @@cache_last_reset = Time.now
27
+
28
+ def initialize
29
+ ensure_connection
30
+ reset_cache_if_needed
31
+ end
32
+
33
+ # Returns a sorted list of all tables in the database
34
+ # @return [Array<String>] List of table names
35
+ def tables
36
+ return [] unless connection.respond_to?(:tables)
37
+
38
+ begin
39
+ # Get and sort tables, cache is handled at the database adapter level
40
+ connection.tables.sort
41
+ rescue => e
42
+ Rails.logger.error("[DBViewer] Error retrieving tables: #{e.message}")
43
+ []
44
+ end
45
+ end
46
+
47
+ # Returns column information for a specific table
48
+ # @param table_name [String] Name of the table
49
+ # @return [Array<Hash>] List of column details with name, type, null, default
50
+ def table_columns(table_name)
51
+ return [] if table_name.blank?
52
+
53
+ # Return from cache if available
54
+ return @@table_columns_cache[table_name] if @@table_columns_cache[table_name]
55
+
56
+ begin
57
+ columns = connection.columns(table_name).map do |column|
58
+ {
59
+ name: column.name,
60
+ type: column.type,
61
+ null: column.null,
62
+ default: column.default,
63
+ primary: column.name == primary_key(table_name)
64
+ }
65
+ end
66
+
67
+ # Cache the result
68
+ @@table_columns_cache[table_name] = columns
69
+ columns
70
+ rescue => e
71
+ Rails.logger.error("[DBViewer] Error retrieving columns for table #{table_name}: #{e.message}")
72
+ []
73
+ end
74
+ end
75
+
76
+ # Get detailed metadata about a table (primary keys, indexes, foreign keys)
77
+ # @param table_name [String] Name of the table
78
+ # @return [Hash] Table metadata
79
+ def table_metadata(table_name)
80
+ return {} if table_name.blank?
81
+
82
+ # Return from cache if available
83
+ return @@table_metadata_cache[table_name] if @@table_metadata_cache[table_name]
84
+
85
+ begin
86
+ metadata = {
87
+ primary_key: primary_key(table_name),
88
+ indexes: fetch_indexes(table_name),
89
+ foreign_keys: fetch_foreign_keys(table_name)
90
+ }
91
+
92
+ # Cache the result
93
+ @@table_metadata_cache[table_name] = metadata
94
+ metadata
95
+ rescue => e
96
+ Rails.logger.error("[DBViewer] Error retrieving metadata for table #{table_name}: #{e.message}")
97
+ { primary_key: nil, indexes: [], foreign_keys: [] }
98
+ end
99
+ end
100
+
101
+ # Get the total count of records in a table
102
+ # @param table_name [String] Name of the table
103
+ # @return [Integer] Number of records
104
+ def table_count(table_name)
105
+ begin
106
+ model = get_model_for(table_name)
107
+ model.count
108
+ rescue => e
109
+ Rails.logger.error("[DBViewer] Error counting records in table #{table_name}: #{e.message}")
110
+ 0
111
+ end
112
+ end
113
+
114
+ # Get records from a table with pagination and sorting
115
+ # @param table_name [String] Name of the table
116
+ # @param page [Integer] Page number (1-based)
117
+ # @param order_by [String] Column to sort by
118
+ # @param direction [String] Sort direction ('ASC' or 'DESC')
119
+ # @param per_page [Integer] Number of records per page
120
+ # @return [ActiveRecord::Result] Result set with columns and rows
121
+ def table_records(table_name, page = 1, order_by = nil, direction = "ASC", per_page = nil)
122
+ page = [ 1, page.to_i ].max
123
+
124
+ # Use class method if defined, otherwise fall back to constant
125
+ default_per_page = self.class.respond_to?(:default_per_page) ? self.class.default_per_page : DEFAULT_PER_PAGE
126
+ max_records = self.class.respond_to?(:max_records) ? self.class.max_records : MAX_RECORDS
127
+
128
+ per_page = (per_page || default_per_page).to_i
129
+ per_page = default_per_page if per_page <= 0
130
+
131
+ # Ensure we don't fetch too many records for performance/memory reasons
132
+ per_page = [ per_page, max_records ].min
133
+
134
+ model = get_model_for(table_name)
135
+ query = model.all
136
+
137
+ # Apply sorting if provided
138
+ if order_by.present? && column_exists?(table_name, order_by)
139
+ direction = %w[ASC DESC].include?(direction.to_s.upcase) ? direction.to_s.upcase : "ASC"
140
+ query = query.order("#{connection.quote_column_name(order_by)} #{direction}")
141
+ end
142
+
143
+ # Apply pagination
144
+ records = query.limit(per_page).offset((page - 1) * per_page)
145
+
146
+ # Transform the ActiveRecord::Relation to the format expected by the application
147
+ to_result_set(records, table_name)
148
+ end
149
+
150
+ # Get the number of records in a table (alias for table_count)
151
+ # @param table_name [String] Name of the table
152
+ # @return [Integer] Number of records
153
+ def record_count(table_name)
154
+ table_count(table_name)
155
+ end
156
+
157
+ # Get the number of columns in a table
158
+ # @param table_name [String] Name of the table
159
+ # @return [Integer] Number of columns
160
+ def column_count(table_name)
161
+ begin
162
+ table_columns(table_name).size
163
+ rescue => e
164
+ Rails.logger.error("[DBViewer] Error counting columns in table #{table_name}: #{e.message}")
165
+ 0
166
+ end
167
+ end
168
+
169
+ # Get the primary key of a table
170
+ # @param table_name [String] Name of the table
171
+ # @return [String, nil] Primary key column name or nil if not found
172
+ def primary_key(table_name)
173
+ begin
174
+ connection.primary_key(table_name)
175
+ rescue => e
176
+ Rails.logger.error("[DBViewer] Error retrieving primary key for table #{table_name}: #{e.message}")
177
+ nil
178
+ end
179
+ end
180
+
181
+ # Check if a column exists in a table
182
+ # @param table_name [String] Name of the table
183
+ # @param column_name [String] Name of the column
184
+ # @return [Boolean] true if column exists, false otherwise
185
+ def column_exists?(table_name, column_name)
186
+ return false if table_name.blank? || column_name.blank?
187
+
188
+ begin
189
+ columns = table_columns(table_name)
190
+ columns.any? { |col| col[:name].to_s == column_name.to_s }
191
+ rescue => e
192
+ Rails.logger.error("[DBViewer] Error checking column existence for #{column_name} in #{table_name}: #{e.message}")
193
+ false
194
+ end
195
+ end
196
+
197
+ # Execute a raw SQL query after validating for safety
198
+ # @param sql [String] SQL query to execute
199
+ # @return [ActiveRecord::Result] Result set with columns and rows
200
+ # @raise [StandardError] If the query is invalid or unsafe
201
+ def execute_query(sql)
202
+ # Use the SqlValidator class to validate and normalize the SQL query
203
+ begin
204
+ # Validate and normalize the SQL
205
+ normalized_sql = ::Dbviewer::SqlValidator.validate!(sql.to_s)
206
+
207
+ # Get max records from configuration if available
208
+ max_records = self.class.respond_to?(:max_records) ? self.class.max_records : MAX_RECORDS
209
+
210
+ # Add a safety limit if not already present
211
+ unless normalized_sql =~ /\bLIMIT\s+\d+\s*$/i
212
+ normalized_sql = "#{normalized_sql} LIMIT #{max_records}"
213
+ end
214
+
215
+ # Log and execute the query
216
+ Rails.logger.debug("[DBViewer] Executing SQL query: #{normalized_sql}")
217
+ start_time = Time.now
218
+ result = connection.exec_query(normalized_sql)
219
+ duration = Time.now - start_time
220
+
221
+ Rails.logger.debug("[DBViewer] Query completed in #{duration.round(2)}s, returned #{result.rows.size} rows")
222
+ result
223
+ rescue => e
224
+ Rails.logger.error("[DBViewer] SQL query error: #{e.message} for query: #{sql}")
225
+ raise e
226
+ end
227
+ end
228
+
229
+ # Execute a SQLite PRAGMA command without adding a LIMIT clause
230
+ # @param pragma [String] PRAGMA command to execute (without the "PRAGMA" keyword)
231
+ # @return [ActiveRecord::Result] Result set with the PRAGMA value
232
+ # @raise [StandardError] If the query is invalid or cannot be executed
233
+ def execute_sqlite_pragma(pragma)
234
+ begin
235
+ sql = "PRAGMA #{pragma}"
236
+ Rails.logger.debug("[DBViewer] Executing SQLite pragma: #{sql}")
237
+ result = connection.exec_query(sql)
238
+ Rails.logger.debug("[DBViewer] Pragma completed, returned #{result.rows.size} rows")
239
+ result
240
+ rescue => error
241
+ Rails.logger.error("[DBViewer] SQLite pragma error: #{error.message} for pragma: #{pragma}")
242
+ raise error
243
+ end
244
+ end
245
+
246
+ # Query a table with more granular control using ActiveRecord
247
+ # @param table_name [String] Name of the table
248
+ # @param select [String, Array] Columns to select
249
+ # @param order [String, Hash] Order by clause
250
+ # @param limit [Integer] Maximum number of records to return
251
+ # @param offset [Integer] Offset from which to start returning records
252
+ # @param where [String, Hash] Where conditions
253
+ # @return [ActiveRecord::Result] Result set with columns and rows
254
+ def query_table(table_name, select: nil, order: nil, limit: nil, offset: nil, where: nil)
255
+ begin
256
+ model = get_model_for(table_name)
257
+ query = model.all
258
+
259
+ query = query.select(select) if select.present?
260
+ query = query.where(where) if where.present?
261
+ query = query.order(order) if order.present?
262
+
263
+ # Get max records from configuration if available
264
+ max_records = self.class.respond_to?(:max_records) ? self.class.max_records : MAX_RECORDS
265
+ query = query.limit([ limit || max_records, max_records ].min) # Apply safety limit
266
+ query = query.offset(offset) if offset.present?
267
+
268
+ to_result_set(query, table_name)
269
+ rescue => e
270
+ Rails.logger.error("[DBViewer] Error querying table #{table_name}: #{e.message}")
271
+ raise e
272
+ end
273
+ end
274
+
275
+ # Get table indexes
276
+ # @param table_name [String] Name of the table
277
+ # @return [Array<Hash>] List of indexes with details
278
+ def fetch_indexes(table_name)
279
+ return [] if table_name.blank?
280
+
281
+ begin
282
+ # Only some adapters support index retrieval
283
+ if connection.respond_to?(:indexes)
284
+ connection.indexes(table_name).map do |index|
285
+ {
286
+ name: index.name,
287
+ columns: index.columns,
288
+ unique: index.unique
289
+ }
290
+ end
291
+ else
292
+ []
293
+ end
294
+ rescue => e
295
+ Rails.logger.error("[DBViewer] Error retrieving indexes for table #{table_name}: #{e.message}")
296
+ []
297
+ end
298
+ end
299
+
300
+ # Get foreign keys
301
+ # @param table_name [String] Name of the table
302
+ # @return [Array<Hash>] List of foreign keys with details
303
+ def fetch_foreign_keys(table_name)
304
+ return [] if table_name.blank?
305
+
306
+ begin
307
+ # Only some adapters support foreign key retrieval
308
+ if connection.respond_to?(:foreign_keys)
309
+ connection.foreign_keys(table_name).map do |fk|
310
+ {
311
+ name: fk.name,
312
+ from_table: fk.from_table,
313
+ to_table: fk.to_table,
314
+ column: fk.column,
315
+ primary_key: fk.primary_key
316
+ }
317
+ end
318
+ else
319
+ []
320
+ end
321
+ rescue => e
322
+ Rails.logger.error("[DBViewer] Error retrieving foreign keys for table #{table_name}: #{e.message}")
323
+ []
324
+ end
325
+ end
326
+
327
+ private
328
+
329
+ # Ensure we have a valid database connection
330
+ # @return [ActiveRecord::ConnectionAdapters::AbstractAdapter] The database connection
331
+ def ensure_connection
332
+ return @connection if @connection
333
+
334
+ begin
335
+ @connection = ActiveRecord::Base.connection
336
+ @adapter_name = @connection.adapter_name.downcase
337
+ @connection
338
+ rescue => e
339
+ Rails.logger.error("[DBViewer] Database connection error: #{e.message}")
340
+ raise e
341
+ end
342
+ end
343
+
344
+ # Reset caches if they've been around too long
345
+ def reset_cache_if_needed
346
+ # Get cache expiry from configuration if available
347
+ cache_expiry = self.class.respond_to?(:cache_expiry) ? self.class.cache_expiry : CACHE_EXPIRY
348
+
349
+ if Time.now - @@cache_last_reset > cache_expiry
350
+ @@table_columns_cache = {}
351
+ @@table_metadata_cache = {}
352
+ @@cache_last_reset = Time.now
353
+ Rails.logger.debug("[DBViewer] Cache reset due to expiry after #{cache_expiry} seconds")
354
+ end
355
+ end
356
+
357
+ # Clear all caches - useful when schema changes are detected
358
+ def clear_all_caches
359
+ @@dynamic_models = {}
360
+ @@table_columns_cache = {}
361
+ @@table_metadata_cache = {}
362
+ @@cache_last_reset = Time.now
363
+ Rails.logger.debug("[DBViewer] All caches cleared")
364
+ end
365
+
366
+ # Dynamically create an ActiveRecord model for a table
367
+ # @param table_name [String] Name of the table
368
+ # @return [Class] ActiveRecord model class for the table
369
+ def get_model_for(table_name)
370
+ return nil if table_name.blank?
371
+
372
+ begin
373
+ model_name = table_name.classify
374
+
375
+ # Return cached model if available
376
+ return @@dynamic_models[table_name] if @@dynamic_models[table_name]
377
+
378
+ # Create a new model class dynamically
379
+ model = Class.new(ActiveRecord::Base) do
380
+ self.table_name = table_name
381
+
382
+ # Some tables might not have primary keys, so we handle that
383
+ begin
384
+ primary_key = connection.primary_key(table_name)
385
+ self.primary_key = primary_key if primary_key.present?
386
+ rescue => e
387
+ # If we can't determine the primary key, default to id or set to nil
388
+ self.primary_key = "id"
389
+ end
390
+
391
+ # Disable STI
392
+ self.inheritance_column = :_type_disabled
393
+
394
+ # Disable timestamps for better compatibility
395
+ self.record_timestamps = false
396
+ end
397
+
398
+ # Set model name constant if not already taken
399
+ # Use a namespace to avoid polluting the global namespace
400
+ unless Dbviewer.const_defined?("DynamicModel_#{model_name}")
401
+ Dbviewer.const_set("DynamicModel_#{model_name}", model)
402
+ end
403
+
404
+ # Cache the model
405
+ @@dynamic_models[table_name] = model
406
+ model
407
+ rescue => e
408
+ Rails.logger.error("[DBViewer] Error creating model for table #{table_name}: #{e.message}")
409
+ raise e
410
+ end
411
+ end
412
+
413
+ # Convert ActiveRecord::Relation to the expected result format
414
+ # @param records [ActiveRecord::Relation] Records to convert
415
+ # @param table_name [String] Name of the table
416
+ # @return [ActiveRecord::Result] Result set with columns and rows
417
+ def to_result_set(records, table_name)
418
+ begin
419
+ column_names = table_columns(table_name).map { |c| c[:name] }
420
+
421
+ rows = records.map do |record|
422
+ column_names.map do |col|
423
+ # Handle serialized attributes
424
+ value = record[col]
425
+ serialize_if_needed(value)
426
+ end
427
+ end
428
+
429
+ ActiveRecord::Result.new(column_names, rows)
430
+ rescue => e
431
+ Rails.logger.error("[DBViewer] Error converting to result set for table #{table_name}: #{e.message}")
432
+ ActiveRecord::Result.new([], [])
433
+ end
434
+ end
435
+
436
+ # Serialize complex objects for display
437
+ # @param value [Object] Value to serialize
438
+ # @return [String, Object] Serialized value or original value
439
+ def serialize_if_needed(value)
440
+ case value
441
+ when Hash, Array
442
+ value.to_json rescue value.to_s
443
+ when Time, Date, DateTime
444
+ value.to_s
445
+ else
446
+ value
447
+ end
448
+ end
449
+ end
450
+ end
@@ -0,0 +1,20 @@
1
+ module Dbviewer
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Dbviewer
4
+
5
+ # Ensure lib directory is in the autoload path
6
+ config.autoload_paths << File.expand_path("../../", __FILE__)
7
+ config.eager_load_paths << File.expand_path("../../", __FILE__)
8
+
9
+ # Initialize the engine safely
10
+ initializer "dbviewer.setup", after: :load_config_initializers do |app|
11
+ Dbviewer.init
12
+ Dbviewer.setup
13
+ end
14
+
15
+ # Handle database connections at the appropriate time
16
+ config.to_prepare do
17
+ ActiveRecord::Base.connection if Rails.application.initialized?
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ module Dbviewer
2
+ class Initializer
3
+ class << self
4
+ def setup
5
+ if defined?(ActiveRecord::Base)
6
+ Rails.logger.warn "ActiveRecord is not available, skipping database connection check"
7
+ return
8
+ end
9
+
10
+ ensure_activerecord_connection
11
+ end
12
+
13
+ private
14
+
15
+ def ensure_activerecord_connection
16
+ ActiveRecord::Base.connection
17
+ Rails.logger.info "DBViewer successfully connected to database"
18
+ rescue => e
19
+ Rails.logger.error "DBViewer could not connect to database: #{e.message}"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,102 @@
1
+ module Dbviewer
2
+ # Logger captures and analyzes SQL queries for debugging and performance monitoring
3
+ class Logger
4
+ include Singleton
5
+
6
+ def initialize
7
+ @request_counter = 0
8
+ @current_request_id = nil
9
+ @last_query_time = nil
10
+ @mutex = Mutex.new
11
+
12
+ setup_storage
13
+ subscribe_to_sql_notifications
14
+ end
15
+
16
+ # Clear all stored queries
17
+ def clear
18
+ @storage.clear
19
+ end
20
+
21
+ # Get recent queries, optionally filtered
22
+ def recent_queries(limit: 100, table_filter: nil, request_id: nil, min_duration: nil)
23
+ @storage.filter(
24
+ limit: limit,
25
+ table_filter: table_filter,
26
+ request_id: request_id,
27
+ min_duration: min_duration
28
+ )
29
+ end
30
+
31
+ # Get stats about all queries
32
+ def stats
33
+ stats_for_queries(@storage.all)
34
+ end
35
+
36
+ # Calculate stats for a specific set of queries (can be filtered)
37
+ def stats_for_queries(queries)
38
+ QueryAnalyzer.generate_stats(queries)
39
+ end
40
+
41
+ private
42
+
43
+ def mode
44
+ @mode ||= Dbviewer.configuration.query_logging_mode || :memory
45
+ end
46
+
47
+ # TODO: pass storage class as a parameter to the constructor
48
+ def setup_storage
49
+ @storage ||= case mode
50
+ when :file
51
+ Storage::FileStorage.new
52
+ else
53
+ Storage::InMemoryStorage.new
54
+ end
55
+
56
+ Rails.logger.info("[DBViewer] Query Logger initialized with #{mode} storage mode")
57
+ end
58
+
59
+ def subscribe_to_sql_notifications
60
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
61
+ event = ActiveSupport::Notifications::Event.new(*args)
62
+
63
+ # Skip internal queries and schema queries
64
+ next if QueryParser.should_skip_query?(event)
65
+
66
+ # Record the query details
67
+ process_sql_event(event)
68
+ end
69
+ end
70
+
71
+ def process_sql_event(event)
72
+ @mutex.synchronize do
73
+ current_time = Time.now
74
+ update_request_id(current_time)
75
+ @last_query_time = current_time
76
+
77
+ # Create and store the query
78
+ @storage.add({
79
+ sql: event.payload[:sql],
80
+ name: event.payload[:name],
81
+ timestamp: current_time,
82
+ duration_ms: event.duration.round(2),
83
+ binds: QueryParser.format_binds(event.payload[:binds]),
84
+ request_id: @current_request_id,
85
+ thread_id: Thread.current.object_id.to_s,
86
+ caller: event.payload[:caller]
87
+ })
88
+ end
89
+ end
90
+
91
+ # Generate a request ID if:
92
+ # 1. This is the first query, or
93
+ # 2. More than 1 second has passed since the last query
94
+ #
95
+ # TODO: explore rails request ID and implement it here, otherwise fallback to current approach
96
+ def update_request_id(current_time)
97
+ if @current_request_id.nil? || @last_query_time.nil? || (current_time - @last_query_time) > 1.0
98
+ @current_request_id = "req-#{Time.now.to_i}-#{@request_counter += 1}"
99
+ end
100
+ end
101
+ end
102
+ end