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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/controllers/concerns/dbviewer/database_operations.rb +89 -100
- data/app/controllers/dbviewer/api/entity_relationship_diagrams_controller.rb +25 -23
- data/app/controllers/dbviewer/tables_controller.rb +9 -9
- data/app/helpers/dbviewer/application_helper.rb +33 -13
- data/app/views/dbviewer/tables/show.html.erb +225 -139
- data/app/views/layouts/dbviewer/application.html.erb +55 -0
- data/lib/dbviewer/database/dynamic_model_factory.rb +40 -5
- data/lib/dbviewer/datatable/query_operations.rb +52 -197
- data/lib/dbviewer/engine.rb +1 -22
- data/lib/dbviewer/query/executor.rb +1 -1
- data/lib/dbviewer/query/notification_subscriber.rb +46 -0
- data/lib/dbviewer/validator/sql.rb +198 -0
- data/lib/dbviewer/validator.rb +9 -0
- data/lib/dbviewer/version.rb +1 -1
- data/lib/dbviewer.rb +69 -45
- data/lib/generators/dbviewer/templates/initializer.rb +15 -0
- metadata +5 -3
- data/lib/dbviewer/sql_validator.rb +0 -194
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b378480a03f4c61f60ee8e6b918b81bfabb09fcb3f7256010d6ee7d59795feb3
|
4
|
+
data.tar.gz: 0ed1b725b27aa7c1290f767ce900ee8d5be1e2dd54df5edcaecef71f8945658f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d7e70b3cb0c7ad44b8d5dee1fc476d85738a3169b1aaccac9e9aea242f0e1cf2801836341f4c391f9dd7d184985606376ca09366dada9944cd8058111a407105
|
7
|
+
data.tar.gz: 9914b89d8c4678cf949edad75d713d74cdc9aa9ece9f67cabe967afd42d411730d8d34839ee9c006393ec0f35bc2a3cfe88d8195b49caaec99d6aab09f5c9d03
|
data/README.md
CHANGED
@@ -373,7 +373,7 @@ graph TB
|
|
373
373
|
subgraph "DBViewer Engine"
|
374
374
|
Engine[Engine<br/>Rails::Engine]
|
375
375
|
Config[Configuration<br/>Settings & Defaults]
|
376
|
-
|
376
|
+
Validator::Sql[Validator::Sql<br/>Query Validation]
|
377
377
|
end
|
378
378
|
|
379
379
|
subgraph "Controllers Layer"
|
@@ -239,31 +239,33 @@ module Dbviewer
|
|
239
239
|
|
240
240
|
# Fetch relationships between tables for ERD visualization
|
241
241
|
def fetch_table_relationships
|
242
|
-
relationships
|
243
|
-
|
244
|
-
|
245
|
-
table_name = table[:name]
|
246
|
-
|
247
|
-
# Get foreign keys defined in this table pointing to others
|
248
|
-
begin
|
249
|
-
metadata = database_manager.table_metadata(table_name)
|
250
|
-
if metadata && metadata[:foreign_keys].present?
|
251
|
-
metadata[:foreign_keys].each do |fk|
|
252
|
-
relationships << {
|
253
|
-
from_table: table_name,
|
254
|
-
to_table: fk[:to_table],
|
255
|
-
from_column: fk[:column],
|
256
|
-
to_column: fk[:primary_key],
|
257
|
-
name: fk[:name]
|
258
|
-
}
|
259
|
-
end
|
260
|
-
end
|
261
|
-
rescue => e
|
262
|
-
Rails.logger.error("Error fetching relationships for #{table_name}: #{e.message}")
|
263
|
-
end
|
242
|
+
# Use functional approach: flat_map to extract all relationships from all tables
|
243
|
+
@tables.flat_map do |table|
|
244
|
+
extract_table_relationships_from_metadata(table[:name])
|
264
245
|
end
|
246
|
+
end
|
265
247
|
|
266
|
-
|
248
|
+
private
|
249
|
+
|
250
|
+
# Extract relationships for a single table from its metadata
|
251
|
+
# @param table_name [String] The name of the table to process
|
252
|
+
# @return [Array<Hash>] Array of relationship hashes for this table
|
253
|
+
def extract_table_relationships_from_metadata(table_name)
|
254
|
+
metadata = database_manager.table_metadata(table_name)
|
255
|
+
return [] unless metadata&.dig(:foreign_keys)&.present?
|
256
|
+
|
257
|
+
metadata[:foreign_keys].map do |fk|
|
258
|
+
{
|
259
|
+
from_table: table_name,
|
260
|
+
to_table: fk[:to_table],
|
261
|
+
from_column: fk[:column],
|
262
|
+
to_column: fk[:primary_key],
|
263
|
+
name: fk[:name]
|
264
|
+
}
|
265
|
+
end
|
266
|
+
rescue => e
|
267
|
+
Rails.logger.error("Error fetching relationships for #{table_name}: #{e.message}")
|
268
|
+
[] # Return empty array to continue processing other tables
|
267
269
|
end
|
268
270
|
|
269
271
|
# Get mini ERD data for a specific table and its relationships
|
@@ -399,7 +401,7 @@ module Dbviewer
|
|
399
401
|
@query = params[:query].present? ? params[:query].to_s : default_query
|
400
402
|
|
401
403
|
# Validate query for security
|
402
|
-
unless ::Dbviewer::
|
404
|
+
unless ::Dbviewer::Validator::Sql.safe_query?(@query)
|
403
405
|
@query = default_query
|
404
406
|
flash.now[:warning] = "Only SELECT queries are allowed. Your query contained potentially unsafe operations. Using default query instead."
|
405
407
|
end
|
@@ -439,8 +441,71 @@ module Dbviewer
|
|
439
441
|
csv_data
|
440
442
|
end
|
441
443
|
|
444
|
+
# Consolidated method to fetch all datatable-related data in one call
|
445
|
+
# Returns a hash containing all necessary datatable information
|
446
|
+
def fetch_datatable_data(table_name, query_params)
|
447
|
+
# Fetch all required data using functional programming patterns
|
448
|
+
columns = fetch_table_columns(table_name)
|
449
|
+
|
450
|
+
# Handle case where table has no columns (should rarely happen)
|
451
|
+
return default_datatable_structure(table_name) if columns.empty?
|
452
|
+
|
453
|
+
# Fetch records with error handling for null cases
|
454
|
+
records = begin
|
455
|
+
fetch_table_records(table_name, query_params)
|
456
|
+
rescue => e
|
457
|
+
Rails.logger.error("Error fetching table records for #{table_name}: #{e.message}")
|
458
|
+
nil
|
459
|
+
end
|
460
|
+
|
461
|
+
# Calculate total count - use filtered count if filters are present
|
462
|
+
total_count = begin
|
463
|
+
if query_params.column_filters.empty?
|
464
|
+
fetch_table_record_count(table_name)
|
465
|
+
else
|
466
|
+
fetch_filtered_record_count(table_name, query_params.column_filters)
|
467
|
+
end
|
468
|
+
rescue => e
|
469
|
+
Rails.logger.error("Error fetching record count for #{table_name}: #{e.message}")
|
470
|
+
0
|
471
|
+
end
|
472
|
+
|
473
|
+
# Calculate pagination data functionally with safety check
|
474
|
+
total_pages = total_count > 0 ? (total_count.to_f / query_params.per_page).ceil : 0
|
475
|
+
|
476
|
+
# Get metadata with error handling
|
477
|
+
metadata = fetch_table_metadata(table_name)
|
478
|
+
|
479
|
+
{
|
480
|
+
columns: columns,
|
481
|
+
records: records,
|
482
|
+
total_count: total_count,
|
483
|
+
total_pages: total_pages,
|
484
|
+
metadata: metadata,
|
485
|
+
current_page: query_params.page,
|
486
|
+
per_page: query_params.per_page,
|
487
|
+
order_by: query_params.order_by,
|
488
|
+
direction: query_params.direction
|
489
|
+
}
|
490
|
+
end
|
491
|
+
|
442
492
|
private
|
443
493
|
|
494
|
+
# Default structure for tables with no data/columns
|
495
|
+
def default_datatable_structure(table_name)
|
496
|
+
{
|
497
|
+
columns: [],
|
498
|
+
records: [],
|
499
|
+
total_count: 0,
|
500
|
+
total_pages: 0,
|
501
|
+
metadata: {},
|
502
|
+
current_page: 1,
|
503
|
+
per_page: 25,
|
504
|
+
order_by: nil,
|
505
|
+
direction: "ASC"
|
506
|
+
}
|
507
|
+
end
|
508
|
+
|
444
509
|
# Format cell values for CSV export to handle nil values and special characters
|
445
510
|
def format_csv_value(value)
|
446
511
|
return "" if value.nil?
|
@@ -452,81 +517,5 @@ module Dbviewer
|
|
452
517
|
columns = fetch_table_columns(table_name)
|
453
518
|
columns.any? { |col| col[:name] == "created_at" && [ :datetime, :timestamp ].include?(col[:type]) }
|
454
519
|
end
|
455
|
-
|
456
|
-
# Fetch timestamp data for visualization (hourly, daily, weekly)
|
457
|
-
def fetch_timestamp_data(table_name, grouping = "daily")
|
458
|
-
return nil unless has_timestamp_column?(table_name)
|
459
|
-
|
460
|
-
quoted_table = safe_quote_table_name(table_name)
|
461
|
-
adapter = database_manager.connection.adapter_name.downcase
|
462
|
-
|
463
|
-
sql_query = case grouping
|
464
|
-
when "hourly"
|
465
|
-
case adapter
|
466
|
-
when /mysql/
|
467
|
-
"SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 48"
|
468
|
-
when /postgres/
|
469
|
-
"SELECT date_trunc('hour', created_at) as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 48"
|
470
|
-
else # SQLite and others
|
471
|
-
"SELECT strftime('%Y-%m-%d %H:00:00', created_at) as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 48"
|
472
|
-
end
|
473
|
-
when "daily"
|
474
|
-
case adapter
|
475
|
-
when /mysql/
|
476
|
-
"SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 30"
|
477
|
-
when /postgres/
|
478
|
-
"SELECT date_trunc('day', created_at) as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 30"
|
479
|
-
else # SQLite and others
|
480
|
-
"SELECT strftime('%Y-%m-%d', created_at) as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 30"
|
481
|
-
end
|
482
|
-
when "weekly"
|
483
|
-
case adapter
|
484
|
-
when /mysql/
|
485
|
-
"SELECT DATE_FORMAT(created_at, '%Y-%u') as time_group, YEARWEEK(created_at) as sort_key, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY sort_key DESC LIMIT 26"
|
486
|
-
when /postgres/
|
487
|
-
"SELECT date_trunc('week', created_at) as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 26"
|
488
|
-
else # SQLite and others
|
489
|
-
"SELECT strftime('%Y-%W', created_at) as time_group, COUNT(*) as count FROM #{quoted_table} GROUP BY time_group ORDER BY time_group DESC LIMIT 26"
|
490
|
-
end
|
491
|
-
else
|
492
|
-
return nil
|
493
|
-
end
|
494
|
-
|
495
|
-
begin
|
496
|
-
result = database_manager.execute_query(sql_query)
|
497
|
-
|
498
|
-
# Format the data for the chart
|
499
|
-
result.map do |row|
|
500
|
-
time_str = row["time_group"].to_s
|
501
|
-
count = row["count"].to_i
|
502
|
-
|
503
|
-
# Format the label based on grouping type
|
504
|
-
label = case grouping
|
505
|
-
when "hourly"
|
506
|
-
# For hourly, show "May 10, 2PM"
|
507
|
-
time = time_str.is_a?(Time) ? time_str : Time.parse(time_str)
|
508
|
-
time.strftime("%b %d, %l%p")
|
509
|
-
when "daily"
|
510
|
-
# For daily, show "May 10"
|
511
|
-
time = time_str.is_a?(Time) ? time_str : (time_str.include?("-") ? Time.parse(time_str) : Time.now)
|
512
|
-
time.strftime("%b %d")
|
513
|
-
when "weekly"
|
514
|
-
# For weekly, show "Week 19" or the week's start date
|
515
|
-
if time_str.include?("-")
|
516
|
-
week_num = time_str.split("-").last.to_i
|
517
|
-
"Week #{week_num}"
|
518
|
-
else
|
519
|
-
time = time_str.is_a?(Time) ? time_str : Time.parse(time_str)
|
520
|
-
"Week #{time.strftime('%W')}"
|
521
|
-
end
|
522
|
-
end
|
523
|
-
|
524
|
-
{ label: label, value: count, raw_date: time_str }
|
525
|
-
end
|
526
|
-
rescue => e
|
527
|
-
Rails.logger.error("[DBViewer] Error fetching timestamp data: #{e.message}")
|
528
|
-
nil
|
529
|
-
end
|
530
|
-
end
|
531
520
|
end
|
532
521
|
end
|
@@ -13,29 +13,10 @@ module Dbviewer
|
|
13
13
|
|
14
14
|
def table_relationships
|
15
15
|
table_names = params[:tables]&.split(",") || []
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
begin
|
22
|
-
metadata = fetch_table_metadata(table_name)
|
23
|
-
if metadata && metadata[:foreign_keys].present?
|
24
|
-
metadata[:foreign_keys].each do |fk|
|
25
|
-
relationships << {
|
26
|
-
from_table: table_name,
|
27
|
-
to_table: fk[:to_table],
|
28
|
-
from_column: fk[:column],
|
29
|
-
to_column: fk[:primary_key],
|
30
|
-
name: fk[:name]
|
31
|
-
}
|
32
|
-
end
|
33
|
-
end
|
34
|
-
rescue => e
|
35
|
-
Rails.logger.error("[DBViewer] Error fetching relationships for #{table_name}: #{e.message}")
|
36
|
-
# Continue with other tables even if one fails
|
37
|
-
end
|
38
|
-
end
|
16
|
+
|
17
|
+
relationships = table_names
|
18
|
+
.filter { |table_name| @tables.any? { |t| t[:name] == table_name } }
|
19
|
+
.flat_map { |table_name| extract_table_relationships(table_name) }
|
39
20
|
|
40
21
|
render_success({
|
41
22
|
relationships: relationships,
|
@@ -49,6 +30,27 @@ module Dbviewer
|
|
49
30
|
def set_tables
|
50
31
|
@tables = fetch_tables
|
51
32
|
end
|
33
|
+
|
34
|
+
# Extract relationships for a single table, handling errors gracefully
|
35
|
+
# @param table_name [String] The name of the table to process
|
36
|
+
# @return [Array<Hash>] Array of relationship hashes for this table
|
37
|
+
def extract_table_relationships(table_name)
|
38
|
+
metadata = fetch_table_metadata(table_name)
|
39
|
+
return [] unless metadata&.dig(:foreign_keys)&.present?
|
40
|
+
|
41
|
+
metadata[:foreign_keys].map do |fk|
|
42
|
+
{
|
43
|
+
from_table: table_name,
|
44
|
+
to_table: fk[:to_table],
|
45
|
+
from_column: fk[:column],
|
46
|
+
to_column: fk[:primary_key],
|
47
|
+
name: fk[:name]
|
48
|
+
}
|
49
|
+
end
|
50
|
+
rescue => e
|
51
|
+
Rails.logger.error("[DBViewer] Error fetching relationships for #{table_name}: #{e.message}")
|
52
|
+
[] # Return empty array to continue processing other tables
|
53
|
+
end
|
52
54
|
end
|
53
55
|
end
|
54
56
|
end
|
@@ -18,16 +18,16 @@ module Dbviewer
|
|
18
18
|
direction: @order_direction,
|
19
19
|
column_filters: @column_filters.reject { |_, v| v.blank? }
|
20
20
|
)
|
21
|
-
@total_count = fetch_total_count(@table_name, query_params)
|
22
|
-
@records = fetch_table_records(@table_name, query_params)
|
23
|
-
@total_pages = calculate_total_pages(@total_count, @per_page)
|
24
|
-
@columns = fetch_table_columns(@table_name)
|
25
|
-
@metadata = fetch_table_metadata(@table_name)
|
26
21
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
22
|
+
# Get all datatable data in one method call
|
23
|
+
datatable_data = fetch_datatable_data(@table_name, query_params)
|
24
|
+
|
25
|
+
# Assign to instance variables for view access
|
26
|
+
@total_count = datatable_data[:total_count]
|
27
|
+
@records = datatable_data[:records]
|
28
|
+
@total_pages = datatable_data[:total_pages]
|
29
|
+
@columns = datatable_data[:columns]
|
30
|
+
@metadata = datatable_data[:metadata]
|
31
31
|
|
32
32
|
respond_to do |format|
|
33
33
|
format.html # Default HTML response
|
@@ -505,19 +505,39 @@ module Dbviewer
|
|
505
505
|
end
|
506
506
|
|
507
507
|
content_tag(:td, class: "text-center action-column") do
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
508
|
+
content_tag(:div, class: "d-flex gap-1 justify-content-center") do
|
509
|
+
# View Record button (existing)
|
510
|
+
view_button = button_tag(
|
511
|
+
type: "button",
|
512
|
+
class: "btn btn-sm btn-primary view-record-btn",
|
513
|
+
title: "View Record Details",
|
514
|
+
data: {
|
515
|
+
bs_toggle: "modal",
|
516
|
+
bs_target: "#recordDetailModal",
|
517
|
+
record_data: data_attributes.to_json,
|
518
|
+
foreign_keys: metadata && metadata[:foreign_keys] ? metadata[:foreign_keys].to_json : "[]",
|
519
|
+
reverse_foreign_keys: metadata && metadata[:reverse_foreign_keys] ? metadata[:reverse_foreign_keys].to_json : "[]"
|
520
|
+
}
|
521
|
+
) do
|
522
|
+
content_tag(:i, "", class: "bi bi-eye")
|
523
|
+
end
|
524
|
+
|
525
|
+
# Copy FactoryBot button (new)
|
526
|
+
copy_factory_button = button_tag(
|
527
|
+
type: "button",
|
528
|
+
class: "btn btn-sm btn-outline-secondary copy-factory-btn",
|
529
|
+
title: "Copy to JSON",
|
530
|
+
data: {
|
531
|
+
record_data: data_attributes.to_json,
|
532
|
+
table_name: @table_name
|
533
|
+
},
|
534
|
+
onclick: "copyToJson(this)"
|
535
|
+
) do
|
536
|
+
content_tag(:i, "", class: "bi bi-clipboard")
|
537
|
+
end
|
538
|
+
|
539
|
+
# Concatenate both buttons
|
540
|
+
view_button + copy_factory_button
|
521
541
|
end
|
522
542
|
end
|
523
543
|
end
|