dbviewer 0.3.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +250 -0
- data/Rakefile +8 -0
- data/app/assets/stylesheets/dbviewer/application.css +21 -0
- data/app/assets/stylesheets/dbviewer/dbviewer.css +0 -0
- data/app/assets/stylesheets/dbviewer/enhanced.css +0 -0
- data/app/controllers/concerns/dbviewer/database_operations.rb +354 -0
- data/app/controllers/concerns/dbviewer/error_handling.rb +42 -0
- data/app/controllers/concerns/dbviewer/pagination_concern.rb +43 -0
- data/app/controllers/dbviewer/application_controller.rb +21 -0
- data/app/controllers/dbviewer/databases_controller.rb +0 -0
- data/app/controllers/dbviewer/entity_relationship_diagrams_controller.rb +24 -0
- data/app/controllers/dbviewer/home_controller.rb +10 -0
- data/app/controllers/dbviewer/logs_controller.rb +39 -0
- data/app/controllers/dbviewer/tables_controller.rb +73 -0
- data/app/helpers/dbviewer/application_helper.rb +118 -0
- data/app/jobs/dbviewer/application_job.rb +4 -0
- data/app/mailers/dbviewer/application_mailer.rb +6 -0
- data/app/models/dbviewer/application_record.rb +5 -0
- 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 +82 -0
- data/app/services/dbviewer/query_storage.rb +0 -0
- data/app/views/dbviewer/entity_relationship_diagrams/index.html.erb +564 -0
- data/app/views/dbviewer/home/index.html.erb +237 -0
- data/app/views/dbviewer/logs/index.html.erb +614 -0
- data/app/views/dbviewer/shared/_sidebar.html.erb +177 -0
- data/app/views/dbviewer/tables/_table_structure.html.erb +102 -0
- data/app/views/dbviewer/tables/index.html.erb +128 -0
- data/app/views/dbviewer/tables/query.html.erb +600 -0
- data/app/views/dbviewer/tables/show.html.erb +271 -0
- data/app/views/layouts/dbviewer/application.html.erb +728 -0
- data/config/routes.rb +22 -0
- data/lib/dbviewer/configuration.rb +79 -0
- data/lib/dbviewer/database_manager.rb +450 -0
- data/lib/dbviewer/engine.rb +20 -0
- data/lib/dbviewer/initializer.rb +23 -0
- data/lib/dbviewer/logger.rb +102 -0
- data/lib/dbviewer/query_analyzer.rb +109 -0
- data/lib/dbviewer/query_collection.rb +41 -0
- data/lib/dbviewer/query_parser.rb +82 -0
- data/lib/dbviewer/sql_validator.rb +194 -0
- data/lib/dbviewer/storage/base.rb +31 -0
- data/lib/dbviewer/storage/file_storage.rb +96 -0
- data/lib/dbviewer/storage/in_memory_storage.rb +59 -0
- data/lib/dbviewer/version.rb +3 -0
- data/lib/dbviewer.rb +65 -0
- data/lib/tasks/dbviewer_tasks.rake +4 -0
- metadata +126 -0
@@ -0,0 +1,42 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module ErrorHandling
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
# Ensure common database errors are handled gracefully
|
7
|
+
rescue_from ActiveRecord::ActiveRecordError, with: :handle_database_error
|
8
|
+
end
|
9
|
+
|
10
|
+
# Handle database connection errors
|
11
|
+
def handle_database_error(exception)
|
12
|
+
message = case exception
|
13
|
+
when ActiveRecord::ConnectionNotEstablished
|
14
|
+
"Database connection could not be established."
|
15
|
+
when ActiveRecord::StatementInvalid
|
16
|
+
"Invalid SQL statement: #{exception.message}"
|
17
|
+
else
|
18
|
+
"Database error: #{exception.message}"
|
19
|
+
end
|
20
|
+
|
21
|
+
flash.now[:error] = message
|
22
|
+
Rails.logger.error("Database error: #{exception.message}\n#{exception.backtrace.join("\n")}")
|
23
|
+
|
24
|
+
# Determine where to redirect based on the current action
|
25
|
+
if action_name == "show" || action_name == "query"
|
26
|
+
@error = message
|
27
|
+
@records = nil
|
28
|
+
render action_name
|
29
|
+
else
|
30
|
+
@tables = []
|
31
|
+
render :index
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def log_error(error, prefix = "Error")
|
36
|
+
error_msg = "#{prefix}: #{error.message}"
|
37
|
+
flash.now[:error] = error_msg
|
38
|
+
Rails.logger.error("#{prefix}: #{error.message}\n#{error.backtrace.join("\n")}")
|
39
|
+
error_msg
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module PaginationConcern
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
# Sort direction validation
|
7
|
+
const_set(:VALID_SORT_DIRECTIONS, %w[ASC DESC].freeze) unless const_defined?(:VALID_SORT_DIRECTIONS)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
# Use configuration values from Dbviewer module
|
12
|
+
def per_page_options
|
13
|
+
Dbviewer.configuration.per_page_options
|
14
|
+
end
|
15
|
+
|
16
|
+
def default_per_page
|
17
|
+
Dbviewer.configuration.default_per_page
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Set pagination parameters from request or defaults
|
22
|
+
def set_pagination_params
|
23
|
+
@current_page = [ 1, params[:page].to_i ].max
|
24
|
+
@per_page = params[:per_page] ? params[:per_page].to_i : self.class.default_per_page
|
25
|
+
@per_page = self.class.default_per_page unless self.class.per_page_options.include?(@per_page)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Set sorting parameters from request or defaults
|
29
|
+
def set_sorting_params
|
30
|
+
@order_by = params[:order_by].presence ||
|
31
|
+
database_manager.primary_key(@table_name).presence ||
|
32
|
+
(@columns.first ? @columns.first[:name] : nil)
|
33
|
+
|
34
|
+
@order_direction = params[:order_direction].upcase if params[:order_direction].present?
|
35
|
+
@order_direction = "ASC" unless self.class::VALID_SORT_DIRECTIONS.include?(@order_direction)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Calculate the total number of pages
|
39
|
+
def calculate_total_pages(total_count, per_page)
|
40
|
+
(total_count.to_f / per_page).ceil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
class ApplicationController < ActionController::Base
|
3
|
+
include Dbviewer::DatabaseOperations
|
4
|
+
include Dbviewer::ErrorHandling
|
5
|
+
|
6
|
+
before_action :ensure_development_environment
|
7
|
+
before_action :set_tables
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def ensure_development_environment
|
12
|
+
unless Rails.env.development? || Rails.env.test? || params[:override_env_check] == ENV["DBVIEWER_PRODUCTION_ACCESS_KEY"]
|
13
|
+
render plain: "DBViewer is only available in development and test environments for security reasons.", status: :forbidden
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def set_tables
|
18
|
+
@tables = fetch_tables_with_stats
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
File without changes
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
class EntityRelationshipDiagramsController < ApplicationController
|
3
|
+
def index
|
4
|
+
@tables = fetch_tables_with_stats
|
5
|
+
|
6
|
+
if @tables.present?
|
7
|
+
@table_relationships = fetch_table_relationships
|
8
|
+
else
|
9
|
+
@table_relationships = []
|
10
|
+
flash.now[:warning] = "No tables found in database to generate ERD."
|
11
|
+
end
|
12
|
+
|
13
|
+
respond_to do |format|
|
14
|
+
format.html # Default to HTML view
|
15
|
+
format.json do
|
16
|
+
render json: {
|
17
|
+
tables: @tables,
|
18
|
+
relationships: @table_relationships
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
class LogsController < ApplicationController
|
3
|
+
before_action :set_filters, only: [ :index ]
|
4
|
+
|
5
|
+
def index
|
6
|
+
@queries = Dbviewer::Logger.instance.recent_queries(
|
7
|
+
limit: @limit,
|
8
|
+
table_filter: @table_filter,
|
9
|
+
request_id: @request_id,
|
10
|
+
min_duration: @min_duration
|
11
|
+
)
|
12
|
+
|
13
|
+
if @request_id.present? || @table_filter.present? || @min_duration.present?
|
14
|
+
@stats = Dbviewer::Logger.instance.stats_for_queries(@queries)
|
15
|
+
@filtered_stats = true
|
16
|
+
else
|
17
|
+
@stats = Dbviewer::Logger.instance.stats
|
18
|
+
@filtered_stats = false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def destroy_all
|
23
|
+
Dbviewer::Logger.instance.clear
|
24
|
+
flash[:success] = "Query logs cleared successfully"
|
25
|
+
|
26
|
+
redirect_to logs_path
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def set_filters
|
32
|
+
@table_filter = params[:table_filter]
|
33
|
+
@request_id = params[:request_id]
|
34
|
+
@min_duration = params[:min_duration]
|
35
|
+
@limit = (params[:limit] || 100).to_i
|
36
|
+
@limit = 1000 if @limit > 1000
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
class TablesController < ApplicationController
|
3
|
+
include Dbviewer::PaginationConcern
|
4
|
+
|
5
|
+
def index
|
6
|
+
@tables = fetch_tables_with_stats
|
7
|
+
end
|
8
|
+
|
9
|
+
def show
|
10
|
+
@table_name = params[:id]
|
11
|
+
@columns = fetch_table_columns(@table_name)
|
12
|
+
@metadata = fetch_table_metadata(@table_name)
|
13
|
+
@tables = fetch_tables_with_stats # Fetch tables for sidebar
|
14
|
+
|
15
|
+
set_pagination_params
|
16
|
+
set_sorting_params
|
17
|
+
|
18
|
+
@total_count = fetch_table_record_count(@table_name)
|
19
|
+
@total_pages = calculate_total_pages(@total_count, @per_page)
|
20
|
+
@records = fetch_table_records(@table_name)
|
21
|
+
|
22
|
+
# Fetch timestamp visualization data if the table has a created_at column
|
23
|
+
if has_timestamp_column?(@table_name)
|
24
|
+
@time_grouping = params[:time_group] || "daily"
|
25
|
+
@timestamp_data = fetch_timestamp_data(@table_name, @time_grouping)
|
26
|
+
end
|
27
|
+
|
28
|
+
respond_to do |format|
|
29
|
+
format.html # Default HTML response
|
30
|
+
format.json do
|
31
|
+
render json: {
|
32
|
+
table_name: @table_name,
|
33
|
+
columns: @columns,
|
34
|
+
metadata: @metadata,
|
35
|
+
record_count: @total_count
|
36
|
+
}
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def query
|
42
|
+
@table_name = params[:id]
|
43
|
+
@read_only_mode = true # Flag to indicate we're in read-only mode
|
44
|
+
@columns = fetch_table_columns(@table_name)
|
45
|
+
@tables = fetch_tables_with_stats # Fetch tables for sidebar
|
46
|
+
|
47
|
+
# Set active table for sidebar highlighting
|
48
|
+
@active_table = @table_name
|
49
|
+
|
50
|
+
prepare_query
|
51
|
+
execute_query
|
52
|
+
|
53
|
+
render :query
|
54
|
+
end
|
55
|
+
|
56
|
+
def export_csv
|
57
|
+
table_name = params[:id]
|
58
|
+
limit = (params[:limit] || 10000).to_i
|
59
|
+
include_headers = params[:include_headers] != "0"
|
60
|
+
|
61
|
+
csv_data = export_table_to_csv(table_name, limit, include_headers)
|
62
|
+
|
63
|
+
# Set filename with timestamp for uniqueness
|
64
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
65
|
+
filename = "#{table_name}_export_#{timestamp}.csv"
|
66
|
+
|
67
|
+
# Send data as file
|
68
|
+
send_data csv_data,
|
69
|
+
type: "text/csv; charset=utf-8; header=present",
|
70
|
+
disposition: "attachment; filename=#{filename}"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module ApplicationHelper
|
3
|
+
def format_cell_value(value)
|
4
|
+
return "NULL" if value.nil?
|
5
|
+
return value.to_s.truncate(100) unless value.is_a?(String)
|
6
|
+
|
7
|
+
case value
|
8
|
+
when /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
|
9
|
+
# ISO 8601 datetime
|
10
|
+
begin
|
11
|
+
Time.parse(value).strftime("%Y-%m-%d %H:%M:%S")
|
12
|
+
rescue
|
13
|
+
value.to_s.truncate(100)
|
14
|
+
end
|
15
|
+
when /\A\d{4}-\d{2}-\d{2}\z/
|
16
|
+
# Date
|
17
|
+
value
|
18
|
+
when /\A{.+}\z/, /\A\[.+\]\z/
|
19
|
+
# JSON
|
20
|
+
begin
|
21
|
+
JSON.pretty_generate(JSON.parse(value)).truncate(100)
|
22
|
+
rescue
|
23
|
+
value.to_s.truncate(100)
|
24
|
+
end
|
25
|
+
else
|
26
|
+
value.to_s.truncate(100)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Dark mode helper methods
|
31
|
+
|
32
|
+
# Returns the theme toggle icon based on the current theme
|
33
|
+
def theme_toggle_icon
|
34
|
+
'<i class="bi bi-moon"></i><i class="bi bi-sun"></i>'.html_safe
|
35
|
+
end
|
36
|
+
|
37
|
+
# Returns the aria label for the theme toggle button
|
38
|
+
def theme_toggle_label
|
39
|
+
"Toggle dark mode"
|
40
|
+
end
|
41
|
+
|
42
|
+
# Returns the appropriate background class for stat cards that adapts to dark mode
|
43
|
+
def stat_card_bg_class
|
44
|
+
"stat-card-bg"
|
45
|
+
end
|
46
|
+
|
47
|
+
# Helper method for code blocks background that adapts to dark mode
|
48
|
+
def code_block_bg_class
|
49
|
+
"sql-code-block"
|
50
|
+
end
|
51
|
+
|
52
|
+
# Determine if the current table should be active in the sidebar
|
53
|
+
def current_table?(table_name)
|
54
|
+
@table_name.present? && @table_name == table_name
|
55
|
+
end
|
56
|
+
|
57
|
+
# Format table name for display - truncate if too long
|
58
|
+
def format_table_name(table_name)
|
59
|
+
if table_name.length > 20
|
60
|
+
"#{table_name.first(17)}..."
|
61
|
+
else
|
62
|
+
table_name
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get appropriate icon for column data type
|
67
|
+
def column_type_icon(column_type)
|
68
|
+
case column_type.to_s.downcase
|
69
|
+
when /int/, /serial/, /number/, /decimal/, /float/, /double/
|
70
|
+
"bi-123"
|
71
|
+
when /char/, /text/, /string/, /uuid/
|
72
|
+
"bi-fonts"
|
73
|
+
when /date/, /time/
|
74
|
+
"bi-calendar"
|
75
|
+
when /bool/
|
76
|
+
"bi-toggle-on"
|
77
|
+
when /json/, /jsonb/
|
78
|
+
"bi-braces"
|
79
|
+
when /array/
|
80
|
+
"bi-list-ol"
|
81
|
+
else
|
82
|
+
"bi-file-earmark"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Helper method to determine if current controller and action match
|
87
|
+
def active_nav_class(controller_name, action_name = nil)
|
88
|
+
current_controller = params[:controller].split("/").last
|
89
|
+
active = current_controller == controller_name
|
90
|
+
|
91
|
+
if action_name.present?
|
92
|
+
active = active && params[:action] == action_name
|
93
|
+
end
|
94
|
+
|
95
|
+
active ? "active" : ""
|
96
|
+
end
|
97
|
+
|
98
|
+
# Helper for highlighting dashboard link
|
99
|
+
def dashboard_nav_class
|
100
|
+
active_nav_class("home")
|
101
|
+
end
|
102
|
+
|
103
|
+
# Helper for highlighting tables link
|
104
|
+
def tables_nav_class
|
105
|
+
active_nav_class("tables")
|
106
|
+
end
|
107
|
+
|
108
|
+
# Helper for highlighting ERD link
|
109
|
+
def erd_nav_class
|
110
|
+
active_nav_class("entity_relationship_diagrams")
|
111
|
+
end
|
112
|
+
|
113
|
+
# Helper for highlighting SQL Logs link
|
114
|
+
def logs_nav_class
|
115
|
+
active_nav_class("logs")
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# QueryParser handles parsing SQL queries and extracting useful information
|
3
|
+
class QueryParser
|
4
|
+
# Extract table names from an SQL query string
|
5
|
+
def self.extract_tables(sql)
|
6
|
+
return [] if sql.nil?
|
7
|
+
|
8
|
+
# Convert to lowercase for case-insensitive matching
|
9
|
+
sql = sql.downcase
|
10
|
+
|
11
|
+
# Extract table names after FROM or JOIN
|
12
|
+
sql.scan(/(?:from|join)\s+[`"']?(\w+)[`"']?/).flatten.uniq
|
13
|
+
end
|
14
|
+
|
15
|
+
# Normalize a SQL query to find similar patterns
|
16
|
+
# Replaces specific values with placeholders
|
17
|
+
def self.normalize(sql)
|
18
|
+
return "" if sql.nil?
|
19
|
+
|
20
|
+
sql.gsub(/\b\d+\b/, "N")
|
21
|
+
.gsub(/'[^']*'/, "'X'")
|
22
|
+
.gsub(/"[^"]*"/, '"X"')
|
23
|
+
end
|
24
|
+
|
25
|
+
# Check if the query is from the DBViewer library
|
26
|
+
def self.from_dbviewer?(event)
|
27
|
+
# Check if the SQL itself references DBViewer tables
|
28
|
+
if event.payload[:sql].match(/\b(from|join|update|into)\s+["`']?dbviewer_/i)
|
29
|
+
return true
|
30
|
+
end
|
31
|
+
|
32
|
+
# Check the caller information if available
|
33
|
+
caller = event.payload[:caller]
|
34
|
+
if caller.is_a?(String) && caller.include?("/dbviewer/")
|
35
|
+
return true
|
36
|
+
end
|
37
|
+
|
38
|
+
# Check if query name indicates it's from DBViewer
|
39
|
+
if event.payload[:name].is_a?(String) &&
|
40
|
+
(event.payload[:name].include?("Dbviewer") || event.payload[:name].include?("DBViewer") || event.payload[:name] == "SQL")
|
41
|
+
return true
|
42
|
+
end
|
43
|
+
|
44
|
+
# Check for common DBViewer operations
|
45
|
+
sql = event.payload[:sql].downcase
|
46
|
+
if sql.include?("table_structure") ||
|
47
|
+
sql.include?("schema_migrations") ||
|
48
|
+
sql.include?("database_analytics")
|
49
|
+
return true
|
50
|
+
end
|
51
|
+
|
52
|
+
false
|
53
|
+
end
|
54
|
+
|
55
|
+
# Format bind parameters for storage
|
56
|
+
def self.format_binds(binds)
|
57
|
+
return [] unless binds.respond_to?(:map)
|
58
|
+
|
59
|
+
binds.map do |bind|
|
60
|
+
if bind.respond_to?(:value)
|
61
|
+
bind.value
|
62
|
+
elsif bind.is_a?(Array) && bind.size == 2
|
63
|
+
bind.last
|
64
|
+
else
|
65
|
+
bind.to_s
|
66
|
+
end
|
67
|
+
end
|
68
|
+
rescue
|
69
|
+
[]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Determine if a query should be skipped based on content
|
73
|
+
def self.should_skip_query?(event)
|
74
|
+
event.payload[:name] == "SCHEMA" ||
|
75
|
+
event.payload[:sql].include?("SHOW TABLES") ||
|
76
|
+
event.payload[:sql].include?("sqlite_master") ||
|
77
|
+
event.payload[:sql].include?("information_schema") ||
|
78
|
+
event.payload[:sql].include?("pg_catalog") ||
|
79
|
+
from_dbviewer?(event)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
File without changes
|