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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Dbviewer
2
+ VERSION = "0.3.1"
3
+ 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
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :dbviewer do
3
+ # # Task goes here
4
+ # end