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 +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 +20 -0
- 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 +132 -8
- data/app/views/layouts/dbviewer/application.html.erb +22 -1
- data/config/routes.rb +15 -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
@@ -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;
|
@@ -777,7 +786,11 @@
|
|
777
786
|
const primaryKeyValue = recordData[Object.keys(recordData).find(key => key === 'id') || Object.keys(recordData)[0]];
|
778
787
|
|
779
788
|
if (primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== '') {
|
780
|
-
|
789
|
+
const hasManySection = createRelationshipSection('Has Many', reverseForeignKeys, recordData, 'has_many', primaryKeyValue);
|
790
|
+
relationshipsContent.appendChild(hasManySection);
|
791
|
+
|
792
|
+
// Fetch relationship counts asynchronously
|
793
|
+
fetchRelationshipCounts('<%= @table_name %>', primaryKeyValue, reverseForeignKeys, hasManySection);
|
781
794
|
}
|
782
795
|
}
|
783
796
|
|
@@ -814,11 +827,61 @@
|
|
814
827
|
};
|
815
828
|
}
|
816
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
|
+
|
817
877
|
// Function to submit the form
|
818
878
|
const submitForm = debounce(function() {
|
819
879
|
filterForm.submit();
|
820
880
|
}, 500);
|
821
881
|
|
882
|
+
// Initialize the null operators handling
|
883
|
+
setupNullOperators();
|
884
|
+
|
822
885
|
// Add event listeners to all filter inputs
|
823
886
|
columnFilters.forEach(function(filter) {
|
824
887
|
// For text fields use input event
|
@@ -1433,6 +1496,55 @@
|
|
1433
1496
|
});
|
1434
1497
|
|
1435
1498
|
// Helper function to create relationship sections
|
1499
|
+
// Function to fetch relationship counts from API
|
1500
|
+
async function fetchRelationshipCounts(tableName, recordId, relationships, hasManySection) {
|
1501
|
+
try {
|
1502
|
+
const response = await fetch(`/dbviewer/api/tables/${tableName}/relationship_counts?record_id=${recordId}`);
|
1503
|
+
if (!response.ok) {
|
1504
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
1505
|
+
}
|
1506
|
+
|
1507
|
+
const data = await response.json();
|
1508
|
+
|
1509
|
+
// Update each count in the UI
|
1510
|
+
const countSpans = hasManySection.querySelectorAll('.relationship-count');
|
1511
|
+
|
1512
|
+
relationships.forEach((relationship, index) => {
|
1513
|
+
const countSpan = countSpans[index];
|
1514
|
+
if (countSpan) {
|
1515
|
+
const relationshipData = data.relationships.find(r =>
|
1516
|
+
r.table === relationship.from_table && r.foreign_key === relationship.column
|
1517
|
+
);
|
1518
|
+
|
1519
|
+
if (relationshipData) {
|
1520
|
+
const count = relationshipData.count;
|
1521
|
+
let badgeClass = 'bg-secondary';
|
1522
|
+
let badgeText = `${count} record${count !== 1 ? 's' : ''}`;
|
1523
|
+
|
1524
|
+
// Use different colors based on count
|
1525
|
+
if (count > 0) {
|
1526
|
+
badgeClass = count > 10 ? 'bg-warning' : 'bg-success';
|
1527
|
+
}
|
1528
|
+
|
1529
|
+
countSpan.innerHTML = `<span class="badge ${badgeClass}">${badgeText}</span>`;
|
1530
|
+
} else {
|
1531
|
+
// Fallback if no data found
|
1532
|
+
countSpan.innerHTML = '<span class="badge bg-danger">Error</span>';
|
1533
|
+
}
|
1534
|
+
}
|
1535
|
+
});
|
1536
|
+
|
1537
|
+
} catch (error) {
|
1538
|
+
console.error('Error fetching relationship counts:', error);
|
1539
|
+
|
1540
|
+
// Show error state in UI
|
1541
|
+
const countSpans = hasManySection.querySelectorAll('.relationship-count');
|
1542
|
+
countSpans.forEach(span => {
|
1543
|
+
span.innerHTML = '<span class="badge bg-danger">Error</span>';
|
1544
|
+
});
|
1545
|
+
}
|
1546
|
+
}
|
1547
|
+
|
1436
1548
|
function createRelationshipSection(title, relationships, recordData, type, primaryKeyValue = null) {
|
1437
1549
|
const section = document.createElement('div');
|
1438
1550
|
section.className = 'relationship-section mb-4';
|
@@ -1503,7 +1615,12 @@
|
|
1503
1615
|
<span class="text-muted">${fk.from_table}.</span><strong>${fk.column}</strong>
|
1504
1616
|
</td>
|
1505
1617
|
<td>
|
1506
|
-
<span class="
|
1618
|
+
<span class="relationship-count">
|
1619
|
+
<span class="badge bg-secondary">
|
1620
|
+
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
1621
|
+
Loading...
|
1622
|
+
</span>
|
1623
|
+
</span>
|
1507
1624
|
</td>
|
1508
1625
|
<td>
|
1509
1626
|
<a href="/dbviewer/tables/${fk.from_table}?column_filters[${fk.column}]=${encodeURIComponent(primaryKeyValue)}"
|
@@ -2108,12 +2225,6 @@
|
|
2108
2225
|
background: transparent !important;
|
2109
2226
|
}
|
2110
2227
|
|
2111
|
-
[data-bs-theme="dark"] .flatpickr-day.disabled:hover {
|
2112
|
-
background: transparent !important;
|
2113
|
-
color: #6c757d !important;
|
2114
|
-
cursor: not-allowed;
|
2115
|
-
}
|
2116
|
-
|
2117
2228
|
/* Dark mode other day states */
|
2118
2229
|
[data-bs-theme="dark"] .flatpickr-day.nextMonthDay,
|
2119
2230
|
[data-bs-theme="dark"] .flatpickr-day.prevMonthDay {
|
@@ -2263,6 +2374,19 @@
|
|
2263
2374
|
.flatpickr-next-month:hover {
|
2264
2375
|
background: rgba(var(--bs-primary-rgb), 0.1);
|
2265
2376
|
}
|
2377
|
+
|
2378
|
+
/* Relationship count styling */
|
2379
|
+
.relationship-count .badge {
|
2380
|
+
min-width: 80px;
|
2381
|
+
display: inline-flex;
|
2382
|
+
align-items: center;
|
2383
|
+
justify-content: center;
|
2384
|
+
}
|
2385
|
+
|
2386
|
+
.relationship-count .spinner-border-sm {
|
2387
|
+
width: 0.875rem;
|
2388
|
+
height: 0.875rem;
|
2389
|
+
}
|
2266
2390
|
</style>
|
2267
2391
|
|
2268
2392
|
<script>
|
@@ -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
|
@@ -25,6 +31,9 @@ Dbviewer::Engine.routes.draw do
|
|
25
31
|
get "records"
|
26
32
|
get "relationships_count"
|
27
33
|
end
|
34
|
+
member do
|
35
|
+
get "relationship_counts"
|
36
|
+
end
|
28
37
|
end
|
29
38
|
|
30
39
|
resources :entity_relationship_diagrams, only: [] do
|
@@ -43,6 +52,12 @@ Dbviewer::Engine.routes.draw do
|
|
43
52
|
get "recent"
|
44
53
|
end
|
45
54
|
end
|
55
|
+
|
56
|
+
resources :connections, only: [] do
|
57
|
+
member do
|
58
|
+
get "test"
|
59
|
+
end
|
60
|
+
end
|
46
61
|
end
|
47
62
|
|
48
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
|