dbviewer 0.9.3 → 0.9.4.pre.alpha.2
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 +3 -1
- data/app/assets/javascripts/dbviewer/record_creation.js +178 -0
- data/app/assets/javascripts/dbviewer/record_deletion.js +171 -0
- data/app/assets/stylesheets/dbviewer/table.css +133 -0
- data/app/controllers/dbviewer/tables_controller.rb +102 -51
- data/app/helpers/dbviewer/application_helper.rb +1 -0
- data/app/helpers/dbviewer/datatable_ui_form_helper.rb +18 -0
- data/app/helpers/dbviewer/datatable_ui_table_helper.rb +21 -2
- data/app/views/dbviewer/tables/new_record.html.erb +70 -0
- data/app/views/dbviewer/tables/show.html.erb +76 -0
- data/config/routes.rb +3 -0
- data/lib/dbviewer/configuration.rb +4 -0
- data/lib/dbviewer/database/dynamic_model_factory.rb +20 -26
- data/lib/dbviewer/database/manager.rb +1 -1
- data/lib/dbviewer/query/parser.rb +1 -1
- data/lib/dbviewer/version.rb +1 -1
- data/lib/generators/dbviewer/templates/initializer.rb +3 -0
- metadata +8 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bab71132b89e3a2b246802692149a2d662bc42bcfa54b9db4d71ab9593dc2c2a
|
4
|
+
data.tar.gz: 8d2160e56a9872ad9e1dfa274e5ccbdcb16c8ff1b65c134be00b806d91841a55
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c00de395e8f283afd2f6c905ae1e448438296ecfdc2167606d2216d3c39a492d27d2c60334d35234e5d460472a66730a6893fe79b81ce1f972b48b67d767b1b6
|
7
|
+
data.tar.gz: 8f9a53eb14f23c5aa2bb006458dcda9de39d031aa49907ff0a017f76d96950d101f14785292a7e0a062aac91915face67bbe34f28bfffc1fdf126076375127a8
|
data/README.md
CHANGED
@@ -14,6 +14,7 @@ It's designed for development, debugging, and database analysis, offering a clea
|
|
14
14
|
- **Detailed Schema Information** - Column details, indexes, and constraints
|
15
15
|
- **Entity Relationship Diagram (ERD)** - Interactive database schema visualization
|
16
16
|
- **Data Browsing** - Paginated record viewing with search and filtering
|
17
|
+
- **Data Management** - Create and delete database records directly from the interface
|
17
18
|
- **SQL Queries** - Safe SQL query execution with validation
|
18
19
|
- **Multiple Database Connections** - Support for multiple database sources
|
19
20
|
- **PII Data Masking** - Configurable masking for sensitive data
|
@@ -98,6 +99,7 @@ Dbviewer.configure do |config|
|
|
98
99
|
config.cache_expiry = 300 # Cache expiration in seconds
|
99
100
|
config.max_records = 10000 # Maximum records to return in any query
|
100
101
|
config.enable_data_export = false # Whether to allow data exporting
|
102
|
+
config.enable_record_deletion = true # Whether to allow record deletion
|
101
103
|
config.query_timeout = 30 # SQL query timeout in seconds
|
102
104
|
|
103
105
|
# Query logging options
|
@@ -197,7 +199,7 @@ DBViewer includes comprehensive security features to protect your database:
|
|
197
199
|
|
198
200
|
### Core Security
|
199
201
|
|
200
|
-
- **
|
202
|
+
- **Data Manipulation**: Create and read operations are supported through the UI with proper validation
|
201
203
|
- **SQL Validation**: Prevents potentially harmful operations with comprehensive validation
|
202
204
|
- **Query Limits**: Automatic LIMIT clause added to prevent excessive data retrieval
|
203
205
|
- **Pattern Detection**: Detection of SQL injection patterns and suspicious constructs
|
@@ -0,0 +1,178 @@
|
|
1
|
+
// Record Creation Modal
|
2
|
+
document.addEventListener("DOMContentLoaded", function () {
|
3
|
+
const addRecordButton = document.getElementById("floatingAddRecordBtn");
|
4
|
+
if (!addRecordButton) return;
|
5
|
+
|
6
|
+
const tableName = document.getElementById("table_name").value;
|
7
|
+
const newRecordModal = document.getElementById("newRecordModal");
|
8
|
+
|
9
|
+
addRecordButton.addEventListener("click", async function () {
|
10
|
+
const modal = new bootstrap.Modal(newRecordModal);
|
11
|
+
const modalBody = newRecordModal.querySelector(".modal-content");
|
12
|
+
|
13
|
+
// Show loading state
|
14
|
+
modalBody.innerHTML = `
|
15
|
+
<div class="modal-body text-center py-5">
|
16
|
+
<div class="spinner-border text-primary" role="status">
|
17
|
+
<span class="visually-hidden">Loading...</span>
|
18
|
+
</div>
|
19
|
+
<p class="mt-3">Loading form...</p>
|
20
|
+
</div>
|
21
|
+
`;
|
22
|
+
modal.show();
|
23
|
+
|
24
|
+
try {
|
25
|
+
const response = await fetch(`/dbviewer/tables/${tableName}/new_record`);
|
26
|
+
const html = await response.text();
|
27
|
+
modalBody.innerHTML = html;
|
28
|
+
initializeFormElements();
|
29
|
+
} catch (error) {
|
30
|
+
modalBody.innerHTML = `
|
31
|
+
<div class="modal-header">
|
32
|
+
<h5 class="modal-title">Error</h5>
|
33
|
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
34
|
+
</div>
|
35
|
+
<div class="modal-body">
|
36
|
+
<div class="alert alert-danger">
|
37
|
+
Failed to load form: ${error.message}
|
38
|
+
</div>
|
39
|
+
</div>
|
40
|
+
<div class="modal-footer">
|
41
|
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
42
|
+
</div>
|
43
|
+
`;
|
44
|
+
}
|
45
|
+
});
|
46
|
+
|
47
|
+
newRecordModal.addEventListener("submit", handleNewRecordSubmit);
|
48
|
+
});
|
49
|
+
|
50
|
+
// Initialize Select2 dropdowns and other form elements
|
51
|
+
function initializeFormElements() {
|
52
|
+
if (typeof $.fn.select2 !== "undefined") {
|
53
|
+
// Use jquery select2 for now, maybe we can change the dependency later
|
54
|
+
$(".select2-dropdown").select2({
|
55
|
+
dropdownParent: $("#newRecordModal"),
|
56
|
+
theme: "bootstrap-5",
|
57
|
+
width: "100%",
|
58
|
+
});
|
59
|
+
}
|
60
|
+
}
|
61
|
+
|
62
|
+
async function handleNewRecordSubmit(event) {
|
63
|
+
event.preventDefault();
|
64
|
+
|
65
|
+
const form = event.target;
|
66
|
+
const submitButton = document.getElementById("createRecordButton");
|
67
|
+
const originalText = submitButton.innerHTML;
|
68
|
+
const formData = new FormData(form);
|
69
|
+
|
70
|
+
disableSubmitButton(submitButton);
|
71
|
+
|
72
|
+
try {
|
73
|
+
const response = await fetch(form.action, {
|
74
|
+
method: "POST",
|
75
|
+
body: formData,
|
76
|
+
headers: {
|
77
|
+
"X-Requested-With": "XMLHttpRequest",
|
78
|
+
},
|
79
|
+
});
|
80
|
+
|
81
|
+
const result = await response.json();
|
82
|
+
|
83
|
+
if (!response.ok) {
|
84
|
+
handleErrors(result, form);
|
85
|
+
return;
|
86
|
+
}
|
87
|
+
|
88
|
+
handleSuccess(result);
|
89
|
+
} catch (error) {
|
90
|
+
displayGenericError(form, error.message);
|
91
|
+
} finally {
|
92
|
+
enableSubmitButton(submitButton, originalText);
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
function disableSubmitButton(button) {
|
97
|
+
button.disabled = true;
|
98
|
+
button.innerHTML =
|
99
|
+
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Creating...';
|
100
|
+
}
|
101
|
+
|
102
|
+
function enableSubmitButton(button, originalText) {
|
103
|
+
button.disabled = false;
|
104
|
+
button.innerHTML = originalText;
|
105
|
+
}
|
106
|
+
|
107
|
+
function handleSuccess() {
|
108
|
+
const modal = bootstrap.Modal.getInstance(newRecordModal);
|
109
|
+
modal.hide();
|
110
|
+
|
111
|
+
Toastify({
|
112
|
+
text: `<span class="toast-icon"><i class="bi bi-clipboard-check"></i></span> Record created successfully!`,
|
113
|
+
className: "toast-factory-bot",
|
114
|
+
duration: 3000,
|
115
|
+
gravity: "bottom",
|
116
|
+
position: "right",
|
117
|
+
escapeMarkup: false,
|
118
|
+
style: {
|
119
|
+
animation: "slideInRight 0.3s ease-out, slideOutRight 0.3s ease-out 2.7s",
|
120
|
+
},
|
121
|
+
}).showToast();
|
122
|
+
|
123
|
+
setTimeout(() => {
|
124
|
+
window.location.reload();
|
125
|
+
}, 1000);
|
126
|
+
}
|
127
|
+
|
128
|
+
function handleErrors(result, form) {
|
129
|
+
if (result.errors) {
|
130
|
+
showToastErrors(result.messages);
|
131
|
+
showFieldErrors(result.errors, form);
|
132
|
+
}
|
133
|
+
initializeFormElements();
|
134
|
+
}
|
135
|
+
|
136
|
+
function showToastErrors(messages) {
|
137
|
+
const combinedMessages = Object.values(messages)
|
138
|
+
.flat()
|
139
|
+
.map((msg) => `<div>${msg}</div>`)
|
140
|
+
.join("");
|
141
|
+
|
142
|
+
Toastify({
|
143
|
+
text: `<span class="toast-icon"><i class="bi bi-exclamation-triangle"></i></span> ${combinedMessages}`,
|
144
|
+
className: "toast-factory-bot toast-error",
|
145
|
+
duration: 5000,
|
146
|
+
gravity: "bottom",
|
147
|
+
position: "right",
|
148
|
+
escapeMarkup: false,
|
149
|
+
style: {
|
150
|
+
animation: "slideInRight 0.3s ease-out, slideOutRight 0.3s ease-out 4.7s",
|
151
|
+
background: "#dc3545",
|
152
|
+
},
|
153
|
+
}).showToast();
|
154
|
+
}
|
155
|
+
|
156
|
+
function showFieldErrors(errors, form) {
|
157
|
+
form.querySelectorAll(".invalid-feedback").forEach((el) => el.remove());
|
158
|
+
Object.entries(errors).forEach(([field, messages]) => {
|
159
|
+
const input = document.getElementById(`record_${field}`);
|
160
|
+
if (!input) return;
|
161
|
+
|
162
|
+
let errorDiv = input.parentElement.querySelector(".invalid-feedback");
|
163
|
+
if (!errorDiv) {
|
164
|
+
errorDiv = document.createElement("div");
|
165
|
+
errorDiv.className = "invalid-feedback";
|
166
|
+
input.parentElement.appendChild(errorDiv);
|
167
|
+
}
|
168
|
+
errorDiv.innerHTML = messages.join("<br>");
|
169
|
+
input.classList.add("is-invalid");
|
170
|
+
});
|
171
|
+
}
|
172
|
+
|
173
|
+
function displayGenericError(form, message) {
|
174
|
+
const errorDiv = document.createElement("div");
|
175
|
+
errorDiv.className = "alert alert-danger";
|
176
|
+
errorDiv.textContent = `Error: ${message}`;
|
177
|
+
form.prepend(errorDiv);
|
178
|
+
}
|
@@ -0,0 +1,171 @@
|
|
1
|
+
document.addEventListener("DOMContentLoaded", () => {
|
2
|
+
const tableName = document.getElementById("table_name")?.value;
|
3
|
+
const deleteConfirmModal = document.getElementById("deleteConfirmModal");
|
4
|
+
const recordDeleteForm = document.getElementById("recordDeleteForm");
|
5
|
+
|
6
|
+
// === TOASTIFY UTIL ===
|
7
|
+
const showToast = (message, type = "success") => {
|
8
|
+
const icons = {
|
9
|
+
success: "clipboard-check",
|
10
|
+
info: "info-circle",
|
11
|
+
danger: "exclamation-triangle",
|
12
|
+
warning: "exclamation-diamond",
|
13
|
+
};
|
14
|
+
|
15
|
+
Toastify({
|
16
|
+
text: `
|
17
|
+
<span class="toast-icon">
|
18
|
+
<i class="bi bi-${icons[type] || "info-circle"}"></i>
|
19
|
+
</span>
|
20
|
+
${message}
|
21
|
+
`,
|
22
|
+
className: `toast-factory-bot toast-${type}`,
|
23
|
+
duration: 3000,
|
24
|
+
gravity: "bottom",
|
25
|
+
position: "right",
|
26
|
+
escapeMarkup: false,
|
27
|
+
style: {
|
28
|
+
animation:
|
29
|
+
"slideInRight 0.3s ease-out, slideOutRight 0.3s ease-out 2.7s",
|
30
|
+
background: type === "danger" ? "#dc3545" : undefined,
|
31
|
+
},
|
32
|
+
}).showToast();
|
33
|
+
};
|
34
|
+
|
35
|
+
// === SETUP DELETE MODAL ===
|
36
|
+
const setupDeleteConfirmModal = (recordData, pkName, pkValue) => {
|
37
|
+
if (!recordData || !pkValue)
|
38
|
+
return console.error("Missing record data for delete confirmation");
|
39
|
+
|
40
|
+
const infoDiv = document.getElementById("deleteRecordInfo");
|
41
|
+
const idInput = document.getElementById("deleteRecordId");
|
42
|
+
|
43
|
+
idInput.value = pkValue;
|
44
|
+
recordDeleteForm.action = `${
|
45
|
+
window.location.pathname
|
46
|
+
}/records/${encodeURIComponent(pkValue)}`;
|
47
|
+
infoDiv.innerHTML = `<div><strong>${pkName}:</strong> ${pkValue}</div>`;
|
48
|
+
|
49
|
+
const importantFields = [
|
50
|
+
"name",
|
51
|
+
"title",
|
52
|
+
"email",
|
53
|
+
"username",
|
54
|
+
"code",
|
55
|
+
"reference",
|
56
|
+
];
|
57
|
+
let count = 0;
|
58
|
+
|
59
|
+
for (const key of Object.keys(recordData)) {
|
60
|
+
if (count >= 3) break;
|
61
|
+
const lowerKey = key.toLowerCase();
|
62
|
+
if (
|
63
|
+
key !== pkName &&
|
64
|
+
importantFields.some((f) => lowerKey.includes(f)) &&
|
65
|
+
recordData[key]
|
66
|
+
) {
|
67
|
+
const fieldDiv = document.createElement("div");
|
68
|
+
fieldDiv.className = "mt-1";
|
69
|
+
fieldDiv.innerHTML = `<strong>${key}:</strong> ${recordData[key]}`;
|
70
|
+
infoDiv.appendChild(fieldDiv);
|
71
|
+
count++;
|
72
|
+
}
|
73
|
+
}
|
74
|
+
};
|
75
|
+
|
76
|
+
// === DELETE FROM DETAIL MODAL ===
|
77
|
+
const detailDeleteBtn = document.getElementById("recordDetailDeleteBtn");
|
78
|
+
if (detailDeleteBtn) {
|
79
|
+
detailDeleteBtn.addEventListener("click", () => {
|
80
|
+
const rows = document.querySelectorAll("#recordDetailTableBody tr");
|
81
|
+
const recordData = {};
|
82
|
+
|
83
|
+
rows.forEach((row) => {
|
84
|
+
const [keyCell, valueCell] = row.querySelectorAll("td");
|
85
|
+
if (keyCell && valueCell) {
|
86
|
+
recordData[keyCell.textContent.trim()] = valueCell.textContent.trim();
|
87
|
+
}
|
88
|
+
});
|
89
|
+
|
90
|
+
const pkName =
|
91
|
+
Object.keys(recordData).find((k) => k.toLowerCase() === "id") ||
|
92
|
+
Object.keys(recordData)[0];
|
93
|
+
const pkValue = recordData[pkName];
|
94
|
+
|
95
|
+
setupDeleteConfirmModal(recordData, pkName, pkValue);
|
96
|
+
|
97
|
+
bootstrap.Modal.getInstance(
|
98
|
+
document.getElementById("recordDetailModal")
|
99
|
+
)?.hide();
|
100
|
+
setTimeout(() => new bootstrap.Modal(deleteConfirmModal).show(), 500);
|
101
|
+
});
|
102
|
+
}
|
103
|
+
|
104
|
+
// === DELETE FROM TABLE ROW ===
|
105
|
+
document.querySelectorAll(".delete-record-btn").forEach((button) => {
|
106
|
+
button.addEventListener("click", () => {
|
107
|
+
const recordData = JSON.parse(button.dataset.recordData || "{}");
|
108
|
+
const pkName =
|
109
|
+
Object.keys(recordData).find((k) => k.toLowerCase() === "id") ||
|
110
|
+
Object.keys(recordData)[0];
|
111
|
+
const pkValue = recordData[pkName];
|
112
|
+
setupDeleteConfirmModal(recordData, pkName, pkValue);
|
113
|
+
});
|
114
|
+
});
|
115
|
+
|
116
|
+
// === FORM SUBMIT (DELETE) ===
|
117
|
+
if (recordDeleteForm) {
|
118
|
+
recordDeleteForm.addEventListener("submit", async (e) => {
|
119
|
+
e.preventDefault();
|
120
|
+
|
121
|
+
const form = e.target;
|
122
|
+
const submitButton = document.querySelector(
|
123
|
+
"#recordDeleteForm button[type='submit']"
|
124
|
+
);
|
125
|
+
const originalText = submitButton.innerHTML;
|
126
|
+
const csrfToken = document.querySelector(
|
127
|
+
'meta[name="csrf-token"]'
|
128
|
+
)?.content;
|
129
|
+
|
130
|
+
disableSubmitButton(submitButton);
|
131
|
+
|
132
|
+
try {
|
133
|
+
const response = await fetch(form.action, {
|
134
|
+
method: "DELETE",
|
135
|
+
headers: {
|
136
|
+
"Content-Type": "application/json",
|
137
|
+
"X-CSRF-Token": csrfToken,
|
138
|
+
},
|
139
|
+
credentials: "same-origin",
|
140
|
+
});
|
141
|
+
|
142
|
+
const result = await response.json();
|
143
|
+
|
144
|
+
if (!response.ok) {
|
145
|
+
throw new Error(result?.error || "Failed to delete record");
|
146
|
+
}
|
147
|
+
|
148
|
+
showToast(result.message || "Record deleted successfully", "success");
|
149
|
+
|
150
|
+
const modal = bootstrap.Modal.getInstance(deleteConfirmModal);
|
151
|
+
modal.hide();
|
152
|
+
|
153
|
+
setTimeout(() => window.location.reload(), 1000);
|
154
|
+
} catch (error) {
|
155
|
+
console.error("Delete Error:", error);
|
156
|
+
showToast(error.message || "Unexpected error occurred", "danger");
|
157
|
+
} finally {
|
158
|
+
enableSubmitButton(submitButton, originalText);
|
159
|
+
}
|
160
|
+
});
|
161
|
+
}
|
162
|
+
|
163
|
+
// === BUTTON HELPERS ===
|
164
|
+
function disableSubmitButton(button) {
|
165
|
+
button.disabled = true;
|
166
|
+
}
|
167
|
+
|
168
|
+
function enableSubmitButton(button) {
|
169
|
+
button.disabled = false;
|
170
|
+
}
|
171
|
+
});
|
@@ -1067,3 +1067,136 @@ span.flatpickr-weekday {
|
|
1067
1067
|
width: 0.875rem;
|
1068
1068
|
height: 0.875rem;
|
1069
1069
|
}
|
1070
|
+
|
1071
|
+
/* ========== FLOATING ADD RECORD BUTTON STYLES ========== */
|
1072
|
+
.floating-add-record {
|
1073
|
+
position: fixed;
|
1074
|
+
bottom: 100px;
|
1075
|
+
right: 30px;
|
1076
|
+
z-index: 1050;
|
1077
|
+
}
|
1078
|
+
|
1079
|
+
.floating-add-record .btn {
|
1080
|
+
width: 60px;
|
1081
|
+
height: 60px;
|
1082
|
+
border-radius: 50%;
|
1083
|
+
font-size: 1.5rem;
|
1084
|
+
display: flex;
|
1085
|
+
align-items: center;
|
1086
|
+
justify-content: center;
|
1087
|
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
1088
|
+
transition: all 0.3s ease;
|
1089
|
+
}
|
1090
|
+
|
1091
|
+
.floating-add-record .btn:hover {
|
1092
|
+
transform: translateY(-3px);
|
1093
|
+
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2);
|
1094
|
+
}
|
1095
|
+
|
1096
|
+
.floating-add-record .btn:active {
|
1097
|
+
transform: translateY(-1px);
|
1098
|
+
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
|
1099
|
+
}
|
1100
|
+
|
1101
|
+
/* Dark mode styles */
|
1102
|
+
[data-bs-theme="dark"] .floating-add-record .btn {
|
1103
|
+
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
1104
|
+
}
|
1105
|
+
|
1106
|
+
/* Style for form select2 dropdowns */
|
1107
|
+
.select2-container--bootstrap-5 .select2-selection {
|
1108
|
+
min-height: 38px;
|
1109
|
+
}
|
1110
|
+
|
1111
|
+
/* ============================= */
|
1112
|
+
/* DARK MODE SUPPORT FOR SELECT2 */
|
1113
|
+
/* ============================= */
|
1114
|
+
|
1115
|
+
[data-bs-theme="dark"] .select2-container--bootstrap-5 .select2-selection {
|
1116
|
+
background-color: var(--bs-dark);
|
1117
|
+
color: var(--bs-light);
|
1118
|
+
border-color: var(--bs-border-color-translucent);
|
1119
|
+
}
|
1120
|
+
|
1121
|
+
[data-bs-theme="dark"]
|
1122
|
+
.select2-container--bootstrap-5
|
1123
|
+
.select2-selection--single
|
1124
|
+
.select2-selection__rendered {
|
1125
|
+
color: var(--bs-light);
|
1126
|
+
}
|
1127
|
+
|
1128
|
+
[data-bs-theme="dark"]
|
1129
|
+
.select2-container--bootstrap-5
|
1130
|
+
.select2-selection--multiple
|
1131
|
+
.select2-selection__choice {
|
1132
|
+
background-color: var(--bs-secondary-bg);
|
1133
|
+
color: var(--bs-light);
|
1134
|
+
border-color: var(--bs-border-color-translucent);
|
1135
|
+
}
|
1136
|
+
|
1137
|
+
[data-bs-theme="dark"]
|
1138
|
+
.select2-container--bootstrap-5
|
1139
|
+
.select2-selection__placeholder {
|
1140
|
+
color: var(--bs-secondary-color);
|
1141
|
+
}
|
1142
|
+
|
1143
|
+
[data-bs-theme="dark"]
|
1144
|
+
.select2-container--bootstrap-5
|
1145
|
+
.select2-selection__arrow
|
1146
|
+
b {
|
1147
|
+
border-color: var(--bs-light) transparent transparent transparent;
|
1148
|
+
}
|
1149
|
+
|
1150
|
+
[data-bs-theme="dark"]
|
1151
|
+
.select2-container--bootstrap-5.select2-container--open
|
1152
|
+
.select2-selection {
|
1153
|
+
border-color: var(--bs-primary);
|
1154
|
+
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
|
1155
|
+
}
|
1156
|
+
|
1157
|
+
[data-bs-theme="dark"] .select2-dropdown {
|
1158
|
+
background-color: var(--bs-dark);
|
1159
|
+
border-color: var(--bs-border-color-translucent);
|
1160
|
+
color: var(--bs-light);
|
1161
|
+
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.5);
|
1162
|
+
}
|
1163
|
+
|
1164
|
+
[data-bs-theme="dark"] .select2-results__option {
|
1165
|
+
background-color: var(--bs-dark);
|
1166
|
+
color: var(--bs-light);
|
1167
|
+
}
|
1168
|
+
|
1169
|
+
[data-bs-theme="dark"] .select2-results__option--highlighted {
|
1170
|
+
background-color: var(--bs-primary);
|
1171
|
+
color: #fff;
|
1172
|
+
}
|
1173
|
+
|
1174
|
+
[data-bs-theme="dark"]
|
1175
|
+
.select2-container--bootstrap-5
|
1176
|
+
.select2-dropdown
|
1177
|
+
.select2-search
|
1178
|
+
.select2-search__field {
|
1179
|
+
background-color: var(--bs-dark);
|
1180
|
+
color: var(--bs-light);
|
1181
|
+
border: 1px solid var(--bs-border-color-translucent);
|
1182
|
+
}
|
1183
|
+
|
1184
|
+
[data-bs-theme="dark"]
|
1185
|
+
.select2-container--bootstrap-5
|
1186
|
+
.select2-search__field::placeholder {
|
1187
|
+
color: var(--bs-secondary-color);
|
1188
|
+
}
|
1189
|
+
|
1190
|
+
/* Ensure select2 and form fields use Bootstrap default font size */
|
1191
|
+
.select2-container--bootstrap-5 .select2-selection,
|
1192
|
+
.select2-container--bootstrap-5 .select2-results__option,
|
1193
|
+
.select2-container--bootstrap-5 .select2-search__field,
|
1194
|
+
.form-control {
|
1195
|
+
font-size: 1rem !important; /* Bootstrap default */
|
1196
|
+
line-height: 1.5; /* Also aligns vertically */
|
1197
|
+
}
|
1198
|
+
|
1199
|
+
/* Optional: improve vertical alignment of Select2 with input fields */
|
1200
|
+
.select2-container--bootstrap-5 .select2-selection {
|
1201
|
+
padding: 0.375rem 0.75rem;
|
1202
|
+
}
|
@@ -3,7 +3,7 @@ module Dbviewer
|
|
3
3
|
include Dbviewer::AccessControlValidation
|
4
4
|
|
5
5
|
before_action :set_table_name, except: [ :index ]
|
6
|
-
before_action :validate_table, only: [ :show, :query, :export_csv ]
|
6
|
+
before_action :validate_table, only: [ :show, :query, :export_csv, :new_record, :create_record, :destroy_record ]
|
7
7
|
before_action :set_query_filters, only: [ :show, :export_csv ]
|
8
8
|
before_action :set_global_filters, only: [ :show, :export_csv ]
|
9
9
|
|
@@ -30,6 +30,25 @@ module Dbviewer
|
|
30
30
|
@metadata = datatable_data[:metadata]
|
31
31
|
end
|
32
32
|
|
33
|
+
def new_record
|
34
|
+
@table_columns = filter_accessible_columns(@table_name, database_manager.table_columns(@table_name))
|
35
|
+
@metadata = database_manager.table_metadata(@table_name)
|
36
|
+
@foreign_key_options = load_foreign_key_options(@metadata)
|
37
|
+
|
38
|
+
render layout: false
|
39
|
+
end
|
40
|
+
|
41
|
+
def create_record
|
42
|
+
model_class = database_manager.get_model_for(@table_name)
|
43
|
+
record = model_class.new(record_params)
|
44
|
+
|
45
|
+
if record.save
|
46
|
+
render json: { message: "Record created successfully" }
|
47
|
+
else
|
48
|
+
render json: { errors: record.errors, messages: record.errors.full_messages }, status: :unprocessable_entity
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
33
52
|
def query
|
34
53
|
all_columns = fetch_table_columns(@table_name)
|
35
54
|
@columns = filter_accessible_columns(@table_name, all_columns)
|
@@ -45,8 +64,6 @@ module Dbviewer
|
|
45
64
|
end
|
46
65
|
|
47
66
|
@records = execute_query(@query)
|
48
|
-
|
49
|
-
render :query
|
50
67
|
end
|
51
68
|
|
52
69
|
def export_csv
|
@@ -56,7 +73,6 @@ module Dbviewer
|
|
56
73
|
return
|
57
74
|
end
|
58
75
|
|
59
|
-
include_headers = params[:include_headers] != "0"
|
60
76
|
query_params = Dbviewer::Datatable::QueryParams.new(
|
61
77
|
page: @current_page,
|
62
78
|
per_page: (params[:limit] || 10000).to_i,
|
@@ -64,18 +80,49 @@ module Dbviewer
|
|
64
80
|
direction: @order_direction,
|
65
81
|
column_filters: @column_filters.reject { |_, v| v.blank? }
|
66
82
|
)
|
67
|
-
csv_data = export_table_to_csv(@table_name, query_params, include_headers)
|
68
83
|
|
69
|
-
|
70
|
-
filename = "#{@table_name}_#{
|
84
|
+
csv_data = export_table_to_csv(@table_name, query_params, params[:include_headers] != "0")
|
85
|
+
filename = "#{@table_name}_#{Time.now.strftime('%Y%m%d%H%M%S')}.csv"
|
71
86
|
|
72
87
|
send_data csv_data,
|
73
88
|
type: "text/csv; charset=utf-8; header=present",
|
74
89
|
disposition: "attachment; filename=#{filename}"
|
75
90
|
end
|
76
91
|
|
92
|
+
def destroy_record
|
93
|
+
model_class = database_manager.get_model_for(@table_name)
|
94
|
+
primary_key = database_manager.primary_key(@table_name) || "id"
|
95
|
+
record = model_class.find_by(primary_key => params[:record_id])
|
96
|
+
|
97
|
+
if record.nil?
|
98
|
+
render json: { error: "Record not found" }, status: :not_found
|
99
|
+
return
|
100
|
+
end
|
101
|
+
|
102
|
+
begin
|
103
|
+
if record.destroy
|
104
|
+
render json: { message: "Record deleted successfully" }, status: :ok
|
105
|
+
else
|
106
|
+
render json: { errors: record.errors.full_messages, message: "Failed to delete record" }, status: :unprocessable_entity
|
107
|
+
end
|
108
|
+
rescue => e
|
109
|
+
Rails.logger.error("Error deleting record from #{@table_name}: #{e.message}")
|
110
|
+
render json: { error: "Failed to delete record: #{e.message}" }, status: :internal_server_error
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
77
114
|
private
|
78
115
|
|
116
|
+
def record_params
|
117
|
+
accessible_columns = filter_accessible_columns(@table_name, database_manager.table_columns(@table_name))
|
118
|
+
|
119
|
+
permitted_fields = accessible_columns
|
120
|
+
.reject { |col| %w[id created_at updated_at].include?(col[:name]) }
|
121
|
+
.map { |col| col[:name].to_sym }
|
122
|
+
|
123
|
+
params.require(:record).permit(*permitted_fields)
|
124
|
+
end
|
125
|
+
|
79
126
|
def set_table_name
|
80
127
|
@table_name = params[:id]
|
81
128
|
end
|
@@ -86,78 +133,82 @@ module Dbviewer
|
|
86
133
|
|
87
134
|
def set_query_filters
|
88
135
|
@current_page = [ 1, params[:page].to_i ].max
|
89
|
-
@per_page = params[:per_page] ? params[:per_page].to_i : Dbviewer.configuration.default_per_page
|
90
|
-
@per_page = Dbviewer.configuration.default_per_page unless Dbviewer.configuration.per_page_options.include?(@per_page)
|
136
|
+
@per_page = Dbviewer.configuration.per_page_options.include?(params[:per_page].to_i) ? params[:per_page].to_i : Dbviewer.configuration.default_per_page
|
91
137
|
@order_by = params[:order_by].presence || determine_default_order_column
|
92
|
-
@order_direction = params[:order_direction].upcase
|
93
|
-
@order_direction = "DESC" unless %w[ASC DESC].include?(@order_direction)
|
138
|
+
@order_direction = %w[ASC DESC].include?(params[:order_direction].to_s.upcase) ? params[:order_direction].upcase : "DESC"
|
94
139
|
@column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
|
95
140
|
end
|
96
141
|
|
97
142
|
def set_global_filters
|
98
|
-
|
99
|
-
if params[:
|
100
|
-
session[:creation_filter_start] = params[:creation_filter_start]
|
101
|
-
end
|
102
|
-
|
103
|
-
if params[:creation_filter_end].present?
|
104
|
-
session[:creation_filter_end] = params[:creation_filter_end]
|
105
|
-
end
|
143
|
+
session[:creation_filter_start] = params[:creation_filter_start] if params[:creation_filter_start].present?
|
144
|
+
session[:creation_filter_end] = params[:creation_filter_end] if params[:creation_filter_end].present?
|
106
145
|
|
107
|
-
# Clear filters if explicitly requested
|
108
146
|
if params[:clear_creation_filter] == "true"
|
109
147
|
session.delete(:creation_filter_start)
|
110
148
|
session.delete(:creation_filter_end)
|
111
149
|
end
|
112
150
|
|
113
|
-
# Set instance variables for view access
|
114
151
|
@creation_filter_start = session[:creation_filter_start]
|
115
152
|
@creation_filter_end = session[:creation_filter_end]
|
116
|
-
|
117
|
-
# Initialize column_filters if not present
|
118
153
|
@column_filters ||= {}
|
119
154
|
|
120
|
-
# Apply creation filters to column_filters if the table has a created_at column
|
121
155
|
if has_timestamp_column?(@table_name) && (@creation_filter_start.present? || @creation_filter_end.present?)
|
122
|
-
# Clear any existing created_at filters
|
123
156
|
%w[created_at created_at_operator created_at_end].each { |key| @column_filters.delete(key) }
|
124
157
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
when @creation_filter_start.present?
|
132
|
-
@column_filters.merge!({
|
133
|
-
"created_at" => @creation_filter_start,
|
134
|
-
"created_at_operator" => "gte"
|
135
|
-
})
|
136
|
-
when @creation_filter_end.present?
|
137
|
-
@column_filters.merge!({
|
138
|
-
"created_at" => @creation_filter_end,
|
139
|
-
"created_at_operator" => "lte"
|
140
|
-
})
|
158
|
+
if @creation_filter_start.present? && @creation_filter_end.present?
|
159
|
+
@column_filters.merge!("created_at" => @creation_filter_start, "created_at_end" => @creation_filter_end)
|
160
|
+
elsif @creation_filter_start.present?
|
161
|
+
@column_filters.merge!("created_at" => @creation_filter_start, "created_at_operator" => "gte")
|
162
|
+
elsif @creation_filter_end.present?
|
163
|
+
@column_filters.merge!("created_at" => @creation_filter_end, "created_at_operator" => "lte")
|
141
164
|
end
|
142
165
|
end
|
143
166
|
end
|
144
167
|
|
145
|
-
# Determine the default order column using configurable ordering logic
|
146
168
|
def determine_default_order_column
|
147
|
-
# Get the table columns to check what's available
|
148
169
|
columns = @columns || fetch_table_columns(@table_name)
|
149
170
|
column_names = columns.map { |col| col[:name] }
|
150
171
|
|
151
|
-
|
152
|
-
|
153
|
-
|
172
|
+
Dbviewer.configuration.default_order_column.presence_in(column_names) ||
|
173
|
+
database_manager.primary_key(@table_name) ||
|
174
|
+
columns.first&.dig(:name)
|
175
|
+
end
|
176
|
+
|
177
|
+
def find_display_column(columns)
|
178
|
+
column_names = columns.map { |c| c[:name] }
|
179
|
+
# Common display columns to check for (TODO: next we can add this to configuration for better dev control)
|
180
|
+
# If none found, fallback to first non-id column or id column
|
181
|
+
# This ensures we have a sensible default for display purposes
|
182
|
+
%w[name title label display_name username email description].find { |name| column_names.include?(name) } ||
|
183
|
+
columns.find { |c| [ :string, :text ].include?(c[:type]) && c[:name] != "id" }&.[](:name) ||
|
184
|
+
columns.find { |c| c[:name] != "id" }&.[](:name) || "id"
|
185
|
+
end
|
154
186
|
|
155
|
-
|
156
|
-
|
157
|
-
|
187
|
+
def load_foreign_key_options(metadata)
|
188
|
+
options = {}
|
189
|
+
metadata[:foreign_keys].each do |fk|
|
190
|
+
foreign_table = fk[:to_table]
|
191
|
+
foreign_key_column = fk[:primary_key] || "id"
|
192
|
+
next unless access_control.table_accessible?(foreign_table)
|
158
193
|
|
159
|
-
|
160
|
-
|
194
|
+
begin
|
195
|
+
foreign_columns = database_manager.table_columns(foreign_table)
|
196
|
+
display_column = find_display_column(foreign_columns)
|
197
|
+
|
198
|
+
foreign_model = database_manager.get_model_for(foreign_table)
|
199
|
+
records = foreign_model
|
200
|
+
.select([ foreign_key_column, display_column ].uniq)
|
201
|
+
.limit(100)
|
202
|
+
.order(display_column)
|
203
|
+
.map { |r| [ r[display_column].to_s, r[foreign_key_column] ] }
|
204
|
+
|
205
|
+
options[fk[:column]] = records
|
206
|
+
rescue => e
|
207
|
+
Rails.logger.error("Error fetching foreign key options for #{foreign_table}: #{e.message}")
|
208
|
+
options[fk[:column]] = []
|
209
|
+
end
|
210
|
+
end
|
211
|
+
options
|
161
212
|
end
|
162
213
|
end
|
163
214
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
module DatatableUiFormHelper
|
3
|
+
def determine_field_type(column_type)
|
4
|
+
case column_type
|
5
|
+
when :boolean
|
6
|
+
:check_box
|
7
|
+
when :text
|
8
|
+
:text_area
|
9
|
+
when :date
|
10
|
+
:date_field
|
11
|
+
when :datetime, :timestamp
|
12
|
+
:datetime_local_field
|
13
|
+
else
|
14
|
+
:text_field
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -137,8 +137,27 @@ module Dbviewer
|
|
137
137
|
content_tag(:i, "", class: "bi bi-clipboard")
|
138
138
|
end
|
139
139
|
|
140
|
-
#
|
141
|
-
|
140
|
+
# Delete Record button (only if enabled in configuration)
|
141
|
+
delete_button = if Dbviewer.configuration.enable_record_deletion
|
142
|
+
button_tag(
|
143
|
+
type: "button",
|
144
|
+
class: "btn btn-sm btn-outline-danger delete-record-btn",
|
145
|
+
title: "Delete Record",
|
146
|
+
data: {
|
147
|
+
bs_toggle: "modal",
|
148
|
+
bs_target: "#deleteConfirmModal",
|
149
|
+
record_data: data_attributes.to_json,
|
150
|
+
table_name: table_name
|
151
|
+
}
|
152
|
+
) do
|
153
|
+
content_tag(:i, "", class: "bi bi-trash")
|
154
|
+
end
|
155
|
+
else
|
156
|
+
"".html_safe
|
157
|
+
end
|
158
|
+
|
159
|
+
# Concatenate all buttons
|
160
|
+
view_button + copy_factory_button + delete_button
|
142
161
|
end
|
143
162
|
end
|
144
163
|
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
<% if @errors.present? %>
|
2
|
+
<div class="alert alert-danger">
|
3
|
+
<ul class="mb-0">
|
4
|
+
<% @errors.full_messages.each do |message| %>
|
5
|
+
<li><%= message %></li>
|
6
|
+
<% end %>
|
7
|
+
</ul>
|
8
|
+
</div>
|
9
|
+
<% end %>
|
10
|
+
|
11
|
+
<div class="modal-header">
|
12
|
+
<h5 class="modal-title" id="newRecordModalLabel">
|
13
|
+
<i class="bi bi-plus-circle me-1"></i> Create New <%= @table_name.humanize.titleize %> Record
|
14
|
+
</h5>
|
15
|
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
16
|
+
</div>
|
17
|
+
|
18
|
+
<div class="modal-body">
|
19
|
+
<%= form_with url: create_record_table_path(@table_name), method: :post, id: "newRecordForm" do |form| %>
|
20
|
+
<% @table_columns.each do |column| %>
|
21
|
+
<%
|
22
|
+
column_name = column[:name]
|
23
|
+
is_primary_key = @metadata[:primary_key] == column_name
|
24
|
+
skip_column = is_primary_key && %w[id].include?(column_name.downcase)
|
25
|
+
|
26
|
+
# Skip auto-increment primary keys and timestamps
|
27
|
+
next if skip_column || %w[created_at updated_at].include?(column_name)
|
28
|
+
%>
|
29
|
+
|
30
|
+
<div class="mb-3">
|
31
|
+
<%
|
32
|
+
# Handle different field types
|
33
|
+
field_type = determine_field_type(column[:type])
|
34
|
+
foreign_key = @metadata[:foreign_keys].find { |fk| fk[:column] == column_name }
|
35
|
+
field_id = "record_#{column_name}"
|
36
|
+
required = !column[:null]
|
37
|
+
%>
|
38
|
+
|
39
|
+
<%= form.label "record[#{column_name}]", column_name.humanize, class: "form-label" %>
|
40
|
+
|
41
|
+
<% if foreign_key && @foreign_key_options[column_name].present? %>
|
42
|
+
<%= form.select "record[#{column_name}]",
|
43
|
+
options_for_select(@foreign_key_options[column_name]),
|
44
|
+
{ include_blank: column[:null] ? "-- Select --" : false },
|
45
|
+
{ class: "form-select form-control select2-dropdown" }
|
46
|
+
%>
|
47
|
+
<% elsif field_type == :check_box %>
|
48
|
+
<div class="form-check">
|
49
|
+
<%= form.check_box "record[#{column_name}]", class: "form-check-input", id: field_id %>
|
50
|
+
</div>
|
51
|
+
<% elsif field_type == :text_area %>
|
52
|
+
<%= form.text_area "record[#{column_name}]", class: "form-control", id: field_id, rows: 3, required: required %>
|
53
|
+
<% else %>
|
54
|
+
<%= form.send(field_type, "record[#{column_name}]", class: "form-control", id: field_id, required: required) %>
|
55
|
+
<% end %>
|
56
|
+
|
57
|
+
<% if column[:default].present? && column[:default] != "NULL" %>
|
58
|
+
<div class="form-text text-muted">Default: <%= column[:default] %></div>
|
59
|
+
<% end %>
|
60
|
+
</div>
|
61
|
+
<% end %>
|
62
|
+
<% end %>
|
63
|
+
</div>
|
64
|
+
|
65
|
+
<div class="modal-footer">
|
66
|
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
67
|
+
<button type="submit" form="newRecordForm" class="btn btn-primary" id="createRecordButton">
|
68
|
+
<i class="bi bi-plus-lg me-1"></i>Create Record
|
69
|
+
</button>
|
70
|
+
</div>
|
@@ -5,8 +5,16 @@
|
|
5
5
|
<% content_for :head do %>
|
6
6
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
7
7
|
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
|
8
|
+
<!-- jQuery for Select2 -->
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js"></script>
|
10
|
+
<!-- Select2 for searchable dropdowns -->
|
11
|
+
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
12
|
+
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
13
|
+
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
8
14
|
<%= stylesheet_link_tag "dbviewer/table", "data-turbo-track": "reload" %>
|
9
15
|
<%= javascript_include_tag "dbviewer/table", "data-turbo-track": "reload" %>
|
16
|
+
<%= javascript_include_tag "dbviewer/record_creation", "data-turbo-track": "reload" %>
|
17
|
+
<%= javascript_include_tag "dbviewer/record_deletion", "data-turbo-track": "reload" %>
|
10
18
|
<% end %>
|
11
19
|
|
12
20
|
<% content_for :sidebar_active do %>active<% end %>
|
@@ -185,6 +193,11 @@
|
|
185
193
|
</div>
|
186
194
|
<div class="modal-footer">
|
187
195
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
196
|
+
<% if Dbviewer.configuration.enable_record_deletion %>
|
197
|
+
<button type="button" class="btn btn-danger" id="recordDetailDeleteBtn" data-record-id="">
|
198
|
+
<i class="bi bi-trash me-1"></i>Delete Record
|
199
|
+
</button>
|
200
|
+
<% end %>
|
188
201
|
</div>
|
189
202
|
</div>
|
190
203
|
</div>
|
@@ -352,5 +365,68 @@
|
|
352
365
|
</div>
|
353
366
|
</div>
|
354
367
|
<% end %>
|
368
|
+
|
369
|
+
<!-- Floating Add Record Button -->
|
370
|
+
<%# TODO: move this to helpers so that we can have centralized creation check %>
|
371
|
+
<% if @table_name != 'schema_migrations'%>
|
372
|
+
<div class="floating-add-record d-none d-lg-block">
|
373
|
+
<button id="floatingAddRecordBtn"
|
374
|
+
class="btn btn-success btn-lg shadow-lg rounded-circle"
|
375
|
+
type="button"
|
376
|
+
title="Add New Record">
|
377
|
+
<i class="bi bi-plus-lg"></i>
|
378
|
+
</button>
|
379
|
+
</div>
|
380
|
+
<% end %>
|
381
|
+
|
382
|
+
<!-- New Record Modal -->
|
383
|
+
<div id="newRecordModal" class="modal fade" tabindex="-1" aria-labelledby="newRecordModalLabel" aria-hidden="true">
|
384
|
+
<div class="modal-dialog modal-lg">
|
385
|
+
<div class="modal-content">
|
386
|
+
<!-- Content will be loaded via AJAX -->
|
387
|
+
</div>
|
388
|
+
</div>
|
389
|
+
</div>
|
390
|
+
|
391
|
+
<% if Dbviewer.configuration.enable_record_deletion %>
|
392
|
+
<!-- Delete Confirmation Modal -->
|
393
|
+
<div class="modal fade" id="deleteConfirmModal" tabindex="-1" aria-labelledby="deleteConfirmModalLabel" aria-hidden="true">
|
394
|
+
<div class="modal-dialog">
|
395
|
+
<div class="modal-content">
|
396
|
+
<div class="modal-header bg-danger text-white">
|
397
|
+
<h5 class="modal-title" id="deleteConfirmModalLabel">
|
398
|
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>Confirm Deletion
|
399
|
+
</h5>
|
400
|
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
401
|
+
</div>
|
402
|
+
<div class="modal-body">
|
403
|
+
<p>Are you sure you want to delete this record from <strong><%= @table_name %></strong>?</p>
|
404
|
+
<div class="alert alert-warning">
|
405
|
+
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
406
|
+
<strong>Warning:</strong> This action cannot be undone.
|
407
|
+
</div>
|
408
|
+
|
409
|
+
<!-- Primary key info will be inserted here -->
|
410
|
+
<div id="deleteRecordInfo" class="mt-3 mb-2 p-2 border-start border-4 border-danger ps-3">
|
411
|
+
<!-- Record info will be displayed here -->
|
412
|
+
</div>
|
413
|
+
</div>
|
414
|
+
<div class="modal-footer">
|
415
|
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
416
|
+
<%= form_with(url: "#", method: :delete, id: "recordDeleteForm") do |form| %>
|
417
|
+
<input type="hidden" id="deleteRecordId" name="record_id" value="">
|
418
|
+
<button type="submit" class="btn btn-danger">
|
419
|
+
<i class="bi bi-trash me-1"></i>Delete Record
|
420
|
+
</button>
|
421
|
+
<% end %>
|
422
|
+
</div>
|
423
|
+
</div>
|
424
|
+
</div>
|
425
|
+
</div>
|
426
|
+
<% end %>
|
427
|
+
|
428
|
+
<!-- Toast container for notifications -->
|
429
|
+
<div id="toast-container" class="toast-container position-fixed bottom-0 end-0 p-3"></div>
|
430
|
+
|
355
431
|
<input type="hidden" id="mini_erd_table_path" name="mini_erd_table_path" value="<%= dbviewer.mini_erd_api_table_path(@table_name, format: :json) %>">
|
356
432
|
<input type="hidden" id="table_name" name="table_name" value="<%= @table_name %>">
|
data/config/routes.rb
CHANGED
@@ -117,6 +117,9 @@ module Dbviewer
|
|
117
117
|
# Maximum number of security events to keep in memory
|
118
118
|
attr_accessor :max_security_events
|
119
119
|
|
120
|
+
# Enable or disable record deletion functionality
|
121
|
+
attr_accessor :enable_record_deletion
|
122
|
+
|
120
123
|
def initialize
|
121
124
|
@per_page_options = [ 10, 20, 50, 100 ]
|
122
125
|
@default_per_page = 20
|
@@ -124,6 +127,7 @@ module Dbviewer
|
|
124
127
|
@max_query_length = 10000
|
125
128
|
@cache_expiry = 300
|
126
129
|
@enable_data_export = false
|
130
|
+
@enable_record_deletion = true
|
127
131
|
@query_timeout = 30
|
128
132
|
@query_logging_mode = :memory
|
129
133
|
@query_log_path = "log/dbviewer.log"
|
@@ -2,12 +2,13 @@ module Dbviewer
|
|
2
2
|
module Database
|
3
3
|
# DynamicModelFactory creates and manages ActiveRecord models for database tables
|
4
4
|
class DynamicModelFactory
|
5
|
-
# Initialize with a connection and
|
5
|
+
# Initialize with a connection, cache manager, and metadata manager
|
6
6
|
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
|
7
|
-
# @param cache_manager [Dbviewer::
|
8
|
-
def initialize(connection, cache_manager)
|
7
|
+
# @param cache_manager [Dbviewer::Cache::Base] Cache manager instance
|
8
|
+
def initialize(connection, cache_manager, metadata_manager)
|
9
9
|
@connection = connection
|
10
10
|
@cache_manager = cache_manager
|
11
|
+
@metadata_manager = metadata_manager
|
11
12
|
end
|
12
13
|
|
13
14
|
# Get or create an ActiveRecord model for a table
|
@@ -16,7 +17,7 @@ module Dbviewer
|
|
16
17
|
def get_model_for(table_name)
|
17
18
|
# Cache models for shorter time since they might need refreshing more frequently
|
18
19
|
@cache_manager.fetch("model-#{table_name}", expires_in: 300) do
|
19
|
-
|
20
|
+
find_or_create_model_for(table_name)
|
20
21
|
end
|
21
22
|
end
|
22
23
|
|
@@ -25,14 +26,15 @@ module Dbviewer
|
|
25
26
|
# Create a new ActiveRecord model for a table
|
26
27
|
# @param table_name [String] Name of the table
|
27
28
|
# @return [Class] ActiveRecord model class for the table
|
28
|
-
def
|
29
|
+
def find_or_create_model_for(table_name)
|
29
30
|
class_name = table_name.classify
|
30
31
|
|
31
32
|
# Check if we can reuse an existing constant
|
32
33
|
existing_model = handle_existing_constant(class_name, table_name)
|
33
34
|
return existing_model if existing_model
|
34
35
|
|
35
|
-
|
36
|
+
table_metadata = @metadata_manager.table_metadata(table_name)
|
37
|
+
model = create_active_record_model(class_name, table_name, table_metadata)
|
36
38
|
model.establish_connection(@connection.instance_variable_get(:@config))
|
37
39
|
model
|
38
40
|
end
|
@@ -64,7 +66,7 @@ module Dbviewer
|
|
64
66
|
# @param class_name [String] The constant name for the model
|
65
67
|
# @param table_name [String] The table name this model should represent
|
66
68
|
# @return [Class] New ActiveRecord model class
|
67
|
-
def create_active_record_model(class_name, table_name)
|
69
|
+
def create_active_record_model(class_name, table_name, table_metadata)
|
68
70
|
Dbviewer.const_set(class_name, Class.new(ActiveRecord::Base) do
|
69
71
|
self.table_name = table_name
|
70
72
|
|
@@ -76,25 +78,17 @@ module Dbviewer
|
|
76
78
|
self.primary_key = "id"
|
77
79
|
end
|
78
80
|
|
79
|
-
#
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
# Disable write operations at the class level
|
91
|
-
class << self
|
92
|
-
def delete_all(*)
|
93
|
-
raise ActiveRecord::ReadOnlyRecord, "#{name} is a read-only model"
|
94
|
-
end
|
95
|
-
|
96
|
-
def update_all(*)
|
97
|
-
raise ActiveRecord::ReadOnlyRecord, "#{name} is a read-only model"
|
81
|
+
# Add validations from indexes
|
82
|
+
table_metadata[:indexes].each do |index|
|
83
|
+
next unless index[:unique] && index[:columns].present?
|
84
|
+
|
85
|
+
columns = index[:columns]
|
86
|
+
column = columns.first
|
87
|
+
if columns.size == 1
|
88
|
+
validates column, uniqueness: true
|
89
|
+
elsif columns.size > 1
|
90
|
+
# Validate composite uniqueness using `scope`
|
91
|
+
validates column, uniqueness: { scope: columns[1..].map(&:to_sym) }
|
98
92
|
end
|
99
93
|
end
|
100
94
|
end)
|
@@ -12,7 +12,7 @@ module Dbviewer
|
|
12
12
|
ensure_connection
|
13
13
|
@cache_manager = ::Dbviewer::Cache::InMemory.new(configuration.cache_expiry)
|
14
14
|
@table_metadata_manager = ::Dbviewer::Database::MetadataManager.new(@connection, @cache_manager)
|
15
|
-
@dynamic_model_factory = ::Dbviewer::Database::DynamicModelFactory.new(@connection, @cache_manager)
|
15
|
+
@dynamic_model_factory = ::Dbviewer::Database::DynamicModelFactory.new(@connection, @cache_manager, @table_metadata_manager)
|
16
16
|
@query_executor = ::Dbviewer::Query::Executor.new(@connection, configuration)
|
17
17
|
@table_query_operations = ::Dbviewer::Datatable::QueryOperations.new(
|
18
18
|
@connection,
|
@@ -61,7 +61,7 @@ module Dbviewer
|
|
61
61
|
# @param event [ActiveSupport::Notifications::Event] The notification event
|
62
62
|
# @return [Boolean] True if the query should be skipped
|
63
63
|
def self.should_skip_internal_query?(event)
|
64
|
-
event.payload[:name]
|
64
|
+
event.payload[:name]&.include?("Dbviewer::") ||
|
65
65
|
# SQLite specific check for size queries
|
66
66
|
event.payload[:sql].include?("PRAGMA") ||
|
67
67
|
# PostgreSQL specific check for size queries
|
data/lib/dbviewer/version.rb
CHANGED
@@ -13,6 +13,9 @@ Dbviewer.configure do |config|
|
|
13
13
|
config.query_log_path = "log/dbviewer.log" # Path for query log file when in :file mode
|
14
14
|
config.max_memory_queries = 1000 # Maximum number of queries to store in memory
|
15
15
|
|
16
|
+
# Data Management options
|
17
|
+
config.enable_record_deletion = true # Whether to allow record deletion functionality
|
18
|
+
|
16
19
|
# Authentication options (Basic Auth)
|
17
20
|
# config.admin_credentials = {
|
18
21
|
# username: "admin",
|
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.9.
|
4
|
+
version: 0.9.4.pre.alpha.2
|
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-06
|
11
|
+
date: 2025-07-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -86,6 +86,8 @@ files:
|
|
86
86
|
- app/assets/javascripts/dbviewer/home.js
|
87
87
|
- app/assets/javascripts/dbviewer/layout.js
|
88
88
|
- app/assets/javascripts/dbviewer/query.js
|
89
|
+
- app/assets/javascripts/dbviewer/record_creation.js
|
90
|
+
- app/assets/javascripts/dbviewer/record_deletion.js
|
89
91
|
- app/assets/javascripts/dbviewer/sidebar.js
|
90
92
|
- app/assets/javascripts/dbviewer/table.js
|
91
93
|
- app/assets/javascripts/dbviewer/utility.js
|
@@ -119,6 +121,7 @@ files:
|
|
119
121
|
- app/helpers/dbviewer/application_helper.rb
|
120
122
|
- app/helpers/dbviewer/database_helper.rb
|
121
123
|
- app/helpers/dbviewer/datatable_ui_filter_helper.rb
|
124
|
+
- app/helpers/dbviewer/datatable_ui_form_helper.rb
|
122
125
|
- app/helpers/dbviewer/datatable_ui_helper.rb
|
123
126
|
- app/helpers/dbviewer/datatable_ui_pagination_helper.rb
|
124
127
|
- app/helpers/dbviewer/datatable_ui_sorting_helper.rb
|
@@ -136,6 +139,7 @@ files:
|
|
136
139
|
- app/views/dbviewer/shared/_tables_sidebar.html.erb
|
137
140
|
- app/views/dbviewer/tables/_table_structure.html.erb
|
138
141
|
- app/views/dbviewer/tables/index.html.erb
|
142
|
+
- app/views/dbviewer/tables/new_record.html.erb
|
139
143
|
- app/views/dbviewer/tables/query.html.erb
|
140
144
|
- app/views/dbviewer/tables/show.html.erb
|
141
145
|
- app/views/layouts/dbviewer/application.html.erb
|
@@ -191,9 +195,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
191
195
|
version: '0'
|
192
196
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
193
197
|
requirements:
|
194
|
-
- - "
|
198
|
+
- - ">"
|
195
199
|
- !ruby/object:Gem::Version
|
196
|
-
version:
|
200
|
+
version: 1.3.1
|
197
201
|
requirements: []
|
198
202
|
rubygems_version: 3.4.10
|
199
203
|
signing_key:
|