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
package/routes/create.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
const db = require("@saltcorn/data/db");
|
|
2
|
+
const { requireAdmin } = require("../lib/auth");
|
|
3
|
+
const { escapeHtml, page } = require("../lib/html");
|
|
4
|
+
const { currentTenantSchema, ensurePostgres } = require("../lib/introspection");
|
|
5
|
+
const { buildCreateFunctionSql, buildCreateProcedureSql, validateCreateRoutineDdl } = require("../lib/sql-builders");
|
|
6
|
+
const { assertSafeSqlFragment } = require("../lib/validation");
|
|
7
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
8
|
+
const { listViewHref, detailViewHref, viewContextInput, getViewBaseUrl } = require("../lib/view-context");
|
|
9
|
+
|
|
10
|
+
function fieldValue(body, name, fallback = "") {
|
|
11
|
+
return typeof body?.[name] === "undefined" ? fallback : body[name];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function csrfInput(req) {
|
|
15
|
+
return typeof req.csrfToken === "function" ? `<input type="hidden" name="_csrf" value="${escapeHtml(req.csrfToken())}">` : "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function selected(value, expected) {
|
|
19
|
+
return value === expected ? " selected" : "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formStyles() {
|
|
23
|
+
return `<style>
|
|
24
|
+
.db-code-form .card-header { gap: .75rem; }
|
|
25
|
+
.db-code-form .db-code-form-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
26
|
+
.db-code-form .form-section-title { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: var(--bs-secondary-color, #6c757d); font-weight: 700; margin-bottom: .75rem; }
|
|
27
|
+
.db-code-form .sticky-help { position: sticky; top: 1rem; }
|
|
28
|
+
.db-code-form textarea.to-code + div, .db-code-form .monaco-editor { border-radius: .375rem; }
|
|
29
|
+
</style>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shell({ title, subtitle, icon, backHref = "/db-code", backLabel = "Back to DB Code", error, aside, body }) {
|
|
33
|
+
return `${formStyles()}<div class="db-code-form">
|
|
34
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>${escapeHtml(backLabel)}</a></p>
|
|
35
|
+
${error ? `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(error)}</div>` : ""}
|
|
36
|
+
<div class="card mt-0 card-max-full-screen">
|
|
37
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
38
|
+
<div class="d-flex align-items-center">
|
|
39
|
+
<span class="db-code-form-icon rounded bg-primary text-white me-2"><i class="${icon}"></i></span>
|
|
40
|
+
<div><h5 class="mb-0">${escapeHtml(title)}</h5><div class="small text-muted">${escapeHtml(subtitle)}</div></div>
|
|
41
|
+
</div>
|
|
42
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="card-body">
|
|
45
|
+
<div class="row g-4">
|
|
46
|
+
<div class="col-lg-8">${body}</div>
|
|
47
|
+
<div class="col-lg-4"><div class="sticky-help">${aside}</div></div>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function helpCard(items, warning = null) {
|
|
55
|
+
return `<div class="card bg-light border-0"><div class="card-body">
|
|
56
|
+
<div class="form-section-title">Guidance</div>
|
|
57
|
+
${warning ? `<div class="alert alert-warning py-2 small">${warning}</div>` : ""}
|
|
58
|
+
<ul class="small mb-0">${items.map((item) => `<li>${item}</li>`).join("")}</ul>
|
|
59
|
+
</div></div>`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hasCopilot() {
|
|
63
|
+
return !!getState()?.functions?.copilot_generate_javascript;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function aiModal(targetField, routineType) {
|
|
67
|
+
return `<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="showDbCodeAiModal()"><i class="fas fa-wand-magic-sparkles me-1"></i>Edit with AI</button>
|
|
68
|
+
<div class="modal fade" id="dbCodeAiModal" tabindex="-1">
|
|
69
|
+
<div class="modal-dialog"><div class="modal-content">
|
|
70
|
+
<div class="modal-header"><h5 class="modal-title">Edit with AI</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
71
|
+
<div class="modal-body">
|
|
72
|
+
<p class="text-muted small">Describe the ${routineType} you want to generate.</p>
|
|
73
|
+
<textarea id="dbCodeAiPrompt" class="form-control" rows="4" placeholder="Create a ${routineType} that ..."></textarea>
|
|
74
|
+
<div id="dbCodeAiError" class="alert alert-danger mt-2 mb-0 d-none"></div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="modal-footer">
|
|
77
|
+
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
78
|
+
<button id="dbCodeAiGenBtn" class="btn btn-primary" onclick="runDbCodeAi()" disabled>Generate</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div></div>
|
|
81
|
+
</div>
|
|
82
|
+
<script>
|
|
83
|
+
function showDbCodeAiModal(){ new bootstrap.Modal(document.getElementById('dbCodeAiModal')).show(); }
|
|
84
|
+
document.addEventListener('DOMContentLoaded', function(){ var ta=document.getElementById('dbCodeAiPrompt'); var btn=document.getElementById('dbCodeAiGenBtn'); if(ta&&btn) ta.addEventListener('input', function(){ btn.disabled=!ta.value.trim(); });});
|
|
85
|
+
function runDbCodeAi(){
|
|
86
|
+
var ta=document.getElementById('dbCodeAiPrompt'); var prompt=ta.value.trim(); if(!prompt) return;
|
|
87
|
+
var btn=document.getElementById('dbCodeAiGenBtn'); var err=document.getElementById('dbCodeAiError');
|
|
88
|
+
btn.disabled=true; btn.textContent='Generating...'; err.classList.add('d-none');
|
|
89
|
+
var edta=document.querySelector('textarea[name="${targetField}"]');
|
|
90
|
+
var existing=edta?edta.value:'';
|
|
91
|
+
fetch('/db-code/ai/generate-sql',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','CSRF-Token':_sc_globalCsrf},body:JSON.stringify({description:prompt,existing_code:existing,routine_type:${JSON.stringify(routineType)}})})
|
|
92
|
+
.then(r=>r.json()).then(data=>{
|
|
93
|
+
btn.textContent='Generate';
|
|
94
|
+
if(data.error){ err.textContent=data.error; err.classList.remove('d-none'); btn.disabled=!ta.value.trim(); return; }
|
|
95
|
+
if(edta){ var m=edta.nextElementSibling; if(m){ var e=$(m).data('monaco-editor'); if(e) e.setValue(data.code); else edta.value=data.code; } else edta.value=data.code; }
|
|
96
|
+
ta.value=''; btn.disabled=true; bootstrap.Modal.getInstance(document.getElementById('dbCodeAiModal')).hide();
|
|
97
|
+
}).catch(e=>{ btn.textContent='Generate'; btn.disabled=!ta.value.trim(); err.textContent=e.message||'Generation failed'; err.classList.remove('d-none'); });
|
|
98
|
+
}
|
|
99
|
+
</script>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderStructuredRoutineForm(req, routineType, values = {}, error = null, viewBaseUrl = null) {
|
|
103
|
+
const isProcedure = routineType === "procedure";
|
|
104
|
+
const language = fieldValue(values, "language", "plpgsql");
|
|
105
|
+
const volatility = fieldValue(values, "volatility", "VOLATILE");
|
|
106
|
+
const security = fieldValue(values, "security", "INVOKER");
|
|
107
|
+
const title = isProcedure ? "New stored procedure" : "New function";
|
|
108
|
+
const action = isProcedure ? "/db-code/new-procedure" : "/db-code/new";
|
|
109
|
+
const objectLabel = isProcedure ? "stored procedure" : "function";
|
|
110
|
+
const backHref = listViewHref(viewBaseUrl);
|
|
111
|
+
const aside = helpCard([
|
|
112
|
+
"The tenant schema is selected automatically and cannot be changed from the form.",
|
|
113
|
+
"Use structured fields for safer routine creation. Use New from DDL only when you already trust the full SQL.",
|
|
114
|
+
`For plpgsql, you may write a full BEGIN/END block or simple statements; simple bodies are wrapped automatically.`,
|
|
115
|
+
isProcedure ? "Procedures are invoked with CALL and do not return a value directly." : "Functions are invoked with SELECT and require a return type."
|
|
116
|
+
]);
|
|
117
|
+
const body = `<form method="post" action="${action}">
|
|
118
|
+
${csrfInput(req)}
|
|
119
|
+
${viewContextInput(viewBaseUrl)}
|
|
120
|
+
<div class="form-section-title">Signature</div>
|
|
121
|
+
<div class="row g-3">
|
|
122
|
+
<div class="col-md-6">
|
|
123
|
+
<label class="form-label">${isProcedure ? "Procedure" : "Function"} name</label>
|
|
124
|
+
<input class="form-control" name="name" required pattern="[A-Za-z_][A-Za-z0-9_]*" value="${escapeHtml(fieldValue(values, "name"))}" placeholder="my_${isProcedure ? "procedure" : "function"}">
|
|
125
|
+
<div class="form-text">Letters, numbers, and underscores only.</div>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="col-md-6">
|
|
128
|
+
<label class="form-label">Arguments</label>
|
|
129
|
+
<input class="form-control" name="argumentsSql" placeholder="user_id integer, active boolean" value="${escapeHtml(fieldValue(values, "argumentsSql"))}">
|
|
130
|
+
</div>
|
|
131
|
+
${isProcedure ? "" : `<div class="col-md-6">
|
|
132
|
+
<label class="form-label">Return type</label>
|
|
133
|
+
<input class="form-control" name="returnType" required placeholder="integer, text, jsonb, setof table_name" value="${escapeHtml(fieldValue(values, "returnType"))}">
|
|
134
|
+
</div>`}
|
|
135
|
+
</div>
|
|
136
|
+
<hr>
|
|
137
|
+
<div class="form-section-title">Execution properties</div>
|
|
138
|
+
<div class="row g-3">
|
|
139
|
+
<div class="col-md-4">
|
|
140
|
+
<label class="form-label">Language</label>
|
|
141
|
+
<select class="form-select" name="language"><option value="plpgsql"${selected(language, "plpgsql")}>plpgsql</option><option value="sql"${selected(language, "sql")}>sql</option></select>
|
|
142
|
+
</div>
|
|
143
|
+
${isProcedure ? "" : `<div class="col-md-4">
|
|
144
|
+
<label class="form-label">Volatility</label>
|
|
145
|
+
<select class="form-select" name="volatility"><option value="VOLATILE"${selected(volatility, "VOLATILE")}>VOLATILE</option><option value="STABLE"${selected(volatility, "STABLE")}>STABLE</option><option value="IMMUTABLE"${selected(volatility, "IMMUTABLE")}>IMMUTABLE</option></select>
|
|
146
|
+
</div>`}
|
|
147
|
+
<div class="col-md-4">
|
|
148
|
+
<label class="form-label">Security</label>
|
|
149
|
+
<select class="form-select" name="security"><option value="INVOKER"${selected(security, "INVOKER")}>INVOKER</option><option value="DEFINER"${selected(security, "DEFINER")}>DEFINER</option></select>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<hr>
|
|
153
|
+
<div class="form-section-title">Code</div>
|
|
154
|
+
<div class="mb-3">
|
|
155
|
+
<label class="form-label">${isProcedure ? "Procedure" : "Function"} body</label>
|
|
156
|
+
<textarea class="form-control to-code font-monospace" mode="text/x-sql" name="body" rows="14" required>${escapeHtml(fieldValue(values, "body"))}</textarea>
|
|
157
|
+
<div class="form-text">Do not include CREATE ${isProcedure ? "PROCEDURE" : "FUNCTION"} or dollar-quote delimiters.</div>
|
|
158
|
+
${hasCopilot() ? aiModal("body", isProcedure ? "stored procedure" : "function") : ""}
|
|
159
|
+
</div>
|
|
160
|
+
<div class="mb-3">
|
|
161
|
+
<label class="form-label">Description</label>
|
|
162
|
+
<input class="form-control" name="description" value="${escapeHtml(fieldValue(values, "description"))}" placeholder="Optional routine description">
|
|
163
|
+
</div>
|
|
164
|
+
<div class="d-flex gap-2"><button class="btn btn-primary" type="submit"><i class="fas fa-save me-1"></i>Create ${objectLabel}</button><a class="btn btn-outline-secondary" href="${backHref}">Cancel</a></div>
|
|
165
|
+
</form>`;
|
|
166
|
+
return shell({ title, subtitle: `Create a PostgreSQL ${objectLabel} in the current tenant schema`, icon: isProcedure ? "fas fa-cogs" : "fas fa-cube", error, aside, backHref, body });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderDdlForm(req, values = {}, error = null, viewBaseUrl = null) {
|
|
170
|
+
const backHref = listViewHref(viewBaseUrl);
|
|
171
|
+
const aside = helpCard([
|
|
172
|
+
"DDL mode is intended for trusted SQL you already have.",
|
|
173
|
+
"The statement must start with CREATE FUNCTION or CREATE PROCEDURE.",
|
|
174
|
+
"The routine must be created explicitly in the current tenant schema.",
|
|
175
|
+
"Use structured forms when possible; they apply stronger validation."
|
|
176
|
+
], "DDL mode executes administrator-provided SQL.");
|
|
177
|
+
const body = `<form method="post" action="/db-code/new-ddl">
|
|
178
|
+
${csrfInput(req)}
|
|
179
|
+
${viewContextInput(viewBaseUrl)}
|
|
180
|
+
<div class="form-section-title">Full DDL</div>
|
|
181
|
+
<div class="mb-3">
|
|
182
|
+
<label class="form-label">CREATE FUNCTION / CREATE PROCEDURE DDL</label>
|
|
183
|
+
<textarea class="form-control to-code font-monospace" mode="text/x-sql" name="ddl" rows="22" required>${escapeHtml(fieldValue(values, "ddl"))}</textarea>
|
|
184
|
+
${hasCopilot() ? aiModal("ddl", "function or stored procedure") : ""}
|
|
185
|
+
</div>
|
|
186
|
+
<div class="d-flex gap-2"><button class="btn btn-primary" type="submit"><i class="fas fa-file-code me-1"></i>Create from DDL</button><a class="btn btn-outline-secondary" href="${backHref}">Cancel</a></div>
|
|
187
|
+
</form>`;
|
|
188
|
+
return shell({ title: "New from DDL", subtitle: "Create a function or stored procedure from full PostgreSQL DDL", icon: "fas fa-file-code", error, aside, backHref, body });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function createFormRoute(req, res) {
|
|
192
|
+
if (!requireAdmin(req, res)) return;
|
|
193
|
+
page(req, res, "New function", renderStructuredRoutineForm(req, "function", {}, null, getViewBaseUrl(req)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function createProcedureFormRoute(req, res) {
|
|
197
|
+
if (!requireAdmin(req, res)) return;
|
|
198
|
+
page(req, res, "New stored procedure", renderStructuredRoutineForm(req, "procedure", {}, null, getViewBaseUrl(req)));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function createDdlFormRoute(req, res) {
|
|
202
|
+
if (!requireAdmin(req, res)) return;
|
|
203
|
+
page(req, res, "New from DDL", renderDdlForm(req, {}, null, getViewBaseUrl(req)));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function createPostRoute(req, res) {
|
|
207
|
+
if (!requireAdmin(req, res)) return;
|
|
208
|
+
const values = req.body || {};
|
|
209
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
210
|
+
try {
|
|
211
|
+
ensurePostgres();
|
|
212
|
+
const argumentsSql = assertSafeSqlFragment(values.argumentsSql, "Arguments");
|
|
213
|
+
const returnType = assertSafeSqlFragment(values.returnType, "Return type", { required: true });
|
|
214
|
+
const sql = buildCreateFunctionSql({ schema: currentTenantSchema(), name: values.name, argumentsSql, returnType, language: values.language || "plpgsql", volatility: values.volatility || "VOLATILE", security: values.security || "INVOKER", body: values.body || "", description: values.description || "" });
|
|
215
|
+
await db.query(sql);
|
|
216
|
+
if (typeof req.flash === "function") req.flash("success", `Function ${escapeHtml(values.name)} created`);
|
|
217
|
+
res.redirect(listViewHref(viewBaseUrl, "function"));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
page(req, res, "New function", renderStructuredRoutineForm(req, "function", values, error.message, viewBaseUrl));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function createProcedurePostRoute(req, res) {
|
|
224
|
+
if (!requireAdmin(req, res)) return;
|
|
225
|
+
const values = req.body || {};
|
|
226
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
227
|
+
try {
|
|
228
|
+
ensurePostgres();
|
|
229
|
+
const argumentsSql = assertSafeSqlFragment(values.argumentsSql, "Arguments");
|
|
230
|
+
const sql = buildCreateProcedureSql({ schema: currentTenantSchema(), name: values.name, argumentsSql, language: values.language || "plpgsql", security: values.security || "INVOKER", body: values.body || "", description: values.description || "" });
|
|
231
|
+
await db.query(sql);
|
|
232
|
+
if (typeof req.flash === "function") req.flash("success", `Stored procedure ${escapeHtml(values.name)} created`);
|
|
233
|
+
res.redirect(listViewHref(viewBaseUrl, "procedure"));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
page(req, res, "New stored procedure", renderStructuredRoutineForm(req, "procedure", values, error.message, viewBaseUrl));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function createDdlPostRoute(req, res) {
|
|
240
|
+
if (!requireAdmin(req, res)) return;
|
|
241
|
+
const values = req.body || {};
|
|
242
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
243
|
+
try {
|
|
244
|
+
ensurePostgres();
|
|
245
|
+
const ddl = validateCreateRoutineDdl(values.ddl, currentTenantSchema());
|
|
246
|
+
await db.query(ddl);
|
|
247
|
+
if (typeof req.flash === "function") req.flash("success", "Routine created from DDL");
|
|
248
|
+
res.redirect(listViewHref(viewBaseUrl));
|
|
249
|
+
} catch (error) {
|
|
250
|
+
page(req, res, "New from DDL", renderDdlForm(req, values, error.message, viewBaseUrl));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = { createFormRoute, createPostRoute, createProcedureFormRoute, createProcedurePostRoute, createDdlFormRoute, createDdlPostRoute };
|
package/routes/delete.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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 { buildDropRoutineSql } = require("../lib/sql-builders");
|
|
6
|
+
const { detailViewHref, listViewHref, viewContextInput, getViewBaseUrl } = require("../lib/view-context");
|
|
7
|
+
|
|
8
|
+
function csrfInput(req) {
|
|
9
|
+
return typeof req.csrfToken === "function" ? `<input type="hidden" name="_csrf" value="${escapeHtml(req.csrfToken())}">` : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function getRoutineDependencies(oid) {
|
|
13
|
+
const parsedOid = Number.parseInt(oid, 10);
|
|
14
|
+
const { rows } = await db.query(
|
|
15
|
+
`SELECT
|
|
16
|
+
d.deptype,
|
|
17
|
+
pg_describe_object(d.classid, d.objid, d.objsubid) AS dependent_object
|
|
18
|
+
FROM pg_depend d
|
|
19
|
+
WHERE d.refclassid = 'pg_proc'::regclass
|
|
20
|
+
AND d.refobjid = $1
|
|
21
|
+
AND d.deptype NOT IN ('i', 'p')
|
|
22
|
+
ORDER BY dependent_object`,
|
|
23
|
+
[parsedOid]
|
|
24
|
+
);
|
|
25
|
+
return rows;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formStyles() {
|
|
29
|
+
return `<style>
|
|
30
|
+
.db-code-delete .db-code-delete-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
31
|
+
.db-code-delete .form-section-title { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: var(--bs-secondary-color, #6c757d); font-weight: 700; margin-bottom: .75rem; }
|
|
32
|
+
.db-code-delete .sticky-help { position: sticky; top: 1rem; }
|
|
33
|
+
</style>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderDeleteForm(req, routine, dependencies = [], error = null, viewBaseUrl = null) {
|
|
37
|
+
const dropSql = buildDropRoutineSql({
|
|
38
|
+
schema: routine.schema,
|
|
39
|
+
name: routine.name,
|
|
40
|
+
identityArguments: routine.identity_arguments || "",
|
|
41
|
+
kind: routine.kind
|
|
42
|
+
});
|
|
43
|
+
const hasDependencies = dependencies.length > 0;
|
|
44
|
+
const backHref = detailViewHref(viewBaseUrl, routine.oid);
|
|
45
|
+
return `${formStyles()}<div class="db-code-delete">
|
|
46
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to routine</a></p>
|
|
47
|
+
${error ? `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(error)}</div>` : ""}
|
|
48
|
+
<div class="card mt-0">
|
|
49
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
50
|
+
<div class="d-flex align-items-center">
|
|
51
|
+
<span class="db-code-delete-icon rounded bg-danger text-white me-2"><i class="fas fa-trash"></i></span>
|
|
52
|
+
<div><h5 class="mb-0">Delete ${escapeHtml(routine.kind)}: ${escapeHtml(routine.name)}</h5><div class="small text-muted">Drop this PostgreSQL routine from the current tenant schema</div></div>
|
|
53
|
+
</div>
|
|
54
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="card-body">
|
|
57
|
+
<div class="row g-4">
|
|
58
|
+
<div class="col-lg-8">
|
|
59
|
+
<div class="alert alert-danger">
|
|
60
|
+
<strong>This action cannot be undone.</strong> DB Code will execute a plain DROP statement without CASCADE.
|
|
61
|
+
</div>
|
|
62
|
+
<div class="form-section-title">SQL to execute</div>
|
|
63
|
+
<pre class="border rounded p-3 bg-light"><code>${escapeHtml(dropSql)}</code></pre>
|
|
64
|
+
<form method="post" action="/db-code/routine/${routine.oid}/delete">
|
|
65
|
+
${csrfInput(req)}
|
|
66
|
+
${viewContextInput(viewBaseUrl)}
|
|
67
|
+
<div class="mb-3">
|
|
68
|
+
<label class="form-label">Type the routine name to confirm</label>
|
|
69
|
+
<input class="form-control" name="confirmName" required autocomplete="off" placeholder="${escapeHtml(routine.name)}">
|
|
70
|
+
</div>
|
|
71
|
+
<div class="d-flex gap-2">
|
|
72
|
+
<button class="btn btn-danger" type="submit"><i class="fas fa-trash me-1"></i>Delete routine</button>
|
|
73
|
+
<a class="btn btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
74
|
+
</div>
|
|
75
|
+
</form>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="col-lg-4"><div class="sticky-help">
|
|
78
|
+
<div class="card bg-light border-0 mb-3"><div class="card-body">
|
|
79
|
+
<div class="form-section-title">Routine identity</div>
|
|
80
|
+
<table class="table table-sm mb-0">
|
|
81
|
+
<tr><th>Schema</th><td><code>${escapeHtml(routine.schema)}</code></td></tr>
|
|
82
|
+
<tr><th>Name</th><td><code>${escapeHtml(routine.name)}</code></td></tr>
|
|
83
|
+
<tr><th>Type</th><td>${escapeHtml(routine.kind)}</td></tr>
|
|
84
|
+
<tr><th>Arguments</th><td><code>${escapeHtml(routine.identity_arguments || "")}</code></td></tr>
|
|
85
|
+
</table>
|
|
86
|
+
</div></div>
|
|
87
|
+
<div class="card ${hasDependencies ? "border-warning" : "border-success"}"><div class="card-body">
|
|
88
|
+
<div class="form-section-title">Dependencies</div>
|
|
89
|
+
${hasDependencies ? `<div class="alert alert-warning py-2 small">PostgreSQL may reject the drop because dependent objects exist. CASCADE is intentionally not used.</div><ul class="small mb-0">${dependencies.map((dep) => `<li>${escapeHtml(dep.dependent_object || dep.deptype)}</li>`).join("")}</ul>` : `<div class="text-success small"><i class="fas fa-check me-1"></i>No direct dependencies found through pg_depend.</div>`}
|
|
90
|
+
</div></div>
|
|
91
|
+
</div></div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function deleteFormRoute(req, res) {
|
|
99
|
+
if (!requireAdmin(req, res)) return;
|
|
100
|
+
try {
|
|
101
|
+
ensurePostgres();
|
|
102
|
+
const routine = await getRoutineByOid(req.params.oid);
|
|
103
|
+
if (!routine) {
|
|
104
|
+
if (typeof res.status === "function") res.status(404);
|
|
105
|
+
return page(req, res, "Routine not found", `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`);
|
|
106
|
+
}
|
|
107
|
+
const dependencies = await getRoutineDependencies(routine.oid);
|
|
108
|
+
page(req, res, `Delete ${routine.kind}: ${routine.name}`, renderDeleteForm(req, routine, dependencies, null, getViewBaseUrl(req)));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
page(req, res, "Delete routine", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function deletePostRoute(req, res) {
|
|
115
|
+
if (!requireAdmin(req, res)) return;
|
|
116
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
117
|
+
try {
|
|
118
|
+
ensurePostgres();
|
|
119
|
+
const routine = await getRoutineByOid(req.params.oid);
|
|
120
|
+
if (!routine) {
|
|
121
|
+
if (typeof res.status === "function") res.status(404);
|
|
122
|
+
return page(req, res, "Routine not found", `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`);
|
|
123
|
+
}
|
|
124
|
+
if ((req.body?.confirmName || "") !== routine.name) {
|
|
125
|
+
const dependencies = await getRoutineDependencies(routine.oid);
|
|
126
|
+
return page(req, res, `Delete ${routine.kind}: ${routine.name}`, renderDeleteForm(req, routine, dependencies, "Confirmation name does not match.", viewBaseUrl));
|
|
127
|
+
}
|
|
128
|
+
const dropSql = buildDropRoutineSql({
|
|
129
|
+
schema: routine.schema,
|
|
130
|
+
name: routine.name,
|
|
131
|
+
identityArguments: routine.identity_arguments || "",
|
|
132
|
+
kind: routine.kind
|
|
133
|
+
});
|
|
134
|
+
await db.query(dropSql);
|
|
135
|
+
if (typeof req.flash === "function") req.flash("success", `Routine ${escapeHtml(routine.name)} deleted`);
|
|
136
|
+
res.redirect(listViewHref(viewBaseUrl, routine.kind === "procedure" ? "procedure" : "function"));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const routine = await getRoutineByOid(req.params.oid).catch(() => null);
|
|
139
|
+
if (routine) {
|
|
140
|
+
const dependencies = await getRoutineDependencies(routine.oid).catch(() => []);
|
|
141
|
+
page(req, res, `Delete ${routine.kind}: ${routine.name}`, renderDeleteForm(req, routine, dependencies, error.message, viewBaseUrl));
|
|
142
|
+
} else {
|
|
143
|
+
page(req, res, "Delete routine", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { deleteFormRoute, deletePostRoute, getRoutineDependencies };
|
package/routes/edit.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const db = require("@saltcorn/data/db");
|
|
2
|
+
const { requireAdmin } = require("../lib/auth");
|
|
3
|
+
const { escapeHtml, page } = require("../lib/html");
|
|
4
|
+
const { currentTenantSchema, ensurePostgres, getRoutineByOid } = require("../lib/introspection");
|
|
5
|
+
const { validateCreateRoutineDdl } = require("../lib/sql-builders");
|
|
6
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
7
|
+
const { detailViewHref, listViewHref, 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-form .card-header { gap: .75rem; }
|
|
16
|
+
.db-code-form .db-code-form-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
17
|
+
.db-code-form .form-section-title { font-size: .78rem; letter-spacing: .06em; text-transform: uppercase; color: var(--bs-secondary-color, #6c757d); font-weight: 700; margin-bottom: .75rem; }
|
|
18
|
+
.db-code-form .sticky-help { position: sticky; top: 1rem; }
|
|
19
|
+
</style>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function helpCard(routine) {
|
|
23
|
+
return `<div class="card bg-light border-0"><div class="card-body">
|
|
24
|
+
<div class="form-section-title">Routine identity</div>
|
|
25
|
+
<table class="table table-sm mb-3">
|
|
26
|
+
<tr><th>Name</th><td><code>${escapeHtml(routine.schema)}.${escapeHtml(routine.name)}</code></td></tr>
|
|
27
|
+
<tr><th>Type</th><td>${escapeHtml(routine.kind)}</td></tr>
|
|
28
|
+
<tr><th>Arguments</th><td><code>${escapeHtml(routine.identity_arguments || "")}</code></td></tr>
|
|
29
|
+
<tr><th>Language</th><td>${escapeHtml(routine.language || "")}</td></tr>
|
|
30
|
+
</table>
|
|
31
|
+
<div class="alert alert-warning py-2 small">Editing runs the DDL below. Keep the same routine identity unless you intentionally want PostgreSQL to create a different overload.</div>
|
|
32
|
+
<ul class="small mb-0">
|
|
33
|
+
<li>Changing a function return type may require dropping and recreating it.</li>
|
|
34
|
+
<li>The edited DDL must target the current tenant schema.</li>
|
|
35
|
+
<li>Use CREATE OR REPLACE for safer iterative edits.</li>
|
|
36
|
+
</ul>
|
|
37
|
+
</div></div>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasCopilot() {
|
|
41
|
+
return !!getState()?.functions?.copilot_generate_javascript;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function aiModal(routineType) {
|
|
45
|
+
return `<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="showDbCodeAiModal()"><i class="fas fa-wand-magic-sparkles me-1"></i>Edit with AI</button>
|
|
46
|
+
<div class="modal fade" id="dbCodeAiModal" tabindex="-1"><div class="modal-dialog"><div class="modal-content">
|
|
47
|
+
<div class="modal-header"><h5 class="modal-title">Edit with AI</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
|
48
|
+
<div class="modal-body"><p class="text-muted small">Describe the changes to apply to this ${routineType}.</p><textarea id="dbCodeAiPrompt" class="form-control" rows="4"></textarea><div id="dbCodeAiError" class="alert alert-danger mt-2 mb-0 d-none"></div></div>
|
|
49
|
+
<div class="modal-footer"><button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button><button id="dbCodeAiGenBtn" class="btn btn-primary" onclick="runDbCodeAi()" disabled>Generate</button></div>
|
|
50
|
+
</div></div></div>
|
|
51
|
+
<script>
|
|
52
|
+
function showDbCodeAiModal(){ new bootstrap.Modal(document.getElementById('dbCodeAiModal')).show(); }
|
|
53
|
+
document.addEventListener('DOMContentLoaded', function(){ var ta=document.getElementById('dbCodeAiPrompt'); var btn=document.getElementById('dbCodeAiGenBtn'); if(ta&&btn) ta.addEventListener('input', function(){ btn.disabled=!ta.value.trim(); });});
|
|
54
|
+
function runDbCodeAi(){ var ta=document.getElementById('dbCodeAiPrompt'); var prompt=ta.value.trim(); if(!prompt) return; var btn=document.getElementById('dbCodeAiGenBtn'); var err=document.getElementById('dbCodeAiError'); btn.disabled=true; btn.textContent='Generating...'; err.classList.add('d-none'); var edta=document.querySelector('textarea[name="ddl"]'); var existing=edta?edta.value:''; fetch('/db-code/ai/generate-sql',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest','CSRF-Token':_sc_globalCsrf},body:JSON.stringify({description:prompt,existing_code:existing,routine_type:${JSON.stringify(routineType)}})}).then(r=>r.json()).then(data=>{ btn.textContent='Generate'; if(data.error){ err.textContent=data.error; err.classList.remove('d-none'); btn.disabled=!ta.value.trim(); return; } if(edta){ var m=edta.nextElementSibling; if(m){ var e=$(m).data('monaco-editor'); if(e) e.setValue(data.code); else edta.value=data.code; } else edta.value=data.code; } ta.value=''; btn.disabled=true; bootstrap.Modal.getInstance(document.getElementById('dbCodeAiModal')).hide(); }).catch(e=>{ btn.textContent='Generate'; btn.disabled=!ta.value.trim(); err.textContent=e.message||'Generation failed'; err.classList.remove('d-none'); }); }
|
|
55
|
+
</script>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderEditForm(req, routine, values = {}, error = null, viewBaseUrl = null) {
|
|
59
|
+
const ddl = typeof values.ddl === "undefined" ? routine.definition : values.ddl;
|
|
60
|
+
const icon = routine.kind === "procedure" ? "fas fa-cogs" : "fas fa-cube";
|
|
61
|
+
const backHref = detailViewHref(viewBaseUrl, routine.oid);
|
|
62
|
+
return `${formStyles()}<div class="db-code-form">
|
|
63
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to routine</a></p>
|
|
64
|
+
${error ? `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle me-2"></i>${escapeHtml(error)}</div>` : ""}
|
|
65
|
+
<div class="card mt-0 card-max-full-screen">
|
|
66
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
|
67
|
+
<div class="d-flex align-items-center">
|
|
68
|
+
<span class="db-code-form-icon rounded bg-primary text-white me-2"><i class="${icon}"></i></span>
|
|
69
|
+
<div><h5 class="mb-0">Edit ${escapeHtml(routine.kind)}: ${escapeHtml(routine.name)}</h5><div class="small text-muted">Modify the PostgreSQL DDL for this routine</div></div>
|
|
70
|
+
</div>
|
|
71
|
+
<a class="btn btn-sm btn-outline-secondary" href="${backHref}">Cancel</a>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="card-body">
|
|
74
|
+
<div class="row g-4">
|
|
75
|
+
<div class="col-lg-8">
|
|
76
|
+
<form method="post" action="/db-code/routine/${routine.oid}/edit">
|
|
77
|
+
${csrfInput(req)}
|
|
78
|
+
${viewContextInput(viewBaseUrl)}
|
|
79
|
+
<div class="form-section-title">Routine DDL</div>
|
|
80
|
+
<div class="mb-3">
|
|
81
|
+
<textarea class="form-control to-code font-monospace" mode="text/x-sql" name="ddl" rows="24" required>${escapeHtml(ddl)}</textarea>
|
|
82
|
+
${hasCopilot() ? aiModal(routine.kind) : ""}
|
|
83
|
+
</div>
|
|
84
|
+
<div class="d-flex gap-2"><button class="btn btn-primary" type="submit"><i class="fas fa-save me-1"></i>Save routine</button><a class="btn btn-outline-secondary" href="${backHref}">Cancel</a></div>
|
|
85
|
+
</form>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="col-lg-4"><div class="sticky-help">${helpCard(routine)}</div></div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function editFormRoute(req, res) {
|
|
95
|
+
if (!requireAdmin(req, res)) return;
|
|
96
|
+
try {
|
|
97
|
+
ensurePostgres();
|
|
98
|
+
const routine = await getRoutineByOid(req.params.oid);
|
|
99
|
+
if (!routine) {
|
|
100
|
+
if (typeof res.status === "function") res.status(404);
|
|
101
|
+
return page(req, res, "Routine not found", `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`);
|
|
102
|
+
}
|
|
103
|
+
page(req, res, `Edit ${routine.kind}: ${routine.name}`, renderEditForm(req, routine, {}, null, getViewBaseUrl(req)));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
page(req, res, "Edit routine", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function editPostRoute(req, res) {
|
|
110
|
+
if (!requireAdmin(req, res)) return;
|
|
111
|
+
const values = req.body || {};
|
|
112
|
+
const viewBaseUrl = getViewBaseUrl(req);
|
|
113
|
+
try {
|
|
114
|
+
ensurePostgres();
|
|
115
|
+
const routine = await getRoutineByOid(req.params.oid);
|
|
116
|
+
if (!routine) {
|
|
117
|
+
if (typeof res.status === "function") res.status(404);
|
|
118
|
+
return page(req, res, "Routine not found", `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`);
|
|
119
|
+
}
|
|
120
|
+
const ddl = validateCreateRoutineDdl(values.ddl, currentTenantSchema());
|
|
121
|
+
await db.query(ddl);
|
|
122
|
+
if (typeof req.flash === "function") req.flash("success", `Routine ${escapeHtml(routine.name)} saved`);
|
|
123
|
+
res.redirect(detailViewHref(viewBaseUrl, routine.oid));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const routine = await getRoutineByOid(req.params.oid).catch(() => null);
|
|
126
|
+
if (routine) page(req, res, `Edit ${routine.kind}: ${routine.name}`, renderEditForm(req, routine, values, error.message, viewBaseUrl));
|
|
127
|
+
else page(req, res, "Edit routine", `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { editFormRoute, editPostRoute };
|