dbviewer 0.3.15 → 0.3.16

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.
@@ -2,13 +2,17 @@ require "dbviewer/cache_manager"
2
2
  require "dbviewer/table_metadata_manager"
3
3
  require "dbviewer/dynamic_model_factory"
4
4
  require "dbviewer/query_executor"
5
+ require "dbviewer/table_query_operations"
5
6
  require "dbviewer/error_handler"
6
7
 
7
8
  module Dbviewer
8
9
  # DatabaseManager handles all database interactions for the DBViewer engine
9
10
  # It provides methods to access database structure and data
10
11
  class DatabaseManager
11
- attr_reader :connection, :adapter_name
12
+ attr_reader :connection, :adapter_name, :table_query_operations
13
+
14
+ # Expose query_operations as an alias
15
+ alias_method :query_operations, :table_query_operations
12
16
 
13
17
  # Initialize the database manager
14
18
  def initialize
@@ -17,6 +21,12 @@ module Dbviewer
17
21
  @table_metadata_manager = TableMetadataManager.new(@connection, @cache_manager)
18
22
  @dynamic_model_factory = DynamicModelFactory.new(@connection, @cache_manager)
19
23
  @query_executor = QueryExecutor.new(@connection, self.class.configuration)
24
+ @table_query_operations = TableQueryOperations.new(
25
+ @connection,
26
+ @dynamic_model_factory,
27
+ @query_executor,
28
+ @table_metadata_manager
29
+ )
20
30
  reset_cache_if_needed
21
31
  end
22
32
 
@@ -64,77 +74,25 @@ module Dbviewer
64
74
  # @param table_name [String] Name of the table
65
75
  # @return [Integer] Number of records
66
76
  def table_count(table_name)
67
- model = get_model_for(table_name)
68
- model.count
77
+ @table_query_operations.table_count(table_name)
69
78
  end
70
79
 
71
80
  # Get records from a table with pagination and sorting
72
81
  # @param table_name [String] Name of the table
73
- # @param page [Integer] Page number (1-based)
74
- # @param order_by [String] Column to sort by
75
- # @param direction [String] Sort direction ('ASC' or 'DESC')
76
- # @param per_page [Integer] Number of records per page
82
+ # @param query_params [TableQueryParams] Query parameters for pagination and sorting
77
83
  # @return [ActiveRecord::Result] Result set with columns and rows
78
- def table_records(table_name, page = 1, order_by = nil, direction = "ASC", per_page = nil, column_filters = nil)
79
- page = [ 1, page.to_i ].max
80
- default_per_page = self.class.default_per_page
81
- column_filters ||= {}
82
- max_records = self.class.max_records
83
- per_page = (per_page || default_per_page).to_i
84
-
85
- # Ensure we don't fetch too many records for performance/memory reasons
86
- per_page = [ per_page, max_records ].min
87
-
88
- model = get_model_for(table_name)
89
- query = model.all
90
-
91
- # Apply column filters if provided
92
- if column_filters.present?
93
- column_filters.each do |column, value|
94
- next if value.blank?
95
- next unless column_exists?(table_name, column)
96
-
97
- # Use LIKE for string-based searches, = for exact matches on other types
98
- column_info = table_columns(table_name).find { |c| c[:name] == column }
99
- if column_info
100
- column_type = column_info[:type].to_s
101
-
102
- if column_type =~ /char|text|string|uuid|enum/i
103
- query = query.where("#{connection.quote_column_name(column)} LIKE ?", "%#{value}%")
104
- else
105
- # For numeric types, try exact match if value looks like a number
106
- if value =~ /\A[+-]?\d+(\.\d+)?\z/
107
- query = query.where(column => value)
108
- else
109
- # Otherwise, try string comparison for non-string fields
110
- query = query.where("CAST(#{connection.quote_column_name(column)} AS CHAR) LIKE ?", "%#{value}%")
111
- end
112
- end
113
- end
114
- end
115
- end
116
-
117
- # Apply sorting if provided
118
- if order_by.present? && column_exists?(table_name, order_by)
119
- direction = %w[ASC DESC].include?(direction.to_s.upcase) ? direction.to_s.upcase : "ASC"
120
- query = query.order("#{connection.quote_column_name(order_by)} #{direction}")
121
- end
122
-
123
- # Apply pagination
124
- records = query.limit(per_page).offset((page - 1) * per_page)
125
-
126
- # Get column names for consistent ordering
127
- column_names = table_columns(table_name).map { |c| c[:name] }
128
-
129
- # Format results
130
- @query_executor.to_result_set(records, column_names)
84
+ def table_records(table_name, query_params)
85
+ @table_query_operations.table_records(
86
+ table_name,
87
+ query_params
88
+ )
131
89
  end
132
90
 
133
91
  # Get the number of records in a table (alias for table_count)
134
92
  # @param table_name [String] Name of the table
135
93
  # @return [Integer] Number of records
136
94
  def record_count(table_name)
137
- table_count(table_name)
95
+ @table_query_operations.record_count(table_name)
138
96
  end
139
97
 
140
98
  # Get the number of records in a table with filters applied
@@ -142,36 +100,7 @@ module Dbviewer
142
100
  # @param column_filters [Hash] Hash of column_name => filter_value for filtering
143
101
  # @return [Integer] Number of filtered records
144
102
  def filtered_record_count(table_name, column_filters = {})
145
- model = get_model_for(table_name)
146
- query = model.all
147
-
148
- # Apply column filters if provided
149
- if column_filters.present?
150
- column_filters.each do |column, value|
151
- next if value.blank?
152
- next unless column_exists?(table_name, column)
153
-
154
- # Use LIKE for string-based searches, = for exact matches on other types
155
- column_info = table_columns(table_name).find { |c| c[:name] == column }
156
- if column_info
157
- column_type = column_info[:type].to_s
158
-
159
- if column_type =~ /char|text|string|uuid|enum/i
160
- query = query.where("#{connection.quote_column_name(column)} LIKE ?", "%#{value}%")
161
- else
162
- # For numeric types, try exact match if value looks like a number
163
- if value =~ /\A[+-]?\d+(\.\d+)?\z/
164
- query = query.where(column => value)
165
- else
166
- # Otherwise, try string comparison for non-string fields
167
- query = query.where("CAST(#{connection.quote_column_name(column)} AS CHAR) LIKE ?", "%#{value}%")
168
- end
169
- end
170
- end
171
- end
172
- end
173
-
174
- query.count
103
+ @table_query_operations.filtered_record_count(table_name, column_filters)
175
104
  end
176
105
 
177
106
  # Get the number of columns in a table
@@ -201,7 +130,7 @@ module Dbviewer
201
130
  # @return [ActiveRecord::Result] Result set with columns and rows
202
131
  # @raise [StandardError] If the query is invalid or unsafe
203
132
  def execute_query(sql)
204
- @query_executor.execute_query(sql)
133
+ @table_query_operations.execute_query(sql)
205
134
  end
206
135
 
207
136
  # Execute a SQLite PRAGMA command without adding a LIMIT clause
@@ -209,7 +138,7 @@ module Dbviewer
209
138
  # @return [ActiveRecord::Result] Result set with the PRAGMA value
210
139
  # @raise [StandardError] If the query is invalid or cannot be executed
211
140
  def execute_sqlite_pragma(pragma)
212
- @query_executor.execute_sqlite_pragma(pragma)
141
+ @table_query_operations.execute_sqlite_pragma(pragma)
213
142
  end
214
143
 
215
144
  # Query a table with more granular control using ActiveRecord
@@ -221,28 +150,15 @@ module Dbviewer
221
150
  # @param where [String, Hash] Where conditions
222
151
  # @return [ActiveRecord::Result] Result set with columns and rows
223
152
  def query_table(table_name, select: nil, order: nil, limit: nil, offset: nil, where: nil)
224
- model = get_model_for(table_name)
225
- query = model.all
226
-
227
- query = query.select(select) if select.present?
228
- query = query.where(where) if where.present?
229
- query = query.order(order) if order.present?
230
-
231
- # Get max records from configuration
232
- max_records = self.class.max_records
233
- query = query.limit([ limit || max_records, max_records ].min) # Apply safety limit
234
- query = query.offset(offset) if offset.present?
235
-
236
- # Get column names for the result set
237
- column_names = if select.is_a?(Array)
238
- select
239
- elsif select.is_a?(String) && !select.include?("*")
240
- select.split(",").map(&:strip)
241
- else
242
- table_columns(table_name).map { |c| c[:name] }
243
- end
244
-
245
- @query_executor.to_result_set(query, column_names)
153
+ @table_query_operations.query_table(
154
+ table_name,
155
+ select: select,
156
+ order: order,
157
+ limit: limit,
158
+ offset: offset,
159
+ where: where,
160
+ max_records: self.class.max_records
161
+ )
246
162
  end
247
163
 
248
164
  # Get table indexes
@@ -14,6 +14,51 @@ module Dbviewer
14
14
  }.merge(calculate_request_stats(queries))
15
15
  end
16
16
 
17
+ # Instance methods for query analysis
18
+ attr_reader :connection, :adapter_name
19
+
20
+ # Initialize the analyzer for instance methods
21
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
22
+ def initialize(connection)
23
+ @connection = connection
24
+ @adapter_name = connection.adapter_name.downcase
25
+ end
26
+
27
+ # Check if a table has an index on a column
28
+ # @param table_name [String] Name of the table
29
+ # @param column_name [String] Name of the column
30
+ # @return [Boolean] True if column has an index, false otherwise
31
+ def has_index_on?(table_name, column_name)
32
+ indexes = connection.indexes(table_name)
33
+ indexes.any? do |index|
34
+ index.columns.include?(column_name) ||
35
+ (index.columns.length == 1 && index.columns[0] == column_name)
36
+ end
37
+ end
38
+
39
+ # Analyze a query and provide performance statistics and recommendations
40
+ # @param table_name [String] Name of the table
41
+ # @param query_params [TableQueryParams] Query parameters with filters
42
+ # @return [Hash] Analysis results with statistics and recommendations
43
+ def analyze_query(table_name, query_params)
44
+ results = {
45
+ table: table_name,
46
+ filters: query_params.column_filters.keys,
47
+ analysis: [],
48
+ recommendations: []
49
+ }
50
+
51
+ # Check created_at filter performance
52
+ if query_params.column_filters["created_at"].present?
53
+ analyze_timestamp_query(table_name, "created_at", results)
54
+ end
55
+
56
+ # Add general performance recommendations based on database type
57
+ add_database_specific_recommendations(results)
58
+
59
+ results
60
+ end
61
+
17
62
  # Detect potential N+1 query patterns
18
63
  def self.detect_potential_n_plus_1(queries)
19
64
  potential_issues = []
@@ -49,6 +94,91 @@ module Dbviewer
49
94
 
50
95
  private
51
96
 
97
+ # Analyze timestamp column query performance
98
+ def analyze_timestamp_query(table_name, column_name, results)
99
+ # Check if column exists
100
+ begin
101
+ unless connection.column_exists?(table_name, column_name)
102
+ results[:analysis] << "Column '#{column_name}' not found in table '#{table_name}'"
103
+ return
104
+ end
105
+
106
+ # Check if there's an index on the timestamp column
107
+ unless has_index_on?(table_name, column_name)
108
+ results[:recommendations] << {
109
+ type: "missing_index",
110
+ message: "Consider adding an index on '#{column_name}' for faster filtering",
111
+ sql: index_creation_sql(table_name, column_name)
112
+ }
113
+ end
114
+
115
+ # Estimate data distribution if possible
116
+ if adapter_supports_statistics?(adapter_name)
117
+ add_data_distribution_stats(table_name, column_name, results)
118
+ end
119
+ rescue => e
120
+ results[:analysis] << "Error analyzing timestamp query: #{e.message}"
121
+ end
122
+ end
123
+
124
+ # Check if adapter supports statistics gathering
125
+ def adapter_supports_statistics?(adapter_name)
126
+ adapter_name.include?("postgresql")
127
+ end
128
+
129
+ # Add data distribution statistics
130
+ def add_data_distribution_stats(table_name, column_name, results)
131
+ case adapter_name
132
+ when /postgresql/
133
+ begin
134
+ # Get approximate date range and distribution
135
+ range_query = "SELECT min(#{column_name}), max(#{column_name}) FROM #{table_name}"
136
+ range_result = connection.execute(range_query).first
137
+
138
+ if range_result["min"] && range_result["max"]
139
+ min_date = range_result["min"]
140
+ max_date = range_result["max"]
141
+
142
+ results[:analysis] << {
143
+ type: "date_range",
144
+ min_date: min_date,
145
+ max_date: max_date,
146
+ span_days: ((Time.parse(max_date) - Time.parse(min_date)) / 86400).round
147
+ }
148
+ end
149
+ rescue => e
150
+ results[:analysis] << "Error getting date distribution: #{e.message}"
151
+ end
152
+ end
153
+ end
154
+
155
+ # Generate SQL for index creation
156
+ def index_creation_sql(table_name, column_name)
157
+ index_name = "index_#{table_name.gsub('.', '_')}_on_#{column_name}"
158
+ "CREATE INDEX #{index_name} ON #{table_name} (#{column_name})"
159
+ end
160
+
161
+ # Add database-specific recommendations
162
+ def add_database_specific_recommendations(results)
163
+ case adapter_name
164
+ when /mysql/
165
+ results[:recommendations] << {
166
+ type: "performance",
167
+ message: "For MySQL, consider optimizing the query with appropriate indexes and use EXPLAIN to verify query plan"
168
+ }
169
+ when /postgresql/
170
+ results[:recommendations] << {
171
+ type: "performance",
172
+ message: "For PostgreSQL, consider using EXPLAIN ANALYZE to verify query execution plan"
173
+ }
174
+ when /sqlite/
175
+ results[:recommendations] << {
176
+ type: "performance",
177
+ message: "For SQLite, consider using EXPLAIN QUERY PLAN to verify query execution strategy"
178
+ }
179
+ end
180
+ end
181
+
52
182
  def self.calculate_average_duration(queries)
53
183
  queries.any? ? (queries.sum { |q| q[:duration_ms] } / queries.size.to_f).round(2) : 0
54
184
  end