dbviewer 0.5.8 → 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 +4 -4
- data/README.md +33 -6
- data/app/assets/javascripts/dbviewer/connections.js +70 -0
- data/app/controllers/concerns/dbviewer/database_operations.rb +110 -6
- data/app/controllers/dbviewer/api/entity_relationship_diagrams_controller.rb +30 -60
- data/app/controllers/dbviewer/api/tables_controller.rb +12 -47
- data/app/controllers/dbviewer/connections_controller.rb +35 -0
- data/app/controllers/dbviewer/home_controller.rb +5 -0
- data/app/helpers/dbviewer/application_helper.rb +46 -8
- data/app/views/dbviewer/connections/index.html.erb +119 -0
- data/app/views/dbviewer/connections/new.html.erb +79 -0
- data/app/views/dbviewer/shared/_tables_sidebar.html.erb +49 -0
- data/app/views/dbviewer/tables/index.html.erb +0 -8
- data/app/views/dbviewer/tables/show.html.erb +59 -0
- data/app/views/layouts/dbviewer/application.html.erb +22 -1
- data/config/routes.rb +12 -0
- data/lib/dbviewer/configuration.rb +17 -0
- data/lib/dbviewer/database/dynamic_model_factory.rb +4 -12
- data/lib/dbviewer/database/manager.rb +19 -10
- data/lib/dbviewer/datatable/query_operations.rb +21 -1
- data/lib/dbviewer/version.rb +1 -1
- data/lib/dbviewer.rb +37 -4
- metadata +7 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 53152d4109f427b7d3d32f38292f6c7add1aecc15e7bf9ce8db74458f24f8de8
|
4
|
+
data.tar.gz: dc556f4746314538c53a16cd1eabd811b91cfb6c35bee177971de3eae40f9f93
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
#
|
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
|
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 =
|
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
|
72
|
-
|
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
|
-
|
8
|
-
|
9
|
-
@table_relationships
|
10
|
-
|
11
|
-
|
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
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
@@ -20,55 +20,20 @@ module Dbviewer
|
|
20
20
|
table_name = params[:id]
|
21
21
|
record_id = params[:record_id]
|
22
22
|
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
+
}
|
26
30
|
end
|
27
31
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
render_error("Table not found", 404)
|
34
|
-
return
|
35
|
-
end
|
36
|
-
|
37
|
-
# Get reverse foreign keys (has_many relationships)
|
38
|
-
reverse_foreign_keys = metadata.dig(:reverse_foreign_keys) || []
|
39
|
-
|
40
|
-
relationship_counts = reverse_foreign_keys.map do |rel|
|
41
|
-
begin
|
42
|
-
# Count records in the related table that reference this record
|
43
|
-
count_query = "SELECT COUNT(*) as count FROM #{rel[:from_table]} WHERE #{rel[:column]} = ?"
|
44
|
-
result = database_manager.connection.exec_query(count_query, "Count Query", [ record_id ])
|
45
|
-
count = result.rows.first&.first || 0
|
46
|
-
|
47
|
-
{
|
48
|
-
table: rel[:from_table],
|
49
|
-
foreign_key: rel[:column],
|
50
|
-
count: count.to_i
|
51
|
-
}
|
52
|
-
rescue => e
|
53
|
-
Rails.logger.error "Error counting relationships for #{rel[:from_table]}: #{e.message}"
|
54
|
-
{
|
55
|
-
table: rel[:from_table],
|
56
|
-
foreign_key: rel[:column],
|
57
|
-
count: 0,
|
58
|
-
error: e.message
|
59
|
-
}
|
60
|
-
end
|
61
|
-
end
|
62
|
-
|
63
|
-
render_success({
|
64
|
-
table_name: table_name,
|
65
|
-
record_id: record_id,
|
66
|
-
relationships: relationship_counts
|
67
|
-
})
|
68
|
-
rescue => e
|
69
|
-
Rails.logger.error "Error fetching relationship counts: #{e.message}"
|
70
|
-
render_error("Error fetching relationship counts: #{e.message}", 500)
|
71
|
-
end
|
32
|
+
render_success({
|
33
|
+
table_name: table_name,
|
34
|
+
record_id: record_id,
|
35
|
+
relationships: relationship_counts
|
36
|
+
})
|
72
37
|
end
|
73
38
|
|
74
39
|
private
|
@@ -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
|
@@ -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
|
-
|
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
|
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:
|
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:
|
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:
|
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:
|
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
|
|
@@ -0,0 +1,119 @@
|
|
1
|
+
<% content_for :title, "Database Connections" %>
|
2
|
+
|
3
|
+
<div class="container-fluid">
|
4
|
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
5
|
+
<h1><i class="bi bi-database-fill me-2"></i> Database Connections</h1>
|
6
|
+
</div>
|
7
|
+
|
8
|
+
<div class="row">
|
9
|
+
<div class="col-12">
|
10
|
+
<div class="alert alert-info">
|
11
|
+
<i class="bi bi-info-circle me-2"></i>
|
12
|
+
You can switch between multiple database connections to view different databases in your application.
|
13
|
+
</div>
|
14
|
+
</div>
|
15
|
+
</div>
|
16
|
+
|
17
|
+
<% if flash[:alert] %>
|
18
|
+
<div class="row">
|
19
|
+
<div class="col-12">
|
20
|
+
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
21
|
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
22
|
+
<%= flash[:alert] %>
|
23
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
24
|
+
</div>
|
25
|
+
</div>
|
26
|
+
</div>
|
27
|
+
<% end %>
|
28
|
+
|
29
|
+
<% if flash[:notice] %>
|
30
|
+
<div class="row">
|
31
|
+
<div class="col-12">
|
32
|
+
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
33
|
+
<i class="bi bi-check-circle-fill me-2"></i>
|
34
|
+
<%= flash[:notice] %>
|
35
|
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
36
|
+
</div>
|
37
|
+
</div>
|
38
|
+
</div>
|
39
|
+
<% end %>
|
40
|
+
|
41
|
+
<div class="row">
|
42
|
+
<% @connections.each do |connection| %>
|
43
|
+
<div class="col-md-6 col-lg-4 mb-4">
|
44
|
+
<div class="card dbviewer-card h-100 <%= 'border-primary' if connection[:current] %>">
|
45
|
+
<div class="card-header d-flex justify-content-between align-items-center">
|
46
|
+
<h5 class="card-title mb-0">
|
47
|
+
<% if connection[:adapter_name]&.downcase&.include?('sqlite') %>
|
48
|
+
<i class="bi bi-database-fill me-2 text-success"></i>
|
49
|
+
<% elsif connection[:adapter_name]&.downcase&.include?('mysql') %>
|
50
|
+
<i class="bi bi-database-fill me-2 text-warning"></i>
|
51
|
+
<% elsif connection[:adapter_name]&.downcase&.include?('postgres') %>
|
52
|
+
<i class="bi bi-database-fill me-2 text-info"></i>
|
53
|
+
<% else %>
|
54
|
+
<i class="bi bi-database me-2"></i>
|
55
|
+
<% end %>
|
56
|
+
<%= connection[:name] %>
|
57
|
+
</h5>
|
58
|
+
<% if connection[:current] %>
|
59
|
+
<span class="badge bg-success">Current</span>
|
60
|
+
<% end %>
|
61
|
+
</div>
|
62
|
+
<div class="card-body">
|
63
|
+
<% if connection[:current] %>
|
64
|
+
<p class="mb-3"><em>Currently active connection</em></p>
|
65
|
+
<% end %>
|
66
|
+
<p><strong>Key:</strong> <%= connection[:key] %></p>
|
67
|
+
<% if connection[:adapter_name] %>
|
68
|
+
<p><strong>Adapter:</strong> <%= connection[:adapter_name] %></p>
|
69
|
+
<% end %>
|
70
|
+
|
71
|
+
<div class="d-flex flex-column mt-3">
|
72
|
+
<div class="d-flex justify-content-between mb-2">
|
73
|
+
<% if connection[:current] %>
|
74
|
+
<button class="btn btn-outline-secondary btn-sm" disabled>
|
75
|
+
<i class="bi bi-check-circle-fill me-1"></i> Currently Active
|
76
|
+
</button>
|
77
|
+
<% else %>
|
78
|
+
<%= button_to connection_path(connection[:key]), method: :post, class: "btn btn-primary btn-sm" do %>
|
79
|
+
<i class="bi bi-lightning-charge me-1"></i> Switch to this Connection
|
80
|
+
<% end %>
|
81
|
+
<% end %>
|
82
|
+
</div>
|
83
|
+
</div>
|
84
|
+
</div>
|
85
|
+
</div>
|
86
|
+
</div>
|
87
|
+
<% end %>
|
88
|
+
</div>
|
89
|
+
</div>
|
90
|
+
|
91
|
+
<script>
|
92
|
+
// Form validation script
|
93
|
+
document.addEventListener('DOMContentLoaded', function() {
|
94
|
+
const forms = document.querySelectorAll('.needs-validation')
|
95
|
+
|
96
|
+
// Loop over them and prevent submission
|
97
|
+
Array.from(forms).forEach(form => {
|
98
|
+
form.addEventListener('submit', event => {
|
99
|
+
if (!form.checkValidity()) {
|
100
|
+
event.preventDefault()
|
101
|
+
event.stopPropagation()
|
102
|
+
}
|
103
|
+
|
104
|
+
form.classList.add('was-validated')
|
105
|
+
}, false)
|
106
|
+
})
|
107
|
+
|
108
|
+
// Auto-generate a key from the name
|
109
|
+
const nameInput = document.getElementById('connection_name')
|
110
|
+
const keyInput = document.getElementById('connection_key')
|
111
|
+
|
112
|
+
nameInput.addEventListener('input', function() {
|
113
|
+
keyInput.value = this.value
|
114
|
+
.toLowerCase()
|
115
|
+
.replace(/\s+/g, '_')
|
116
|
+
.replace(/[^a-z0-9_]/g, '')
|
117
|
+
})
|
118
|
+
})
|
119
|
+
</script>
|
@@ -0,0 +1,79 @@
|
|
1
|
+
<% content_for :title, "Add New Database Connection" %>
|
2
|
+
|
3
|
+
<div class="container-fluid">
|
4
|
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
5
|
+
<h1><i class="bi bi-plus-circle me-2"></i> Add New Database Connection</h1>
|
6
|
+
</div>
|
7
|
+
|
8
|
+
<div class="row">
|
9
|
+
<div class="col-12 col-md-8 col-lg-6 mx-auto">
|
10
|
+
<div class="card dbviewer-card">
|
11
|
+
<div class="card-header">
|
12
|
+
<h5 class="card-title mb-0">Connection Details</h5>
|
13
|
+
</div>
|
14
|
+
<div class="card-body">
|
15
|
+
<%= form_tag connections_path, method: :post, class: "needs-validation", novalidate: true do %>
|
16
|
+
<div class="mb-3">
|
17
|
+
<label for="connection_name" class="form-label">Connection Name*</label>
|
18
|
+
<input type="text" class="form-control" id="connection_name" name="connection_name"
|
19
|
+
placeholder="e.g. Blog Database" required>
|
20
|
+
<div class="form-text">A human-readable name for this connection</div>
|
21
|
+
</div>
|
22
|
+
|
23
|
+
<div class="mb-3">
|
24
|
+
<label for="connection_key" class="form-label">Connection Key*</label>
|
25
|
+
<input type="text" class="form-control" id="connection_key" name="connection_key"
|
26
|
+
placeholder="e.g. blog_db" required>
|
27
|
+
<div class="form-text">A unique identifier for this connection (lowercase, no spaces)</div>
|
28
|
+
</div>
|
29
|
+
|
30
|
+
<div class="mb-3">
|
31
|
+
<label for="connection_class" class="form-label">Connection Class*</label>
|
32
|
+
<input type="text" class="form-control" id="connection_class" name="connection_class"
|
33
|
+
placeholder="e.g. BlogDatabase" required>
|
34
|
+
<div class="form-text">
|
35
|
+
The fully qualified class name that establishes the connection.
|
36
|
+
This class must inherit from ActiveRecord::Base.
|
37
|
+
</div>
|
38
|
+
</div>
|
39
|
+
|
40
|
+
<div class="d-flex justify-content-between mt-4">
|
41
|
+
<%= link_to "Cancel", connections_path, class: "btn btn-secondary" %>
|
42
|
+
<button type="submit" class="btn btn-primary">Add Connection</button>
|
43
|
+
</div>
|
44
|
+
<% end %>
|
45
|
+
</div>
|
46
|
+
</div>
|
47
|
+
</div>
|
48
|
+
</div>
|
49
|
+
</div>
|
50
|
+
|
51
|
+
<script>
|
52
|
+
// Form validation script
|
53
|
+
document.addEventListener('DOMContentLoaded', function() {
|
54
|
+
const forms = document.querySelectorAll('.needs-validation')
|
55
|
+
|
56
|
+
// Loop over them and prevent submission
|
57
|
+
Array.from(forms).forEach(form => {
|
58
|
+
form.addEventListener('submit', event => {
|
59
|
+
if (!form.checkValidity()) {
|
60
|
+
event.preventDefault()
|
61
|
+
event.stopPropagation()
|
62
|
+
}
|
63
|
+
|
64
|
+
form.classList.add('was-validated')
|
65
|
+
}, false)
|
66
|
+
})
|
67
|
+
|
68
|
+
// Auto-generate a key from the name
|
69
|
+
const nameInput = document.getElementById('connection_name')
|
70
|
+
const keyInput = document.getElementById('connection_key')
|
71
|
+
|
72
|
+
nameInput.addEventListener('input', function() {
|
73
|
+
keyInput.value = this.value
|
74
|
+
.toLowerCase()
|
75
|
+
.replace(/\s+/g, '_')
|
76
|
+
.replace(/[^a-z0-9_]/g, '')
|
77
|
+
})
|
78
|
+
})
|
79
|
+
</script>
|
@@ -0,0 +1,49 @@
|
|
1
|
+
<%# Table list for sidebar %>
|
2
|
+
<% if tables.any? %>
|
3
|
+
<% tables.each do |table| %>
|
4
|
+
<%
|
5
|
+
# Build table URL with creation filter params if they exist
|
6
|
+
table_url_params = {}
|
7
|
+
table_url_params[:creation_filter_start] = @creation_filter_start if defined?(@creation_filter_start) && @creation_filter_start.present?
|
8
|
+
table_url_params[:creation_filter_end] = @creation_filter_end if defined?(@creation_filter_end) && @creation_filter_end.present?
|
9
|
+
%>
|
10
|
+
<%= link_to dbviewer.table_path(table[:name], table_url_params),
|
11
|
+
title: table[:name],
|
12
|
+
class: "list-group-item list-group-item-action d-flex align-items-center #{'active' if current_table?(table[:name])}",
|
13
|
+
tabindex: "0",
|
14
|
+
data: { table_name: table[:name] },
|
15
|
+
onkeydown: "
|
16
|
+
if(event.key === 'ArrowDown') {
|
17
|
+
event.preventDefault();
|
18
|
+
let next = this.nextElementSibling;
|
19
|
+
while(next && next.classList.contains('d-none')) {
|
20
|
+
next = next.nextElementSibling;
|
21
|
+
}
|
22
|
+
if(next) next.focus();
|
23
|
+
} else if(event.key === 'ArrowUp') {
|
24
|
+
event.preventDefault();
|
25
|
+
let prev = this.previousElementSibling;
|
26
|
+
while(prev && prev.classList.contains('d-none')) {
|
27
|
+
prev = prev.previousElementSibling;
|
28
|
+
}
|
29
|
+
if(prev) prev.focus();
|
30
|
+
}" do %>
|
31
|
+
<div class="d-flex justify-content-between align-items-center w-100">
|
32
|
+
<div class="text-truncate">
|
33
|
+
<i class="bi bi-table me-2 small"></i>
|
34
|
+
<span><%= table[:name] %></span>
|
35
|
+
</div>
|
36
|
+
<% if table[:record_count].present? %>
|
37
|
+
<span class="badge bg-light text-dark fw-normal">
|
38
|
+
<%= number_with_delimiter(table[:record_count]) %>
|
39
|
+
</span>
|
40
|
+
<% end %>
|
41
|
+
</div>
|
42
|
+
<% end %>
|
43
|
+
<% end %>
|
44
|
+
<% else %>
|
45
|
+
<div class="list-group-item text-muted small">
|
46
|
+
<i class="bi bi-info-circle me-1"></i>
|
47
|
+
No tables found in this database
|
48
|
+
</div>
|
49
|
+
<% end %>
|
@@ -4,14 +4,6 @@
|
|
4
4
|
|
5
5
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
6
6
|
<h1>Database Tables</h1>
|
7
|
-
<div>
|
8
|
-
<%= link_to dashboard_path, class: "btn btn-outline-primary me-2" do %>
|
9
|
-
<i class="bi bi-house-door me-1"></i> Dashboard
|
10
|
-
<% end %>
|
11
|
-
<%= link_to entity_relationship_diagrams_path, class: "btn btn-outline-primary" do %>
|
12
|
-
<i class="bi bi-diagram-3 me-1"></i> View ERD
|
13
|
-
<% end %>
|
14
|
-
</div>
|
15
7
|
</div>
|
16
8
|
|
17
9
|
<% if flash[:error] %>
|
@@ -120,6 +120,15 @@
|
|
120
120
|
background-color: var(--bs-tertiary-bg, #f8f9fa);
|
121
121
|
}
|
122
122
|
|
123
|
+
/* Styling for disabled input fields (IS NULL, IS NOT NULL) */
|
124
|
+
.column-filter:disabled, .disabled-filter {
|
125
|
+
background-color: var(--bs-tertiary-bg, #f0f0f0);
|
126
|
+
border-color: var(--bs-border-color, #dee2e6);
|
127
|
+
color: var(--bs-secondary-color, #6c757d);
|
128
|
+
opacity: 0.6;
|
129
|
+
cursor: not-allowed;
|
130
|
+
}
|
131
|
+
|
123
132
|
/* Action column styling */
|
124
133
|
.action-column {
|
125
134
|
width: 60px;
|
@@ -818,11 +827,61 @@
|
|
818
827
|
};
|
819
828
|
}
|
820
829
|
|
830
|
+
// Function to handle operator changes for IS NULL and IS NOT NULL operators
|
831
|
+
function setupNullOperators() {
|
832
|
+
operatorSelects.forEach(select => {
|
833
|
+
// Initial setup for existing null operators
|
834
|
+
if (select.value === 'is_null' || select.value === 'is_not_null') {
|
835
|
+
const columnName = select.name.match(/\[(.*?)_operator\]/)[1];
|
836
|
+
const inputContainer = select.closest('.filter-input-group');
|
837
|
+
// Check for display field (the visible disabled field)
|
838
|
+
const displayField = inputContainer.querySelector(`[data-column="${columnName}_display"]`);
|
839
|
+
if (displayField) {
|
840
|
+
displayField.classList.add('disabled-filter');
|
841
|
+
}
|
842
|
+
|
843
|
+
// Make sure the value field properly reflects the null operator
|
844
|
+
const valueField = inputContainer.querySelector(`[data-column="${columnName}"]`);
|
845
|
+
if (valueField) {
|
846
|
+
valueField.value = select.value;
|
847
|
+
}
|
848
|
+
}
|
849
|
+
|
850
|
+
// Handle operator changes
|
851
|
+
select.addEventListener('change', function() {
|
852
|
+
const columnName = this.name.match(/\[(.*?)_operator\]/)[1];
|
853
|
+
const filterForm = this.closest('form');
|
854
|
+
const inputContainer = this.closest('.filter-input-group');
|
855
|
+
const hiddenField = inputContainer.querySelector(`[data-column="${columnName}"]`);
|
856
|
+
const displayField = inputContainer.querySelector(`[data-column="${columnName}_display"]`);
|
857
|
+
const wasNullOperator = hiddenField && (hiddenField.value === 'is_null' || hiddenField.value === 'is_not_null');
|
858
|
+
const isNullOperator = this.value === 'is_null' || this.value === 'is_not_null';
|
859
|
+
|
860
|
+
if (isNullOperator) {
|
861
|
+
// Configure for null operator
|
862
|
+
if (hiddenField) {
|
863
|
+
hiddenField.value = this.value;
|
864
|
+
}
|
865
|
+
// Submit immediately
|
866
|
+
filterForm.submit();
|
867
|
+
} else if (wasNullOperator) {
|
868
|
+
// Clear value when switching from null operator to regular operator
|
869
|
+
if (hiddenField) {
|
870
|
+
hiddenField.value = '';
|
871
|
+
}
|
872
|
+
}
|
873
|
+
});
|
874
|
+
});
|
875
|
+
}
|
876
|
+
|
821
877
|
// Function to submit the form
|
822
878
|
const submitForm = debounce(function() {
|
823
879
|
filterForm.submit();
|
824
880
|
}, 500);
|
825
881
|
|
882
|
+
// Initialize the null operators handling
|
883
|
+
setupNullOperators();
|
884
|
+
|
826
885
|
// Add event listeners to all filter inputs
|
827
886
|
columnFilters.forEach(function(filter) {
|
828
887
|
// For text fields use input event
|
@@ -1357,6 +1357,27 @@
|
|
1357
1357
|
<% end %>
|
1358
1358
|
</ul>
|
1359
1359
|
<ul class="navbar-nav ms-auto">
|
1360
|
+
<li class="nav-item dropdown">
|
1361
|
+
<a class="nav-link dropdown-toggle" href="#" id="navbarDatabaseDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
1362
|
+
<i class="bi bi-database"></i> <%= (current_conn = available_connections.find { |c| c[:current] }) ? current_conn[:name] : "Database" %>
|
1363
|
+
</a>
|
1364
|
+
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDatabaseDropdown">
|
1365
|
+
<% available_connections.each do |connection| %>
|
1366
|
+
<li>
|
1367
|
+
<%= button_to connection_path(connection[:key]), method: :post, class: "dropdown-item border-0 w-100 text-start #{'active' if connection[:current]}" do %>
|
1368
|
+
<% if connection[:current] %>
|
1369
|
+
<i class="bi bi-check2-circle me-2"></i>
|
1370
|
+
<% else %>
|
1371
|
+
<i class="bi bi-circle me-2"></i>
|
1372
|
+
<% end %>
|
1373
|
+
<%= connection[:name] %>
|
1374
|
+
<% end %>
|
1375
|
+
</li>
|
1376
|
+
<% end %>
|
1377
|
+
<li><hr class="dropdown-divider"></li>
|
1378
|
+
<li><%= link_to "<i class='bi bi-gear'></i> Manage Connections".html_safe, connections_path, class: "dropdown-item" %></li>
|
1379
|
+
</ul>
|
1380
|
+
</li>
|
1360
1381
|
<li class="nav-item">
|
1361
1382
|
<button type="button" class="theme-toggle nav-link" aria-label="<%= theme_toggle_label %>">
|
1362
1383
|
<%= theme_toggle_icon %>
|
@@ -1364,7 +1385,7 @@
|
|
1364
1385
|
</li>
|
1365
1386
|
<li class="nav-item">
|
1366
1387
|
<span class="navbar-text ms-2 text-light d-flex align-items-center">
|
1367
|
-
<small><i class="bi bi-
|
1388
|
+
<small><i class="bi bi-tools"></i> <%= Rails.env %> environment</small>
|
1368
1389
|
</span>
|
1369
1390
|
</li>
|
1370
1391
|
</ul>
|
data/config/routes.rb
CHANGED
@@ -10,6 +10,12 @@ Dbviewer::Engine.routes.draw do
|
|
10
10
|
|
11
11
|
resources :entity_relationship_diagrams, only: [ :index ]
|
12
12
|
|
13
|
+
resources :connections, only: [ :index, :new, :create, :destroy ] do
|
14
|
+
member do
|
15
|
+
post :update
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
13
19
|
resources :logs, only: [ :index ] do
|
14
20
|
collection do
|
15
21
|
delete :destroy_all
|
@@ -46,6 +52,12 @@ Dbviewer::Engine.routes.draw do
|
|
46
52
|
get "recent"
|
47
53
|
end
|
48
54
|
end
|
55
|
+
|
56
|
+
resources :connections, only: [] do
|
57
|
+
member do
|
58
|
+
get "test"
|
59
|
+
end
|
60
|
+
end
|
49
61
|
end
|
50
62
|
|
51
63
|
root to: "home#index"
|
@@ -41,6 +41,16 @@ module Dbviewer
|
|
41
41
|
# Default column to order table details by (e.g., 'updated_at')
|
42
42
|
attr_accessor :default_order_column
|
43
43
|
|
44
|
+
# Multiple database connections configuration
|
45
|
+
# @example {
|
46
|
+
# primary: { connection_class: "ActiveRecord::Base", name: "Primary DB" },
|
47
|
+
# secondary: { connection_class: "SomeClass", name: "Secondary DB" }
|
48
|
+
# }
|
49
|
+
attr_accessor :database_connections
|
50
|
+
|
51
|
+
# The key of the current active connection
|
52
|
+
attr_accessor :current_connection
|
53
|
+
|
44
54
|
def initialize
|
45
55
|
@per_page_options = [ 10, 20, 50, 100 ]
|
46
56
|
@default_per_page = 20
|
@@ -55,6 +65,13 @@ module Dbviewer
|
|
55
65
|
@enable_query_logging = true
|
56
66
|
@admin_credentials = nil
|
57
67
|
@default_order_column = "updated_at"
|
68
|
+
@database_connections = {
|
69
|
+
default: {
|
70
|
+
connection_class: "ActiveRecord::Base",
|
71
|
+
name: "Default Database"
|
72
|
+
}
|
73
|
+
}
|
74
|
+
@current_connection = :default
|
58
75
|
end
|
59
76
|
end
|
60
77
|
end
|
@@ -14,8 +14,7 @@ module Dbviewer
|
|
14
14
|
# @param table_name [String] Name of the table
|
15
15
|
# @return [Class] ActiveRecord model class for the table
|
16
16
|
def get_model_for(table_name)
|
17
|
-
|
18
|
-
return cached_model if cached_model
|
17
|
+
return @cache_manager.get_model(table_name) if @cache_manager.has_model?(table_name)
|
19
18
|
|
20
19
|
model = create_model_for(table_name)
|
21
20
|
@cache_manager.store_model(table_name, model)
|
@@ -28,10 +27,7 @@ module Dbviewer
|
|
28
27
|
# @param table_name [String] Name of the table
|
29
28
|
# @return [Class] ActiveRecord model class for the table
|
30
29
|
def create_model_for(table_name)
|
31
|
-
|
32
|
-
|
33
|
-
# Create a new model class dynamically
|
34
|
-
model = Class.new(ActiveRecord::Base) do
|
30
|
+
model = Dbviewer.const_set(table_name.classify, Class.new(ActiveRecord::Base) do
|
35
31
|
self.table_name = table_name
|
36
32
|
|
37
33
|
# Some tables might not have primary keys, so we handle that
|
@@ -47,13 +43,9 @@ module Dbviewer
|
|
47
43
|
|
48
44
|
# Disable timestamps for better compatibility
|
49
45
|
self.record_timestamps = false
|
50
|
-
end
|
46
|
+
end)
|
51
47
|
|
52
|
-
|
53
|
-
# Use a namespace to avoid polluting the global namespace
|
54
|
-
unless Dbviewer.const_defined?("DynamicModel_#{model_name}")
|
55
|
-
Dbviewer.const_set("DynamicModel_#{model_name}", model)
|
56
|
-
end
|
48
|
+
model.establish_connection(@connection.instance_variable_get(:@config))
|
57
49
|
|
58
50
|
model
|
59
51
|
end
|
@@ -3,10 +3,12 @@ module Dbviewer
|
|
3
3
|
# Manager handles all database interactions for the DBViewer engine
|
4
4
|
# It provides methods to access database structure and data
|
5
5
|
class Manager
|
6
|
-
attr_reader :connection, :adapter_name, :table_query_operations
|
6
|
+
attr_reader :connection, :adapter_name, :table_query_operations, :connection_key
|
7
7
|
|
8
8
|
# Initialize the database manager
|
9
|
-
|
9
|
+
# @param connection_key [Symbol] The key identifying the connection in configuration
|
10
|
+
def initialize(connection_key = nil)
|
11
|
+
@connection_key = connection_key || Dbviewer.configuration.current_connection
|
10
12
|
ensure_connection
|
11
13
|
@cache_manager = ::Dbviewer::Database::CacheManager.new(configuration.cache_expiry)
|
12
14
|
@table_metadata_manager = ::Dbviewer::Database::MetadataManager.new(@connection, @cache_manager)
|
@@ -151,6 +153,13 @@ module Dbviewer
|
|
151
153
|
end
|
152
154
|
end
|
153
155
|
|
156
|
+
# Get a dynamic AR model for a table
|
157
|
+
# @param table_name [String] Name of the table
|
158
|
+
# @return [Class] ActiveRecord model class
|
159
|
+
def get_model_for(table_name)
|
160
|
+
@dynamic_model_factory.get_model_for(table_name)
|
161
|
+
end
|
162
|
+
|
154
163
|
private
|
155
164
|
|
156
165
|
def fetch_mysql_size
|
@@ -182,8 +191,15 @@ module Dbviewer
|
|
182
191
|
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter] The database connection
|
183
192
|
def ensure_connection
|
184
193
|
return @connection if @connection
|
194
|
+
connection_config = Dbviewer.configuration.database_connections[@connection_key]
|
195
|
+
|
196
|
+
if connection_config && connection_config[:connection_class]
|
197
|
+
@connection = connection_config[:connection_class].constantize.connection
|
198
|
+
else
|
199
|
+
Rails.logger.warn "DBViewer: Using default connection for key: #{@connection_key}"
|
200
|
+
@connection = ActiveRecord::Base.connection
|
201
|
+
end
|
185
202
|
|
186
|
-
@connection = ActiveRecord::Base.connection
|
187
203
|
@adapter_name = @connection.adapter_name.downcase
|
188
204
|
@connection
|
189
205
|
end
|
@@ -192,13 +208,6 @@ module Dbviewer
|
|
192
208
|
def reset_cache_if_needed
|
193
209
|
@cache_manager.reset_if_needed
|
194
210
|
end
|
195
|
-
|
196
|
-
# Get a dynamic AR model for a table
|
197
|
-
# @param table_name [String] Name of the table
|
198
|
-
# @return [Class] ActiveRecord model class
|
199
|
-
def get_model_for(table_name)
|
200
|
-
@dynamic_model_factory.get_model_for(table_name)
|
201
|
-
end
|
202
211
|
end
|
203
212
|
end
|
204
213
|
end
|
@@ -273,9 +273,29 @@ module Dbviewer
|
|
273
273
|
# Apply remaining simple column filters
|
274
274
|
filters.each do |column, value|
|
275
275
|
next unless column_exists?(table_name, column)
|
276
|
-
|
276
|
+
|
277
|
+
# Check if this is a column operator field
|
278
|
+
if column.end_with?("_operator")
|
279
|
+
next # Skip operator fields - they're processed with their column
|
280
|
+
end
|
277
281
|
|
278
282
|
column_sym = column.to_sym
|
283
|
+
operator = filters["#{column}_operator"]
|
284
|
+
|
285
|
+
# Special handling for is_null and is_not_null operators that don't need a value
|
286
|
+
if operator == "is_null" || value == "is_null"
|
287
|
+
Rails.logger.debug("[DBViewer] Applying null filter: #{column} IS NULL")
|
288
|
+
query = query.where("#{column} IS NULL")
|
289
|
+
next
|
290
|
+
elsif operator == "is_not_null" || value == "is_not_null"
|
291
|
+
Rails.logger.debug("[DBViewer] Applying not null filter: #{column} IS NOT NULL")
|
292
|
+
query = query.where("#{column} IS NOT NULL")
|
293
|
+
next
|
294
|
+
end
|
295
|
+
|
296
|
+
# Skip if no value and we're not using a special operator
|
297
|
+
next if value.blank? || value == "is_null" || value == "is_not_null"
|
298
|
+
|
279
299
|
Rails.logger.debug("[DBViewer] Applying filter: #{column} = #{value}")
|
280
300
|
|
281
301
|
# Handle different types of filtering
|
data/lib/dbviewer/version.rb
CHANGED
data/lib/dbviewer.rb
CHANGED
@@ -57,10 +57,43 @@ module Dbviewer
|
|
57
57
|
query_logging_mode: configuration.query_logging_mode
|
58
58
|
)
|
59
59
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
60
|
+
# Check all configured connections
|
61
|
+
connection_errors = []
|
62
|
+
configuration.database_connections.each do |key, config|
|
63
|
+
begin
|
64
|
+
connection_class = nil
|
65
|
+
|
66
|
+
if config[:connection]
|
67
|
+
connection_class = config[:connection]
|
68
|
+
elsif config[:connection_class].is_a?(String)
|
69
|
+
# Try to load the class if it's defined as a string
|
70
|
+
begin
|
71
|
+
connection_class = config[:connection_class].constantize
|
72
|
+
rescue NameError => e
|
73
|
+
Rails.logger.warn "DBViewer could not load connection class #{config[:connection_class]}: #{e.message}"
|
74
|
+
next
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
if connection_class
|
79
|
+
connection = connection_class.connection
|
80
|
+
adapter_name = connection.adapter_name
|
81
|
+
Rails.logger.info "DBViewer successfully connected to #{config[:name] || key.to_s} database (#{adapter_name})"
|
82
|
+
|
83
|
+
# Store the resolved connection class back in the config
|
84
|
+
config[:connection] = connection_class
|
85
|
+
else
|
86
|
+
raise "No valid connection configuration found for #{key}"
|
87
|
+
end
|
88
|
+
rescue => e
|
89
|
+
connection_errors << { key: key, error: e.message }
|
90
|
+
Rails.logger.error "DBViewer could not connect to #{config[:name] || key.to_s} database: #{e.message}"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
if connection_errors.length == configuration.database_connections.length
|
95
|
+
raise "DBViewer could not connect to any configured database"
|
96
|
+
end
|
64
97
|
end
|
65
98
|
|
66
99
|
# Initialize engine with default values or user-provided configuration
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dbviewer
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Wailan Tirajoh
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-05-
|
11
|
+
date: 2025-05-31 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -49,6 +49,7 @@ files:
|
|
49
49
|
- MIT-LICENSE
|
50
50
|
- README.md
|
51
51
|
- Rakefile
|
52
|
+
- app/assets/javascripts/dbviewer/connections.js
|
52
53
|
- app/assets/stylesheets/dbviewer/application.css
|
53
54
|
- app/assets/stylesheets/dbviewer/dbviewer.css
|
54
55
|
- app/assets/stylesheets/dbviewer/enhanced.css
|
@@ -61,6 +62,7 @@ files:
|
|
61
62
|
- app/controllers/dbviewer/api/queries_controller.rb
|
62
63
|
- app/controllers/dbviewer/api/tables_controller.rb
|
63
64
|
- app/controllers/dbviewer/application_controller.rb
|
65
|
+
- app/controllers/dbviewer/connections_controller.rb
|
64
66
|
- app/controllers/dbviewer/entity_relationship_diagrams_controller.rb
|
65
67
|
- app/controllers/dbviewer/home_controller.rb
|
66
68
|
- app/controllers/dbviewer/logs_controller.rb
|
@@ -69,9 +71,12 @@ files:
|
|
69
71
|
- app/jobs/dbviewer/application_job.rb
|
70
72
|
- app/mailers/dbviewer/application_mailer.rb
|
71
73
|
- app/models/dbviewer/application_record.rb
|
74
|
+
- app/views/dbviewer/connections/index.html.erb
|
75
|
+
- app/views/dbviewer/connections/new.html.erb
|
72
76
|
- app/views/dbviewer/entity_relationship_diagrams/index.html.erb
|
73
77
|
- app/views/dbviewer/home/index.html.erb
|
74
78
|
- app/views/dbviewer/logs/index.html.erb
|
79
|
+
- app/views/dbviewer/shared/_tables_sidebar.html.erb
|
75
80
|
- app/views/dbviewer/tables/_table_structure.html.erb
|
76
81
|
- app/views/dbviewer/tables/index.html.erb
|
77
82
|
- app/views/dbviewer/tables/mini_erd.html.erb
|