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.
- checksums.yaml +4 -4
- data/README.md +34 -2
- data/app/controllers/concerns/dbviewer/database_operations.rb +23 -21
- data/app/controllers/concerns/dbviewer/pagination_concern.rb +8 -0
- data/app/controllers/dbviewer/tables_controller.rb +100 -21
- data/app/helpers/dbviewer/application_helper.rb +73 -0
- data/app/views/dbviewer/shared/_sidebar.html.erb +146 -1
- data/app/views/dbviewer/tables/index.html.erb +7 -1
- data/app/views/dbviewer/tables/show.html.erb +361 -28
- data/lib/dbviewer/database_manager.rb +31 -115
- data/lib/dbviewer/query_analyzer.rb +130 -0
- data/lib/dbviewer/table_query_operations.rb +621 -0
- data/lib/dbviewer/table_query_params.rb +39 -0
- data/lib/dbviewer/version.rb +1 -1
- data/lib/dbviewer.rb +1 -0
- metadata +4 -9
- 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 +0 -82
- data/app/services/dbviewer/query_storage.rb +0 -0
@@ -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
|
-
|
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
|
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,
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
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
|
-
|
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
|
-
@
|
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
|
-
@
|
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
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
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
|