dbviewer 0.9.2 → 0.9.4.pre.alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/app/assets/javascripts/dbviewer/layout.js +0 -5
- data/app/assets/javascripts/dbviewer/record_creation.js +178 -0
- data/app/assets/stylesheets/dbviewer/table.css +144 -4
- data/app/controllers/dbviewer/tables_controller.rb +80 -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 +1 -1
- data/app/views/dbviewer/entity_relationship_diagrams/index.html.erb +2 -2
- data/app/views/dbviewer/logs/index.html.erb +0 -21
- data/app/views/dbviewer/tables/new_record.html.erb +70 -0
- data/app/views/dbviewer/tables/query.html.erb +3 -3
- data/app/views/dbviewer/tables/show.html.erb +43 -9
- data/config/routes.rb +2 -0
- data/lib/dbviewer/database/dynamic_model_factory.rb +20 -26
- data/lib/dbviewer/database/manager.rb +1 -1
- data/lib/dbviewer/pii_configuration.rb +28 -0
- data/lib/dbviewer/query/parser.rb +1 -1
- data/lib/dbviewer/version.rb +1 -1
- data/lib/dbviewer.rb +2 -27
- 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: f79e82d83d69572cd7fa4331831e7fa7cc385f7d0655fec2ab9cee57bf5dcdcd
|
4
|
+
data.tar.gz: 8a34270cfb5f9ede4b77d9b531a67cb1c6b3a022923b359080b4e171e725dadb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: edfcfc4a7eb88659393cd59cac7af960339a686010aa57d6da0ccfcf000d9ad33ee18ebed955294ca741119bca0f1d63f2579682fab48f70e9ca6ec93bce936f
|
7
|
+
data.tar.gz: b1e37a36cee23d2e04284c957e3f865afa24da128579b811f25cede3f2afcda9230abfaab332b48a7525ab190da5f53a270a7d0acc594de22324c9c624019f72
|
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 Creation** - Create new 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
|
@@ -197,7 +198,7 @@ DBViewer includes comprehensive security features to protect your database:
|
|
197
198
|
|
198
199
|
### Core Security
|
199
200
|
|
200
|
-
- **
|
201
|
+
- **Data Manipulation**: Create and read operations are supported through the UI with proper validation
|
201
202
|
- **SQL Validation**: Prevents potentially harmful operations with comprehensive validation
|
202
203
|
- **Query Limits**: Automatic LIMIT clause added to prevent excessive data retrieval
|
203
204
|
- **Pattern Detection**: Detection of SQL injection patterns and suspicious constructs
|
@@ -24,7 +24,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|
24
24
|
}
|
25
25
|
|
26
26
|
const toggleBtn = document.querySelector(".dbviewer-sidebar-toggle");
|
27
|
-
const closeBtn = document.querySelector(".dbviewer-sidebar-close");
|
28
27
|
const sidebar = document.querySelector(".dbviewer-sidebar");
|
29
28
|
const overlay = document.createElement("div");
|
30
29
|
|
@@ -61,10 +60,6 @@ document.addEventListener("DOMContentLoaded", function () {
|
|
61
60
|
}
|
62
61
|
});
|
63
62
|
|
64
|
-
closeBtn.addEventListener("click", function () {
|
65
|
-
hideSidebar();
|
66
|
-
});
|
67
|
-
|
68
63
|
overlay.addEventListener("click", function () {
|
69
64
|
hideSidebar();
|
70
65
|
});
|
@@ -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
|
+
}
|
@@ -125,16 +125,23 @@ tbody tr td.action-column {
|
|
125
125
|
/* ========== ACTION COLUMN STYLING ========== */
|
126
126
|
/* Action column styling */
|
127
127
|
.action-column {
|
128
|
-
width: 100px; /*
|
129
|
-
min-width: 100px;
|
128
|
+
width: 100px; /* Default for desktop */
|
129
|
+
min-width: 100px;
|
130
130
|
white-space: nowrap;
|
131
131
|
position: sticky;
|
132
132
|
left: 0;
|
133
|
-
z-index: 30;
|
134
|
-
background-color: var(--bs-body-bg, #fff);
|
133
|
+
z-index: 30;
|
134
|
+
background-color: var(--bs-body-bg, #fff);
|
135
135
|
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.04);
|
136
136
|
}
|
137
137
|
|
138
|
+
@media (max-width: 767.98px) {
|
139
|
+
.action-column {
|
140
|
+
width: 30px;
|
141
|
+
min-width: 30px;
|
142
|
+
}
|
143
|
+
}
|
144
|
+
|
138
145
|
.copy-factory-btn,
|
139
146
|
.view-record-btn {
|
140
147
|
padding: 0.1rem 0.4rem;
|
@@ -1060,3 +1067,136 @@ span.flatpickr-weekday {
|
|
1060
1067
|
width: 0.875rem;
|
1061
1068
|
height: 0.875rem;
|
1062
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 ]
|
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,10 +80,9 @@ 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",
|
@@ -76,6 +91,16 @@ module Dbviewer
|
|
76
91
|
|
77
92
|
private
|
78
93
|
|
94
|
+
def record_params
|
95
|
+
accessible_columns = filter_accessible_columns(@table_name, database_manager.table_columns(@table_name))
|
96
|
+
|
97
|
+
permitted_fields = accessible_columns
|
98
|
+
.reject { |col| %w[id created_at updated_at].include?(col[:name]) }
|
99
|
+
.map { |col| col[:name].to_sym }
|
100
|
+
|
101
|
+
params.require(:record).permit(*permitted_fields)
|
102
|
+
end
|
103
|
+
|
79
104
|
def set_table_name
|
80
105
|
@table_name = params[:id]
|
81
106
|
end
|
@@ -86,78 +111,82 @@ module Dbviewer
|
|
86
111
|
|
87
112
|
def set_query_filters
|
88
113
|
@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)
|
114
|
+
@per_page = Dbviewer.configuration.per_page_options.include?(params[:per_page].to_i) ? params[:per_page].to_i : Dbviewer.configuration.default_per_page
|
91
115
|
@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)
|
116
|
+
@order_direction = %w[ASC DESC].include?(params[:order_direction].to_s.upcase) ? params[:order_direction].upcase : "DESC"
|
94
117
|
@column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
|
95
118
|
end
|
96
119
|
|
97
120
|
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
|
121
|
+
session[:creation_filter_start] = params[:creation_filter_start] if params[:creation_filter_start].present?
|
122
|
+
session[:creation_filter_end] = params[:creation_filter_end] if params[:creation_filter_end].present?
|
106
123
|
|
107
|
-
# Clear filters if explicitly requested
|
108
124
|
if params[:clear_creation_filter] == "true"
|
109
125
|
session.delete(:creation_filter_start)
|
110
126
|
session.delete(:creation_filter_end)
|
111
127
|
end
|
112
128
|
|
113
|
-
# Set instance variables for view access
|
114
129
|
@creation_filter_start = session[:creation_filter_start]
|
115
130
|
@creation_filter_end = session[:creation_filter_end]
|
116
|
-
|
117
|
-
# Initialize column_filters if not present
|
118
131
|
@column_filters ||= {}
|
119
132
|
|
120
|
-
# Apply creation filters to column_filters if the table has a created_at column
|
121
133
|
if has_timestamp_column?(@table_name) && (@creation_filter_start.present? || @creation_filter_end.present?)
|
122
|
-
# Clear any existing created_at filters
|
123
134
|
%w[created_at created_at_operator created_at_end].each { |key| @column_filters.delete(key) }
|
124
135
|
|
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
|
-
})
|
136
|
+
if @creation_filter_start.present? && @creation_filter_end.present?
|
137
|
+
@column_filters.merge!("created_at" => @creation_filter_start, "created_at_end" => @creation_filter_end)
|
138
|
+
elsif @creation_filter_start.present?
|
139
|
+
@column_filters.merge!("created_at" => @creation_filter_start, "created_at_operator" => "gte")
|
140
|
+
elsif @creation_filter_end.present?
|
141
|
+
@column_filters.merge!("created_at" => @creation_filter_end, "created_at_operator" => "lte")
|
141
142
|
end
|
142
143
|
end
|
143
144
|
end
|
144
145
|
|
145
|
-
# Determine the default order column using configurable ordering logic
|
146
146
|
def determine_default_order_column
|
147
|
-
# Get the table columns to check what's available
|
148
147
|
columns = @columns || fetch_table_columns(@table_name)
|
149
148
|
column_names = columns.map { |col| col[:name] }
|
150
149
|
|
151
|
-
|
152
|
-
|
153
|
-
|
150
|
+
Dbviewer.configuration.default_order_column.presence_in(column_names) ||
|
151
|
+
database_manager.primary_key(@table_name) ||
|
152
|
+
columns.first&.dig(:name)
|
153
|
+
end
|
154
154
|
|
155
|
-
|
156
|
-
|
157
|
-
|
155
|
+
def find_display_column(columns)
|
156
|
+
column_names = columns.map { |c| c[:name] }
|
157
|
+
# Common display columns to check for (TODO: next we can add this to configuration for better dev control)
|
158
|
+
# If none found, fallback to first non-id column or id column
|
159
|
+
# This ensures we have a sensible default for display purposes
|
160
|
+
%w[name title label display_name username email description].find { |name| column_names.include?(name) } ||
|
161
|
+
columns.find { |c| [ :string, :text ].include?(c[:type]) && c[:name] != "id" }&.[](:name) ||
|
162
|
+
columns.find { |c| c[:name] != "id" }&.[](:name) || "id"
|
163
|
+
end
|
164
|
+
|
165
|
+
def load_foreign_key_options(metadata)
|
166
|
+
options = {}
|
167
|
+
metadata[:foreign_keys].each do |fk|
|
168
|
+
foreign_table = fk[:to_table]
|
169
|
+
foreign_key_column = fk[:primary_key] || "id"
|
170
|
+
next unless access_control.table_accessible?(foreign_table)
|
158
171
|
|
159
|
-
|
160
|
-
|
172
|
+
begin
|
173
|
+
foreign_columns = database_manager.table_columns(foreign_table)
|
174
|
+
display_column = find_display_column(foreign_columns)
|
175
|
+
|
176
|
+
foreign_model = database_manager.get_model_for(foreign_table)
|
177
|
+
records = foreign_model
|
178
|
+
.select([ foreign_key_column, display_column ].uniq)
|
179
|
+
.limit(100)
|
180
|
+
.order(display_column)
|
181
|
+
.map { |r| [ r[display_column].to_s, r[foreign_key_column] ] }
|
182
|
+
|
183
|
+
options[fk[:column]] = records
|
184
|
+
rescue => e
|
185
|
+
Rails.logger.error("Error fetching foreign key options for #{foreign_table}: #{e.message}")
|
186
|
+
options[fk[:column]] = []
|
187
|
+
end
|
188
|
+
end
|
189
|
+
options
|
161
190
|
end
|
162
191
|
end
|
163
192
|
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
|
@@ -106,7 +106,7 @@ module Dbviewer
|
|
106
106
|
end
|
107
107
|
|
108
108
|
content_tag(:td, class: "text-center action-column") do
|
109
|
-
content_tag(:div, class: "d-flex gap-1 justify-content-center") do
|
109
|
+
content_tag(:div, class: "d-flex flex-column flex-md-row gap-1 justify-content-center") do
|
110
110
|
# View Record button (existing)
|
111
111
|
view_button = button_tag(
|
112
112
|
type: "button",
|
@@ -15,7 +15,7 @@
|
|
15
15
|
<div class="row h-100">
|
16
16
|
<div class="col-md-12 p-0">
|
17
17
|
<div class="card h-100">
|
18
|
-
<div class="card-header d-flex justify-content-between align-items-center">
|
18
|
+
<div class="card-header d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2">
|
19
19
|
<h5 class="mb-0">
|
20
20
|
<i class="bi bi-diagram-3"></i> Entity Relationship Diagram
|
21
21
|
</h5>
|
@@ -28,7 +28,7 @@
|
|
28
28
|
<i class="bi bi-zoom-out"></i>
|
29
29
|
</button>
|
30
30
|
<button id="resetView" class="btn btn-sm btn-outline-secondary me-1">
|
31
|
-
<i class="bi bi-arrow-counterclockwise"></i>
|
31
|
+
<i class="bi bi-arrow-counterclockwise"></i>
|
32
32
|
</button>
|
33
33
|
<div class="dropdown">
|
34
34
|
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" id="downloadButton" data-bs-toggle="dropdown" aria-expanded="false">
|
@@ -240,27 +240,6 @@
|
|
240
240
|
</div>
|
241
241
|
<% end %>
|
242
242
|
|
243
|
-
<!-- Table Access Chart -->
|
244
|
-
<% if @stats[:tables_queried].present? %>
|
245
|
-
<div class="row mb-4">
|
246
|
-
<div class="col-md-12">
|
247
|
-
<div class="card <%= 'border-info' if @filtered_stats %>">
|
248
|
-
<div class="card-header <%= @filtered_stats ? 'bg-info-subtle' : '' %>">
|
249
|
-
<div class="d-flex justify-content-between align-items-center">
|
250
|
-
<h5 class="card-title mb-0">Table Access Frequency</h5>
|
251
|
-
<% if @filtered_stats %>
|
252
|
-
<span class="badge bg-info text-dark">Filtered</span>
|
253
|
-
<% end %>
|
254
|
-
</div>
|
255
|
-
</div>
|
256
|
-
<div class="card-body">
|
257
|
-
<canvas id="tablesChart" width="400" height="150"></canvas>
|
258
|
-
</div>
|
259
|
-
</div>
|
260
|
-
</div>
|
261
|
-
</div>
|
262
|
-
<% end %>
|
263
|
-
|
264
243
|
<!-- Filters -->
|
265
244
|
<div class="card mb-4">
|
266
245
|
<div class="card-header">
|
@@ -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>
|
@@ -22,7 +22,7 @@
|
|
22
22
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
23
23
|
<h1>Query: <%= @table_name %></h1>
|
24
24
|
<div>
|
25
|
-
<%= link_to table_path(@table_name), class: "btn btn-outline-primary" do %>
|
25
|
+
<%= link_to table_path(@table_name), class: "btn btn-outline-primary d-none d-md-inline-flex" do %>
|
26
26
|
<i class="bi bi-arrow-left me-1"></i> Back to Table
|
27
27
|
<% end %>
|
28
28
|
</div>
|
@@ -41,8 +41,8 @@
|
|
41
41
|
<%= form.hidden_field :query, id: "query-input", value: @query.to_s %>
|
42
42
|
</div>
|
43
43
|
|
44
|
-
<div class="d-flex justify-content-between align-items-start">
|
45
|
-
<div class="form-text">
|
44
|
+
<div class="d-md-flex justify-content-between align-items-start">
|
45
|
+
<div class="form-text mb-3 mb-md-0">
|
46
46
|
<strong>Examples:</strong><br>
|
47
47
|
<div class="example-queries">
|
48
48
|
<code class="example-query btn btn-sm btn-outline-secondary mb-1">SELECT * FROM <%= @table_name %> LIMIT 100</code>
|
@@ -5,8 +5,15 @@
|
|
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" %>
|
10
17
|
<% end %>
|
11
18
|
|
12
19
|
<% content_for :sidebar_active do %>active<% end %>
|
@@ -18,18 +25,22 @@
|
|
18
25
|
</div>
|
19
26
|
<div class="d-flex flex-wrap gap-2">
|
20
27
|
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#tableStructureModal">
|
21
|
-
<i class="bi bi-table me-1"></i>
|
28
|
+
<i class="bi bi-table me-1"></i>
|
29
|
+
<span class="d-none d-sm-inline">Table Structure</span>
|
22
30
|
</button>
|
23
31
|
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#miniErdModal">
|
24
|
-
<i class="bi bi-diagram-3 me-1"></i>
|
32
|
+
<i class="bi bi-diagram-3 me-1"></i>
|
33
|
+
<span class="d-none d-sm-inline">View Relationships</span>
|
25
34
|
</button>
|
26
35
|
<% if Dbviewer.configuration.enable_data_export %>
|
27
36
|
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#csvExportModal">
|
28
|
-
|
37
|
+
<i class="bi bi-file-earmark-spreadsheet me-1"></i>
|
38
|
+
<span class="d-none d-sm-inline">Export CSV</span>
|
29
39
|
</button>
|
30
40
|
<% end %>
|
31
41
|
<%= link_to query_table_path(@table_name), class: "btn btn-primary" do %>
|
32
|
-
<i class="bi bi-code-square me-1"></i>
|
42
|
+
<i class="bi bi-code-square me-1"></i>
|
43
|
+
<span class="d-none d-sm-inline">Run SQL Query</span>
|
33
44
|
<% end %>
|
34
45
|
</div>
|
35
46
|
</div>
|
@@ -99,25 +110,25 @@
|
|
99
110
|
<div class="dbviewer-card card mb-4" id="table-section">
|
100
111
|
<div class="card-header d-flex justify-content-between align-items-center">
|
101
112
|
<h5 class="mb-0">
|
102
|
-
<select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?<%= per_page_url_params(@table_name) %>'">
|
113
|
+
<select id="per-page-select" class="form-select form-select-sm pe-4" onchange="window.location.href='<%= table_path(@table_name) %>?<%= per_page_url_params(@table_name) %>'">
|
103
114
|
<% Dbviewer.configuration.per_page_options.each do |option| %>
|
104
115
|
<option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
|
105
116
|
<% end %>
|
106
117
|
</select>
|
107
118
|
</h5>
|
108
|
-
<div class="d-flex align-items-center table-actions">
|
119
|
+
<div class="d-none d-md-flex align-items-center table-actions">
|
109
120
|
<% if @order_by.present? %>
|
110
121
|
<span class="badge bg-primary me-2" title="Sort order">
|
111
122
|
<i class="bi bi-sort-<%= @order_direction == "ASC" ? "up" : "down" %> me-1"></i>
|
112
123
|
<%= @order_by %> (<%= @order_direction == "ASC" ? "ascending" : "descending" %>)
|
113
124
|
</span>
|
114
125
|
<% end %>
|
115
|
-
<span class="badge bg-secondary">Total: <%= @total_count %> records</span>
|
116
|
-
<% active_filters = @column_filters.reject { |
|
126
|
+
<span class="d-none d-md-block badge bg-secondary">Total: <%= @total_count %> records</span>
|
127
|
+
<% active_filters = @column_filters.reject { |k, v| v.blank? || k.to_s.ends_with?('_operator') }.size %>
|
117
128
|
<% if active_filters > 0 %>
|
118
129
|
<span class="badge bg-info ms-2" title="Active filters"><i class="bi bi-funnel-fill me-1"></i><%= active_filters %></span>
|
119
130
|
<% end %>
|
120
|
-
<button type="button" class="btn btn-outline-secondary btn-sm ms-2" id="fullscreen-toggle" title="Toggle fullscreen">
|
131
|
+
<button type="button" class="d-none d-md-block btn btn-outline-secondary btn-sm ms-2" id="fullscreen-toggle" title="Toggle fullscreen">
|
121
132
|
<i class="bi bi-fullscreen" id="fullscreen-icon"></i>
|
122
133
|
</button>
|
123
134
|
</div>
|
@@ -348,5 +359,28 @@
|
|
348
359
|
</div>
|
349
360
|
</div>
|
350
361
|
<% end %>
|
362
|
+
|
363
|
+
<!-- Floating Add Record Button -->
|
364
|
+
<%# TODO: move this to helpers so that we can have centralized creation check %>
|
365
|
+
<% if @table_name != 'schema_migrations'%>
|
366
|
+
<div class="floating-add-record d-none d-lg-block">
|
367
|
+
<button id="floatingAddRecordBtn"
|
368
|
+
class="btn btn-success btn-lg shadow-lg rounded-circle"
|
369
|
+
type="button"
|
370
|
+
title="Add New Record">
|
371
|
+
<i class="bi bi-plus-lg"></i>
|
372
|
+
</button>
|
373
|
+
</div>
|
374
|
+
<% end %>
|
375
|
+
|
376
|
+
<!-- New Record Modal -->
|
377
|
+
<div id="newRecordModal" class="modal fade" tabindex="-1" aria-labelledby="newRecordModalLabel" aria-hidden="true">
|
378
|
+
<div class="modal-dialog modal-lg">
|
379
|
+
<div class="modal-content">
|
380
|
+
<!-- Content will be loaded via AJAX -->
|
381
|
+
</div>
|
382
|
+
</div>
|
383
|
+
</div>
|
384
|
+
|
351
385
|
<input type="hidden" id="mini_erd_table_path" name="mini_erd_table_path" value="<%= dbviewer.mini_erd_api_table_path(@table_name, format: :json) %>">
|
352
386
|
<input type="hidden" id="table_name" name="table_name" value="<%= @table_name %>">
|
data/config/routes.rb
CHANGED
@@ -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,
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Dbviewer
|
2
|
+
# Helper class for configuring PII masking rules
|
3
|
+
class PiiConfigurator
|
4
|
+
def initialize(configuration)
|
5
|
+
@configuration = configuration
|
6
|
+
end
|
7
|
+
|
8
|
+
# Define a PII masking rule
|
9
|
+
# @param column_spec [String] Table and column in format "table.column"
|
10
|
+
# @param with [Symbol, Proc] Masking rule - either built-in symbol or custom proc
|
11
|
+
def mask(column_spec, with:)
|
12
|
+
@configuration.pii_rules[column_spec] = with
|
13
|
+
end
|
14
|
+
|
15
|
+
# Define a custom masking function
|
16
|
+
# @param name [Symbol] Name of the custom mask
|
17
|
+
# @param block [Proc] The masking function
|
18
|
+
def custom_mask(name, block)
|
19
|
+
@configuration.custom_pii_masks[name] = block
|
20
|
+
end
|
21
|
+
|
22
|
+
# Enable or disable PII masking globally
|
23
|
+
# @param enabled [Boolean] Whether to enable PII masking
|
24
|
+
def enabled=(enabled)
|
25
|
+
@configuration.enable_pii_masking = enabled
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -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
data/lib/dbviewer.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require "dbviewer/version"
|
2
2
|
require "dbviewer/configuration"
|
3
3
|
require "dbviewer/engine"
|
4
|
+
require "dbviewer/pii_configuration"
|
5
|
+
|
4
6
|
require "dbviewer/validator/sql"
|
5
7
|
require "dbviewer/security/sql_parser"
|
6
8
|
require "dbviewer/security/access_control"
|
@@ -32,33 +34,6 @@ require "propshaft"
|
|
32
34
|
module Dbviewer
|
33
35
|
# Main module for the database viewer
|
34
36
|
|
35
|
-
# Helper class for configuring PII masking rules
|
36
|
-
class PiiConfigurator
|
37
|
-
def initialize(configuration)
|
38
|
-
@configuration = configuration
|
39
|
-
end
|
40
|
-
|
41
|
-
# Define a PII masking rule
|
42
|
-
# @param column_spec [String] Table and column in format "table.column"
|
43
|
-
# @param with [Symbol, Proc] Masking rule - either built-in symbol or custom proc
|
44
|
-
def mask(column_spec, with:)
|
45
|
-
@configuration.pii_rules[column_spec] = with
|
46
|
-
end
|
47
|
-
|
48
|
-
# Define a custom masking function
|
49
|
-
# @param name [Symbol] Name of the custom mask
|
50
|
-
# @param block [Proc] The masking function
|
51
|
-
def custom_mask(name, block)
|
52
|
-
@configuration.custom_pii_masks[name] = block
|
53
|
-
end
|
54
|
-
|
55
|
-
# Enable or disable PII masking globally
|
56
|
-
# @param enabled [Boolean] Whether to enable PII masking
|
57
|
-
def enabled=(enabled)
|
58
|
-
@configuration.enable_pii_masking = enabled
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
37
|
class << self
|
63
38
|
# Module accessor for configuration
|
64
39
|
attr_writer :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.9.
|
4
|
+
version: 0.9.4.pre.alpha.1
|
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-
|
11
|
+
date: 2025-07-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -86,6 +86,7 @@ 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
|
89
90
|
- app/assets/javascripts/dbviewer/sidebar.js
|
90
91
|
- app/assets/javascripts/dbviewer/table.js
|
91
92
|
- app/assets/javascripts/dbviewer/utility.js
|
@@ -119,6 +120,7 @@ files:
|
|
119
120
|
- app/helpers/dbviewer/application_helper.rb
|
120
121
|
- app/helpers/dbviewer/database_helper.rb
|
121
122
|
- app/helpers/dbviewer/datatable_ui_filter_helper.rb
|
123
|
+
- app/helpers/dbviewer/datatable_ui_form_helper.rb
|
122
124
|
- app/helpers/dbviewer/datatable_ui_helper.rb
|
123
125
|
- app/helpers/dbviewer/datatable_ui_pagination_helper.rb
|
124
126
|
- app/helpers/dbviewer/datatable_ui_sorting_helper.rb
|
@@ -136,6 +138,7 @@ files:
|
|
136
138
|
- app/views/dbviewer/shared/_tables_sidebar.html.erb
|
137
139
|
- app/views/dbviewer/tables/_table_structure.html.erb
|
138
140
|
- app/views/dbviewer/tables/index.html.erb
|
141
|
+
- app/views/dbviewer/tables/new_record.html.erb
|
139
142
|
- app/views/dbviewer/tables/query.html.erb
|
140
143
|
- app/views/dbviewer/tables/show.html.erb
|
141
144
|
- app/views/layouts/dbviewer/application.html.erb
|
@@ -152,6 +155,7 @@ files:
|
|
152
155
|
- lib/dbviewer/datatable/query_operations.rb
|
153
156
|
- lib/dbviewer/datatable/query_params.rb
|
154
157
|
- lib/dbviewer/engine.rb
|
158
|
+
- lib/dbviewer/pii_configuration.rb
|
155
159
|
- lib/dbviewer/query/analyzer.rb
|
156
160
|
- lib/dbviewer/query/executor.rb
|
157
161
|
- lib/dbviewer/query/logger.rb
|
@@ -190,9 +194,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
190
194
|
version: '0'
|
191
195
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
192
196
|
requirements:
|
193
|
-
- - "
|
197
|
+
- - ">"
|
194
198
|
- !ruby/object:Gem::Version
|
195
|
-
version:
|
199
|
+
version: 1.3.1
|
196
200
|
requirements: []
|
197
201
|
rubygems_version: 3.4.10
|
198
202
|
signing_key:
|