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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +250 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/dbviewer/application.css +21 -0
- data/app/assets/stylesheets/dbviewer/dbviewer.css +0 -0
- data/app/assets/stylesheets/dbviewer/enhanced.css +0 -0
- data/app/controllers/concerns/dbviewer/database_operations.rb +354 -0
- data/app/controllers/concerns/dbviewer/error_handling.rb +42 -0
- data/app/controllers/concerns/dbviewer/pagination_concern.rb +43 -0
- data/app/controllers/dbviewer/application_controller.rb +21 -0
- data/app/controllers/dbviewer/databases_controller.rb +0 -0
- data/app/controllers/dbviewer/entity_relationship_diagrams_controller.rb +24 -0
- data/app/controllers/dbviewer/home_controller.rb +10 -0
- data/app/controllers/dbviewer/logs_controller.rb +39 -0
- data/app/controllers/dbviewer/tables_controller.rb +73 -0
- data/app/helpers/dbviewer/application_helper.rb +118 -0
- data/app/jobs/dbviewer/application_job.rb +4 -0
- data/app/mailers/dbviewer/application_mailer.rb +6 -0
- data/app/models/dbviewer/application_record.rb +5 -0
- data/app/services/dbviewer/file_storage.rb +0 -0
- data/app/services/dbviewer/in_memory_storage.rb +0 -0
- data/app/services/dbviewer/query_analyzer.rb +0 -0
- data/app/services/dbviewer/query_collection.rb +0 -0
- data/app/services/dbviewer/query_logger.rb +0 -0
- data/app/services/dbviewer/query_parser.rb +82 -0
- data/app/services/dbviewer/query_storage.rb +0 -0
- data/app/views/dbviewer/entity_relationship_diagrams/index.html.erb +564 -0
- data/app/views/dbviewer/home/index.html.erb +237 -0
- data/app/views/dbviewer/logs/index.html.erb +614 -0
- data/app/views/dbviewer/shared/_sidebar.html.erb +177 -0
- data/app/views/dbviewer/tables/_table_structure.html.erb +102 -0
- data/app/views/dbviewer/tables/index.html.erb +128 -0
- data/app/views/dbviewer/tables/query.html.erb +600 -0
- data/app/views/dbviewer/tables/show.html.erb +271 -0
- data/app/views/layouts/dbviewer/application.html.erb +728 -0
- data/config/routes.rb +22 -0
- data/lib/dbviewer/configuration.rb +79 -0
- data/lib/dbviewer/database_manager.rb +450 -0
- data/lib/dbviewer/engine.rb +20 -0
- data/lib/dbviewer/initializer.rb +23 -0
- data/lib/dbviewer/logger.rb +102 -0
- data/lib/dbviewer/query_analyzer.rb +109 -0
- data/lib/dbviewer/query_collection.rb +41 -0
- data/lib/dbviewer/query_parser.rb +82 -0
- data/lib/dbviewer/sql_validator.rb +194 -0
- data/lib/dbviewer/storage/base.rb +31 -0
- data/lib/dbviewer/storage/file_storage.rb +96 -0
- data/lib/dbviewer/storage/in_memory_storage.rb +59 -0
- data/lib/dbviewer/version.rb +3 -0
- data/lib/dbviewer.rb +65 -0
- data/lib/tasks/dbviewer_tasks.rake +4 -0
- 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
|