dbviewer 0.5.7 → 0.6.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3385fea543f18e7af2bd9a924b5095f76235612d569fc1e1a828222b8b1a3b53
4
- data.tar.gz: 762f1ee1b3774c9186cbdac735beb0717c6a3feb64ffecd7833ec8732ce91898
3
+ metadata.gz: 53152d4109f427b7d3d32f38292f6c7add1aecc15e7bf9ce8db74458f24f8de8
4
+ data.tar.gz: dc556f4746314538c53a16cd1eabd811b91cfb6c35bee177971de3eae40f9f93
5
5
  SHA512:
6
- metadata.gz: a1552e63407dd92eb3bfa821a4712f6cb57b5c68462c6028a9e3debe14f2e72ce073c631ec1616aa50a5158939ae667899ef4999c2ac5feff8a6f22edd783b08
7
- data.tar.gz: 2852a40ddec1a2a86d2b9764bf527fd21dd140aa57a7ef36da6d7dcf312362c6b24b753c6badd04aee89328557136c954adbad95a442540a233f53affac5489c
6
+ metadata.gz: e4b267eee75e4a92a6a139e226834ff051e306524e1450833b644467b3661c83bffcadb9920450f1196c12919b474f6dca7c3cc6b12f352899c5803984c930e7
7
+ data.tar.gz: f62a2d830f539f8636522035fa60e08535f27c8b0cb77eed42c4baf1103171533e1b8327975a696f5d3c7125bfbc9ae4dc20c1a21d145b3f7b56117f95ccb742
data/README.md CHANGED
@@ -32,6 +32,11 @@ It's designed for development, debugging, and database analysis, offering a clea
32
32
  - View table structure reference while writing queries
33
33
  - Protection against potentially harmful SQL operations
34
34
  - Query execution statistics and timing
35
+ - **Multiple Database Connections**:
36
+ - Connect to multiple databases within your application
37
+ - Switch between connections on-the-fly to view different database schemas
38
+ - Add new database connections from the UI without code changes
39
+ - Test connections to verify they're working properly
35
40
  - **Enhanced UI Features**:
36
41
  - Responsive, Bootstrap-based interface that works on desktop and mobile
37
42
  - Fixed header navigation with quick access to all features
@@ -142,6 +147,34 @@ You can also create this file manually if you prefer.
142
147
 
143
148
  The configuration is accessed through `Dbviewer.configuration` throughout the codebase. You can also access it via `Dbviewer.config` which is an alias for backward compatibility.
144
149
 
150
+ ### Multiple Database Connections
151
+
152
+ DBViewer supports working with multiple database connections in your application. This is useful for applications that connect to multiple databases or use different connection pools.
153
+
154
+ To configure multiple database connections, set them up in your initializer:
155
+
156
+ ```ruby
157
+ # config/initializers/dbviewer.rb
158
+ Dbviewer.configure do |config|
159
+ # Multiple database connections configuration
160
+ config.database_connections = {
161
+ primary: {
162
+ connection_class: "ActiveRecord::Base",
163
+ name: "Primary Database"
164
+ },
165
+ secondary: {
166
+ connection_class: "SecondaryDatabase",
167
+ name: "Blog Database"
168
+ }
169
+ }
170
+
171
+ # Set the default active connection
172
+ config.current_connection = :primary
173
+ end
174
+ ```
175
+
176
+ Each connection needs to reference an ActiveRecord class that establishes a database connection. For more details, see [Multiple Database Connections](docs/multiple_connections.md).
177
+
145
178
  ## 🪵 Query Logging
146
179
 
147
180
  DBViewer includes a powerful SQL query logging system that captures and analyzes database queries. You can access this log through the `/dbviewer/logs` endpoint. The logging system offers two storage backends:
@@ -413,12 +446,6 @@ graph TB
413
446
  Engine -.->|"setup()"| QueryLogger
414
447
  Config -.->|"logging settings"| QueryLogger
415
448
 
416
- classDef decoupled fill:#e1f5fe,stroke:#01579b,stroke-width:2px
417
- classDef controller fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
418
- classDef database fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px
419
- classDef query fill:#fff3e0,stroke:#e65100,stroke-width:2px
420
- classDef storage fill:#fce4ec,stroke:#880e4f,stroke-width:2px
421
-
422
449
  class CacheManager,QueryLogger decoupled
423
450
  class HomeController,TablesController,LogsController,ERDController,APIController controller
424
451
  class Manager,MetadataManager,DynamicModelFactory database
@@ -0,0 +1,70 @@
1
+
2
+ document.addEventListener('DOMContentLoaded', function() {
3
+ // Add connection form validation
4
+ const forms = document.querySelectorAll('.needs-validation');
5
+
6
+ Array.from(forms).forEach(form => {
7
+ form.addEventListener('submit', event => {
8
+ if (!form.checkValidity()) {
9
+ event.preventDefault();
10
+ event.stopPropagation();
11
+ }
12
+
13
+ form.classList.add('was-validated');
14
+ }, false);
15
+ });
16
+
17
+ // Handle connection test
18
+ const testButtons = document.querySelectorAll('.test-connection-btn');
19
+
20
+ Array.from(testButtons).forEach(button => {
21
+ button.addEventListener('click', async function(event) {
22
+ event.preventDefault();
23
+ const connectionKey = this.dataset.connectionKey;
24
+ const statusElement = document.getElementById(`connection-status-${connectionKey}`);
25
+
26
+ if (!statusElement) return;
27
+
28
+ statusElement.innerHTML = '<i class="bi bi-arrow-repeat spin me-1"></i> Testing connection...';
29
+ statusElement.classList.remove('text-success', 'text-danger');
30
+
31
+ try {
32
+ const response = await fetch(`/dbviewer/api/connections/${connectionKey}/test`, {
33
+ method: 'GET',
34
+ headers: {
35
+ 'Accept': 'application/json',
36
+ 'X-Requested-With': 'XMLHttpRequest'
37
+ }
38
+ });
39
+
40
+ const data = await response.json();
41
+
42
+ if (data.success) {
43
+ statusElement.innerHTML = '<i class="bi bi-check-circle-fill me-1"></i> Connection successful';
44
+ statusElement.classList.add('text-success');
45
+ } else {
46
+ statusElement.innerHTML = `<i class="bi bi-x-circle-fill me-1"></i> ${data.error || 'Connection failed'}`;
47
+ statusElement.classList.add('text-danger');
48
+ }
49
+ } catch (error) {
50
+ statusElement.innerHTML = '<i class="bi bi-x-circle-fill me-1"></i> Error testing connection';
51
+ statusElement.classList.add('text-danger');
52
+ }
53
+ });
54
+ });
55
+ });
56
+
57
+ // Auto-generate connection key from name
58
+ function setupConnectionKeyGenerator() {
59
+ const nameInput = document.getElementById('connection_name');
60
+ const keyInput = document.getElementById('connection_key');
61
+
62
+ if (nameInput && keyInput) {
63
+ nameInput.addEventListener('input', function() {
64
+ keyInput.value = this.value
65
+ .toLowerCase()
66
+ .replace(/\s+/g, '_')
67
+ .replace(/[^a-z0-9_]/g, '');
68
+ });
69
+ }
70
+ }
@@ -5,12 +5,100 @@ module Dbviewer
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- helper_method :current_table?, :get_database_name, :get_adapter_name if respond_to?(:helper_method)
8
+ helper_method :current_table?, :get_database_name, :get_adapter_name,
9
+ :current_connection_key, :available_connections if respond_to?(:helper_method)
9
10
  end
10
11
 
11
- # Initialize the database manager
12
+ # Get the current active connection key
13
+ def current_connection_key
14
+ # Get the connection key from the session or fall back to the default
15
+ key = session[:dbviewer_connection] || Dbviewer.configuration.current_connection
16
+
17
+ # Ensure the key actually exists in our configured connections
18
+ if key && Dbviewer.configuration.database_connections.key?(key.to_sym)
19
+ return key.to_sym
20
+ end
21
+
22
+ # If the key doesn't exist, fall back to any available connection
23
+ first_key = Dbviewer.configuration.database_connections.keys.first
24
+ if first_key
25
+ session[:dbviewer_connection] = first_key # Update the session
26
+ return first_key
27
+ end
28
+
29
+ # If there are no connections configured, use a default key
30
+ # This should never happen in normal operation, but it's a safety measure
31
+ :default
32
+ end
33
+
34
+ # Set the current connection to use
35
+ def switch_connection(connection_key)
36
+ connection_key = connection_key.to_sym if connection_key.respond_to?(:to_sym)
37
+
38
+ if connection_key && Dbviewer.configuration.database_connections.key?(connection_key)
39
+ session[:dbviewer_connection] = connection_key
40
+ # Clear the database manager to force it to be recreated with the new connection
41
+ @database_manager = nil
42
+ return true
43
+ else
44
+ # If the connection key doesn't exist, reset to default connection
45
+ if Dbviewer.configuration.database_connections.key?(Dbviewer.configuration.current_connection)
46
+ session[:dbviewer_connection] = Dbviewer.configuration.current_connection
47
+ @database_manager = nil
48
+ return true
49
+ else
50
+ # If even the default connection isn't valid, try the first available connection
51
+ first_key = Dbviewer.configuration.database_connections.keys.first
52
+ if first_key
53
+ session[:dbviewer_connection] = first_key
54
+ @database_manager = nil
55
+ return true
56
+ end
57
+ end
58
+ end
59
+
60
+ false # Return false if we couldn't set a valid connection
61
+ end
62
+
63
+ # Get list of available connections
64
+ def available_connections
65
+ connections = Dbviewer.configuration.database_connections.map do |key, config|
66
+ # Try to determine the adapter name if it's not already stored
67
+ adapter_name = nil
68
+ if config[:adapter_name].present?
69
+ adapter_name = config[:adapter_name]
70
+ elsif config[:connection].present?
71
+ begin
72
+ adapter_name = config[:connection].connection.adapter_name
73
+ rescue => e
74
+ Rails.logger.error("Error getting adapter name: #{e.message}")
75
+ end
76
+ end
77
+
78
+ {
79
+ key: key,
80
+ name: config[:name] || key.to_s.humanize,
81
+ adapter_name: adapter_name,
82
+ current: key.to_sym == current_connection_key.to_sym
83
+ }
84
+ end
85
+
86
+ # Ensure at least one connection is marked as current
87
+ unless connections.any? { |c| c[:current] }
88
+ # If no connection is current, mark the first one as current
89
+ if connections.any?
90
+ connections.first[:current] = true
91
+ # Also update the session
92
+ session[:dbviewer_connection] = connections.first[:key]
93
+ end
94
+ end
95
+
96
+ connections
97
+ end
98
+
99
+ # Initialize the database manager with the current connection
12
100
  def database_manager
13
- @database_manager ||= ::Dbviewer::Database::Manager.new
101
+ @database_manager = ::Dbviewer::Database::Manager.new(current_connection_key)
14
102
  end
15
103
 
16
104
  # Initialize the table query operations manager
@@ -21,6 +109,12 @@ module Dbviewer
21
109
 
22
110
  # Get the name of the current database
23
111
  def get_database_name
112
+ # First check if this connection has a name in the configuration
113
+ current_conn_config = Dbviewer.configuration.database_connections[current_connection_key]
114
+ if current_conn_config && current_conn_config[:name].present?
115
+ return current_conn_config[:name]
116
+ end
117
+
24
118
  adapter = database_manager.connection.adapter_name.downcase
25
119
 
26
120
  case adapter
@@ -34,7 +128,7 @@ module Dbviewer
34
128
  result ? result["db_name"] : "Database"
35
129
  when /sqlite/
36
130
  # For SQLite, extract the database name from the connection_config
37
- database_path = ActiveRecord::Base.connection.pool.spec.config[:database] || ""
131
+ database_path = database_manager.connection.pool.spec.config[:database] || ""
38
132
  File.basename(database_path, ".*") || "SQLite Database"
39
133
  else
40
134
  "Database" # Default fallback
@@ -68,11 +162,21 @@ module Dbviewer
68
162
  name: table_name
69
163
  }
70
164
 
71
- # Only fetch record counts if explicitly requested
72
- table_stats[:record_count] = database_manager.record_count(table_name) if include_record_counts
165
+ # Only fetch record count if specifically requested
166
+ if include_record_counts
167
+ begin
168
+ table_stats[:record_count] = database_manager.record_count(table_name)
169
+ rescue => e
170
+ Rails.logger.error("Error fetching record count for #{table_name}: #{e.message}")
171
+ table_stats[:record_count] = 0
172
+ end
173
+ end
73
174
 
74
175
  table_stats
75
176
  end
177
+ rescue => e
178
+ Rails.logger.error("Error fetching tables: #{e.message}")
179
+ []
76
180
  end
77
181
 
78
182
  # Gather database analytics information
@@ -4,74 +4,44 @@ module Dbviewer
4
4
  before_action :set_tables
5
5
 
6
6
  def relationships
7
- # Fetch all relationships asynchronously
8
- begin
9
- @table_relationships = fetch_table_relationships
10
- render_success({
11
- relationships: @table_relationships,
12
- status: "success"
13
- })
14
- rescue => e
15
- Rails.logger.error("[DBViewer] Error fetching relationships: #{e.message}")
16
- render json: {
17
- relationships: [],
18
- status: "error",
19
- error: e.message
20
- }, status: :internal_server_error
21
- end
7
+ @table_relationships = fetch_table_relationships
8
+ render_success({
9
+ relationships: @table_relationships,
10
+ status: "success"
11
+ })
22
12
  end
23
13
 
24
14
  def table_relationships
25
- # Fetch relationships for specific tables
26
15
  table_names = params[:tables]&.split(",") || []
27
-
28
- if table_names.blank?
29
- render json: {
30
- relationships: [],
31
- status: "error",
32
- error: "No tables specified"
33
- }, status: :bad_request
34
- return
35
- end
36
-
37
- begin
38
- relationships = []
39
-
40
- table_names.each do |table_name|
41
- next unless @tables.any? { |t| t[:name] == table_name }
42
-
43
- begin
44
- metadata = fetch_table_metadata(table_name)
45
- if metadata && metadata[:foreign_keys].present?
46
- metadata[:foreign_keys].each do |fk|
47
- relationships << {
48
- from_table: table_name,
49
- to_table: fk[:to_table],
50
- from_column: fk[:column],
51
- to_column: fk[:primary_key],
52
- name: fk[:name]
53
- }
54
- end
16
+ relationships = []
17
+
18
+ table_names.each do |table_name|
19
+ next unless @tables.any? { |t| t[:name] == table_name }
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
+ }
55
32
  end
56
- rescue => e
57
- Rails.logger.error("[DBViewer] Error fetching relationships for #{table_name}: #{e.message}")
58
- # Continue with other tables even if one fails
59
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
60
37
  end
61
-
62
- render_success({
63
- relationships: relationships,
64
- status: "success",
65
- processed_tables: table_names
66
- })
67
- rescue => e
68
- Rails.logger.error("[DBViewer] Error in table_relationships: #{e.message}")
69
- render json: {
70
- relationships: [],
71
- status: "error",
72
- error: e.message
73
- }, status: :internal_server_error
74
38
  end
39
+
40
+ render_success({
41
+ relationships: relationships,
42
+ status: "success",
43
+ processed_tables: table_names
44
+ })
75
45
  end
76
46
 
77
47
  private
@@ -16,6 +16,26 @@ module Dbviewer
16
16
  render_success(total_relationships: total_relationships)
17
17
  end
18
18
 
19
+ def relationship_counts
20
+ table_name = params[:id]
21
+ record_id = params[:record_id]
22
+
23
+ reverse_foreign_keys = fetch_table_metadata(table_name).dig(:reverse_foreign_keys) || []
24
+ relationship_counts = reverse_foreign_keys.map do |rel|
25
+ {
26
+ table: rel[:from_table],
27
+ foreign_key: rel[:column],
28
+ count: database_manager.get_model_for(rel[:from_table]).where(rel[:column] => record_id).count
29
+ }
30
+ end
31
+
32
+ render_success({
33
+ table_name: table_name,
34
+ record_id: record_id,
35
+ relationships: relationship_counts
36
+ })
37
+ end
38
+
19
39
  private
20
40
 
21
41
  def fetch_tables_count
@@ -0,0 +1,35 @@
1
+ module Dbviewer
2
+ class ConnectionsController < ApplicationController
3
+ # GET /dbviewer/connections
4
+ def index
5
+ @connections = available_connections
6
+
7
+ respond_to do |format|
8
+ format.html
9
+ format.json { render json: @connections }
10
+ end
11
+ end
12
+
13
+ # POST /dbviewer/connections/:id
14
+ def update
15
+ connection_key = params[:id]
16
+
17
+ if switch_connection(connection_key)
18
+ # Safely get the connection name
19
+ connection_config = Dbviewer.configuration.database_connections[connection_key.to_sym]
20
+ connection_name = connection_config && connection_config[:name] ? connection_config[:name] : "selected"
21
+
22
+ respond_to do |format|
23
+ format.html { redirect_to root_path, notice: "Switched to #{connection_name} database." }
24
+ format.json { render json: { success: true, current: connection_key } }
25
+ end
26
+ else
27
+ # Handle the case where switching failed
28
+ respond_to do |format|
29
+ format.html { redirect_to connections_path, alert: "Failed to switch connection. The connection may be invalid." }
30
+ format.json { render json: { success: false, error: "Invalid connection" }, status: :unprocessable_entity }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,6 +1,11 @@
1
1
  module Dbviewer
2
2
  class HomeController < ApplicationController
3
3
  def index
4
+ @current_connection_info = {
5
+ name: get_database_name,
6
+ adapter: get_adapter_name,
7
+ key: current_connection_key
8
+ }
4
9
  end
5
10
 
6
11
  private
@@ -33,7 +33,13 @@ module Dbviewer
33
33
 
34
34
  # Generate operator options based on column type
35
35
  def operator_options_for_column_type(column_type)
36
- if column_type && (column_type =~ /datetime/ || column_type =~ /^date$/ || column_type =~ /^time$/)
36
+ # Common operators for all types
37
+ common_operators = [
38
+ [ "is null", "is_null" ],
39
+ [ "is not null", "is_not_null" ]
40
+ ]
41
+
42
+ type_specific_operators = if column_type && (column_type =~ /datetime/ || column_type =~ /^date$/ || column_type =~ /^time$/)
37
43
  # Date/Time operators
38
44
  [
39
45
  [ "=", "eq" ],
@@ -64,28 +70,58 @@ module Dbviewer
64
70
  [ "ends with", "ends_with" ]
65
71
  ]
66
72
  end
73
+
74
+ # Return type-specific operators first, then common operators
75
+ type_specific_operators + common_operators
67
76
  end
68
77
 
69
78
  # Render column filter input based on column type
70
79
  def render_column_filter_input(form, column_name, column_type, column_filters)
71
- if column_type && column_type =~ /datetime/
80
+ # Get selected operator to check if it's a null operator
81
+ operator = column_filters["#{column_name}_operator"]
82
+ is_null_operator = operator == "is_null" || operator == "is_not_null"
83
+
84
+ # Clean up the value for non-null operators if the value contains a null operator
85
+ # This ensures we don't carry over 'is_null' or 'is_not_null' values when switching operators
86
+ value = column_filters[column_name]
87
+ if !is_null_operator && value.present? && (value == "is_null" || value == "is_not_null")
88
+ value = nil
89
+ end
90
+
91
+ # For null operators, display a non-editable field without placeholder
92
+ if is_null_operator
93
+ # Keep a hidden field for the actual value
94
+ hidden_field = form.hidden_field("column_filters[#{column_name}]",
95
+ value: operator,
96
+ class: "null-filter-value",
97
+ data: { column: column_name })
98
+
99
+ # Add a visible but disabled text field with no placeholder or value
100
+ visible_field = form.text_field("column_filters[#{column_name}_display]",
101
+ disabled: true,
102
+ value: "",
103
+ class: "form-control form-control-sm column-filter rounded-0 disabled-filter",
104
+ data: { column: "#{column_name}_display" })
105
+
106
+ hidden_field + visible_field
107
+ elsif column_type && column_type =~ /datetime/
72
108
  form.datetime_local_field("column_filters[#{column_name}]",
73
- value: column_filters[column_name],
109
+ value: value,
74
110
  class: "form-control form-control-sm column-filter rounded-0",
75
111
  data: { column: column_name })
76
112
  elsif column_type && column_type =~ /^date$/
77
113
  form.date_field("column_filters[#{column_name}]",
78
- value: column_filters[column_name],
114
+ value: value,
79
115
  class: "form-control form-control-sm column-filter rounded-0",
80
116
  data: { column: column_name })
81
117
  elsif column_type && column_type =~ /^time$/
82
118
  form.time_field("column_filters[#{column_name}]",
83
- value: column_filters[column_name],
119
+ value: value,
84
120
  class: "form-control form-control-sm column-filter rounded-0",
85
121
  data: { column: column_name })
86
122
  else
87
123
  form.text_field("column_filters[#{column_name}]",
88
- value: column_filters[column_name],
124
+ value: value,
89
125
  placeholder: "",
90
126
  class: "form-control form-control-sm column-filter rounded-0",
91
127
  data: { column: column_name })
@@ -113,8 +149,10 @@ module Dbviewer
113
149
  column_type = column_type_from_info(column_name, columns)
114
150
 
115
151
  content_tag(:div, class: "filter-input-group") do
116
- render_operator_select(form, column_name, column_type, column_filters) +
117
- render_column_filter_input(form, column_name, column_type, column_filters)
152
+ operator_select = render_operator_select(form, column_name, column_type, column_filters)
153
+ input_field = render_column_filter_input(form, column_name, column_type, column_filters)
154
+
155
+ operator_select + input_field
118
156
  end
119
157
  end
120
158