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,265 @@
|
|
|
1
|
+
const { escapeHtml } = require("./html");
|
|
2
|
+
const { listRoutines, getRoutineByOid } = require("./introspection");
|
|
3
|
+
const { listViewHref, detailViewHref, routeHref, viewContextInput } = require("./view-context");
|
|
4
|
+
|
|
5
|
+
const KIND_OPTIONS = [
|
|
6
|
+
{ value: "", label: "All", icon: "fas fa-code" },
|
|
7
|
+
{ value: "function", label: "Functions", icon: "fas fa-cube" },
|
|
8
|
+
{ value: "procedure", label: "Stored procedures", icon: "fas fa-cogs" }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function unsupportedHtml(error) {
|
|
12
|
+
return `<div class="alert alert-warning">${escapeHtml(error.message || error)}</div>`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function filterUrl(baseUrl, kind, viewBaseUrl) {
|
|
16
|
+
if (viewBaseUrl) return listViewHref(viewBaseUrl, kind);
|
|
17
|
+
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
|
18
|
+
return `${baseUrl}${suffix}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function kindBadge(kind) {
|
|
22
|
+
const cfg = kind === "procedure"
|
|
23
|
+
? { klass: "bg-warning text-dark", icon: "fas fa-cogs", label: "Stored procedure" }
|
|
24
|
+
: { klass: "bg-primary", icon: "fas fa-cube", label: "Function" };
|
|
25
|
+
return `<span class="badge ${cfg.klass}"><i class="${cfg.icon} me-1"></i>${cfg.label}</span>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function volatilityBadge(volatility) {
|
|
29
|
+
if (!volatility) return "";
|
|
30
|
+
const klass = volatility === "IMMUTABLE" ? "bg-success" : volatility === "STABLE" ? "bg-info text-dark" : "bg-secondary";
|
|
31
|
+
return `<span class="badge ${klass} me-1">${escapeHtml(volatility)}</span>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function renderRoutineList({ baseUrl = "/db-code", useViewState = false, showWriteActions = true, kind = "" } = {}) {
|
|
35
|
+
const viewBaseUrl = useViewState ? baseUrl : null;
|
|
36
|
+
try {
|
|
37
|
+
const allRoutines = await listRoutines();
|
|
38
|
+
const selectedKind = ["function", "procedure"].includes(kind) ? kind : "";
|
|
39
|
+
const routines = selectedKind ? allRoutines.filter((routine) => routine.kind === selectedKind) : allRoutines;
|
|
40
|
+
const functionCount = allRoutines.filter((routine) => routine.kind === "function").length;
|
|
41
|
+
const procedureCount = allRoutines.filter((routine) => routine.kind === "procedure").length;
|
|
42
|
+
const routineHref = (oid) => {
|
|
43
|
+
return detailViewHref(viewBaseUrl, oid) + (selectedKind ? `&kind=${encodeURIComponent(selectedKind)}` : "");
|
|
44
|
+
};
|
|
45
|
+
const rows = routines.map((routine) => {
|
|
46
|
+
const searchable = `${routine.name} ${routine.kind} ${routine.identity_arguments || ""} ${routine.result_type || ""} ${routine.language || ""}`.toLowerCase();
|
|
47
|
+
return `<tr class="db-code-row" data-kind="${escapeHtml(routine.kind)}" data-searchable="${escapeHtml(searchable)}">
|
|
48
|
+
<td>${kindBadge(routine.kind)}</td>
|
|
49
|
+
<td><a class="fw-bold text-decoration-none" href="${routineHref(routine.oid)}">${escapeHtml(routine.name)}</a><div class="small text-muted"><code>${escapeHtml(routine.schema)}.${escapeHtml(routine.name)}</code></div></td>
|
|
50
|
+
<td><code>${escapeHtml(routine.identity_arguments || "")}</code></td>
|
|
51
|
+
<td><code>${escapeHtml(routine.result_type || "")}</code></td>
|
|
52
|
+
<td><span class="badge bg-light text-dark border me-1">${escapeHtml(routine.language)}</span>${volatilityBadge(routine.volatility)}${routine.prosecdef ? `<span class="badge bg-danger">SECURITY DEFINER</span>` : ""}</td>
|
|
53
|
+
<td class="text-nowrap">
|
|
54
|
+
<a class="btn btn-sm btn-outline-primary" href="${routineHref(routine.oid)}"><i class="fas fa-eye me-1"></i>View</a>
|
|
55
|
+
<a class="btn btn-sm btn-outline-secondary" href="${routeHref(`/db-code/routine/${routine.oid}/edit`, viewBaseUrl)}"><i class="fas fa-edit me-1"></i>Edit</a>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>`;
|
|
58
|
+
}).join("");
|
|
59
|
+
|
|
60
|
+
const filterButtons = `<div class="btn-group btn-group-sm" role="group" aria-label="Routine type filters">
|
|
61
|
+
${KIND_OPTIONS.map((opt) => `<a class="btn btn-${selectedKind === opt.value ? "" : "outline-"}primary" href="${filterUrl(baseUrl, opt.value, viewBaseUrl)}"><i class="${opt.icon} me-1"></i>${escapeHtml(opt.label)}</a>`).join("")}
|
|
62
|
+
</div>`;
|
|
63
|
+
|
|
64
|
+
const styles = `<style>
|
|
65
|
+
.db-code-console .db-code-row td { vertical-align: middle; }
|
|
66
|
+
.db-code-console .db-code-toolbar { gap: .5rem; }
|
|
67
|
+
.db-code-console .db-code-stat { min-width: 8rem; }
|
|
68
|
+
.db-code-console .db-code-empty { min-height: 10rem; }
|
|
69
|
+
.db-code-console .table-responsive { border-radius: .375rem; }
|
|
70
|
+
</style>`;
|
|
71
|
+
|
|
72
|
+
const script = `<script>
|
|
73
|
+
(function(){
|
|
74
|
+
const root = document.currentScript.closest('.db-code-console');
|
|
75
|
+
if (!root) return;
|
|
76
|
+
const search = root.querySelector('.db-code-search');
|
|
77
|
+
const rows = Array.from(root.querySelectorAll('.db-code-row'));
|
|
78
|
+
const empty = root.querySelector('.db-code-empty');
|
|
79
|
+
function applySearch(){
|
|
80
|
+
const q = (search && search.value || '').toLowerCase().trim();
|
|
81
|
+
let visible = 0;
|
|
82
|
+
rows.forEach((row) => {
|
|
83
|
+
const ok = !q || (row.getAttribute('data-searchable') || '').includes(q);
|
|
84
|
+
row.classList.toggle('d-none', !ok);
|
|
85
|
+
if (ok) visible += 1;
|
|
86
|
+
});
|
|
87
|
+
if (empty) empty.classList.toggle('d-none', visible !== 0);
|
|
88
|
+
}
|
|
89
|
+
if (search) search.addEventListener('input', applySearch);
|
|
90
|
+
applySearch();
|
|
91
|
+
})();
|
|
92
|
+
</script>`;
|
|
93
|
+
|
|
94
|
+
return `${styles}<div class="db-code-console">
|
|
95
|
+
<div class="card mt-0 card-max-full-screen">
|
|
96
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap db-code-toolbar">
|
|
97
|
+
<div>
|
|
98
|
+
<h5 class="mb-0"><i class="fas fa-database me-2"></i>DB Code Console</h5>
|
|
99
|
+
<div class="small text-muted">PostgreSQL functions and stored procedures in the current tenant schema</div>
|
|
100
|
+
</div>
|
|
101
|
+
${showWriteActions ? `<div class="d-flex flex-wrap gap-1" role="group" aria-label="Routine actions">
|
|
102
|
+
<div class="btn-group btn-group-sm" role="group">
|
|
103
|
+
<a class="btn btn-primary" href="${routeHref("/db-code/new", viewBaseUrl)}"><i class="fas fa-plus me-1"></i>New function</a>
|
|
104
|
+
<a class="btn btn-outline-primary" href="${routeHref("/db-code/new-procedure", viewBaseUrl)}"><i class="fas fa-plus me-1"></i>New stored procedure</a>
|
|
105
|
+
<a class="btn btn-outline-secondary" href="${routeHref("/db-code/new-ddl", viewBaseUrl)}"><i class="fas fa-file-code me-1"></i>New from DDL</a>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="btn-group btn-group-sm" role="group">
|
|
108
|
+
<a class="btn btn-info text-white" href="${routeHref("/db-code/export", viewBaseUrl)}"><i class="fas fa-download me-1"></i>Export</a>
|
|
109
|
+
<a class="btn btn-outline-success" href="${routeHref("/db-code/import", viewBaseUrl)}"><i class="fas fa-upload me-1"></i>Import</a>
|
|
110
|
+
</div>
|
|
111
|
+
</div>` : ""}
|
|
112
|
+
</div>
|
|
113
|
+
<div class="card-body">
|
|
114
|
+
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 db-code-toolbar">
|
|
115
|
+
<div class="d-flex flex-wrap align-items-center db-code-toolbar">
|
|
116
|
+
${filterButtons}
|
|
117
|
+
<div class="input-group input-group-sm" style="max-width: 320px;">
|
|
118
|
+
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
|
119
|
+
<input class="form-control db-code-search" placeholder="Search routines">
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="d-flex flex-wrap db-code-toolbar small">
|
|
123
|
+
<span class="badge bg-secondary db-code-stat">${allRoutines.length} routines</span>
|
|
124
|
+
<span class="badge bg-primary db-code-stat">${functionCount} functions</span>
|
|
125
|
+
<span class="badge bg-warning text-dark db-code-stat">${procedureCount} procedures</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="table-responsive">
|
|
129
|
+
<table class="table table-sm table-hover align-middle mb-0">
|
|
130
|
+
<thead><tr><th>Type</th><th>Name</th><th>Arguments</th><th>Returns</th><th>Metadata</th><th>Actions</th></tr></thead>
|
|
131
|
+
<tbody>${rows}</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
<div class="db-code-empty text-center text-muted py-5 ${rows ? "d-none" : ""}">
|
|
135
|
+
<div class="h5">No routines found</div>
|
|
136
|
+
<div>Try adjusting your search or filter options.</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
${script}
|
|
141
|
+
</div>`;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return unsupportedHtml(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function renderRoutineDetail(oid, { baseUrl = "/db-code", useViewState = false, showWriteActions = true, kind = "" } = {}) {
|
|
148
|
+
const viewBaseUrl = useViewState ? baseUrl : null;
|
|
149
|
+
try {
|
|
150
|
+
const routine = await getRoutineByOid(oid);
|
|
151
|
+
if (!routine) return `<div class="alert alert-warning">Routine not found in the current tenant schema.</div>`;
|
|
152
|
+
const backHref = viewBaseUrl ? listViewHref(viewBaseUrl, kind) : baseUrl;
|
|
153
|
+
const routineIcon = routine.kind === "procedure" ? "fas fa-cogs" : "fas fa-cube";
|
|
154
|
+
const securityBadge = routine.prosecdef
|
|
155
|
+
? `<span class="badge bg-danger"><i class="fas fa-user-shield me-1"></i>SECURITY DEFINER</span>`
|
|
156
|
+
: `<span class="badge bg-light text-dark border"><i class="fas fa-user me-1"></i>SECURITY INVOKER</span>`;
|
|
157
|
+
const styles = `<style>
|
|
158
|
+
.db-code-detail .db-code-detail-icon { width: 2.25rem; height: 2.25rem; display: inline-flex; align-items: center; justify-content: center; }
|
|
159
|
+
.db-code-detail .db-code-toolbar { gap: .5rem; }
|
|
160
|
+
.db-code-detail .db-code-meta th { width: 38%; color: var(--bs-secondary-color, #6c757d); font-weight: 600; }
|
|
161
|
+
.db-code-detail .db-code-definition { max-height: 65vh; overflow: auto; }
|
|
162
|
+
.db-code-detail .db-code-definition textarea { border: none; background: transparent; }
|
|
163
|
+
.db-code-detail .db-code-definition .monaco-editor { border-radius: .375rem; }
|
|
164
|
+
.db-code-detail .sticky-meta { position: sticky; top: 1rem; }
|
|
165
|
+
</style>`;
|
|
166
|
+
const copyScript = `<script>
|
|
167
|
+
(function(){
|
|
168
|
+
const root = document.currentScript.closest('.db-code-detail');
|
|
169
|
+
if (!root) return;
|
|
170
|
+
const wrap = root.querySelector('.db-code-definition');
|
|
171
|
+
// Make Monaco read-only once it initializes
|
|
172
|
+
function lockEditor() {
|
|
173
|
+
var edta = wrap ? wrap.querySelector('textarea') : null;
|
|
174
|
+
if (!edta) return;
|
|
175
|
+
var m = edta.nextElementSibling;
|
|
176
|
+
if (!m) return;
|
|
177
|
+
var ed = $(m).data('monaco-editor');
|
|
178
|
+
if (ed && ed.updateOptions) ed.updateOptions({ readOnly: true, domReadOnly: true });
|
|
179
|
+
}
|
|
180
|
+
// Try immediately and after a delay (Monaco init is async)
|
|
181
|
+
lockEditor();
|
|
182
|
+
setTimeout(lockEditor, 500);
|
|
183
|
+
setTimeout(lockEditor, 1500);
|
|
184
|
+
// Copy button
|
|
185
|
+
const btn = root.querySelector('.db-code-copy-definition');
|
|
186
|
+
if (!btn || !wrap || !navigator.clipboard) return;
|
|
187
|
+
btn.addEventListener('click', async function(){
|
|
188
|
+
var edta2 = wrap.querySelector('textarea');
|
|
189
|
+
var m2 = edta2 ? edta2.nextElementSibling : null;
|
|
190
|
+
var ed2 = m2 ? $(m2).data('monaco-editor') : null;
|
|
191
|
+
var text = '';
|
|
192
|
+
if (ed2 && ed2.getModel) text = ed2.getModel().getValue();
|
|
193
|
+
else if (edta2) text = edta2.value;
|
|
194
|
+
else text = wrap.textContent || '';
|
|
195
|
+
await navigator.clipboard.writeText(text);
|
|
196
|
+
const old = btn.innerHTML;
|
|
197
|
+
btn.innerHTML = '<i class="fas fa-check me-1"></i>Copied';
|
|
198
|
+
setTimeout(function(){ btn.innerHTML = old; }, 1200);
|
|
199
|
+
});
|
|
200
|
+
})();
|
|
201
|
+
</script>`;
|
|
202
|
+
return `${styles}<div class="db-code-detail">
|
|
203
|
+
<p><a class="text-decoration-none" href="${backHref}"><i class="fas fa-arrow-left me-1"></i>Back to DB Code</a></p>
|
|
204
|
+
<div class="card mt-0 card-max-full-screen">
|
|
205
|
+
<div class="card-header d-flex justify-content-between align-items-center flex-wrap db-code-toolbar">
|
|
206
|
+
<div class="d-flex align-items-center">
|
|
207
|
+
<span class="db-code-detail-icon rounded bg-primary text-white me-2"><i class="${routineIcon}"></i></span>
|
|
208
|
+
<div>
|
|
209
|
+
<h5 class="mb-0">${escapeHtml(routine.name)}</h5>
|
|
210
|
+
<div class="small text-muted"><code>${escapeHtml(routine.schema)}.${escapeHtml(routine.name)}(${escapeHtml(routine.identity_arguments || "")})</code></div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
${showWriteActions ? `<div class="btn-group btn-group-sm" role="group" aria-label="Routine actions">
|
|
214
|
+
<a class="btn btn-primary" href="${routeHref(`/db-code/routine/${routine.oid}/edit`, viewBaseUrl)}"><i class="fas fa-edit me-1"></i>Edit</a>
|
|
215
|
+
<a class="btn btn-outline-secondary" href="${routeHref(`/db-code/routine/${routine.oid}/execute`, viewBaseUrl)}"><i class="fas fa-play me-1"></i>Test / execute</a>
|
|
216
|
+
<a class="btn btn-outline-danger" href="${routeHref(`/db-code/routine/${routine.oid}/delete`, viewBaseUrl)}"><i class="fas fa-trash me-1"></i>Delete</a>
|
|
217
|
+
</div>` : ""}
|
|
218
|
+
</div>
|
|
219
|
+
<div class="card-body">
|
|
220
|
+
<div class="row g-4">
|
|
221
|
+
<div class="col-lg-8">
|
|
222
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
223
|
+
<div class="form-section-title text-muted fw-bold text-uppercase small mb-0">Definition</div>
|
|
224
|
+
<button type="button" class="btn btn-sm btn-outline-secondary db-code-copy-definition"><i class="fas fa-copy me-1"></i>Copy SQL</button>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="db-code-definition border rounded bg-light mb-0">
|
|
227
|
+
<textarea class="to-code font-monospace" mode="text/x-sql" readonly style="width:100%;min-height:12rem;border:none;background:transparent;resize:vertical;">${escapeHtml(routine.definition)}</textarea>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="col-lg-4">
|
|
231
|
+
<div class="sticky-meta">
|
|
232
|
+
<div class="card bg-light border-0 mb-3"><div class="card-body">
|
|
233
|
+
<div class="form-section-title text-muted fw-bold text-uppercase small">Metadata</div>
|
|
234
|
+
<div class="mb-3 d-flex flex-wrap gap-1">
|
|
235
|
+
${kindBadge(routine.kind)}
|
|
236
|
+
<span class="badge bg-light text-dark border">${escapeHtml(routine.language)}</span>
|
|
237
|
+
${volatilityBadge(routine.volatility)}
|
|
238
|
+
${securityBadge}
|
|
239
|
+
</div>
|
|
240
|
+
<table class="table table-sm db-code-meta mb-0">
|
|
241
|
+
<tr><th>OID</th><td><code>${escapeHtml(routine.oid)}</code></td></tr>
|
|
242
|
+
<tr><th>Schema</th><td><code>${escapeHtml(routine.schema)}</code></td></tr>
|
|
243
|
+
<tr><th>Type</th><td>${escapeHtml(routine.kind)}</td></tr>
|
|
244
|
+
<tr><th>Arguments</th><td><code>${escapeHtml(routine.arguments || "")}</code></td></tr>
|
|
245
|
+
<tr><th>Identity args</th><td><code>${escapeHtml(routine.identity_arguments || "")}</code></td></tr>
|
|
246
|
+
<tr><th>Returns</th><td><code>${escapeHtml(routine.result_type || "")}</code></td></tr>
|
|
247
|
+
</table>
|
|
248
|
+
</div></div>
|
|
249
|
+
<div class="card border-0 bg-light"><div class="card-body">
|
|
250
|
+
<div class="form-section-title text-muted fw-bold text-uppercase small">Description</div>
|
|
251
|
+
<p class="mb-0 small">${escapeHtml(routine.description || "No description set for this routine.")}</p>
|
|
252
|
+
</div></div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
${copyScript}
|
|
259
|
+
</div>`;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return unsupportedHtml(error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { renderRoutineList, renderRoutineDetail };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { parseArgumentsJson } = require("./action-args");
|
|
2
|
+
|
|
3
|
+
function routineArity(routine) {
|
|
4
|
+
const total = Number.isInteger(routine?.pronargs) ? routine.pronargs : Number(routine?.pronargs || 0);
|
|
5
|
+
const defaults = Number.isInteger(routine?.pronargdefaults)
|
|
6
|
+
? routine.pronargdefaults
|
|
7
|
+
: Number(routine?.pronargdefaults || 0);
|
|
8
|
+
const required = Math.max(0, total - defaults);
|
|
9
|
+
return { required, total };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function coerceRoutineInputArgs(routine) {
|
|
13
|
+
if (Array.isArray(routine?.input_args)) return routine.input_args;
|
|
14
|
+
if (typeof routine?.input_args === "string") {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(routine.input_args);
|
|
17
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveRoutineArguments(routine, argumentsJson, context) {
|
|
26
|
+
const parsed = parseArgumentsJson(argumentsJson, context, { allowObject: true });
|
|
27
|
+
const argMeta = coerceRoutineInputArgs(routine);
|
|
28
|
+
const { required, total } = routineArity(routine);
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(parsed)) {
|
|
31
|
+
if (parsed.length < required || parsed.length > total) {
|
|
32
|
+
throw new Error(`DB_Routine expected ${required}-${total} positional arguments, got ${parsed.length}.`);
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!parsed || typeof parsed !== "object") {
|
|
38
|
+
throw new Error("DB_Routine arguments must be a JSON array or object.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const unnamedRequired = argMeta.slice(0, required).filter((arg) => !arg.name).length;
|
|
42
|
+
if (unnamedRequired > 0) {
|
|
43
|
+
throw new Error("This routine has unnamed required parameters. Use positional JSON array arguments.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const values = [];
|
|
47
|
+
for (let i = 0; i < total; i += 1) {
|
|
48
|
+
const name = argMeta[i]?.name;
|
|
49
|
+
const hasValue = name && Object.prototype.hasOwnProperty.call(parsed, name);
|
|
50
|
+
if (hasValue) {
|
|
51
|
+
values.push(parsed[name]);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (i < required) throw new Error(`Missing required argument: ${name || `#${i + 1}`}.`);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
return values;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function encodeTemplateValue(str) {
|
|
61
|
+
return Buffer.from(String(str || ""), "utf8").toString("base64");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeTemplateValue(str) {
|
|
65
|
+
if (!str) return "";
|
|
66
|
+
return Buffer.from(String(str), "base64").toString("utf8");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildArgumentTemplates(routine) {
|
|
70
|
+
const args = coerceRoutineInputArgs(routine);
|
|
71
|
+
const { required, total } = routineArity(routine);
|
|
72
|
+
const reqArgs = args.slice(0, required);
|
|
73
|
+
const allArgs = args.slice(0, total);
|
|
74
|
+
|
|
75
|
+
const positionalRequired = JSON.stringify(
|
|
76
|
+
reqArgs.map((arg, i) => `{{row.${arg?.name || `arg${i + 1}`}}}`),
|
|
77
|
+
null,
|
|
78
|
+
2
|
|
79
|
+
);
|
|
80
|
+
const positionalAll = JSON.stringify(
|
|
81
|
+
allArgs.map((arg, i) => `{{row.${arg?.name || `arg${i + 1}`}}}`),
|
|
82
|
+
null,
|
|
83
|
+
2
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const namedRequiredObj = {};
|
|
87
|
+
reqArgs.forEach((arg, i) => {
|
|
88
|
+
if (arg?.name) namedRequiredObj[arg.name] = `{{row.${arg.name}}}`;
|
|
89
|
+
else namedRequiredObj[`arg${i + 1}`] = `{{row.arg${i + 1}}}`;
|
|
90
|
+
});
|
|
91
|
+
const namedRequired = JSON.stringify(namedRequiredObj, null, 2);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
positionalRequired,
|
|
95
|
+
positionalAll,
|
|
96
|
+
namedRequired,
|
|
97
|
+
encoded: {
|
|
98
|
+
positionalRequired: encodeTemplateValue(positionalRequired),
|
|
99
|
+
positionalAll: encodeTemplateValue(positionalAll),
|
|
100
|
+
namedRequired: encodeTemplateValue(namedRequired),
|
|
101
|
+
},
|
|
102
|
+
required,
|
|
103
|
+
total,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
routineArity,
|
|
109
|
+
resolveRoutineArguments,
|
|
110
|
+
coerceRoutineInputArgs,
|
|
111
|
+
buildArgumentTemplates,
|
|
112
|
+
encodeTemplateValue,
|
|
113
|
+
decodeTemplateValue,
|
|
114
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const { assertIdentifier, assertAllowed, ALLOWED_LANGUAGES, ALLOWED_VOLATILITY, ALLOWED_SECURITY } = require("./validation");
|
|
2
|
+
|
|
3
|
+
function quoteIdent(identifier) {
|
|
4
|
+
assertIdentifier(identifier);
|
|
5
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeRoutineBody(body, language) {
|
|
9
|
+
const text = String(body || "").trim();
|
|
10
|
+
if (language !== "plpgsql" || /^\s*(BEGIN|DECLARE)\b/i.test(text)) return text;
|
|
11
|
+
const bodyWithSemicolon = /;\s*$/.test(text) ? text : `${text};`;
|
|
12
|
+
return `BEGIN\n ${bodyWithSemicolon}\nEND`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dollarQuote(body) {
|
|
16
|
+
const tag = "$scdbcode$";
|
|
17
|
+
if (String(body || "").includes(tag)) throw new Error("Routine body cannot contain the $scdbcode$ delimiter.");
|
|
18
|
+
return `${tag}\n${body || ""}\n${tag}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildCreateFunctionSql({ schema, name, argumentsSql = "", returnType, language = "plpgsql", volatility = "VOLATILE", security = "INVOKER", body = "", description }) {
|
|
22
|
+
assertIdentifier(schema, "schema");
|
|
23
|
+
assertIdentifier(name, "function name");
|
|
24
|
+
assertAllowed(language, ALLOWED_LANGUAGES, "language");
|
|
25
|
+
assertAllowed(volatility, ALLOWED_VOLATILITY, "volatility");
|
|
26
|
+
assertAllowed(security, ALLOWED_SECURITY, "security");
|
|
27
|
+
if (!String(returnType || "").trim()) throw new Error("Return type is required.");
|
|
28
|
+
|
|
29
|
+
const normalizedBody = normalizeRoutineBody(body, language);
|
|
30
|
+
const createSql = `CREATE OR REPLACE FUNCTION ${quoteIdent(schema)}.${quoteIdent(name)}(${argumentsSql || ""})\nRETURNS ${returnType}\nLANGUAGE ${language}\n${volatility}\nSECURITY ${security}\nAS ${dollarQuote(normalizedBody)};`;
|
|
31
|
+
if (!description) return createSql;
|
|
32
|
+
return `${createSql}\n\nCOMMENT ON FUNCTION ${quoteIdent(schema)}.${quoteIdent(name)}(${argumentsSql || ""}) IS ${literal(description)};`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildCreateProcedureSql({ schema, name, argumentsSql = "", language = "plpgsql", security = "INVOKER", body = "", description }) {
|
|
36
|
+
assertIdentifier(schema, "schema");
|
|
37
|
+
assertIdentifier(name, "procedure name");
|
|
38
|
+
assertAllowed(language, ALLOWED_LANGUAGES, "language");
|
|
39
|
+
assertAllowed(security, ALLOWED_SECURITY, "security");
|
|
40
|
+
|
|
41
|
+
const normalizedBody = normalizeRoutineBody(body, language);
|
|
42
|
+
const createSql = `CREATE OR REPLACE PROCEDURE ${quoteIdent(schema)}.${quoteIdent(name)}(${argumentsSql || ""})\nLANGUAGE ${language}\nSECURITY ${security}\nAS ${dollarQuote(normalizedBody)};`;
|
|
43
|
+
if (!description) return createSql;
|
|
44
|
+
return `${createSql}\n\nCOMMENT ON PROCEDURE ${quoteIdent(schema)}.${quoteIdent(name)}(${argumentsSql || ""}) IS ${literal(description)};`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildDropRoutineSql({ schema, name, identityArguments = "", kind = "function" }) {
|
|
48
|
+
assertIdentifier(schema, "schema");
|
|
49
|
+
assertIdentifier(name, "routine name");
|
|
50
|
+
const command = kind === "procedure" ? "DROP PROCEDURE" : "DROP FUNCTION";
|
|
51
|
+
return `${command} ${quoteIdent(schema)}.${quoteIdent(name)}(${identityArguments || ""});`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateCreateRoutineDdl(sql, schema) {
|
|
55
|
+
const ddl = String(sql || "").trim();
|
|
56
|
+
if (!ddl) throw new Error("DDL is required.");
|
|
57
|
+
if (!/^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\s+/i.test(ddl)) {
|
|
58
|
+
throw new Error("DDL must start with CREATE FUNCTION, CREATE OR REPLACE FUNCTION, CREATE PROCEDURE, or CREATE OR REPLACE PROCEDURE.");
|
|
59
|
+
}
|
|
60
|
+
const schemaPattern = new RegExp(`^CREATE\\s+(OR\\s+REPLACE\\s+)?(FUNCTION|PROCEDURE)\\s+("${schema.replace(/"/g, '""')}"|${schema})\\.`, "i");
|
|
61
|
+
if (!schemaPattern.test(ddl)) throw new Error(`DDL must create the routine explicitly in the current tenant schema: ${schema}.`);
|
|
62
|
+
return ddl;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function literal(value) {
|
|
66
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { quoteIdent, normalizeRoutineBody, normalizeFunctionBody: normalizeRoutineBody, buildCreateFunctionSql, buildCreateProcedureSql, buildDropRoutineSql, validateCreateRoutineDdl };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
2
|
+
const ALLOWED_LANGUAGES = new Set(["sql", "plpgsql"]);
|
|
3
|
+
const ALLOWED_VOLATILITY = new Set(["VOLATILE", "STABLE", "IMMUTABLE"]);
|
|
4
|
+
const ALLOWED_SECURITY = new Set(["INVOKER", "DEFINER"]);
|
|
5
|
+
|
|
6
|
+
function assertIdentifier(name, label = "identifier") {
|
|
7
|
+
if (!IDENTIFIER_RE.test(String(name || ""))) throw new Error(`Invalid ${label}. Use letters, numbers, and underscores; do not start with a number.`);
|
|
8
|
+
return name;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertAllowed(value, allowed, label) {
|
|
12
|
+
if (!allowed.has(String(value))) throw new Error(`Invalid ${label}: ${value}`);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assertSafeSqlFragment(value, label, { required = false } = {}) {
|
|
17
|
+
const text = String(value || "").trim();
|
|
18
|
+
if (required && !text) throw new Error(`${label} is required.`);
|
|
19
|
+
if (!text) return text;
|
|
20
|
+
if (/[;]|--|\/\*/.test(text)) throw new Error(`${label} contains disallowed SQL tokens.`);
|
|
21
|
+
if (!/^[A-Za-z0-9_.,\s\[\]()"']+$/.test(text)) throw new Error(`${label} contains unsupported characters.`);
|
|
22
|
+
return text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { IDENTIFIER_RE, ALLOWED_LANGUAGES, ALLOWED_VOLATILITY, ALLOWED_SECURITY, assertIdentifier, assertAllowed, assertSafeSqlFragment };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers to build navigation URLs that respect the view context.
|
|
3
|
+
*
|
|
4
|
+
* When the user navigates from a DBCodeConsole view (e.g. /view/MyConsole),
|
|
5
|
+
* `viewBaseUrl` is set to that URL and all links stay within the view.
|
|
6
|
+
* When the user navigates from the direct /db-code routes, `viewBaseUrl`
|
|
7
|
+
* is null and all links use the /db-code paths as before.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the list-page URL for the current context.
|
|
12
|
+
* @param {string|null} viewBaseUrl
|
|
13
|
+
* @param {string} kind Optional kind filter ("function" or "procedure")
|
|
14
|
+
*/
|
|
15
|
+
function listViewHref(viewBaseUrl, kind) {
|
|
16
|
+
if (!viewBaseUrl) {
|
|
17
|
+
return kind ? `/db-code?kind=${encodeURIComponent(kind)}` : "/db-code";
|
|
18
|
+
}
|
|
19
|
+
const params = new URLSearchParams();
|
|
20
|
+
if (kind) params.set("kind", kind);
|
|
21
|
+
const qs = params.toString();
|
|
22
|
+
return qs ? `${viewBaseUrl}?${qs}` : viewBaseUrl;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the detail-page URL for the current context.
|
|
27
|
+
*/
|
|
28
|
+
function detailViewHref(viewBaseUrl, oid) {
|
|
29
|
+
if (!viewBaseUrl) return `/db-code/routine/${oid}`;
|
|
30
|
+
return `${viewBaseUrl}?routine_oid=${oid}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a link to a direct plugin route, appending view_base_url when
|
|
35
|
+
* the user is in a view context so the route can redirect back.
|
|
36
|
+
*/
|
|
37
|
+
function routeHref(routePath, viewBaseUrl) {
|
|
38
|
+
if (!viewBaseUrl) return routePath;
|
|
39
|
+
const sep = routePath.includes("?") ? "&" : "?";
|
|
40
|
+
return `${routePath}${sep}view_base_url=${encodeURIComponent(viewBaseUrl)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hidden form input that carries view_base_url through POST requests.
|
|
45
|
+
*/
|
|
46
|
+
function viewContextInput(viewBaseUrl) {
|
|
47
|
+
if (!viewBaseUrl) return "";
|
|
48
|
+
return `<input type="hidden" name="view_base_url" value="${viewBaseUrl.replace(/"/g, """)}">`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read view_base_url from a request (query on GET, body on POST).
|
|
53
|
+
*/
|
|
54
|
+
function getViewBaseUrl(req) {
|
|
55
|
+
return req.query?.view_base_url || req.body?.view_base_url || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { listViewHref, detailViewHref, routeHref, viewContextInput, getViewBaseUrl };
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "saltcorn-db-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Saltcorn plugin to inspect and manage PostgreSQL database routines (functions and stored procedures) from the Saltcorn UI.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test",
|
|
8
|
+
"lint": "node --check index.js && node --check lib/*.js && node --check routes/*.js && node --check viewtemplates/*.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"saltcorn",
|
|
12
|
+
"saltcorn-plugin",
|
|
13
|
+
"postgresql",
|
|
14
|
+
"database",
|
|
15
|
+
"functions",
|
|
16
|
+
"stored-procedures",
|
|
17
|
+
"sql",
|
|
18
|
+
"ddl"
|
|
19
|
+
],
|
|
20
|
+
"author": "DevGiu",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/DevGiuDev/saltcorn-db-code.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/DevGiuDev/saltcorn-db-code/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/DevGiuDev/saltcorn-db-code#readme",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@saltcorn/data": "*"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/routes/ai.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
2
|
+
const { requireAdmin } = require("../lib/auth");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Strip markdown code fences that LLMs sometimes add despite instructions.
|
|
6
|
+
* Handles ```sql ... ```, ``` ... ```, and leading/trailing whitespace.
|
|
7
|
+
*/
|
|
8
|
+
function stripMarkdownFences(text) {
|
|
9
|
+
let out = String(text || "").trim();
|
|
10
|
+
// Remove opening fence with optional language tag
|
|
11
|
+
out = out.replace(/^```(?:sql|postgresql|plpgsql)?\s*\n?/i, "");
|
|
12
|
+
// Remove closing fence
|
|
13
|
+
out = out.replace(/\n?```\s*$/,
|
|
14
|
+
"");
|
|
15
|
+
return out.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function generateRoutineSqlRoute(req, res) {
|
|
19
|
+
if (!requireAdmin(req, res)) return;
|
|
20
|
+
try {
|
|
21
|
+
const description = String(req.body?.description || "").trim();
|
|
22
|
+
const existingCode = String(req.body?.existing_code || "");
|
|
23
|
+
const routineType = String(req.body?.routine_type || "function");
|
|
24
|
+
if (!description) return res.json({ error: "Prompt is required." });
|
|
25
|
+
|
|
26
|
+
const copilot = getState()?.functions?.copilot_generate_javascript;
|
|
27
|
+
if (!copilot?.run) return res.json({ error: "Copilot generation is not available." });
|
|
28
|
+
|
|
29
|
+
const sqlPrompt = [
|
|
30
|
+
`You are a PostgreSQL code generator.`,
|
|
31
|
+
`Task: ${description}`,
|
|
32
|
+
existingCode ? `Existing code to modify:\n${existingCode}` : "",
|
|
33
|
+
`Constraints:`,
|
|
34
|
+
`- Output MUST be a single valid PostgreSQL ${routineType} DDL statement.`,
|
|
35
|
+
`- It MUST start with CREATE OR REPLACE ${routineType === "stored procedure" ? "PROCEDURE" : "FUNCTION"}.`,
|
|
36
|
+
`- Do NOT include markdown fences, comments, or any text outside the SQL statement.`,
|
|
37
|
+
`- Do NOT wrap output in backticks or code blocks.`,
|
|
38
|
+
`- Use dollar-quoting ($body$...$body$) for the routine body.`,
|
|
39
|
+
`- Output raw SQL only. Nothing else.`,
|
|
40
|
+
].filter(Boolean).join("\n\n");
|
|
41
|
+
const code = stripMarkdownFences(await copilot.run(sqlPrompt, existingCode, null));
|
|
42
|
+
return res.json({ code });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return res.json({ error: error.message || "AI generation failed." });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { generateRoutineSqlRoute };
|