dbviewer 0.6.2 → 0.6.3

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.
@@ -90,104 +90,7 @@ module Dbviewer
90
90
  0
91
91
  end
92
92
 
93
- # Get column histogram/value distribution data for a specific column
94
- # @param table_name [String] Name of the table
95
- # @param column_name [String] Name of the column
96
- # @param limit [Integer] Maximum number of distinct values to return
97
- # @return [Array<Hash>] Array of value distribution data with labels and counts
98
- def fetch_column_distribution(table_name, column_name, limit = 20)
99
- return [] unless column_exists?(table_name, column_name)
100
-
101
- query = "SELECT #{column_name} as label, COUNT(*) as count FROM #{table_name}
102
- WHERE #{column_name} IS NOT NULL
103
- GROUP BY #{column_name}
104
- ORDER BY count DESC LIMIT #{limit}"
105
-
106
- begin
107
- result = @connection.execute(query)
108
- adapter = @connection.adapter_name.downcase
109
-
110
- # Format depends on adapter
111
- if adapter =~ /mysql/
112
- result.to_a.map { |row| { label: row[0], value: row[1] } }
113
- elsif adapter =~ /sqlite/
114
- result.map { |row| { label: row["label"], value: row["count"] } }
115
- else # postgresql
116
- result.map { |row| { label: row["label"], value: row["count"] } }
117
- end
118
- rescue => e
119
- Rails.logger.error("Error fetching column distribution: #{e.message}")
120
- []
121
- end
122
- end
123
-
124
- # Get timestamp aggregation data for charts
125
- # @param table_name [String] Name of the table
126
- # @param grouping [String] Grouping type (hourly, daily, weekly, monthly)
127
- # @param column [String] Timestamp column name (defaults to created_at)
128
- # @return [Array<Hash>] Array of timestamp data with labels and counts
129
- def fetch_timestamp_data(table_name, grouping = "daily", column = "created_at")
130
- return [] unless column_exists?(table_name, column)
131
-
132
- adapter = @connection.adapter_name.downcase
133
-
134
- date_format = case grouping
135
- when "hourly"
136
- if adapter =~ /mysql/
137
- "DATE_FORMAT(#{column}, '%Y-%m-%d %H:00')"
138
- elsif adapter =~ /sqlite/
139
- "strftime('%Y-%m-%d %H:00', #{column})"
140
- else # postgresql
141
- "TO_CHAR(#{column}, 'YYYY-MM-DD HH24:00')"
142
- end
143
- when "weekly"
144
- if adapter =~ /mysql/
145
- "DATE_FORMAT(#{column}, '%Y-%v')"
146
- elsif adapter =~ /sqlite/
147
- "strftime('%Y-%W', #{column})"
148
- else # postgresql
149
- "TO_CHAR(#{column}, 'YYYY-IW')"
150
- end
151
- when "monthly"
152
- if adapter =~ /mysql/
153
- "DATE_FORMAT(#{column}, '%Y-%m')"
154
- elsif adapter =~ /sqlite/
155
- "strftime('%Y-%m', #{column})"
156
- else # postgresql
157
- "TO_CHAR(#{column}, 'YYYY-MM')"
158
- end
159
- else # daily is default
160
- if adapter =~ /mysql/
161
- "DATE(#{column})"
162
- elsif adapter =~ /sqlite/
163
- "date(#{column})"
164
- else # postgresql
165
- "DATE(#{column})"
166
- end
167
- end
168
-
169
- # Query works the same for all database adapters
170
- query = "SELECT #{date_format} as label, COUNT(*) as count FROM #{table_name}
171
- WHERE #{column} IS NOT NULL
172
- GROUP BY label
173
- ORDER BY MIN(#{column}) DESC LIMIT 30"
174
-
175
- begin
176
- result = @connection.execute(query)
177
-
178
- # Format depends on adapter
179
- if adapter =~ /mysql/
180
- result.to_a.map { |row| { label: row[0], value: row[1] } }
181
- elsif adapter =~ /sqlite/
182
- result.map { |row| { label: row["label"], value: row["count"] } }
183
- else # postgresql
184
- result.map { |row| { label: row["label"], value: row["count"] } }
185
- end
186
- rescue => e
187
- Rails.logger.error("Error fetching timestamp data: #{e.message}")
188
- []
189
- end
190
- end
93
+ ## -- Delegator
191
94
 
192
95
  # Execute a raw SQL query after validating for safety
193
96
  # @param sql [String] SQL query to execute
@@ -223,112 +126,64 @@ module Dbviewer
223
126
  def apply_column_filters(query, table_name, column_filters)
224
127
  return query unless column_filters.present?
225
128
 
226
- # Create a copy of column_filters to modify without affecting the original
227
- filters = column_filters.dup
228
-
229
- # First check if we have a datetime range filter for created_at
230
- if filters["created_at"].present? &&
231
- filters["created_at_end"].present? &&
232
- column_exists?(table_name, "created_at")
233
-
234
- # Handle datetime range for created_at
235
- begin
236
- start_datetime = Time.parse(filters["created_at"].to_s)
237
- end_datetime = Time.parse(filters["created_at_end"].to_s)
238
-
239
- # Make sure end_datetime is at the end of the day/minute if it doesn't have time component
240
- if end_datetime.to_s.match(/00:00:00/)
241
- end_datetime = end_datetime.end_of_day
242
- end
243
-
244
- Rails.logger.info("[DBViewer] Applying date range filter on #{table_name}.created_at: #{start_datetime} to #{end_datetime}")
245
-
246
- # Use qualified column name for tables with schema
247
- column_name = "#{table_name}.created_at"
248
-
249
- # Different databases may require different SQL for datetime comparison
250
- adapter_name = connection.adapter_name.downcase
251
-
252
- if adapter_name =~ /mysql/
253
- query = query.where("#{column_name} BETWEEN ? AND ?", start_datetime, end_datetime)
254
- elsif adapter_name =~ /sqlite/
255
- # SQLite needs special handling for datetime format
256
- query = query.where("datetime(#{column_name}) BETWEEN datetime(?) AND datetime(?)", start_datetime, end_datetime)
257
- else # postgresql
258
- query = query.where("#{column_name} >= ? AND #{column_name} <= ?", start_datetime, end_datetime)
259
- end
260
-
261
- # Remove these from filters as they're handled above
262
- filters.delete("created_at")
263
- filters.delete("created_at_end")
264
-
265
- rescue => e
266
- Rails.logger.error("[DBViewer] Error parsing date range: #{e.message}")
267
- # Remove the problematic filters and continue
268
- filters.delete("created_at")
269
- filters.delete("created_at_end")
270
- end
129
+ column_filters.each do |column, value|
130
+ query = apply_single_column_filter(query, table_name, column, value, column_filters)
131
+ end
132
+
133
+ query
134
+ end
135
+
136
+ # Apply a single column filter to a query
137
+ # @param query [ActiveRecord::Relation] The query to apply the filter to
138
+ # @param table_name [String] Name of the table
139
+ # @param column [String] The column name
140
+ # @param value [String] The filter value
141
+ # @param column_filters [Hash] All column filters for accessing operator
142
+ # @return [ActiveRecord::Relation] The filtered query
143
+ def apply_single_column_filter(query, table_name, column, value, column_filters)
144
+ return query unless column_exists?(table_name, column)
145
+ return query if column.end_with?("_operator")
146
+
147
+ operator = column_filters["#{column}_operator"]
148
+
149
+ if special_operators(query)[operator]
150
+ return special_operators(query)[operator].call(column)
271
151
  end
152
+ return query if value.blank?
272
153
 
273
- # Apply remaining simple column filters
274
- filters.each do |column, value|
275
- next unless column_exists?(table_name, column)
276
-
277
- # Check if this is a column operator field
278
- if column.end_with?("_operator")
279
- next # Skip operator fields - they're processed with their column
280
- end
281
-
282
- column_sym = column.to_sym
283
- operator = filters["#{column}_operator"]
284
-
285
- # Special handling for is_null and is_not_null operators that don't need a value
286
- if operator == "is_null" || value == "is_null"
287
- Rails.logger.debug("[DBViewer] Applying null filter: #{column} IS NULL")
288
- query = query.where("#{column} IS NULL")
289
- next
290
- elsif operator == "is_not_null" || value == "is_not_null"
291
- Rails.logger.debug("[DBViewer] Applying not null filter: #{column} IS NOT NULL")
292
- query = query.where("#{column} IS NOT NULL")
293
- next
294
- end
295
-
296
- # Skip if no value and we're not using a special operator
297
- next if value.blank? || value == "is_null" || value == "is_not_null"
298
-
299
- Rails.logger.debug("[DBViewer] Applying filter: #{column} = #{value}")
300
-
301
- # Handle different types of filtering
302
- if value.to_s.include?("%") || value.to_s.include?("*")
303
- # Pattern matching (LIKE operation)
304
- pattern = value.to_s.gsub("*", "%")
305
- query = query.where("#{column} LIKE ?", pattern)
306
- elsif value.to_s.start_with?(">=", "<=", ">", "<", "!=")
307
- # Comparison operators
308
- operator = value.to_s.match(/^(>=|<=|>|<|!=)/)[1]
309
- comparison_value = value.to_s.gsub(/^(>=|<=|>|<|!=)\s*/, "")
310
-
311
- case operator
312
- when ">="
313
- query = query.where("#{column} >= ?", comparison_value)
314
- when "<="
315
- query = query.where("#{column} <= ?", comparison_value)
316
- when ">"
317
- query = query.where("#{column} > ?", comparison_value)
318
- when "<"
319
- query = query.where("#{column} < ?", comparison_value)
320
- when "!="
321
- query = query.where("#{column} != ?", comparison_value)
322
- end
323
- else
324
- # Exact match
325
- query = query.where(column_sym => value)
326
- end
154
+ if operator && operator_handlers(query)[operator]
155
+ return operator_handlers(query)[operator].call(column, value)
156
+ else
157
+ # Default to eq no specific operator is provided
158
+ return query.where("#{column} = ?", value)
327
159
  end
328
160
 
329
161
  query
330
162
  end
331
163
 
164
+ def special_operators(query)
165
+ {
166
+ "is_null" => ->(column) { query.where("#{column} IS NULL") },
167
+ "is_not_null" => ->(column) { query.where("#{column} IS NOT NULL") }
168
+ }
169
+ end
170
+
171
+ def operator_handlers(query)
172
+ {
173
+ "contains" => ->(column, value) { query.where("#{column} LIKE ?", "%#{value}%") },
174
+ "not_contain" => ->(column, value) { query.where("#{column} NOT LIKE ?", "%#{value}%") },
175
+ "starts_with" => ->(column, value) { query.where("#{column} LIKE ?", "#{value}%") },
176
+ "ends_with" => ->(column, value) { query.where("#{column} LIKE ?", "%#{value}") },
177
+ "equals" => ->(column, value) { query.where(column.to_sym => value) },
178
+ "eq" => ->(column, value) { query.where("#{column} = ?", value) },
179
+ "gte" => ->(column, value) { query.where("#{column} >= ?", value) },
180
+ "lte" => ->(column, value) { query.where("#{column} <= ?", value) },
181
+ "gt" => ->(column, value) { query.where("#{column} > ?", value) },
182
+ "lt" => ->(column, value) { query.where("#{column} < ?", value) },
183
+ "neq" => ->(column, value) { query.where("#{column} != ?", value) }
184
+ }
185
+ end
186
+
332
187
  # Helper methods delegated to managers
333
188
  def get_model_for(table_name)
334
189
  @dynamic_model_factory.get_model_for(table_name)
@@ -14,32 +14,11 @@ module Dbviewer
14
14
 
15
15
  # Initialize the engine safely
16
16
  initializer "dbviewer.setup", after: :load_config_initializers do |app|
17
- Dbviewer.init
18
17
  Dbviewer.setup
19
18
  end
20
19
 
21
20
  initializer "dbviewer.notifications" do
22
- next unless Rails.env.development?
23
-
24
- ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
25
- event = ActiveSupport::Notifications::Event.new(*args)
26
-
27
- next if skip_internal_query?(event)
28
-
29
- Dbviewer::Query::Logger.instance.add(event)
30
- end
31
- end
32
-
33
- private
34
-
35
- def skip_internal_query?(event)
36
- caller_locations = caller_locations(1)
37
- return false unless caller_locations
38
-
39
- excluded_caller_locations = caller_locations.filter do |caller_location|
40
- !caller_location.path.include?("lib/dbviewer/engine.rb")
41
- end
42
- excluded_caller_locations.any? { |l| l.path.include?("dbviewer") }
21
+ Dbviewer::Query::NotificationSubscriber.subscribe
43
22
  end
44
23
  end
45
24
  end
@@ -16,7 +16,7 @@ module Dbviewer
16
16
  # @raise [StandardError] If the query is invalid or unsafe
17
17
  def execute_query(sql)
18
18
  # Validate and normalize the SQL
19
- normalized_sql = ::Dbviewer::SqlValidator.validate!(sql.to_s)
19
+ normalized_sql = ::Dbviewer::Validator::Sql.validate!(sql.to_s)
20
20
 
21
21
  # Get max records from configuration
22
22
  max_records = @config.max_records || 10000
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbviewer
4
+ module Query
5
+ # Handles ActiveSupport::Notifications subscription for SQL query monitoring
6
+ # Only active in development environment to capture and log SQL queries
7
+ class NotificationSubscriber
8
+ class << self
9
+ # Subscribe to SQL notifications if in development environment
10
+ def subscribe
11
+ return unless Rails.env.development?
12
+
13
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
14
+ process_notification(*args)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ # Process a single SQL notification event
21
+ # @param args [Array] Notification arguments from ActiveSupport::Notifications
22
+ def process_notification(*args)
23
+ event = ActiveSupport::Notifications::Event.new(*args)
24
+
25
+ return if skip_internal_query?(event)
26
+
27
+ Dbviewer::Query::Logger.instance.add(event)
28
+ end
29
+
30
+ # Determine if this query should be skipped (internal DBViewer queries)
31
+ # @param event [ActiveSupport::Notifications::Event] The notification event
32
+ # @return [Boolean] True if the query should be skipped
33
+ def skip_internal_query?(event)
34
+ caller_locations = caller_locations(1)
35
+ return false unless caller_locations
36
+
37
+ excluded_caller_locations = caller_locations.filter do |caller_location|
38
+ !caller_location.path.include?("lib/dbviewer/engine.rb")
39
+ end
40
+
41
+ excluded_caller_locations.any? { |location| location.path.include?("dbviewer") }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbviewer
4
+ module Validator
5
+ # Sql class handles SQL query validation and normalization
6
+ # to ensure queries are safe (read-only) and properly formatted.
7
+ # This helps prevent potentially destructive SQL operations.
8
+ class Sql
9
+ # List of SQL keywords that could modify data or schema
10
+ FORBIDDEN_KEYWORDS = %w[
11
+ UPDATE INSERT DELETE DROP ALTER CREATE TRUNCATE REPLACE
12
+ RENAME GRANT REVOKE LOCK UNLOCK COMMIT ROLLBACK
13
+ SAVEPOINT INTO CALL EXECUTE EXEC
14
+ ]
15
+
16
+ # List of SQL keywords that should only be allowed in specific contexts
17
+ CONDITIONAL_KEYWORDS = {
18
+ # JOIN is allowed, but we should check for suspicious patterns
19
+ "JOIN" => /\bJOIN\b/i,
20
+ # UNION is allowed, but potential for injection
21
+ "UNION" => /\bUNION\b/i,
22
+ # WITH is allowed for CTEs, but need to ensure it's not a data modification
23
+ "WITH" => /\bWITH\b/i
24
+ }
25
+
26
+ # Maximum allowed query length
27
+ MAX_QUERY_LENGTH = 10000
28
+
29
+ # Determines if a query is safe (read-only)
30
+ # @param sql [String] The SQL query to validate
31
+ # @return [Boolean] true if the query is safe, false otherwise
32
+ def self.safe_query?(sql)
33
+ return false if sql.blank?
34
+
35
+ # Get max query length from configuration
36
+ max_length = Dbviewer.configuration.max_query_length || MAX_QUERY_LENGTH
37
+ return false if sql.length > max_length
38
+
39
+ normalized_sql = normalize(sql)
40
+
41
+ # Case-insensitive check for SELECT at the beginning
42
+ return false unless normalized_sql =~ /\A\s*SELECT\s+/i
43
+
44
+ # Check for forbidden keywords that might be used in subqueries or other SQL constructs
45
+ FORBIDDEN_KEYWORDS.each do |keyword|
46
+ # Look for the keyword with word boundaries to avoid false positives
47
+ return false if normalized_sql =~ /\b#{keyword}\b/i
48
+ end
49
+
50
+ # Check for suspicious patterns that might indicate SQL injection attempts
51
+ return false if has_suspicious_patterns?(normalized_sql)
52
+
53
+ # Check for multiple statements (;) which could allow executing multiple commands
54
+ statements = normalized_sql.split(";").reject(&:blank?)
55
+ return false if statements.size > 1
56
+
57
+ # Additional specific checks for common SQL injection patterns
58
+ return false if has_injection_patterns?(normalized_sql)
59
+
60
+ true
61
+ end
62
+
63
+ # Check for suspicious patterns in SQL that might indicate an attack
64
+ # @param sql [String] Normalized SQL query
65
+ # @return [Boolean] true if suspicious patterns found, false otherwise
66
+ def self.has_suspicious_patterns?(sql)
67
+ # Check for SQL comment sequences that might be used to hide malicious code
68
+ return true if sql =~ /\s+--/ || sql =~ /\/\*/
69
+
70
+ # Check for string concatenation which might be used for injection
71
+ return true if sql =~ /\|\|/ || sql =~ /CONCAT\s*\(/i
72
+
73
+ # Check for excessive number of quotes which might indicate injection
74
+ single_quotes = sql.count("'")
75
+ double_quotes = sql.count('"')
76
+ return true if single_quotes > 20 || double_quotes > 20
77
+
78
+ # Check for hex/binary data which might hide malicious code
79
+ return true if sql =~ /0x[0-9a-f]{16,}/i
80
+
81
+ false
82
+ end
83
+
84
+ # Check for specific SQL injection patterns
85
+ # @param sql [String] Normalized SQL query
86
+ # @return [Boolean] true if injection patterns found, false otherwise
87
+ def self.has_injection_patterns?(sql)
88
+ # Check for typical SQL injection test patterns
89
+ return true if sql =~ /'\s*OR\s*'.*'\s*=\s*'/i
90
+ return true if sql =~ /'\s*OR\s*1\s*=\s*1/i
91
+ return true if sql =~ /'\s*;\s*--/i
92
+
93
+ # Check for attempts to determine database type
94
+ return true if sql =~ /@@version/i
95
+ return true if sql =~ /version\(\)/i
96
+
97
+ false
98
+ end
99
+
100
+ # Normalize SQL by removing comments and extra whitespace
101
+ # @param sql [String] The SQL query to normalize
102
+ # @return [String] The normalized SQL query
103
+ def self.normalize(sql)
104
+ return "" if sql.nil?
105
+
106
+ begin
107
+ # Remove SQL comments (both -- and /* */ styles)
108
+ normalized = sql.gsub(/--.*$/, "") # Remove -- style comments
109
+ .gsub(/\/\*.*?\*\//m, "") # Remove /* */ style comments
110
+ .gsub(/\s+/, " ") # Normalize whitespace
111
+ .strip # Remove leading/trailing whitespace
112
+
113
+ # Replace multiple spaces with a single space
114
+ normalized.gsub(/\s{2,}/, " ")
115
+ rescue => e
116
+ Rails.logger.error("[DBViewer] SQL normalization error: #{e.message}")
117
+ ""
118
+ end
119
+ end
120
+
121
+ # Validates a query and raises an exception if it's unsafe
122
+ # @param sql [String] The SQL query to validate
123
+ # @raise [SecurityError] if the query is unsafe
124
+ # @return [String] The normalized SQL query if it's safe
125
+ def self.validate!(sql)
126
+ if sql.blank?
127
+ raise SecurityError, "Empty query is not allowed"
128
+ end
129
+
130
+ # Get max query length from configuration
131
+ max_length = Dbviewer.configuration.max_query_length || MAX_QUERY_LENGTH
132
+ if sql.length > max_length
133
+ raise SecurityError, "Query exceeds maximum allowed length (#{max_length} chars)"
134
+ end
135
+
136
+ normalized_sql = normalize(sql)
137
+
138
+ # Special case for SQLite PRAGMA statements which are safe read-only commands
139
+ if normalized_sql =~ /\A\s*PRAGMA\s+[a-z0-9_]+\s*\z/i
140
+ return normalized_sql
141
+ end
142
+
143
+ unless normalized_sql =~ /\A\s*SELECT\s+/i
144
+ raise SecurityError, "Query must begin with SELECT for security reasons"
145
+ end
146
+
147
+ FORBIDDEN_KEYWORDS.each do |keyword|
148
+ if normalized_sql =~ /\b#{keyword}\b/i
149
+ raise SecurityError, "Forbidden keyword '#{keyword}' detected in query"
150
+ end
151
+ end
152
+
153
+ if has_suspicious_patterns?(normalized_sql)
154
+ raise SecurityError, "Query contains suspicious patterns that may indicate SQL injection"
155
+ end
156
+
157
+ # Check for multiple statements
158
+ statements = normalized_sql.split(";").reject(&:blank?)
159
+ if statements.size > 1
160
+ raise SecurityError, "Multiple SQL statements are not allowed"
161
+ end
162
+
163
+ if has_injection_patterns?(normalized_sql)
164
+ raise SecurityError, "Query contains patterns commonly associated with SQL injection attempts"
165
+ end
166
+
167
+ normalized_sql
168
+ end
169
+
170
+ # Check if a query is using a specific database feature that might need special handling
171
+ # @param sql [String] The SQL query
172
+ # @param feature [Symbol] The feature to check for (:join, :subquery, :order_by, etc.)
173
+ # @return [Boolean] true if the feature is used in the query
174
+ def self.uses_feature?(sql, feature)
175
+ normalized = normalize(sql)
176
+ case feature
177
+ when :join
178
+ normalized =~ /\b(INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\b/i
179
+ when :subquery
180
+ # Check if there are parentheses that likely contain a subquery
181
+ normalized.count("(") > normalized.count(")")
182
+ when :order_by
183
+ normalized =~ /\bORDER\s+BY\b/i
184
+ when :group_by
185
+ normalized =~ /\bGROUP\s+BY\b/i
186
+ when :having
187
+ normalized =~ /\bHAVING\b/i
188
+ when :union
189
+ normalized =~ /\bUNION\b/i
190
+ when :window_function
191
+ normalized =~ /\bOVER\s*\(/i
192
+ else
193
+ false
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbviewer
4
+ # Validator namespace contains validation classes for different types of content
5
+ # such as SQL queries, configuration parameters, etc.
6
+ module Validator
7
+ # This module serves as a namespace for various validator classes
8
+ end
9
+ end
@@ -1,3 +1,3 @@
1
1
  module Dbviewer
2
- VERSION = "0.6.2"
2
+ VERSION = "0.6.3"
3
3
  end