saltcorn-db-code 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/index.js +41 -0
- package/lib/action-args.js +42 -0
- package/lib/auth.js +18 -0
- package/lib/db-routine-action.js +146 -0
- package/lib/export-import.js +137 -0
- package/lib/html.js +16 -0
- package/lib/introspection.js +87 -0
- package/lib/render-routines.js +265 -0
- package/lib/routine-args.js +114 -0
- package/lib/sql-builders.js +69 -0
- package/lib/validation.js +25 -0
- package/lib/view-context.js +58 -0
- package/package.json +36 -0
- package/routes/ai.js +48 -0
- package/routes/create.js +254 -0
- package/routes/delete.js +148 -0
- package/routes/edit.js +131 -0
- package/routes/execute.js +267 -0
- package/routes/export.js +119 -0
- package/routes/import.js +221 -0
- package/routes/list.js +10 -0
- package/routes/placeholder.js +11 -0
- package/routes/show.js +10 -0
- package/viewtemplates/db-code-console.js +39 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const db = require("@saltcorn/data/db");
|
|
2
|
+
const { requireAdmin } = require("../lib/auth");
|
|
3
|
+
const { escapeHtml, page } = require("../lib/html");
|
|
4
|
+
const { ensurePostgres, getRoutineByOid } = require("../lib/introspection");
|
|
5
|
+
const { quoteIdent } = require("../lib/sql-builders");
|
|
6
|
+
const { coerceRoutineInputArgs, routineArity } = require("../lib/routine-args");
|
|
7
|
+
const { detailViewHref, viewContextInput, getViewBaseUrl } = require("../lib/view-context");
|
|
8
|
+
|
|
9
|
+
function csrfInput(req) {
|
|
10
|
+
return typeof req.csrfToken === "function" ? `<input type="hidden" name="_csrf" value="${escapeHtml(req.csrfToken())}">` : "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formStyles() {
|
|
14
|
+
return `<style>
|
|
15
|
+
.db-code-execute .db-code-execute-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
16
|
+
.db-code-execute .form-section-title { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: var(--bs-secondary-color, #6c757d); font-weight: 700; margin-bottom: .75rem; }
|
|
17
|
+
.db-code-execute .sticky-help { position: sticky; top: 1rem; }
|
|
18
|
+
.db-code-execute .result-table th { white-space: nowrap; }
|
|
19
|
+
.db-code-execute .result-json { max-height: 30rem; overflow: auto; }
|
|
20
|
+
</style>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function argInputField(arg, index, required) {
|
|
24
|
+
const label = escapeHtml(arg.name || `arg${index + 1}`);
|
|
25
|
+
const typeHint = escapeHtml(arg.type || "text");
|
|
26
|
+
const placeholder = `${label}: ${typeHint}`;
|
|
27
|
+
const reqAttr = required ? " required" : "";
|
|
28
|
+
const pgType = (arg.type || "").toLowerCase();
|
|
29
|
+
let inputType = "text";
|
|
30
|
+
let extraAttrs = "";
|
|
31
|
+
if (pgType === "integer" || pgType === "bigint" || pgType === "smallint" || pgType === "int" || pgType === "serial" || pgType === "bigserial") {
|
|
32
|
+
inputType = "number";
|
|
33
|
+
extraAttrs = ' step="1"';
|
|
34
|
+
} else if (pgType === "numeric" || pgType === "decimal" || pgType === "real" || pgType === "double precision") {
|
|
35
|
+
inputType = "number";
|
|
36
|
+
} else if (pgType === "boolean") {
|
|
37
|
+
return `<div class="col-md-6 mb-3">
|
|
38
|
+
<label class="form-label">${label} <code class="small">${typeHint}</code>${required ? ' <span class="text-danger">*</span>' : ' <span class="text-muted">(optional)</span>'}</label>
|
|
39
|
+
<select class="form-select" name="arg_${index}"${reqAttr}>
|
|
40
|
+
<option value="">-- not provided --</option>
|
|
41
|
+
<option value="true">true</option>
|
|
42
|
+
<option value="false">false</option>
|
|
43
|
+
</select>
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
46
|
+
return `<div class="col-md-6 mb-3">
|
|
47
|
+
<label class="form-label">${label} <code class="small">${typeHint}</code>${required ? ' <span class="text-danger">*</span>' : ' <span class="text-muted">(optional)</span>'}</label>
|
|
48
|
+
<input class="form-control" type="${inputType}" name="arg_${index}" placeholder="${placeholder}"${extraAttrs}${reqAttr}>
|
|
49
|
+
</div>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function identityCard(routine) {
|
|
53
|
+
return `<div class="card bg-light border-0 mb-3"><div class="card-body">
|
|
54
|
+
<div class="form-section-title">Routine identity</div>
|
|
55
|
+
<table class="table table-sm mb-0">
|
|
56
|
+
<tr><th>Name</th><td><code>${escapeHtml(routine.schema)}.${escapeHtml(routine.name)}</code></td></tr>
|
|
57
|
+
<tr><th>Type</th><td>${escapeHtml(routine.kind)}</td></tr>
|
|
58
|
+
<tr><th>Arguments</th><td><code>${escapeHtml(routine.identity_arguments || "none")}</code></td></tr>
|
|
59
|
+
${routine.result_type ? `<tr><th>Returns</th><td><code>${escapeHtml(routine.result_type)}</code></td></tr>` : ""}
|
|
60
|
+
<tr><th>Language</th><td>${escapeHtml(routine.language || "")}</td></tr>
|
|
61
|
+
</table>
|
|
62
|
+
</div></div>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderResultTable(rows) {
|
|
66
|
+
if (!rows || rows.length === 0) {
|
|
67
|
+
return `<div class="text-muted text-center py-3"><i class="fas fa-check-circle text-success me-2"></i>Query returned no rows.</div>`;
|
|
68
|
+
}
|
|
69
|
+
const columns = Object.keys(rows[0]);
|
|
70
|
+
const headerCells = columns.map((col) => `<th>${escapeHtml(col)}</th>`).join("");
|
|
71
|
+
const bodyRows = rows.map((row) => {
|
|
72
|
+
const cells = columns.map((col) => {
|
|
73
|
+
const val = row[col];
|
|
74
|
+
if (val === null) return `<td class="text-muted fst-italic">NULL</td>`;
|
|
75
|
+
if (typeof val === "object") return `<td><code>${escapeHtml(JSON.stringify(val))}</code></td>`;
|
|
76
|
+
return `<td>${escapeHtml(String(val))}</td>`;
|
|
77
|
+
}).join("");
|
|
78
|
+
return `<tr>${cells}</tr>`;
|
|
79
|
+
}).join("");
|
|
80
|
+
return `<div class="table-responsive"><table class="table table-sm table-hover table-bordered result-table">
|
|
81
|
+
<thead><tr>${headerCells}</tr></thead>
|
|
82
|
+
<tbody>${bodyRows}</tbody>
|
|
83
|
+
</table></div>
|
|
84
|
+
<div class="small text-muted">${rows.length} row${rows.length !== 1 ? "s" : ""}</div>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderResultJson(data) {
|
|
88
|
+
const json = JSON.stringify(data, null, 2);
|
|
89
|
+
return `<div class="result-json"><pre class="border rounded p-3 bg-light mb-0"><code>${escapeHtml(json)}</code></pre></div>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderError(message) {
|
|
93
|
+
return `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i><strong>Execution error</strong><pre class="mb-0 mt-2 small">${escapeHtml(message)}</pre></div>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderSuccessMeta(meta) {
|
|
97
|
+
const parts = [];
|
|
98
|
+
if (meta.kind) parts.push(`<span class="badge bg-light text-dark border">${escapeHtml(meta.kind)}</span>`);
|
|
99
|
+
if (meta.rowCount != null) parts.push(`<span class="badge bg-light text-dark border">${meta.rowCount} row${meta.rowCount !== 1 ? "s" : ""}</span>`);
|
|
100
|
+
if (meta.durationMs != null) parts.push(`<span class="badge bg-light text-dark border">${meta.durationMs} ms</span>`);
|
|
101
|
+
return parts.length ? `<div class="d-flex flex-wrap gap-1 mb-2">${parts.join("")}</div>` : "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildForm(req, routine, error = null, resultData = null, viewBaseUrl = null) {
|
|
105
|
+
const inputArgs = coerceRoutineInputArgs(routine);
|
|
106
|
+
const { required } = routineArity(routine);
|
|
107
|
+
const icon = routine.kind === "procedure" ? "fas fa-cogs" : "fas fa-cube";
|
|
108
|
+
const isFunction = routine.kind === "function";
|
|
109
|
+
const hasArgs = inputArgs.length > 0;
|
|
110
|
+
const backHref = detailViewHref(viewBaseUrl, routine.oid);
|
|
111
|
+
|
|
112
|
+
const argFields = hasArgs
|
|
113
|
+
? inputArgs.map((arg, i) => argInputField(arg, i, i < required)).join("\n")
|
|
114
|
+
: `<div class="text-muted"><i class="fas fa-info-circle me-1"></i>This ${routine.kind} takes no arguments.</div>`;
|
|
115
|
+
|
|
116
|
+
let resultSection = "";
|
|
117
|
+
if (error) {
|
|
118
|
+
resultSection = `<div class="form-section-title mt-4">Result</div>${renderError(error)}`;
|
|
119
|
+
} else if (resultData) {
|
|
120
|
+
const metaHtml = renderSuccessMeta(resultData.meta || {});
|
|
121
|
+
const bodyHtml = resultData.rows ? renderResultTable(resultData.rows) : renderResultJson(resultData.data);
|
|
122
|
+
resultSection = `<div class="form-section-title mt-4">Result</div>${metaHtml}${bodyHtml}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return `${formStyles()}<div class="db-code-execute">
|
|
126
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to routine</a></p>
|
|
127
|
+
${error && !resultData ? `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(error)}</div>` : ""}
|
|
128
|
+
<div class="card mt-0 card-max-full-screen">
|
|
129
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
130
|
+
<div class="d-flex align-items-center">
|
|
131
|
+
<span class="db-code-execute-icon rounded bg-success text-white me-2"><i class="fas fa-play"></i></span>
|
|
132
|
+
<div>
|
|
133
|
+
<h5 class="mb-0">Execute ${escapeHtml(routine.kind)}: ${escapeHtml(routine.name)}</h5>
|
|
134
|
+
<div class="small text-muted">Run this ${escapeHtml(routine.kind)} with the provided arguments</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="card-body">
|
|
140
|
+
<div class="row g-4">
|
|
141
|
+
<div class="col-lg-8">
|
|
142
|
+
<form method="post" action="/db-code/routine/${routine.oid}/execute">
|
|
143
|
+
${csrfInput(req)}
|
|
144
|
+
${viewContextInput(viewBaseUrl)}
|
|
145
|
+
<div class="form-section-title">Arguments</div>
|
|
146
|
+
<div class="row">${argFields}</div>
|
|
147
|
+
<div class="mb-3">
|
|
148
|
+
<button class="btn btn-success" type="submit"><i class="fas fa-play me-1"></i>Execute</button>
|
|
149
|
+
<a class="btn btn-outline-secondary ms-2" href="${backHref}">Cancel</a>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
${resultSection}
|
|
153
|
+
</div>
|
|
154
|
+
<div class="col-lg-4"><div class="sticky-help">
|
|
155
|
+
${identityCard(routine)}
|
|
156
|
+
<div class="card border-0 bg-light"><div class="card-body">
|
|
157
|
+
<div class="form-section-title">Notes</div>
|
|
158
|
+
<ul class="small mb-0">
|
|
159
|
+
<li>${isFunction ? "Functions are executed with <code>SELECT *</code>." : "Procedures are executed with <code>CALL</code>."}</li>
|
|
160
|
+
<li>Boolean fields use a tri-state selector (unset / true / false).</li>
|
|
161
|
+
<li>Null values are sent as SQL NULL.</li>
|
|
162
|
+
<li>Optional arguments with defaults may be left empty.</li>
|
|
163
|
+
<li>Read-only — this only calls the routine, does not modify its definition.</li>
|
|
164
|
+
</ul>
|
|
165
|
+
</div></div>
|
|
166
|
+
</div></div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse form-submitted argument values into typed JS values for parameterised queries.
|
|
175
|
+
* Handles booleans, numbers, and null/empty → null.
|
|
176
|
+
*/
|
|
177
|
+
function parseArgValue(rawValue, pgType) {
|
|
178
|
+
const str = String(rawValue ?? "").trim();
|
|
179
|
+
if (str === "") return null;
|
|
180
|
+
const pg = (pgType || "").toLowerCase();
|
|
181
|
+
if (pg === "boolean") return str === "true";
|
|
182
|
+
if (pg === "integer" || pg === "bigint" || pg === "smallint" || pg === "int" || pg === "serial" || pg === "bigserial") {
|
|
183
|
+
const n = Number.parseInt(str, 10);
|
|
184
|
+
return Number.isNaN(n) ? str : n;
|
|
185
|
+
}
|
|
186
|
+
if (pg === "numeric" || pg === "decimal" || pg === "real" || pg === "double precision") {
|
|
187
|
+
const n = Number.parseFloat(str);
|
|
188
|
+
return Number.isNaN(n) ? str : n;
|
|
189
|
+
}
|
|
190
|
+
if (pg === "jsonb" || pg === "json") {
|
|
191
|
+
try { return JSON.parse(str); } catch { return str; }
|
|
192
|
+
}
|
|
193
|
+
return str;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function executeFormRoute(req, res) {
|
|
197
|
+
if (!requireAdmin(req, res)) return;
|
|
198
|
+
try {
|
|
199
|
+
ensurePostgres();
|
|
200
|
+
const routine = await getRoutineByOid(req.params.oid);
|
|
201
|
+
if (!routine) {
|
|
202
|
+
if (typeof res.status === "function") res.status(404);
|
|
203
|
+
return page(req, res, "Routine not found", `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`);
|
|
204
|
+
}
|
|
205
|
+
if (!["function", "procedure"].includes(routine.kind)) {
|
|
206
|
+
return page(req, res, "Cannot execute", `<div class="alert alert-warning">Execution is only supported for functions and procedures. This is a ${escapeHtml(routine.kind)}.</div>`);
|
|
207
|
+
}
|
|
208
|
+
page(req, res, `Execute ${routine.kind}: ${routine.name}`, buildForm(req, routine, null, null, getViewBaseUrl(req)));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
page(req, res, "Execute routine", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function executePostRoute(req, res) {
|
|
215
|
+
if (!requireAdmin(req, res)) return;
|
|
216
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
217
|
+
try {
|
|
218
|
+
ensurePostgres();
|
|
219
|
+
const routine = await getRoutineByOid(req.params.oid);
|
|
220
|
+
if (!routine) {
|
|
221
|
+
if (typeof res.status === "function") res.status(404);
|
|
222
|
+
return page(req, res, "Routine not found", `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`);
|
|
223
|
+
}
|
|
224
|
+
if (!["function", "procedure"].includes(routine.kind)) {
|
|
225
|
+
return page(req, res, "Cannot execute", `<div class="alert alert-warning">Execution is only supported for functions and procedures.</div>`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const inputArgs = coerceRoutineInputArgs(routine);
|
|
229
|
+
const { required } = routineArity(routine);
|
|
230
|
+
|
|
231
|
+
const values = [];
|
|
232
|
+
for (let i = 0; i < inputArgs.length; i++) {
|
|
233
|
+
const raw = req.body?.[`arg_${i}`];
|
|
234
|
+
if (i < required && (raw === undefined || raw === null || String(raw).trim() === "")) {
|
|
235
|
+
return page(req, res, `Execute ${routine.kind}: ${routine.name}`, buildForm(req, routine, `Missing required argument: ${inputArgs[i].name || `#${i + 1}`}`, null, viewBaseUrl));
|
|
236
|
+
}
|
|
237
|
+
values.push(parseArgValue(raw, inputArgs[i]?.type));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
|
241
|
+
const routineRef = `${quoteIdent(routine.schema)}.${quoteIdent(routine.name)}`;
|
|
242
|
+
|
|
243
|
+
const start = Date.now();
|
|
244
|
+
let result;
|
|
245
|
+
if (routine.kind === "procedure") {
|
|
246
|
+
result = await db.query(`CALL ${routineRef}(${placeholders})`, values);
|
|
247
|
+
} else {
|
|
248
|
+
result = await db.query(`SELECT * FROM ${routineRef}(${placeholders})`, values);
|
|
249
|
+
}
|
|
250
|
+
const durationMs = Date.now() - start;
|
|
251
|
+
|
|
252
|
+
const resultData = routine.kind === "procedure"
|
|
253
|
+
? { meta: { kind: "procedure", rowCount: result.rowCount || 0, durationMs }, data: { success: true, rowCount: result.rowCount || 0 } }
|
|
254
|
+
: { meta: { kind: "function", rowCount: result.rows?.length || 0, durationMs }, rows: result.rows || [] };
|
|
255
|
+
|
|
256
|
+
page(req, res, `Execute ${routine.kind}: ${routine.name}`, buildForm(req, routine, null, resultData, viewBaseUrl));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const routine = await getRoutineByOid(req.params.oid).catch(() => null);
|
|
259
|
+
if (routine) {
|
|
260
|
+
page(req, res, `Execute ${routine.kind}: ${routine.name}`, buildForm(req, routine, error.message, null, viewBaseUrl));
|
|
261
|
+
} else {
|
|
262
|
+
page(req, res, "Execute routine", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
module.exports = { executeFormRoute, executePostRoute, parseArgValue };
|
package/routes/export.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const { requireAdmin } = require("../lib/auth");
|
|
2
|
+
const { escapeHtml, page } = require("../lib/html");
|
|
3
|
+
const { ensurePostgres, listRoutines, currentTenantSchema } = require("../lib/introspection");
|
|
4
|
+
const { buildExportPack } = require("../lib/export-import");
|
|
5
|
+
const { listViewHref, routeHref, viewContextInput, getViewBaseUrl } = require("../lib/view-context");
|
|
6
|
+
|
|
7
|
+
function csrfInput(req) {
|
|
8
|
+
return typeof req.csrfToken === "function" ? `<input type="hidden" name="_csrf" value="${escapeHtml(req.csrfToken())}">` : "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formStyles() {
|
|
12
|
+
return `<style>
|
|
13
|
+
.db-code-export .db-code-export-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
14
|
+
.db-code-export .form-section-title { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: var(--bs-secondary-color, #6c757d); font-weight: 700; margin-bottom: .75rem; }
|
|
15
|
+
</style>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderExportForm(req, routines, viewBaseUrl) {
|
|
19
|
+
const backHref = listViewHref(viewBaseUrl);
|
|
20
|
+
const routineRows = routines.map((r, i) => {
|
|
21
|
+
const kindIcon = r.kind === "procedure" ? "fas fa-cogs" : "fas fa-cube";
|
|
22
|
+
const kindClass = r.kind === "procedure" ? "bg-warning text-dark" : "bg-primary";
|
|
23
|
+
return `<tr>
|
|
24
|
+
<td><input class="form-check-input" type="checkbox" name="export_${i}" value="${r.oid}" checked></td>
|
|
25
|
+
<td><span class="badge ${kindClass}"><i class="${kindIcon} me-1"></i>${escapeHtml(r.kind === "procedure" ? "Stored proc." : "Function")}</span></td>
|
|
26
|
+
<td><code>${escapeHtml(r.name)}</code></td>
|
|
27
|
+
<td><code>${escapeHtml(r.identity_arguments || "none")}</code></td>
|
|
28
|
+
<td><code>${escapeHtml(r.result_type || "—")}</code></td>
|
|
29
|
+
<td><span class="badge bg-light text-dark border">${escapeHtml(r.language)}</span></td>
|
|
30
|
+
</tr>`;
|
|
31
|
+
}).join("");
|
|
32
|
+
|
|
33
|
+
return `${formStyles()}<div class="db-code-export">
|
|
34
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to DB Code</a></p>
|
|
35
|
+
<div class="card mt-0 card-max-full-screen">
|
|
36
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
37
|
+
<div class="d-flex align-items-center">
|
|
38
|
+
<span class="db-code-export-icon rounded bg-info text-white me-2"><i class="fas fa-download"></i></span>
|
|
39
|
+
<div><h5 class="mb-0">Export routines</h5><div class="small text-muted">Download selected routines as a JSON pack</div></div>
|
|
40
|
+
</div>
|
|
41
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="card-body">
|
|
44
|
+
<form method="post" action="/db-code/export">
|
|
45
|
+
${csrfInput(req)}
|
|
46
|
+
${viewContextInput(viewBaseUrl)}
|
|
47
|
+
${routines.length > 0 ? `<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0">
|
|
48
|
+
<thead><tr>
|
|
49
|
+
<th><input class="form-check-input" type="checkbox" id="exportToggleAll" checked></th>
|
|
50
|
+
<th>Type</th><th>Name</th><th>Arguments</th><th>Returns</th><th>Language</th>
|
|
51
|
+
</tr></thead>
|
|
52
|
+
<tbody>${routineRows}</tbody>
|
|
53
|
+
</table></div>
|
|
54
|
+
<script>
|
|
55
|
+
(function(){
|
|
56
|
+
var toggle = document.getElementById('exportToggleAll');
|
|
57
|
+
if (!toggle) return;
|
|
58
|
+
var boxes = document.querySelectorAll('input[name^="export_"]');
|
|
59
|
+
toggle.addEventListener('change', function(){ boxes.forEach(function(b){ b.checked = toggle.checked; }); });
|
|
60
|
+
})();
|
|
61
|
+
</script>
|
|
62
|
+
<div class="d-flex gap-2 mt-3">
|
|
63
|
+
<button class="btn btn-info text-white" type="submit"><i class="fas fa-download me-1"></i>Export ${routines.length} routine${routines.length !== 1 ? "s" : ""}</button>
|
|
64
|
+
<a class="btn btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
65
|
+
</div>` : `<div class="text-muted text-center py-4"><i class="fas fa-info-circle me-2"></i>No routines found to export.</div>`}
|
|
66
|
+
</form>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* GET /db-code/export — show selection form
|
|
74
|
+
*/
|
|
75
|
+
async function exportFormRoute(req, res) {
|
|
76
|
+
if (!requireAdmin(req, res)) return;
|
|
77
|
+
try {
|
|
78
|
+
ensurePostgres();
|
|
79
|
+
const routines = await listRoutines();
|
|
80
|
+
page(req, res, "Export routines", renderExportForm(req, routines, getViewBaseUrl(req)));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
page(req, res, "Export routines", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POST /db-code/export — generate and download JSON pack
|
|
88
|
+
*/
|
|
89
|
+
async function exportPostRoute(req, res) {
|
|
90
|
+
if (!requireAdmin(req, res)) return;
|
|
91
|
+
try {
|
|
92
|
+
ensurePostgres();
|
|
93
|
+
const allRoutines = await listRoutines();
|
|
94
|
+
const oidSet = new Set();
|
|
95
|
+
for (const [key, value] of Object.entries(req.body || {})) {
|
|
96
|
+
if (key.startsWith("export_") && value) oidSet.add(String(value));
|
|
97
|
+
}
|
|
98
|
+
if (oidSet.size === 0) {
|
|
99
|
+
return page(req, res, "Export routines", renderExportForm(req, allRoutines, getViewBaseUrl(req)));
|
|
100
|
+
}
|
|
101
|
+
const selected = allRoutines.filter((r) => oidSet.has(String(r.oid)));
|
|
102
|
+
if (selected.length === 0) {
|
|
103
|
+
return page(req, res, "Export routines", renderExportForm(req, allRoutines, getViewBaseUrl(req)));
|
|
104
|
+
}
|
|
105
|
+
const schema = currentTenantSchema();
|
|
106
|
+
const pack = buildExportPack(selected, schema);
|
|
107
|
+
const json = JSON.stringify(pack, null, 2);
|
|
108
|
+
const filename = `db-code-routines-${schema}-${new Date().toISOString().slice(0, 10)}.json`;
|
|
109
|
+
|
|
110
|
+
res.setHeader("Content-Type", "application/json");
|
|
111
|
+
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
|
112
|
+
if (typeof res.send === "function") res.send(json);
|
|
113
|
+
else if (typeof res.end === "function") res.end(json);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
page(req, res, "Export routines", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { exportFormRoute, exportPostRoute };
|
package/routes/import.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const { requireAdmin } = require("../lib/auth");
|
|
2
|
+
const { escapeHtml, page } = require("../lib/html");
|
|
3
|
+
const { ensurePostgres, currentTenantSchema } = require("../lib/introspection");
|
|
4
|
+
const { validateImportPack, remapSchemaInDdl, renderPackPreview } = require("../lib/export-import");
|
|
5
|
+
const { listViewHref, viewContextInput, getViewBaseUrl } = require("../lib/view-context");
|
|
6
|
+
|
|
7
|
+
const db = require("@saltcorn/data/db");
|
|
8
|
+
|
|
9
|
+
function csrfInput(req) {
|
|
10
|
+
return typeof req.csrfToken === "function" ? `<input type="hidden" name="_csrf" value="${escapeHtml(req.csrfToken())}">` : "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formStyles() {
|
|
14
|
+
return `<style>
|
|
15
|
+
.db-code-import .db-code-import-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
16
|
+
.db-code-import .form-section-title { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: var(--bs-secondary-color, #6c757d); font-weight: 700; margin-bottom: .75rem; }
|
|
17
|
+
.db-code-import .result-ok { color: var(--bs-success, #198754); }
|
|
18
|
+
.db-code-import .result-err { color: var(--bs-danger, #dc3545); }
|
|
19
|
+
.db-code-import .result-skip { color: var(--bs-secondary-color, #6c757d); }
|
|
20
|
+
</style>`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderUploadForm(req, viewBaseUrl) {
|
|
24
|
+
const backHref = listViewHref(viewBaseUrl);
|
|
25
|
+
return `${formStyles()}<div class="db-code-import">
|
|
26
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to DB Code</a></p>
|
|
27
|
+
<div class="card mt-0 card-max-full-screen">
|
|
28
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
29
|
+
<div class="d-flex align-items-center">
|
|
30
|
+
<span class="db-code-import-icon rounded bg-success text-white me-2"><i class="fas fa-upload"></i></span>
|
|
31
|
+
<div><h5 class="mb-0">Import routines</h5><div class="small text-muted">Upload a DB Code routine pack (JSON)</div></div>
|
|
32
|
+
</div>
|
|
33
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="card-body">
|
|
36
|
+
<form method="post" action="/db-code/import" enctype="multipart/form-data">
|
|
37
|
+
${csrfInput(req)}
|
|
38
|
+
${viewContextInput(viewBaseUrl)}
|
|
39
|
+
<div class="mb-3">
|
|
40
|
+
<label class="form-label">Pack file</label>
|
|
41
|
+
<input class="form-control" type="file" name="packfile" accept=".json" required>
|
|
42
|
+
<div class="form-text">Select a JSON file exported from DB Code.</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="d-flex gap-2">
|
|
45
|
+
<button class="btn btn-success" type="submit"><i class="fas fa-upload me-1"></i>Upload and preview</button>
|
|
46
|
+
<a class="btn btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderPreviewForm(req, pack, viewBaseUrl) {
|
|
55
|
+
const backHref = listViewHref(viewBaseUrl);
|
|
56
|
+
const schema = currentTenantSchema();
|
|
57
|
+
const needsRemap = pack.source_schema && pack.source_schema !== schema;
|
|
58
|
+
const remapNotice = needsRemap
|
|
59
|
+
? `<div class="alert alert-warning py-2"><i class="fas fa-exclamation-triangle me-1"></i>Schema remapping: the pack was exported from <code>${escapeHtml(pack.source_schema)}</code> but the current tenant schema is <code>${escapeHtml(schema)}</code>. DDL statements will be remapped automatically.</div>`
|
|
60
|
+
: "";
|
|
61
|
+
|
|
62
|
+
return `${formStyles()}<div class="db-code-import">
|
|
63
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to DB Code</a></p>
|
|
64
|
+
<div class="card mt-0 card-max-full-screen">
|
|
65
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
66
|
+
<div class="d-flex align-items-center">
|
|
67
|
+
<span class="db-code-import-icon rounded bg-success text-white me-2"><i class="fas fa-upload"></i></span>
|
|
68
|
+
<div><h5 class="mb-0">Import preview</h5><div class="small text-muted">${pack.routines.length} routine${pack.routines.length !== 1 ? "s" : ""} in the pack · exported ${escapeHtml(pack.exported_at || "unknown")}</div></div>
|
|
69
|
+
</div>
|
|
70
|
+
<a class="btn btn-sm btn-outline-secondary" href="/db-code/import">Cancel</a>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="card-body">
|
|
73
|
+
${remapNotice}
|
|
74
|
+
<form method="post" action="/db-code/import/execute">
|
|
75
|
+
${csrfInput(req)}
|
|
76
|
+
${viewContextInput(viewBaseUrl)}
|
|
77
|
+
<input type="hidden" name="pack_json" value="${escapeHtml(JSON.stringify(pack))}">
|
|
78
|
+
<div class="form-section-title">Select routines to import</div>
|
|
79
|
+
${renderPackPreview(pack)}
|
|
80
|
+
<div class="d-flex gap-2 mt-3">
|
|
81
|
+
<button class="btn btn-success" type="submit"><i class="fas fa-play me-1"></i>Import selected routines</button>
|
|
82
|
+
<a class="btn btn-outline-secondary" href="/db-code/import">Cancel</a>
|
|
83
|
+
</div>
|
|
84
|
+
</form>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderResults(req, results, viewBaseUrl) {
|
|
91
|
+
const backHref = listViewHref(viewBaseUrl);
|
|
92
|
+
const okCount = results.filter((r) => r.status === "ok").length;
|
|
93
|
+
const errCount = results.filter((r) => r.status === "error").length;
|
|
94
|
+
const skipCount = results.filter((r) => r.status === "skipped").length;
|
|
95
|
+
|
|
96
|
+
const summary = [];
|
|
97
|
+
if (okCount) summary.push(`<span class="badge bg-success">${okCount} imported</span>`);
|
|
98
|
+
if (errCount) summary.push(`<span class="badge bg-danger">${errCount} failed</span>`);
|
|
99
|
+
if (skipCount) summary.push(`<span class="badge bg-secondary">${skipCount} skipped</span>`);
|
|
100
|
+
|
|
101
|
+
const rows = results.map((r) => {
|
|
102
|
+
const icon = r.status === "ok" ? "fas fa-check-circle result-ok" : r.status === "error" ? "fas fa-times-circle result-err" : "fas fa-minus-circle result-skip";
|
|
103
|
+
const kindClass = r.kind === "procedure" ? "bg-warning text-dark" : "bg-primary";
|
|
104
|
+
const detail = r.status === "error" ? `<div class="small text-danger mt-1">${escapeHtml(r.error)}</div>` : "";
|
|
105
|
+
return `<tr>
|
|
106
|
+
<td><i class="${icon}"></i></td>
|
|
107
|
+
<td><span class="badge ${kindClass}">${escapeHtml(r.kind)}</span></td>
|
|
108
|
+
<td><code>${escapeHtml(r.name)}</code></td>
|
|
109
|
+
<td>${escapeHtml(r.status)}${detail}</td>
|
|
110
|
+
</tr>`;
|
|
111
|
+
}).join("");
|
|
112
|
+
|
|
113
|
+
return `${formStyles()}<div class="db-code-import">
|
|
114
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to DB Code</a></p>
|
|
115
|
+
<div class="card mt-0 card-max-full-screen">
|
|
116
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
117
|
+
<div class="d-flex align-items-center">
|
|
118
|
+
<span class="db-code-import-icon rounded ${errCount > 0 ? "bg-warning text-dark" : "bg-success text-white"} me-2"><i class="fas fa-clipboard-check"></i></span>
|
|
119
|
+
<div><h5 class="mb-0">Import results</h5><div class="small text-muted">${summary.join(" ")}</div></div>
|
|
120
|
+
</div>
|
|
121
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Done</a>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="card-body">
|
|
124
|
+
<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0">
|
|
125
|
+
<thead><tr><th></th><th>Type</th><th>Name</th><th>Status</th></tr></thead>
|
|
126
|
+
<tbody>${rows}</tbody>
|
|
127
|
+
</table></div>
|
|
128
|
+
<div class="d-flex gap-2 mt-3">
|
|
129
|
+
<a class="btn btn-outline-secondary" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to DB Code</a>
|
|
130
|
+
<a class="btn btn-outline-primary" href="/db-code/import"><i class="fas fa-upload me-1"></i>Import another pack</a>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* GET /db-code/import — show upload form
|
|
139
|
+
*/
|
|
140
|
+
async function importFormRoute(req, res) {
|
|
141
|
+
if (!requireAdmin(req, res)) return;
|
|
142
|
+
page(req, res, "Import routines", renderUploadForm(req, getViewBaseUrl(req)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* POST /db-code/import — parse uploaded file, show preview
|
|
147
|
+
*/
|
|
148
|
+
async function importPostRoute(req, res) {
|
|
149
|
+
if (!requireAdmin(req, res)) return;
|
|
150
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
151
|
+
try {
|
|
152
|
+
ensurePostgres();
|
|
153
|
+
// Get uploaded file content
|
|
154
|
+
const file = req.files?.packfile || req.file;
|
|
155
|
+
if (!file) {
|
|
156
|
+
return page(req, res, "Import routines", renderUploadForm(req, viewBaseUrl) + `<div class="alert alert-warning mt-2">No file uploaded.</div>`);
|
|
157
|
+
}
|
|
158
|
+
const rawText = file.data ? file.data.toString("utf8") : String(file.buffer || "");
|
|
159
|
+
let parsed;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(rawText);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
return page(req, res, "Import routines", renderUploadForm(req, viewBaseUrl) + `<div class="alert alert-danger mt-2">Invalid JSON file: ${escapeHtml(e.message)}</div>`);
|
|
164
|
+
}
|
|
165
|
+
const validation = validateImportPack(parsed);
|
|
166
|
+
if (!validation.valid) {
|
|
167
|
+
return page(req, res, "Import routines", renderUploadForm(req, viewBaseUrl) + `<div class="alert alert-danger mt-2">${escapeHtml(validation.error)}</div>`);
|
|
168
|
+
}
|
|
169
|
+
page(req, res, "Import preview", renderPreviewForm(req, validation.pack, viewBaseUrl));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
page(req, res, "Import routines", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* POST /db-code/import/execute — execute selected DDLs
|
|
177
|
+
*/
|
|
178
|
+
async function importExecuteRoute(req, res) {
|
|
179
|
+
if (!requireAdmin(req, res)) return;
|
|
180
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
181
|
+
try {
|
|
182
|
+
ensurePostgres();
|
|
183
|
+
const schema = currentTenantSchema();
|
|
184
|
+
let pack;
|
|
185
|
+
try {
|
|
186
|
+
pack = JSON.parse(req.body?.pack_json || "null");
|
|
187
|
+
} catch {
|
|
188
|
+
return page(req, res, "Import routines", `<div class="alert alert-danger">Corrupted pack data.</div>`);
|
|
189
|
+
}
|
|
190
|
+
const validation = validateImportPack(pack);
|
|
191
|
+
if (!validation.valid) {
|
|
192
|
+
return page(req, res, "Import routines", `<div class="alert alert-danger">${escapeHtml(validation.error)}</div>`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const results = [];
|
|
196
|
+
for (let i = 0; i < validation.pack.routines.length; i++) {
|
|
197
|
+
const r = validation.pack.routines[i];
|
|
198
|
+
const selected = req.body?.[`import_${i}`];
|
|
199
|
+
if (!selected) {
|
|
200
|
+
results.push({ name: r.name, kind: r.kind, status: "skipped" });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
let ddl = r.ddl;
|
|
205
|
+
// Remap schema if necessary
|
|
206
|
+
if (validation.pack.source_schema && validation.pack.source_schema !== schema) {
|
|
207
|
+
ddl = remapSchemaInDdl(ddl, validation.pack.source_schema, schema);
|
|
208
|
+
}
|
|
209
|
+
await db.query(ddl);
|
|
210
|
+
results.push({ name: r.name, kind: r.kind, status: "ok" });
|
|
211
|
+
} catch (err) {
|
|
212
|
+
results.push({ name: r.name, kind: r.kind, status: "error", error: err.message });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
page(req, res, "Import results", renderResults(req, results, viewBaseUrl));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
page(req, res, "Import routines", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { importFormRoute, importPostRoute, importExecuteRoute };
|
package/routes/list.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { requireAdmin } = require("../lib/auth");
|
|
2
|
+
const { page } = require("../lib/html");
|
|
3
|
+
const { renderRoutineList } = require("../lib/render-routines");
|
|
4
|
+
|
|
5
|
+
async function listRoute(req, res) {
|
|
6
|
+
if (!requireAdmin(req, res)) return;
|
|
7
|
+
page(req, res, "DB Code", await renderRoutineList({ kind: req.query?.kind || "" }));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = listRoute;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { requireAdmin } = require("../lib/auth");
|
|
2
|
+
const { escapeHtml, page } = require("../lib/html");
|
|
3
|
+
|
|
4
|
+
function placeholder(title, message) {
|
|
5
|
+
return async function route(req, res) {
|
|
6
|
+
if (!requireAdmin(req, res)) return;
|
|
7
|
+
page(req, res, title, `<p><a href="/db-code">← Back to DB Code</a></p><div class="alert alert-info">${escapeHtml(message)}</div>`);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = placeholder;
|
package/routes/show.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const { requireAdmin } = require("../lib/auth");
|
|
2
|
+
const { page } = require("../lib/html");
|
|
3
|
+
const { renderRoutineDetail } = require("../lib/render-routines");
|
|
4
|
+
|
|
5
|
+
async function showRoute(req, res) {
|
|
6
|
+
if (!requireAdmin(req, res)) return;
|
|
7
|
+
page(req, res, "DB Code routine", await renderRoutineDetail(req.params.oid));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = showRoute;
|