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
@@ -0,0 +1,621 @@
|
|
1
|
+
require "dbviewer/error_handler"
|
2
|
+
require "dbviewer/table_query_params"
|
3
|
+
require "dbviewer/query_analyzer"
|
4
|
+
|
5
|
+
module Dbviewer
|
6
|
+
# TableQueryOperations handles CRUD operations and data querying for database tables
|
7
|
+
# It provides methods to fetch, filter and manipulate data in tables
|
8
|
+
class TableQueryOperations
|
9
|
+
attr_reader :connection, :adapter_name
|
10
|
+
|
11
|
+
# Initialize with dependencies
|
12
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] The database connection
|
13
|
+
# @param dynamic_model_factory [DynamicModelFactory] Factory for creating dynamic AR models
|
14
|
+
# @param query_executor [QueryExecutor] Executor for raw SQL queries
|
15
|
+
# @param table_metadata_manager [TableMetadataManager] Manager for table metadata
|
16
|
+
def initialize(connection, dynamic_model_factory, query_executor, table_metadata_manager)
|
17
|
+
@connection = connection
|
18
|
+
@adapter_name = connection.adapter_name.downcase
|
19
|
+
@dynamic_model_factory = dynamic_model_factory
|
20
|
+
@query_executor = query_executor
|
21
|
+
@table_metadata_manager = table_metadata_manager
|
22
|
+
@query_analyzer = Dbviewer::QueryAnalyzer.new(connection)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Get the number of columns in a table
|
26
|
+
# @param table_name [String] Name of the table
|
27
|
+
# @return [Integer] Number of columns
|
28
|
+
def column_count(table_name)
|
29
|
+
table_columns(table_name).size
|
30
|
+
end
|
31
|
+
|
32
|
+
# Get records from a table with pagination and sorting
|
33
|
+
# @param table_name [String] Name of the table
|
34
|
+
# @param params [TableQueryParams] Query parameters object
|
35
|
+
# @return [ActiveRecord::Result] Result set with columns and rows
|
36
|
+
def table_records(table_name, params)
|
37
|
+
model = get_model_for(table_name)
|
38
|
+
query = model.all
|
39
|
+
|
40
|
+
# Apply column filters if provided
|
41
|
+
query = apply_column_filters(query, table_name, params.column_filters)
|
42
|
+
|
43
|
+
# Apply sorting if provided
|
44
|
+
if params.order_by.present? && column_exists?(table_name, params.order_by)
|
45
|
+
query = query.order("#{connection.quote_column_name(params.order_by)} #{params.direction}")
|
46
|
+
end
|
47
|
+
|
48
|
+
# Apply pagination
|
49
|
+
records = query.limit(params.per_page).offset((params.page - 1) * params.per_page)
|
50
|
+
|
51
|
+
# Get column names for consistent ordering
|
52
|
+
column_names = table_columns(table_name).map { |c| c[:name] }
|
53
|
+
|
54
|
+
# Format results
|
55
|
+
@query_executor.to_result_set(records, column_names)
|
56
|
+
end
|
57
|
+
|
58
|
+
# Get the total count of records in a table
|
59
|
+
# @param table_name [String] Name of the table
|
60
|
+
# @return [Integer] Number of records
|
61
|
+
def table_count(table_name)
|
62
|
+
model = get_model_for(table_name)
|
63
|
+
model.count
|
64
|
+
end
|
65
|
+
|
66
|
+
# Alias for table_count
|
67
|
+
# @param table_name [String] Name of the table
|
68
|
+
# @return [Integer] Number of records
|
69
|
+
def record_count(table_name)
|
70
|
+
table_count(table_name)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Get the number of records in a table with filters applied
|
74
|
+
# @param table_name [String] Name of the table
|
75
|
+
# @param column_filters [Hash] Hash of column_name => filter_value for filtering
|
76
|
+
# @return [Integer] Number of filtered records
|
77
|
+
def filtered_record_count(table_name, column_filters = {})
|
78
|
+
model = get_model_for(table_name)
|
79
|
+
query = model.all
|
80
|
+
|
81
|
+
# Apply column filters if provided
|
82
|
+
query = apply_column_filters(query, table_name, column_filters)
|
83
|
+
|
84
|
+
query.count
|
85
|
+
end
|
86
|
+
|
87
|
+
# Execute a raw SQL query after validating for safety
|
88
|
+
# @param sql [String] SQL query to execute
|
89
|
+
# @return [ActiveRecord::Result] Result set with columns and rows
|
90
|
+
# @raise [StandardError] If the query is invalid or unsafe
|
91
|
+
def execute_query(sql)
|
92
|
+
@query_executor.execute_query(sql)
|
93
|
+
end
|
94
|
+
|
95
|
+
# Execute a SQLite PRAGMA command without adding a LIMIT clause
|
96
|
+
# @param pragma [String] PRAGMA command to execute (without the "PRAGMA" keyword)
|
97
|
+
# @return [ActiveRecord::Result] Result set with the PRAGMA value
|
98
|
+
# @raise [StandardError] If the query is invalid or cannot be executed
|
99
|
+
def execute_sqlite_pragma(pragma)
|
100
|
+
@query_executor.execute_sqlite_pragma(pragma)
|
101
|
+
end
|
102
|
+
|
103
|
+
# Query a table with more granular control using ActiveRecord
|
104
|
+
# @param table_name [String] Name of the table
|
105
|
+
# @param select [String, Array] Columns to select
|
106
|
+
# @param order [String, Hash] Order by clause
|
107
|
+
# @param limit [Integer] Maximum number of records to return
|
108
|
+
# @param offset [Integer] Offset from which to start returning records
|
109
|
+
# @param where [String, Hash] Where conditions
|
110
|
+
# @return [ActiveRecord::Result] Result set with columns and rows
|
111
|
+
def query_table(table_name, select: nil, order: nil, limit: nil, offset: nil, where: nil, max_records: 1000)
|
112
|
+
model = get_model_for(table_name)
|
113
|
+
query = model.all
|
114
|
+
|
115
|
+
query = query.select(select) if select.present?
|
116
|
+
query = query.where(where) if where.present?
|
117
|
+
query = query.order(order) if order.present?
|
118
|
+
|
119
|
+
# Apply safety limit
|
120
|
+
query = query.limit([ limit || max_records, max_records ].min)
|
121
|
+
query = query.offset(offset) if offset.present?
|
122
|
+
|
123
|
+
# Get column names for the result set
|
124
|
+
column_names = if select.is_a?(Array)
|
125
|
+
select
|
126
|
+
elsif select.is_a?(String) && !select.include?("*")
|
127
|
+
select.split(",").map(&:strip)
|
128
|
+
else
|
129
|
+
table_columns(table_name).map { |c| c[:name] }
|
130
|
+
end
|
131
|
+
|
132
|
+
@query_executor.to_result_set(query, column_names)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Fetch timestamp data for visualization
|
136
|
+
# @param table_name [String] Name of the table
|
137
|
+
# @param grouping [String] Grouping type (hourly, daily, weekly, monthly)
|
138
|
+
# @param column [String] Timestamp column name (defaults to created_at)
|
139
|
+
# @return [Array<Hash>] Array of timestamp data with labels and counts
|
140
|
+
def fetch_timestamp_data(table_name, grouping = "daily", column = "created_at")
|
141
|
+
return [] unless column_exists?(table_name, column)
|
142
|
+
|
143
|
+
adapter = @connection.adapter_name.downcase
|
144
|
+
|
145
|
+
date_format = case grouping
|
146
|
+
when "hourly"
|
147
|
+
if adapter =~ /mysql/
|
148
|
+
"DATE_FORMAT(#{column}, '%Y-%m-%d %H:00')"
|
149
|
+
elsif adapter =~ /sqlite/
|
150
|
+
"strftime('%Y-%m-%d %H:00', #{column})"
|
151
|
+
else # postgresql
|
152
|
+
"TO_CHAR(#{column}, 'YYYY-MM-DD HH24:00')"
|
153
|
+
end
|
154
|
+
when "weekly"
|
155
|
+
if adapter =~ /mysql/
|
156
|
+
"DATE_FORMAT(#{column}, '%Y-%v')"
|
157
|
+
elsif adapter =~ /sqlite/
|
158
|
+
"strftime('%Y-%W', #{column})"
|
159
|
+
else # postgresql
|
160
|
+
"TO_CHAR(#{column}, 'YYYY-IW')"
|
161
|
+
end
|
162
|
+
when "monthly"
|
163
|
+
if adapter =~ /mysql/
|
164
|
+
"DATE_FORMAT(#{column}, '%Y-%m')"
|
165
|
+
elsif adapter =~ /sqlite/
|
166
|
+
"strftime('%Y-%m', #{column})"
|
167
|
+
else # postgresql
|
168
|
+
"TO_CHAR(#{column}, 'YYYY-MM')"
|
169
|
+
end
|
170
|
+
else # daily is default
|
171
|
+
if adapter =~ /mysql/
|
172
|
+
"DATE(#{column})"
|
173
|
+
elsif adapter =~ /sqlite/
|
174
|
+
"date(#{column})"
|
175
|
+
else # postgresql
|
176
|
+
"DATE(#{column})"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
# Query works the same for all database adapters
|
181
|
+
query = "SELECT #{date_format} as label, COUNT(*) as count FROM #{table_name}
|
182
|
+
WHERE #{column} IS NOT NULL
|
183
|
+
GROUP BY label
|
184
|
+
ORDER BY MIN(#{column}) DESC LIMIT 30"
|
185
|
+
|
186
|
+
begin
|
187
|
+
result = @connection.execute(query)
|
188
|
+
|
189
|
+
# Format depends on adapter
|
190
|
+
if adapter =~ /mysql/
|
191
|
+
result.to_a.map { |row| { label: row[0], value: row[1] } }
|
192
|
+
elsif adapter =~ /sqlite/
|
193
|
+
result.map { |row| { label: row["label"], value: row["count"] } }
|
194
|
+
else # postgresql
|
195
|
+
result.map { |row| { label: row["label"], value: row["count"] } }
|
196
|
+
end
|
197
|
+
rescue => e
|
198
|
+
Rails.logger.error("Error fetching timestamp data: #{e.message}")
|
199
|
+
[]
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# Analyze query performance for a table with given filters
|
204
|
+
# @param table_name [String] Name of the table
|
205
|
+
# @param params [TableQueryParams] Query parameters object
|
206
|
+
# @return [Hash] Performance analysis and recommendations
|
207
|
+
def analyze_query_performance(table_name, params)
|
208
|
+
@query_analyzer.analyze_query(table_name, params)
|
209
|
+
end
|
210
|
+
|
211
|
+
private
|
212
|
+
|
213
|
+
# Apply column filters to a query
|
214
|
+
# @param query [ActiveRecord::Relation] The query to apply filters to
|
215
|
+
# @param table_name [String] Name of the table
|
216
|
+
# @param column_filters [Hash] Hash of column_name => filter_value pairs
|
217
|
+
# @return [ActiveRecord::Relation] The filtered query
|
218
|
+
def apply_column_filters(query, table_name, column_filters)
|
219
|
+
return query unless column_filters.present?
|
220
|
+
|
221
|
+
# Create a copy of column_filters to modify without affecting the original
|
222
|
+
filters = column_filters.dup
|
223
|
+
|
224
|
+
# First check if we have a datetime range filter for created_at
|
225
|
+
if filters["created_at"].present? &&
|
226
|
+
filters["created_at_end"].present? &&
|
227
|
+
column_exists?(table_name, "created_at")
|
228
|
+
|
229
|
+
# Handle datetime range for created_at
|
230
|
+
begin
|
231
|
+
start_datetime = Time.parse(filters["created_at"].to_s)
|
232
|
+
end_datetime = Time.parse(filters["created_at_end"].to_s)
|
233
|
+
|
234
|
+
# Make sure end_datetime is at the end of the day/minute if it doesn't have time component
|
235
|
+
if end_datetime.to_s.match(/00:00:00/)
|
236
|
+
end_datetime = end_datetime.end_of_day
|
237
|
+
end
|
238
|
+
|
239
|
+
Rails.logger.info("[DBViewer] Applying date range filter on #{table_name}.created_at: #{start_datetime} to #{end_datetime}")
|
240
|
+
|
241
|
+
# Use qualified column name for tables with schema
|
242
|
+
column_name = "#{table_name}.created_at"
|
243
|
+
|
244
|
+
# Different databases may require different SQL for datetime comparison
|
245
|
+
adapter_name = connection.adapter_name.downcase
|
246
|
+
if adapter_name.include?("sqlite")
|
247
|
+
# SQLite needs special handling for datetimes
|
248
|
+
query = query.where("datetime(#{column_name}) BETWEEN datetime(?) AND datetime(?)",
|
249
|
+
start_datetime.iso8601, end_datetime.iso8601)
|
250
|
+
else
|
251
|
+
# Standard SQL for most databases
|
252
|
+
query = query.where("#{column_name} BETWEEN ? AND ?", start_datetime, end_datetime)
|
253
|
+
end
|
254
|
+
|
255
|
+
# Remove these keys so they're not processed again
|
256
|
+
filters.delete("created_at")
|
257
|
+
filters.delete("created_at_end")
|
258
|
+
filters.delete("created_at_operator")
|
259
|
+
rescue => e
|
260
|
+
Rails.logger.error("[DBViewer] Failed to parse datetime range: #{e.message}")
|
261
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
# Process remaining filters
|
266
|
+
filters.each do |column, value|
|
267
|
+
# Skip operator entries (they'll be handled with their corresponding value)
|
268
|
+
next if column.to_s.end_with?("_operator") || column.to_s.end_with?("_end")
|
269
|
+
next if value.blank?
|
270
|
+
next unless column_exists?(table_name, column)
|
271
|
+
|
272
|
+
# Get operator if available, otherwise use default
|
273
|
+
operator = filters["#{column}_operator"]
|
274
|
+
|
275
|
+
query = apply_column_filter(query, column, value, table_name, operator)
|
276
|
+
end
|
277
|
+
|
278
|
+
query
|
279
|
+
end
|
280
|
+
|
281
|
+
# Get a dynamic AR model for a table
|
282
|
+
# @param table_name [String] Name of the table
|
283
|
+
# @return [Class] ActiveRecord model class
|
284
|
+
def get_model_for(table_name)
|
285
|
+
@dynamic_model_factory.get_model_for(table_name)
|
286
|
+
end
|
287
|
+
|
288
|
+
# Get column information for a table
|
289
|
+
# @param table_name [String] Name of the table
|
290
|
+
# @return [Array<Hash>] List of column info hashes
|
291
|
+
def table_columns(table_name)
|
292
|
+
@table_metadata_manager.table_columns(table_name)
|
293
|
+
end
|
294
|
+
|
295
|
+
# Check if column exists in table
|
296
|
+
# @param table_name [String] Name of the table
|
297
|
+
# @param column_name [String] Name of the column
|
298
|
+
# @return [Boolean] True if column exists
|
299
|
+
def column_exists?(table_name, column_name)
|
300
|
+
@table_metadata_manager.column_exists?(table_name, column_name)
|
301
|
+
end
|
302
|
+
|
303
|
+
# Helper method to apply column filters to a query
|
304
|
+
# @param query [ActiveRecord::Relation] The query to apply filters to
|
305
|
+
# @param column [String] The column name to filter on
|
306
|
+
# @param value [String] The value to filter by
|
307
|
+
# @param table_name [String] The name of the table being queried
|
308
|
+
# @param operator [String] The operator to use for filtering (eq, neq, lt, gt, etc.)
|
309
|
+
# @return [ActiveRecord::Relation] The filtered query
|
310
|
+
def apply_column_filter(query, column, value, table_name, operator = nil)
|
311
|
+
column_info = table_columns(table_name).find { |c| c[:name] == column }
|
312
|
+
return query unless column_info
|
313
|
+
|
314
|
+
column_type = column_info[:type].to_s
|
315
|
+
quoted_column = connection.quote_column_name(column)
|
316
|
+
|
317
|
+
# Default to appropriate operator if none specified or "default" value is used
|
318
|
+
operator = default_operator_for_type(column_type) if operator.nil? || operator == "default"
|
319
|
+
|
320
|
+
# Detect the column type for appropriate filtering
|
321
|
+
if datetime_type?(column_type)
|
322
|
+
filter_datetime_column(query, quoted_column, value, operator)
|
323
|
+
elsif date_type?(column_type)
|
324
|
+
filter_date_column(query, quoted_column, value, operator)
|
325
|
+
elsif time_type?(column_type)
|
326
|
+
filter_time_column(query, quoted_column, value, operator)
|
327
|
+
elsif numeric_type?(column_type)
|
328
|
+
filter_numeric_column(query, quoted_column, value, operator)
|
329
|
+
elsif string_type?(column_type)
|
330
|
+
filter_string_column(query, quoted_column, value, operator)
|
331
|
+
else
|
332
|
+
# For unknown types, try numeric filter if value is numeric, otherwise string filter
|
333
|
+
numeric_pattern = /\A[+-]?\d+(\.\d+)?\z/
|
334
|
+
if value =~ numeric_pattern
|
335
|
+
filter_numeric_column(query, quoted_column, value, operator)
|
336
|
+
else
|
337
|
+
filter_string_column(query, quoted_column, value, operator)
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
# Determine if column is a datetime type
|
343
|
+
# @param column_type [String] The column type from database
|
344
|
+
# @return [Boolean] True if datetime type
|
345
|
+
def datetime_type?(column_type)
|
346
|
+
column_type =~ /datetime|timestamp/i
|
347
|
+
end
|
348
|
+
|
349
|
+
# Determine if column is a date type
|
350
|
+
# @param column_type [String] The column type from database
|
351
|
+
# @return [Boolean] True if date type
|
352
|
+
def date_type?(column_type)
|
353
|
+
column_type =~ /^date$/i
|
354
|
+
end
|
355
|
+
|
356
|
+
# Determine if column is a time type
|
357
|
+
# @param column_type [String] The column type from database
|
358
|
+
# @return [Boolean] True if time type
|
359
|
+
def time_type?(column_type)
|
360
|
+
column_type =~ /^time$/i
|
361
|
+
end
|
362
|
+
|
363
|
+
# Determine if column is a numeric type
|
364
|
+
# @param column_type [String] The column type from database
|
365
|
+
# @return [Boolean] True if numeric type
|
366
|
+
def numeric_type?(column_type)
|
367
|
+
column_type =~ /int|float|decimal|double|number|numeric|real|money|bigint|smallint|tinyint|mediumint|bit/i
|
368
|
+
end
|
369
|
+
|
370
|
+
# Determine if column is a string type
|
371
|
+
# @param column_type [String] The column type from database
|
372
|
+
# @return [Boolean] True if string type
|
373
|
+
def string_type?(column_type)
|
374
|
+
column_type =~ /char|text|string|uuid|enum/i
|
375
|
+
end
|
376
|
+
|
377
|
+
# Get default operator based on column type
|
378
|
+
# @param column_type [String] The column type from database
|
379
|
+
# @return [String] The default operator
|
380
|
+
def default_operator_for_type(column_type)
|
381
|
+
if string_type?(column_type)
|
382
|
+
"contains"
|
383
|
+
else
|
384
|
+
"eq"
|
385
|
+
end
|
386
|
+
end
|
387
|
+
|
388
|
+
# Apply comparison operator to query
|
389
|
+
# @param query [ActiveRecord::Relation] The query
|
390
|
+
# @param column [String] The quoted column name
|
391
|
+
# @param value [Object] The value to compare
|
392
|
+
# @param operator [String] The operator
|
393
|
+
# @return [ActiveRecord::Relation] The modified query
|
394
|
+
def apply_comparison_operator(query, column, value, operator)
|
395
|
+
case operator
|
396
|
+
when "eq"
|
397
|
+
query.where("#{column} = ?", value)
|
398
|
+
when "neq"
|
399
|
+
query.where("#{column} != ?", value)
|
400
|
+
when "lt"
|
401
|
+
query.where("#{column} < ?", value)
|
402
|
+
when "gt"
|
403
|
+
query.where("#{column} > ?", value)
|
404
|
+
when "lte"
|
405
|
+
query.where("#{column} <= ?", value)
|
406
|
+
when "gte"
|
407
|
+
query.where("#{column} >= ?", value)
|
408
|
+
else
|
409
|
+
# Default to equality
|
410
|
+
query.where("#{column} = ?", value)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
# Apply string comparison operator
|
415
|
+
# @param query [ActiveRecord::Relation] The query
|
416
|
+
# @param column [String] The quoted column name
|
417
|
+
# @param value [String] The string value to compare
|
418
|
+
# @param operator [String] The operator
|
419
|
+
# @return [ActiveRecord::Relation] The modified query
|
420
|
+
def apply_string_operator(query, column, value, operator)
|
421
|
+
# Cast to text for UUID columns when using string operations like LIKE
|
422
|
+
column_expr = @adapter_name =~ /postgresql/ ? "CAST(#{column} AS TEXT)" : column
|
423
|
+
|
424
|
+
case operator
|
425
|
+
when "contains"
|
426
|
+
query.where("#{column_expr} LIKE ?", "%#{value}%")
|
427
|
+
when "not_contains"
|
428
|
+
query.where("#{column_expr} NOT LIKE ?", "%#{value}%")
|
429
|
+
when "eq"
|
430
|
+
query.where("#{column} = ?", value)
|
431
|
+
when "neq"
|
432
|
+
query.where("#{column} != ?", value)
|
433
|
+
when "starts_with"
|
434
|
+
query.where("#{column_expr} LIKE ?", "#{value}%")
|
435
|
+
when "ends_with"
|
436
|
+
query.where("#{column_expr} LIKE ?", "%#{value}")
|
437
|
+
else
|
438
|
+
# Default to contains
|
439
|
+
query.where("#{column_expr} LIKE ?", "%#{value}%")
|
440
|
+
end
|
441
|
+
end
|
442
|
+
|
443
|
+
# Filter datetime column
|
444
|
+
# @param query [ActiveRecord::Relation] The query
|
445
|
+
# @param quoted_column [String] The quoted column name
|
446
|
+
# @param value [String] The value to filter by
|
447
|
+
# @param operator [String] The operator
|
448
|
+
# @return [ActiveRecord::Relation] The modified query
|
449
|
+
def filter_datetime_column(query, quoted_column, value, operator)
|
450
|
+
begin
|
451
|
+
Rails.logger.info("[DBViewer] Filtering datetime column #{quoted_column} with value: #{value.inspect} and operator: #{operator.inspect}")
|
452
|
+
|
453
|
+
# Handle HTML datetime-local format (2023-05-10T14:30)
|
454
|
+
parsed_date = if value.to_s.include?("T")
|
455
|
+
Time.parse(value.to_s)
|
456
|
+
else
|
457
|
+
# Handle regular date/time string
|
458
|
+
Time.parse(value.to_s)
|
459
|
+
end
|
460
|
+
|
461
|
+
# Different databases handle datetime differently, so adapt the query based on the adapter
|
462
|
+
adapter = connection.adapter_name.downcase
|
463
|
+
|
464
|
+
# Apply the appropriate operator - special handling for 'eq' on dates to match full day
|
465
|
+
if operator == "eq"
|
466
|
+
# For equality, match the entire day (from midnight to 23:59:59)
|
467
|
+
start_of_day = parsed_date.beginning_of_day
|
468
|
+
end_of_day = parsed_date.end_of_day
|
469
|
+
|
470
|
+
if adapter.include?("sqlite")
|
471
|
+
query.where("datetime(#{quoted_column}) BETWEEN datetime(?) AND datetime(?)",
|
472
|
+
start_of_day.iso8601, end_of_day.iso8601)
|
473
|
+
else
|
474
|
+
query.where("#{quoted_column} BETWEEN ? AND ?", start_of_day, end_of_day)
|
475
|
+
end
|
476
|
+
elsif operator == "gte"
|
477
|
+
if adapter.include?("sqlite")
|
478
|
+
query.where("datetime(#{quoted_column}) >= datetime(?)", parsed_date.iso8601)
|
479
|
+
else
|
480
|
+
query.where("#{quoted_column} >= ?", parsed_date)
|
481
|
+
end
|
482
|
+
elsif operator == "lte"
|
483
|
+
if adapter.include?("sqlite")
|
484
|
+
query.where("datetime(#{quoted_column}) <= datetime(?)", parsed_date.iso8601)
|
485
|
+
else
|
486
|
+
query.where("#{quoted_column} <= ?", parsed_date)
|
487
|
+
end
|
488
|
+
elsif operator == "gt"
|
489
|
+
if adapter.include?("sqlite")
|
490
|
+
query.where("datetime(#{quoted_column}) > datetime(?)", parsed_date.iso8601)
|
491
|
+
else
|
492
|
+
query.where("#{quoted_column} > ?", parsed_date)
|
493
|
+
end
|
494
|
+
elsif operator == "lt"
|
495
|
+
if adapter.include?("sqlite")
|
496
|
+
query.where("datetime(#{quoted_column}) < datetime(?)", parsed_date.iso8601)
|
497
|
+
else
|
498
|
+
query.where("#{quoted_column} < ?", parsed_date)
|
499
|
+
end
|
500
|
+
else
|
501
|
+
# Default to equality
|
502
|
+
apply_comparison_operator(query, quoted_column, parsed_date, operator || "eq")
|
503
|
+
end
|
504
|
+
|
505
|
+
Rails.logger.info("[DBViewer] Successfully applied datetime filter")
|
506
|
+
query
|
507
|
+
rescue => e
|
508
|
+
Rails.logger.error("[DBViewer] Failed to parse datetime: #{e.message}")
|
509
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
510
|
+
# Fallback to string comparison if parsing fails
|
511
|
+
query.where("CAST(#{quoted_column} AS CHAR) LIKE ?", "%#{value}%")
|
512
|
+
end
|
513
|
+
end
|
514
|
+
|
515
|
+
# Filter date column
|
516
|
+
# @param query [ActiveRecord::Relation] The query
|
517
|
+
# @param quoted_column [String] The quoted column name
|
518
|
+
# @param value [String] The value to filter by
|
519
|
+
# @param operator [String] The operator
|
520
|
+
# @return [ActiveRecord::Relation] The modified query
|
521
|
+
def filter_date_column(query, quoted_column, value, operator)
|
522
|
+
begin
|
523
|
+
parsed_date = Date.parse(value.to_s)
|
524
|
+
apply_comparison_operator(query, quoted_column, parsed_date, operator)
|
525
|
+
rescue => e
|
526
|
+
Rails.logger.debug("[DBViewer] Failed to parse date: #{e.message}")
|
527
|
+
# Fallback to string comparison if parsing fails
|
528
|
+
query.where("CAST(#{quoted_column} AS CHAR) LIKE ?", "%#{value}%")
|
529
|
+
end
|
530
|
+
end
|
531
|
+
|
532
|
+
# Filter time column
|
533
|
+
# @param query [ActiveRecord::Relation] The query
|
534
|
+
# @param quoted_column [String] The quoted column name
|
535
|
+
# @param value [String] The value to filter by
|
536
|
+
# @param operator [String] The operator
|
537
|
+
# @return [ActiveRecord::Relation] The modified query
|
538
|
+
def filter_time_column(query, quoted_column, value, operator)
|
539
|
+
begin
|
540
|
+
parsed_time = Time.parse(value.to_s)
|
541
|
+
formatted_time = parsed_time.strftime("%H:%M:%S")
|
542
|
+
|
543
|
+
# Prepare time expression based on database adapter
|
544
|
+
time_expr = get_time_expression(quoted_column)
|
545
|
+
|
546
|
+
apply_comparison_operator(query, time_expr, formatted_time, operator)
|
547
|
+
rescue => e
|
548
|
+
Rails.logger.debug("[DBViewer] Failed to parse time: #{e.message}")
|
549
|
+
# Fallback to string comparison if parsing fails
|
550
|
+
query.where("CAST(#{quoted_column} AS CHAR) LIKE ?", "%#{value}%")
|
551
|
+
end
|
552
|
+
end
|
553
|
+
|
554
|
+
# Get the appropriate time expression based on database adapter
|
555
|
+
# @param quoted_column [String] The quoted column name
|
556
|
+
# @return [String] The time expression
|
557
|
+
def get_time_expression(quoted_column)
|
558
|
+
if @adapter_name =~ /mysql/
|
559
|
+
"TIME(#{quoted_column})"
|
560
|
+
elsif @adapter_name =~ /postgresql/
|
561
|
+
"CAST(#{quoted_column} AS TIME)"
|
562
|
+
else
|
563
|
+
# SQLite and others
|
564
|
+
quoted_column
|
565
|
+
end
|
566
|
+
end
|
567
|
+
|
568
|
+
# Filter numeric column
|
569
|
+
# @param query [ActiveRecord::Relation] The query
|
570
|
+
# @param quoted_column [String] The quoted column name
|
571
|
+
# @param value [String] The value to filter by
|
572
|
+
# @param operator [String] The operator
|
573
|
+
# @return [ActiveRecord::Relation] The modified query
|
574
|
+
def filter_numeric_column(query, quoted_column, value, operator)
|
575
|
+
numeric_pattern = /\A[+-]?\d+(\.\d+)?\z/
|
576
|
+
|
577
|
+
if value =~ numeric_pattern
|
578
|
+
# Convert to proper numeric type for comparison
|
579
|
+
numeric_value = value.include?(".") ? value.to_f : value.to_i
|
580
|
+
Rails.logger.debug("[DBViewer] Converting value #{value} to numeric: #{numeric_value}")
|
581
|
+
|
582
|
+
apply_comparison_operator(query, quoted_column, numeric_value, operator)
|
583
|
+
else
|
584
|
+
# Non-numeric value for numeric column, try string comparison
|
585
|
+
filter_non_numeric_value(query, quoted_column, value, operator)
|
586
|
+
end
|
587
|
+
end
|
588
|
+
|
589
|
+
# Filter non-numeric value for numeric column
|
590
|
+
# @param query [ActiveRecord::Relation] The query
|
591
|
+
# @param quoted_column [String] The quoted column name
|
592
|
+
# @param value [String] The value to filter by
|
593
|
+
# @param operator [String] The operator
|
594
|
+
# @return [ActiveRecord::Relation] The modified query
|
595
|
+
def filter_non_numeric_value(query, quoted_column, value, operator)
|
596
|
+
case operator
|
597
|
+
when "contains", "starts_with", "ends_with"
|
598
|
+
query.where("CAST(#{quoted_column} AS CHAR) LIKE ?", "%#{value}%")
|
599
|
+
when "not_contains"
|
600
|
+
query.where("CAST(#{quoted_column} AS CHAR) NOT LIKE ?", "%#{value}%")
|
601
|
+
when "eq"
|
602
|
+
query.where("CAST(#{quoted_column} AS CHAR) = ?", value)
|
603
|
+
when "neq"
|
604
|
+
query.where("CAST(#{quoted_column} AS CHAR) != ?", value)
|
605
|
+
else
|
606
|
+
# Default to contains
|
607
|
+
query.where("CAST(#{quoted_column} AS CHAR) LIKE ?", "%#{value}%")
|
608
|
+
end
|
609
|
+
end
|
610
|
+
|
611
|
+
# Filter string column
|
612
|
+
# @param query [ActiveRecord::Relation] The query
|
613
|
+
# @param quoted_column [String] The quoted column name
|
614
|
+
# @param value [String] The value to filter by
|
615
|
+
# @param operator [String] The operator
|
616
|
+
# @return [ActiveRecord::Relation] The modified query
|
617
|
+
def filter_string_column(query, quoted_column, value, operator)
|
618
|
+
apply_string_operator(query, quoted_column, value, operator)
|
619
|
+
end
|
620
|
+
end
|
621
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# TableQueryParams encapsulates parameters for table querying operations
|
3
|
+
class TableQueryParams
|
4
|
+
attr_reader :page, :order_by, :direction, :per_page, :column_filters, :max_records
|
5
|
+
|
6
|
+
# Initialize query parameters with defaults
|
7
|
+
# @param page [Integer] Page number (1-based)
|
8
|
+
# @param order_by [String, nil] Column to sort by
|
9
|
+
# @param direction [String] Sort direction ('ASC' or 'DESC')
|
10
|
+
# @param per_page [Integer, nil] Number of records per page
|
11
|
+
# @param column_filters [Hash, nil] Hash of column filters
|
12
|
+
# @param max_records [Integer] Maximum number of records to fetch
|
13
|
+
def initialize(
|
14
|
+
page: 1,
|
15
|
+
order_by: nil,
|
16
|
+
direction: "ASC",
|
17
|
+
per_page: nil,
|
18
|
+
column_filters: nil,
|
19
|
+
max_records: 1000
|
20
|
+
)
|
21
|
+
@page = [ 1, page.to_i ].max
|
22
|
+
@order_by = order_by
|
23
|
+
@direction = normalize_direction(direction)
|
24
|
+
@per_page = normalize_per_page(per_page || 25, max_records)
|
25
|
+
@column_filters = column_filters || {}
|
26
|
+
@max_records = max_records
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def normalize_direction(dir)
|
32
|
+
%w[ASC DESC].include?(dir.to_s.upcase) ? dir.to_s.upcase : "ASC"
|
33
|
+
end
|
34
|
+
|
35
|
+
def normalize_per_page(per_page_value, max)
|
36
|
+
[ per_page_value.to_i, max ].min
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/lib/dbviewer/version.rb
CHANGED
data/lib/dbviewer.rb
CHANGED
@@ -6,6 +6,7 @@ require "dbviewer/cache_manager"
|
|
6
6
|
require "dbviewer/table_metadata_manager"
|
7
7
|
require "dbviewer/dynamic_model_factory"
|
8
8
|
require "dbviewer/query_executor"
|
9
|
+
require "dbviewer/table_query_operations"
|
9
10
|
require "dbviewer/database_manager"
|
10
11
|
require "dbviewer/sql_validator"
|
11
12
|
|