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
@@ -0,0 +1,109 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# QueryAnalyzer handles analysis of query patterns and statistics
|
3
|
+
class QueryAnalyzer
|
4
|
+
# Calculate statistics for a collection of queries
|
5
|
+
def self.generate_stats(queries)
|
6
|
+
{
|
7
|
+
total_count: queries.size,
|
8
|
+
total_duration_ms: queries.sum { |q| q[:duration_ms] },
|
9
|
+
avg_duration_ms: calculate_average_duration(queries),
|
10
|
+
max_duration_ms: queries.map { |q| q[:duration_ms] }.max || 0,
|
11
|
+
tables_queried: extract_queried_tables(queries),
|
12
|
+
potential_n_plus_1: detect_potential_n_plus_1(queries),
|
13
|
+
slowest_queries: get_slowest_queries(queries)
|
14
|
+
}.merge(calculate_request_stats(queries))
|
15
|
+
end
|
16
|
+
|
17
|
+
# Detect potential N+1 query patterns
|
18
|
+
def self.detect_potential_n_plus_1(queries)
|
19
|
+
potential_issues = []
|
20
|
+
|
21
|
+
# Group queries by request_id
|
22
|
+
queries.group_by { |q| q[:request_id] }.each do |request_id, request_queries|
|
23
|
+
# Skip if there are too few queries to indicate a problem
|
24
|
+
next if request_queries.size < 5
|
25
|
+
|
26
|
+
# Look for repeated patterns within this request
|
27
|
+
analyze_request_patterns(request_id, request_queries, potential_issues)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Sort by number of repetitions (most problematic first)
|
31
|
+
potential_issues.sort_by { |issue| -issue[:count] }
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get the slowest queries from the dataset
|
35
|
+
def self.get_slowest_queries(queries, limit: 5)
|
36
|
+
# Return top N slowest queries with relevant info
|
37
|
+
queries.sort_by { |q| -q[:duration_ms] }
|
38
|
+
.first(limit)
|
39
|
+
.map do |q|
|
40
|
+
{
|
41
|
+
sql: q[:sql],
|
42
|
+
duration_ms: q[:duration_ms],
|
43
|
+
timestamp: q[:timestamp],
|
44
|
+
request_id: q[:request_id],
|
45
|
+
name: q[:name]
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def self.calculate_average_duration(queries)
|
53
|
+
queries.any? ? (queries.sum { |q| q[:duration_ms] } / queries.size.to_f).round(2) : 0
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.calculate_request_stats(queries)
|
57
|
+
# Calculate request groups statistics
|
58
|
+
requests = queries.group_by { |q| q[:request_id] }
|
59
|
+
{
|
60
|
+
request_count: requests.size,
|
61
|
+
avg_queries_per_request: queries.any? ? (queries.size.to_f / requests.size).round(2) : 0,
|
62
|
+
max_queries_per_request: requests.map { |_id, reqs| reqs.size }.max || 0
|
63
|
+
}
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.extract_queried_tables(queries)
|
67
|
+
tables = Hash.new(0)
|
68
|
+
|
69
|
+
queries.each do |query|
|
70
|
+
extracted = QueryParser.extract_tables(query[:sql])
|
71
|
+
extracted.each { |table| tables[table] += 1 }
|
72
|
+
end
|
73
|
+
|
74
|
+
tables.sort_by { |_table, count| -count }.first(10).to_h
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.analyze_request_patterns(request_id, request_queries, potential_issues)
|
78
|
+
# Group similar queries within this request
|
79
|
+
patterns = {}
|
80
|
+
|
81
|
+
request_queries.each do |query|
|
82
|
+
# Normalize the query to detect patterns
|
83
|
+
normalized = QueryParser.normalize(query[:sql])
|
84
|
+
patterns[normalized] ||= []
|
85
|
+
patterns[normalized] << query
|
86
|
+
end
|
87
|
+
|
88
|
+
# Look for patterns with many repetitions
|
89
|
+
patterns.each do |pattern, pattern_queries|
|
90
|
+
next if pattern_queries.size < 5 # Only interested in repeated patterns
|
91
|
+
|
92
|
+
# Check if these queries target the same table
|
93
|
+
tables = QueryParser.extract_tables(pattern_queries.first[:sql])
|
94
|
+
target_table = tables.size == 1 ? tables.first : nil
|
95
|
+
|
96
|
+
# Add to potential issues
|
97
|
+
total_time = pattern_queries.sum { |q| q[:duration_ms] }
|
98
|
+
potential_issues << {
|
99
|
+
request_id: request_id,
|
100
|
+
pattern: pattern_queries.first[:sql],
|
101
|
+
count: pattern_queries.size,
|
102
|
+
table: target_table,
|
103
|
+
total_duration_ms: total_time.round(2),
|
104
|
+
avg_duration_ms: (total_time / pattern_queries.size).round(2)
|
105
|
+
}
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# QueryCollection handles the storage and retrieval of SQL queries
|
3
|
+
# This class is maintained for backward compatibility
|
4
|
+
# New code should use InMemoryStorage or FileStorage directly
|
5
|
+
class QueryCollection < InMemoryStorage
|
6
|
+
# Maximum number of queries to keep in memory
|
7
|
+
MAX_QUERIES = 1000
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
super
|
11
|
+
end
|
12
|
+
|
13
|
+
# Get recent queries, optionally filtered
|
14
|
+
def filter(limit: 100, table_filter: nil, request_id: nil, min_duration: nil)
|
15
|
+
result = all
|
16
|
+
|
17
|
+
# Apply filters if provided
|
18
|
+
result = filter_by_table(result, table_filter) if table_filter.present?
|
19
|
+
result = filter_by_request_id(result, request_id) if request_id.present?
|
20
|
+
result = filter_by_duration(result, min_duration) if min_duration.present?
|
21
|
+
|
22
|
+
# Return most recent queries first, limited to requested amount
|
23
|
+
result.reverse.first(limit)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def filter_by_table(queries, table_filter)
|
29
|
+
queries.select { |q| q[:sql].downcase.include?(table_filter.downcase) }
|
30
|
+
end
|
31
|
+
|
32
|
+
def filter_by_request_id(queries, request_id)
|
33
|
+
queries.select { |q| q[:request_id].to_s.include?(request_id) }
|
34
|
+
end
|
35
|
+
|
36
|
+
def filter_by_duration(queries, min_duration)
|
37
|
+
min_ms = min_duration.to_f
|
38
|
+
queries.select { |q| q[:duration_ms] >= min_ms }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# QueryParser handles parsing SQL queries and extracting useful information
|
3
|
+
class QueryParser
|
4
|
+
# Extract table names from an SQL query string
|
5
|
+
def self.extract_tables(sql)
|
6
|
+
return [] if sql.nil?
|
7
|
+
|
8
|
+
# Convert to lowercase for case-insensitive matching
|
9
|
+
sql = sql.downcase
|
10
|
+
|
11
|
+
# Extract table names after FROM or JOIN
|
12
|
+
sql.scan(/(?:from|join)\s+[`"']?(\w+)[`"']?/).flatten.uniq
|
13
|
+
end
|
14
|
+
|
15
|
+
# Normalize a SQL query to find similar patterns
|
16
|
+
# Replaces specific values with placeholders
|
17
|
+
def self.normalize(sql)
|
18
|
+
return "" if sql.nil?
|
19
|
+
|
20
|
+
sql.gsub(/\b\d+\b/, "N")
|
21
|
+
.gsub(/'[^']*'/, "'X'")
|
22
|
+
.gsub(/"[^"]*"/, '"X"')
|
23
|
+
end
|
24
|
+
|
25
|
+
# Check if the query is from the DBViewer library
|
26
|
+
def self.from_dbviewer?(event)
|
27
|
+
# Check if the SQL itself references DBViewer tables
|
28
|
+
if event.payload[:sql].match(/\b(from|join|update|into)\s+["`']?dbviewer_/i)
|
29
|
+
return true
|
30
|
+
end
|
31
|
+
|
32
|
+
# Check the caller information if available
|
33
|
+
caller = event.payload[:caller]
|
34
|
+
if caller.is_a?(String) && caller.include?("/dbviewer/")
|
35
|
+
return true
|
36
|
+
end
|
37
|
+
|
38
|
+
# Check if query name indicates it's from DBViewer
|
39
|
+
if event.payload[:name].is_a?(String) &&
|
40
|
+
(event.payload[:name].include?("Dbviewer") || event.payload[:name].include?("DBViewer") || event.payload[:name] == "SQL")
|
41
|
+
return true
|
42
|
+
end
|
43
|
+
|
44
|
+
# Check for common DBViewer operations
|
45
|
+
sql = event.payload[:sql].downcase
|
46
|
+
if sql.include?("table_structure") ||
|
47
|
+
sql.include?("schema_migrations") ||
|
48
|
+
sql.include?("database_analytics")
|
49
|
+
return true
|
50
|
+
end
|
51
|
+
|
52
|
+
false
|
53
|
+
end
|
54
|
+
|
55
|
+
# Format bind parameters for storage
|
56
|
+
def self.format_binds(binds)
|
57
|
+
return [] unless binds.respond_to?(:map)
|
58
|
+
|
59
|
+
binds.map do |bind|
|
60
|
+
if bind.respond_to?(:value)
|
61
|
+
bind.value
|
62
|
+
elsif bind.is_a?(Array) && bind.size == 2
|
63
|
+
bind.last
|
64
|
+
else
|
65
|
+
bind.to_s
|
66
|
+
end
|
67
|
+
end
|
68
|
+
rescue
|
69
|
+
[]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Determine if a query should be skipped based on content
|
73
|
+
def self.should_skip_query?(event)
|
74
|
+
event.payload[:name] == "SCHEMA" ||
|
75
|
+
event.payload[:sql].include?("SHOW TABLES") ||
|
76
|
+
event.payload[:sql].include?("sqlite_master") ||
|
77
|
+
event.payload[:sql].include?("information_schema") ||
|
78
|
+
event.payload[:sql].include?("pg_catalog") ||
|
79
|
+
from_dbviewer?(event)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,194 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# SqlValidator class handles SQL query validation and normalization
|
3
|
+
# to ensure queries are safe (read-only) and properly formatted.
|
4
|
+
# This helps prevent potentially destructive SQL operations.
|
5
|
+
class SqlValidator
|
6
|
+
# List of SQL keywords that could modify data or schema
|
7
|
+
FORBIDDEN_KEYWORDS = %w[
|
8
|
+
UPDATE INSERT DELETE DROP ALTER CREATE TRUNCATE REPLACE
|
9
|
+
RENAME GRANT REVOKE LOCK UNLOCK COMMIT ROLLBACK
|
10
|
+
SAVEPOINT INTO CALL EXECUTE EXEC
|
11
|
+
]
|
12
|
+
|
13
|
+
# List of SQL keywords that should only be allowed in specific contexts
|
14
|
+
CONDITIONAL_KEYWORDS = {
|
15
|
+
# JOIN is allowed, but we should check for suspicious patterns
|
16
|
+
"JOIN" => /\bJOIN\b/i,
|
17
|
+
# UNION is allowed, but potential for injection
|
18
|
+
"UNION" => /\bUNION\b/i,
|
19
|
+
# WITH is allowed for CTEs, but need to ensure it's not a data modification
|
20
|
+
"WITH" => /\bWITH\b/i
|
21
|
+
}
|
22
|
+
|
23
|
+
# Maximum allowed query length
|
24
|
+
MAX_QUERY_LENGTH = 10000
|
25
|
+
|
26
|
+
# Determines if a query is safe (read-only)
|
27
|
+
# @param sql [String] The SQL query to validate
|
28
|
+
# @return [Boolean] true if the query is safe, false otherwise
|
29
|
+
def self.safe_query?(sql)
|
30
|
+
return false if sql.blank?
|
31
|
+
|
32
|
+
# Get max query length from configuration if available
|
33
|
+
max_length = respond_to?(:max_query_length) ? max_query_length : MAX_QUERY_LENGTH
|
34
|
+
return false if sql.length > max_length
|
35
|
+
|
36
|
+
normalized_sql = normalize(sql)
|
37
|
+
|
38
|
+
# Case-insensitive check for SELECT at the beginning
|
39
|
+
return false unless normalized_sql =~ /\A\s*SELECT\s+/i
|
40
|
+
|
41
|
+
# Check for forbidden keywords that might be used in subqueries or other SQL constructs
|
42
|
+
FORBIDDEN_KEYWORDS.each do |keyword|
|
43
|
+
# Look for the keyword with word boundaries to avoid false positives
|
44
|
+
return false if normalized_sql =~ /\b#{keyword}\b/i
|
45
|
+
end
|
46
|
+
|
47
|
+
# Check for suspicious patterns that might indicate SQL injection attempts
|
48
|
+
return false if has_suspicious_patterns?(normalized_sql)
|
49
|
+
|
50
|
+
# Check for multiple statements (;) which could allow executing multiple commands
|
51
|
+
statements = normalized_sql.split(";").reject(&:blank?)
|
52
|
+
return false if statements.size > 1
|
53
|
+
|
54
|
+
# Additional specific checks for common SQL injection patterns
|
55
|
+
return false if has_injection_patterns?(normalized_sql)
|
56
|
+
|
57
|
+
true
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check for suspicious patterns in SQL that might indicate an attack
|
61
|
+
# @param sql [String] Normalized SQL query
|
62
|
+
# @return [Boolean] true if suspicious patterns found, false otherwise
|
63
|
+
def self.has_suspicious_patterns?(sql)
|
64
|
+
# Check for SQL comment sequences that might be used to hide malicious code
|
65
|
+
return true if sql =~ /\s+--/ || sql =~ /\/\*/
|
66
|
+
|
67
|
+
# Check for string concatenation which might be used for injection
|
68
|
+
return true if sql =~ /\|\|/ || sql =~ /CONCAT\s*\(/i
|
69
|
+
|
70
|
+
# Check for excessive number of quotes which might indicate injection
|
71
|
+
single_quotes = sql.count("'")
|
72
|
+
double_quotes = sql.count('"')
|
73
|
+
return true if single_quotes > 20 || double_quotes > 20
|
74
|
+
|
75
|
+
# Check for hex/binary data which might hide malicious code
|
76
|
+
return true if sql =~ /0x[0-9a-f]{16,}/i
|
77
|
+
|
78
|
+
false
|
79
|
+
end
|
80
|
+
|
81
|
+
# Check for specific SQL injection patterns
|
82
|
+
# @param sql [String] Normalized SQL query
|
83
|
+
# @return [Boolean] true if injection patterns found, false otherwise
|
84
|
+
def self.has_injection_patterns?(sql)
|
85
|
+
# Check for typical SQL injection test patterns
|
86
|
+
return true if sql =~ /'\s*OR\s*'.*'\s*=\s*'/i
|
87
|
+
return true if sql =~ /'\s*OR\s*1\s*=\s*1/i
|
88
|
+
return true if sql =~ /'\s*;\s*--/i
|
89
|
+
|
90
|
+
# Check for attempts to determine database type
|
91
|
+
return true if sql =~ /@@version/i
|
92
|
+
return true if sql =~ /version\(\)/i
|
93
|
+
|
94
|
+
false
|
95
|
+
end
|
96
|
+
|
97
|
+
# Normalize SQL by removing comments and extra whitespace
|
98
|
+
# @param sql [String] The SQL query to normalize
|
99
|
+
# @return [String] The normalized SQL query
|
100
|
+
def self.normalize(sql)
|
101
|
+
return "" if sql.nil?
|
102
|
+
|
103
|
+
begin
|
104
|
+
# Remove SQL comments (both -- and /* */ styles)
|
105
|
+
normalized = sql.gsub(/--.*$/, "") # Remove -- style comments
|
106
|
+
.gsub(/\/\*.*?\*\//m, "") # Remove /* */ style comments
|
107
|
+
.gsub(/\s+/, " ") # Normalize whitespace
|
108
|
+
.strip # Remove leading/trailing whitespace
|
109
|
+
|
110
|
+
# Replace multiple spaces with a single space
|
111
|
+
normalized.gsub(/\s{2,}/, " ")
|
112
|
+
rescue => e
|
113
|
+
Rails.logger.error("[DBViewer] SQL normalization error: #{e.message}")
|
114
|
+
""
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Validates a query and raises an exception if it's unsafe
|
119
|
+
# @param sql [String] The SQL query to validate
|
120
|
+
# @raise [SecurityError] if the query is unsafe
|
121
|
+
# @return [String] The normalized SQL query if it's safe
|
122
|
+
def self.validate!(sql)
|
123
|
+
if sql.blank?
|
124
|
+
raise SecurityError, "Empty query is not allowed"
|
125
|
+
end
|
126
|
+
|
127
|
+
# Get max query length from configuration if available
|
128
|
+
max_length = respond_to?(:max_query_length) ? max_query_length : MAX_QUERY_LENGTH
|
129
|
+
if sql.length > max_length
|
130
|
+
raise SecurityError, "Query exceeds maximum allowed length (#{max_length} chars)"
|
131
|
+
end
|
132
|
+
|
133
|
+
normalized_sql = normalize(sql)
|
134
|
+
|
135
|
+
# Special case for SQLite PRAGMA statements which are safe read-only commands
|
136
|
+
if normalized_sql =~ /\A\s*PRAGMA\s+[a-z0-9_]+\s*\z/i
|
137
|
+
return normalized_sql
|
138
|
+
end
|
139
|
+
|
140
|
+
unless normalized_sql =~ /\A\s*SELECT\s+/i
|
141
|
+
raise SecurityError, "Query must begin with SELECT for security reasons"
|
142
|
+
end
|
143
|
+
|
144
|
+
FORBIDDEN_KEYWORDS.each do |keyword|
|
145
|
+
if normalized_sql =~ /\b#{keyword}\b/i
|
146
|
+
raise SecurityError, "Forbidden keyword '#{keyword}' detected in query"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
if has_suspicious_patterns?(normalized_sql)
|
151
|
+
raise SecurityError, "Query contains suspicious patterns that may indicate SQL injection"
|
152
|
+
end
|
153
|
+
|
154
|
+
# Check for multiple statements
|
155
|
+
statements = normalized_sql.split(";").reject(&:blank?)
|
156
|
+
if statements.size > 1
|
157
|
+
raise SecurityError, "Multiple SQL statements are not allowed"
|
158
|
+
end
|
159
|
+
|
160
|
+
if has_injection_patterns?(normalized_sql)
|
161
|
+
raise SecurityError, "Query contains patterns commonly associated with SQL injection attempts"
|
162
|
+
end
|
163
|
+
|
164
|
+
normalized_sql
|
165
|
+
end
|
166
|
+
|
167
|
+
# Check if a query is using a specific database feature that might need special handling
|
168
|
+
# @param sql [String] The SQL query
|
169
|
+
# @param feature [Symbol] The feature to check for (:join, :subquery, :order_by, etc.)
|
170
|
+
# @return [Boolean] true if the feature is used in the query
|
171
|
+
def self.uses_feature?(sql, feature)
|
172
|
+
normalized = normalize(sql)
|
173
|
+
case feature
|
174
|
+
when :join
|
175
|
+
normalized =~ /\b(INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\b/i
|
176
|
+
when :subquery
|
177
|
+
# Check if there are parentheses that likely contain a subquery
|
178
|
+
normalized.count("(") > normalized.count(")")
|
179
|
+
when :order_by
|
180
|
+
normalized =~ /\bORDER\s+BY\b/i
|
181
|
+
when :group_by
|
182
|
+
normalized =~ /\bGROUP\s+BY\b/i
|
183
|
+
when :having
|
184
|
+
normalized =~ /\bHAVING\b/i
|
185
|
+
when :union
|
186
|
+
normalized =~ /\bUNION\b/i
|
187
|
+
when :window_function
|
188
|
+
normalized =~ /\bOVER\s*\(/i
|
189
|
+
else
|
190
|
+
false
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module Storage
|
3
|
+
# BaseStorage is an abstract base class that defines the interface for query storage backends
|
4
|
+
class Base
|
5
|
+
# Initialize the storage backend
|
6
|
+
def initialize
|
7
|
+
raise NotImplementedError, "#{self.class} is an abstract class and cannot be instantiated directly"
|
8
|
+
end
|
9
|
+
|
10
|
+
# Get all stored queries
|
11
|
+
def all
|
12
|
+
raise NotImplementedError, "#{self.class}#all must be implemented by a subclass"
|
13
|
+
end
|
14
|
+
|
15
|
+
# Add a new query to the storage
|
16
|
+
def add(query)
|
17
|
+
raise NotImplementedError, "#{self.class}#add must be implemented by a subclass"
|
18
|
+
end
|
19
|
+
|
20
|
+
# Clear all stored queries
|
21
|
+
def clear
|
22
|
+
raise NotImplementedError, "#{self.class}#clear must be implemented by a subclass"
|
23
|
+
end
|
24
|
+
|
25
|
+
# Filter the queries based on provided criteria
|
26
|
+
def filter(limit:, table_filter:, request_id:, min_duration:)
|
27
|
+
raise NotImplementedError, "#{self.class}#filter must be implemented by a subclass"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "json"
|
2
|
+
require "time"
|
3
|
+
|
4
|
+
module Dbviewer
|
5
|
+
module Storage
|
6
|
+
# FileStorage implements QueryStorage for storing queries in a log file
|
7
|
+
class FileStorage < Base
|
8
|
+
def initialize
|
9
|
+
@mutex = Mutex.new
|
10
|
+
@log_path = Dbviewer.configuration.query_log_path || "log/dbviewer.log"
|
11
|
+
|
12
|
+
# Ensure directory exists
|
13
|
+
FileUtils.mkdir_p(File.dirname(@log_path))
|
14
|
+
|
15
|
+
# Touch the file if it doesn't exist
|
16
|
+
FileUtils.touch(@log_path) unless File.exist?(@log_path)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Get all stored queries
|
20
|
+
# Note: This reads the entire log file - could be inefficient for large files
|
21
|
+
def all
|
22
|
+
@mutex.synchronize do
|
23
|
+
read_queries_from_file
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Add a new query to the storage
|
28
|
+
def add(query)
|
29
|
+
@mutex.synchronize do
|
30
|
+
# Convert query to JSON and append to file
|
31
|
+
query_json = query.to_json
|
32
|
+
File.open(@log_path, "a") do |file|
|
33
|
+
file.puts(query_json)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Clear all stored queries
|
39
|
+
def clear
|
40
|
+
@mutex.synchronize do
|
41
|
+
# Simply truncate the file
|
42
|
+
File.open(@log_path, "w") { }
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Filter the queries based on provided criteria
|
47
|
+
def filter(limit: 100, table_filter: nil, request_id: nil, min_duration: nil)
|
48
|
+
result = all
|
49
|
+
|
50
|
+
# Apply filters if provided
|
51
|
+
result = filter_by_table(result, table_filter) if table_filter.present?
|
52
|
+
result = filter_by_request_id(result, request_id) if request_id.present?
|
53
|
+
result = filter_by_duration(result, min_duration) if min_duration.present?
|
54
|
+
|
55
|
+
# Return most recent queries first, limited to requested amount
|
56
|
+
result.reverse.first(limit)
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def read_queries_from_file
|
62
|
+
queries = []
|
63
|
+
|
64
|
+
# Read the file line by line and parse each line as JSON
|
65
|
+
File.foreach(@log_path) do |line|
|
66
|
+
begin
|
67
|
+
query = JSON.parse(line.strip, symbolize_names: true)
|
68
|
+
|
69
|
+
# Convert timestamp strings back to Time objects
|
70
|
+
query[:timestamp] = Time.parse(query[:timestamp]) if query[:timestamp].is_a?(String)
|
71
|
+
|
72
|
+
queries << query
|
73
|
+
rescue JSON::ParserError => e
|
74
|
+
# Skip malformed lines
|
75
|
+
Rails.logger.warn("Skipping malformed query log entry: #{e.message}")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
queries
|
80
|
+
end
|
81
|
+
|
82
|
+
def filter_by_table(queries, table_filter)
|
83
|
+
queries.select { |q| q[:sql].downcase.include?(table_filter.downcase) }
|
84
|
+
end
|
85
|
+
|
86
|
+
def filter_by_request_id(queries, request_id)
|
87
|
+
queries.select { |q| q[:request_id].to_s.include?(request_id) }
|
88
|
+
end
|
89
|
+
|
90
|
+
def filter_by_duration(queries, min_duration)
|
91
|
+
min_ms = min_duration.to_f
|
92
|
+
queries.select { |q| q[:duration_ms] >= min_ms }
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module Storage
|
3
|
+
# InMemoryStorage implements QueryStorage for storing queries in memory
|
4
|
+
class InMemoryStorage < Base
|
5
|
+
def initialize
|
6
|
+
@queries = []
|
7
|
+
@mutex = Mutex.new
|
8
|
+
end
|
9
|
+
|
10
|
+
# Get all queries
|
11
|
+
def all
|
12
|
+
@mutex.synchronize { @queries.dup }
|
13
|
+
end
|
14
|
+
|
15
|
+
# Add a new query to the collection
|
16
|
+
def add(query)
|
17
|
+
@mutex.synchronize do
|
18
|
+
@queries << query
|
19
|
+
# Trim if we have too many queries
|
20
|
+
max_queries = Dbviewer.configuration.max_memory_queries || 1000
|
21
|
+
@queries.shift if @queries.size > max_queries
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Clear all stored queries
|
26
|
+
def clear
|
27
|
+
@mutex.synchronize { @queries.clear }
|
28
|
+
end
|
29
|
+
|
30
|
+
# Get recent queries, optionally filtered
|
31
|
+
def filter(limit: 100, table_filter: nil, request_id: nil, min_duration: nil)
|
32
|
+
result = all
|
33
|
+
|
34
|
+
# Apply filters if provided
|
35
|
+
result = filter_by_table(result, table_filter) if table_filter.present?
|
36
|
+
result = filter_by_request_id(result, request_id) if request_id.present?
|
37
|
+
result = filter_by_duration(result, min_duration) if min_duration.present?
|
38
|
+
|
39
|
+
# Return most recent queries first, limited to requested amount
|
40
|
+
result.reverse.first(limit)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def filter_by_table(queries, table_filter)
|
46
|
+
queries.select { |q| q[:sql].downcase.include?(table_filter.downcase) }
|
47
|
+
end
|
48
|
+
|
49
|
+
def filter_by_request_id(queries, request_id)
|
50
|
+
queries.select { |q| q[:request_id].to_s.include?(request_id) }
|
51
|
+
end
|
52
|
+
|
53
|
+
def filter_by_duration(queries, min_duration)
|
54
|
+
min_ms = min_duration.to_f
|
55
|
+
queries.select { |q| q[:duration_ms] >= min_ms }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/dbviewer.rb
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
require "dbviewer/version"
|
2
|
+
require "dbviewer/configuration"
|
3
|
+
require "dbviewer/engine"
|
4
|
+
require "dbviewer/initializer"
|
5
|
+
require "dbviewer/database_manager"
|
6
|
+
require "dbviewer/sql_validator"
|
7
|
+
|
8
|
+
module Dbviewer
|
9
|
+
# Main module for the database viewer
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# Module accessor for configuration
|
13
|
+
attr_writer :configuration
|
14
|
+
|
15
|
+
# Get configuration settings
|
16
|
+
def configuration
|
17
|
+
@configuration ||= Configuration.new
|
18
|
+
end
|
19
|
+
|
20
|
+
# Alias for backward compatibility
|
21
|
+
def config
|
22
|
+
configuration
|
23
|
+
end
|
24
|
+
|
25
|
+
# Configure the engine with a block
|
26
|
+
#
|
27
|
+
# @example
|
28
|
+
# Dbviewer.configure do |config|
|
29
|
+
# config.per_page_options = [10, 25, 50]
|
30
|
+
# config.default_per_page = 25
|
31
|
+
# end
|
32
|
+
def configure
|
33
|
+
yield(config) if block_given?
|
34
|
+
end
|
35
|
+
|
36
|
+
# Reset configuration to defaults
|
37
|
+
def reset_configuration
|
38
|
+
@configuration = Configuration.new
|
39
|
+
end
|
40
|
+
|
41
|
+
# This class method will be called by the engine when it's appropriate
|
42
|
+
def setup
|
43
|
+
Dbviewer::Initializer.setup
|
44
|
+
end
|
45
|
+
|
46
|
+
# Initialize engine with default values or user-provided configuration
|
47
|
+
def init
|
48
|
+
Dbviewer::DatabaseManager.singleton_class.class_eval do
|
49
|
+
define_method(:configuration) { Dbviewer.configuration }
|
50
|
+
define_method(:default_per_page) { Dbviewer.configuration.default_per_page }
|
51
|
+
define_method(:max_records) { Dbviewer.configuration.max_records }
|
52
|
+
define_method(:cache_expiry) { Dbviewer.configuration.cache_expiry }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Define class methods to access configuration
|
56
|
+
Dbviewer::SqlValidator.singleton_class.class_eval do
|
57
|
+
define_method(:configuration) { Dbviewer.configuration }
|
58
|
+
define_method(:max_query_length) { Dbviewer.configuration.max_query_length }
|
59
|
+
end
|
60
|
+
|
61
|
+
# Log initialization
|
62
|
+
Rails.logger.info("[DBViewer] Initialized with configuration: #{configuration.inspect}")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|