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/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Saltcorn DB Code
|
|
2
|
+
|
|
3
|
+
Saltcorn DB Code is a Saltcorn plugin for inspecting and managing PostgreSQL database code objects from the Saltcorn UI.
|
|
4
|
+
|
|
5
|
+
- List functions and procedures in the current tenant schema.
|
|
6
|
+
- View routine metadata.
|
|
7
|
+
- View the full SQL definition returned by PostgreSQL.
|
|
8
|
+
- Filter a shared routine list by function or stored procedure.
|
|
9
|
+
- Create functions and stored procedures with structured forms and Saltcorn's code editor for the body.
|
|
10
|
+
- Create routines from full DDL when you already have the exact PostgreSQL SQL.
|
|
11
|
+
- Edit routines from their current PostgreSQL DDL.
|
|
12
|
+
- Delete routines with confirmation and without default `CASCADE`.
|
|
13
|
+
- Call routines from Saltcorn events/workflows through the `DB_Routine` action.
|
|
14
|
+
- Restrict access to Saltcorn administrators.
|
|
15
|
+
- Show a clear unsupported-database message on SQLite.
|
|
16
|
+
|
|
17
|
+
TODO
|
|
18
|
+
|
|
19
|
+
- Storeds as Action from triggers, callable from scheduled or API calls
|
|
20
|
+
|
|
21
|
+
## Some Screenshots
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## Local installation during development
|
|
31
|
+
|
|
32
|
+
From the Saltcorn checkout:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd /home/devgiu/dev/saltcorn
|
|
36
|
+
./packages/saltcorn-cli/bin/saltcorn dev:localize-plugin db-code /home/devgiu/dev/saltcorn-db-code
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
After loading the plugin, create a Saltcorn view with the `DBCodeConsole` view pattern. This is the recommended entrypoint because it behaves like other Saltcorn plugin consoles and can be added to menus normally.
|
|
40
|
+
|
|
41
|
+
Alternative direct route for development:
|
|
42
|
+
|
|
43
|
+
```txt
|
|
44
|
+
/db-code
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Administrators can add either the created view URL, usually `/view/<view-name>`, or the direct `/db-code` route to a normal Saltcorn menu link.
|
|
48
|
+
|
|
49
|
+
## Development commands
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm test
|
|
53
|
+
npm run lint
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Scope
|
|
57
|
+
|
|
58
|
+
The first milestone is read-only PostgreSQL routines. Creation, editing, deletion, execution, configuration, and additional database object types are tracked in `PLAN.md` and `docs/TODO.md`.
|
package/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const listRoute = require("./routes/list");
|
|
2
|
+
const showRoute = require("./routes/show");
|
|
3
|
+
const placeholder = require("./routes/placeholder");
|
|
4
|
+
const { executeFormRoute, executePostRoute } = require("./routes/execute");
|
|
5
|
+
const { createFormRoute, createPostRoute, createProcedureFormRoute, createProcedurePostRoute, createDdlFormRoute, createDdlPostRoute } = require("./routes/create");
|
|
6
|
+
const { editFormRoute, editPostRoute } = require("./routes/edit");
|
|
7
|
+
const { deleteFormRoute, deletePostRoute } = require("./routes/delete");
|
|
8
|
+
const { generateRoutineSqlRoute } = require("./routes/ai");
|
|
9
|
+
const { exportFormRoute, exportPostRoute } = require("./routes/export");
|
|
10
|
+
const { importFormRoute, importPostRoute, importExecuteRoute } = require("./routes/import");
|
|
11
|
+
const { DB_Routine } = require("./lib/db-routine-action");
|
|
12
|
+
const dbCodeConsole = require("./viewtemplates/db-code-console");
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
sc_plugin_api_version: 1,
|
|
16
|
+
plugin_name: "db-code",
|
|
17
|
+
viewtemplates: [dbCodeConsole],
|
|
18
|
+
actions: { DB_Routine },
|
|
19
|
+
routes: [
|
|
20
|
+
{ url: "/db-code", method: "get", callback: listRoute },
|
|
21
|
+
{ url: "/db-code/routine/:oid", method: "get", callback: showRoute },
|
|
22
|
+
{ url: "/db-code/new", method: "get", callback: createFormRoute },
|
|
23
|
+
{ url: "/db-code/new", method: "post", callback: createPostRoute },
|
|
24
|
+
{ url: "/db-code/new-procedure", method: "get", callback: createProcedureFormRoute },
|
|
25
|
+
{ url: "/db-code/new-procedure", method: "post", callback: createProcedurePostRoute },
|
|
26
|
+
{ url: "/db-code/new-ddl", method: "get", callback: createDdlFormRoute },
|
|
27
|
+
{ url: "/db-code/new-ddl", method: "post", callback: createDdlPostRoute },
|
|
28
|
+
{ url: "/db-code/routine/:oid/edit", method: "get", callback: editFormRoute },
|
|
29
|
+
{ url: "/db-code/routine/:oid/edit", method: "post", callback: editPostRoute },
|
|
30
|
+
{ url: "/db-code/routine/:oid/delete", method: "get", callback: deleteFormRoute },
|
|
31
|
+
{ url: "/db-code/routine/:oid/delete", method: "post", callback: deletePostRoute },
|
|
32
|
+
{ url: "/db-code/routine/:oid/execute", method: "get", callback: executeFormRoute },
|
|
33
|
+
{ url: "/db-code/routine/:oid/execute", method: "post", callback: executePostRoute },
|
|
34
|
+
{ url: "/db-code/ai/generate-sql", method: "post", callback: generateRoutineSqlRoute },
|
|
35
|
+
{ url: "/db-code/export", method: "get", callback: exportFormRoute },
|
|
36
|
+
{ url: "/db-code/export", method: "post", callback: exportPostRoute },
|
|
37
|
+
{ url: "/db-code/import", method: "get", callback: importFormRoute },
|
|
38
|
+
{ url: "/db-code/import", method: "post", callback: importPostRoute },
|
|
39
|
+
{ url: "/db-code/import/execute", method: "post", callback: importExecuteRoute }
|
|
40
|
+
]
|
|
41
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function getPath(source, path) {
|
|
2
|
+
if (!path) return undefined;
|
|
3
|
+
return String(path).split(".").reduce((acc, part) => (acc == null ? undefined : acc[part]), source);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function resolveTemplateValue(value, context) {
|
|
7
|
+
if (Array.isArray(value)) return value.map((item) => resolveTemplateValue(item, context));
|
|
8
|
+
if (value && typeof value === "object") {
|
|
9
|
+
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, resolveTemplateValue(val, context)]));
|
|
10
|
+
}
|
|
11
|
+
if (typeof value !== "string") return value;
|
|
12
|
+
const exact = value.match(/^\s*\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}\s*$/);
|
|
13
|
+
if (exact) {
|
|
14
|
+
const key = exact[1];
|
|
15
|
+
if (key.startsWith("row.")) return getPath(context.row, key.slice(4));
|
|
16
|
+
if (key.startsWith("user.")) return getPath(context.user, key.slice(5));
|
|
17
|
+
if (key.startsWith("configuration.")) return getPath(context.configuration, key.slice(14));
|
|
18
|
+
return getPath(context.row, key) ?? getPath(context.configuration, key) ?? getPath(context.user, key);
|
|
19
|
+
}
|
|
20
|
+
return value.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (_, key) => {
|
|
21
|
+
const resolved = key.startsWith("row.")
|
|
22
|
+
? getPath(context.row, key.slice(4))
|
|
23
|
+
: key.startsWith("user.")
|
|
24
|
+
? getPath(context.user, key.slice(5))
|
|
25
|
+
: key.startsWith("configuration.")
|
|
26
|
+
? getPath(context.configuration, key.slice(14))
|
|
27
|
+
: getPath(context.row, key) ?? getPath(context.configuration, key) ?? getPath(context.user, key);
|
|
28
|
+
return resolved == null ? "" : String(resolved);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseArgumentsJson(argumentsJson, context, opts = {}) {
|
|
33
|
+
const text = String(argumentsJson || "").trim();
|
|
34
|
+
if (!text) return [];
|
|
35
|
+
const parsed = JSON.parse(text);
|
|
36
|
+
if (!Array.isArray(parsed) && !(opts.allowObject && parsed && typeof parsed === "object")) {
|
|
37
|
+
throw new Error("DB_Routine arguments JSON must be an array.");
|
|
38
|
+
}
|
|
39
|
+
return resolveTemplateValue(parsed, context);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { getPath, resolveTemplateValue, parseArgumentsJson };
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function isAdmin(req) {
|
|
2
|
+
return Boolean(req && req.user && req.user.role_id === 1);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function requireAdmin(req, res) {
|
|
6
|
+
if (isAdmin(req)) return true;
|
|
7
|
+
if (!req.user && res && typeof res.redirect === "function") {
|
|
8
|
+
res.redirect(`/auth/login?dest=${encodeURIComponent(req.originalUrl || req.url || "/db-code")}`);
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if (res && typeof res.status === "function") res.status(403);
|
|
12
|
+
if (res && typeof res.send === "function") {
|
|
13
|
+
res.send("Forbidden: DB Code is available to administrators only.");
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { isAdmin, requireAdmin };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const db = require("@saltcorn/data/db");
|
|
2
|
+
const { listRoutines, getRoutineByOid } = require("./introspection");
|
|
3
|
+
const { quoteIdent } = require("./sql-builders");
|
|
4
|
+
const { routineArity } = require("./routine-args");
|
|
5
|
+
|
|
6
|
+
function placeholders(count) {
|
|
7
|
+
return Array.from({ length: count }, (_, index) => `$${index + 1}`).join(", ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function callRoutine({ routineOid, argumentsJson, row, user, configuration }) {
|
|
11
|
+
const routine = await getRoutineByOid(routineOid);
|
|
12
|
+
if (!routine) throw new Error("Configured DB routine was not found in the current tenant schema.");
|
|
13
|
+
if (!["function", "procedure"].includes(routine.kind)) throw new Error("DB_Routine only supports functions and procedures.");
|
|
14
|
+
|
|
15
|
+
const values = parseAndValidateArgs(argumentsJson, routine, { row, user, configuration });
|
|
16
|
+
const routineRef = `${quoteIdent(routine.schema)}.${quoteIdent(routine.name)}`;
|
|
17
|
+
if (routine.kind === "procedure") {
|
|
18
|
+
const result = await db.query(`CALL ${routineRef}(${placeholders(values.length)})`, values);
|
|
19
|
+
return { success: true, kind: routine.kind, routine: routine.name, rowCount: result.rowCount || 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = await db.query(`SELECT * FROM ${routineRef}(${placeholders(values.length)})`, values);
|
|
23
|
+
return { success: true, kind: routine.kind, routine: routine.name, rows: result.rows || [], rowCount: result.rowCount || 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolveTemplateValue(value, context) {
|
|
27
|
+
if (Array.isArray(value)) return value.map((item) => resolveTemplateValue(item, context));
|
|
28
|
+
if (value && typeof value === "object") {
|
|
29
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveTemplateValue(v, context)]));
|
|
30
|
+
}
|
|
31
|
+
if (typeof value !== "string") return value;
|
|
32
|
+
return value.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (match) => match);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseAndValidateArgs(argumentsJson, routine, context) {
|
|
36
|
+
const text = String(argumentsJson || "").trim();
|
|
37
|
+
const { required, total } = routineArity(routine);
|
|
38
|
+
|
|
39
|
+
if (!text) {
|
|
40
|
+
if (required > 0) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Routine "${routine.name}" requires ${required} argument${required > 1 ? "s" : ""} (${routine.identity_arguments}) but none were provided.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
throw new Error(`Arguments JSON is not valid JSON: ${e.message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(parsed)) {
|
|
56
|
+
const count = parsed.length;
|
|
57
|
+
if (count < required) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Routine "${routine.name}" requires at least ${required} argument${required > 1 ? "s" : ""} (${routine.identity_arguments}) but ${count} were provided.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (count > total) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Routine "${routine.name}" accepts at most ${total} argument${total > 1 ? "s" : ""} (${routine.identity_arguments}) but ${count} were provided.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return resolveTemplateValue(parsed, context);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (parsed && typeof parsed === "object") {
|
|
71
|
+
if (required === 0 && total === 0) {
|
|
72
|
+
throw new Error(`Routine "${routine.name}" takes no arguments.`);
|
|
73
|
+
}
|
|
74
|
+
return resolveTemplateValue(parsed, context);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error("Arguments JSON must be an array or object.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function routineOptions() {
|
|
81
|
+
const routines = await listRoutines();
|
|
82
|
+
return routines
|
|
83
|
+
.filter((routine) => ["function", "procedure"].includes(routine.kind))
|
|
84
|
+
.map((routine) => ({
|
|
85
|
+
value: String(routine.oid),
|
|
86
|
+
label: `${routine.kind}: ${routine.name}(${routine.identity_arguments || ""})`
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DB_Routine = {
|
|
91
|
+
namespace: "Database",
|
|
92
|
+
description: "Call a PostgreSQL function or stored procedure from the current tenant schema",
|
|
93
|
+
configFields: async () => [
|
|
94
|
+
{
|
|
95
|
+
name: "routine_oid",
|
|
96
|
+
label: "DB routine",
|
|
97
|
+
type: "String",
|
|
98
|
+
input_type: "select",
|
|
99
|
+
required: true,
|
|
100
|
+
options: await routineOptions(),
|
|
101
|
+
sublabel: "Select a function or stored procedure from the current tenant schema."
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
name: "arguments_json",
|
|
105
|
+
label: "Arguments JSON",
|
|
106
|
+
input_type: "code",
|
|
107
|
+
type: "String",
|
|
108
|
+
required: false,
|
|
109
|
+
attributes: { mode: "application/json", nojoins: true },
|
|
110
|
+
sublabel: "JSON array (positional) or JSON object (named). Supports templates like {{row.id}}, {{user.email}}, {{configuration.some_value}}. Leave blank only for zero-argument routines.",
|
|
111
|
+
validator(s) {
|
|
112
|
+
if (!s || !String(s).trim()) return true;
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(s);
|
|
115
|
+
if (!Array.isArray(parsed) && (parsed === null || typeof parsed !== "object")) {
|
|
116
|
+
return "Arguments must be a JSON array or object.";
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return `Invalid JSON: ${e.message}`;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
configuration_summary: (cfg = {}) => `Call DB routine OID ${cfg.routine_oid || "not selected"}`,
|
|
126
|
+
run: async ({ row, user, configuration }) => {
|
|
127
|
+
try {
|
|
128
|
+
if (!configuration?.routine_oid) {
|
|
129
|
+
return {
|
|
130
|
+
error: "DB_Routine is not configured: no routine selected. Open the trigger/action configuration and select a DB routine."
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return await callRoutine({
|
|
134
|
+
routineOid: configuration.routine_oid,
|
|
135
|
+
argumentsJson: configuration.arguments_json,
|
|
136
|
+
row,
|
|
137
|
+
user,
|
|
138
|
+
configuration
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { error: error.message || "DB_Routine execution failed." };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
module.exports = { DB_Routine, callRoutine };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export/import helpers for database routines.
|
|
3
|
+
*
|
|
4
|
+
* Pack format:
|
|
5
|
+
* {
|
|
6
|
+
* format: "saltcorn-db-code-routines",
|
|
7
|
+
* version: 1,
|
|
8
|
+
* exported_at: "2026-05-09T...",
|
|
9
|
+
* source_schema: "public",
|
|
10
|
+
* routines: [
|
|
11
|
+
* {
|
|
12
|
+
* name: "hello",
|
|
13
|
+
* kind: "function",
|
|
14
|
+
* identity_arguments: "",
|
|
15
|
+
* arguments: "",
|
|
16
|
+
* result_type: "text",
|
|
17
|
+
* language: "sql",
|
|
18
|
+
* volatility: "IMMUTABLE",
|
|
19
|
+
* security_definer: false,
|
|
20
|
+
* input_args: [],
|
|
21
|
+
* description: "...",
|
|
22
|
+
* ddl: "CREATE OR REPLACE FUNCTION ..."
|
|
23
|
+
* }
|
|
24
|
+
* ]
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { escapeHtml } = require("./html");
|
|
29
|
+
|
|
30
|
+
const PACK_FORMAT = "saltcorn-db-code-routines";
|
|
31
|
+
const PACK_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build an export pack from an array of routine rows (as returned by listRoutines).
|
|
35
|
+
* Each routine's `definition` (from pg_get_functiondef) is used as the DDL.
|
|
36
|
+
*/
|
|
37
|
+
function buildExportPack(routines, schema) {
|
|
38
|
+
return {
|
|
39
|
+
format: PACK_FORMAT,
|
|
40
|
+
version: PACK_VERSION,
|
|
41
|
+
exported_at: new Date().toISOString(),
|
|
42
|
+
source_schema: schema,
|
|
43
|
+
routines: routines.map((r) => ({
|
|
44
|
+
name: r.name,
|
|
45
|
+
kind: r.kind,
|
|
46
|
+
identity_arguments: r.identity_arguments || "",
|
|
47
|
+
arguments: r.arguments || "",
|
|
48
|
+
result_type: r.result_type || null,
|
|
49
|
+
language: r.language || "plpgsql",
|
|
50
|
+
volatility: r.volatility || "VOLATILE",
|
|
51
|
+
security_definer: !!r.prosecdef,
|
|
52
|
+
input_args: r.input_args || [],
|
|
53
|
+
description: r.description || null,
|
|
54
|
+
ddl: r.definition || "",
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate an imported pack object.
|
|
61
|
+
* Returns { valid: true, pack } or { valid: false, error: "..." }.
|
|
62
|
+
*/
|
|
63
|
+
function validateImportPack(raw) {
|
|
64
|
+
if (!raw || typeof raw !== "object") {
|
|
65
|
+
return { valid: false, error: "Invalid file: not a JSON object." };
|
|
66
|
+
}
|
|
67
|
+
if (raw.format !== PACK_FORMAT) {
|
|
68
|
+
return { valid: false, error: `Unrecognised format "${raw.format || "(missing)"}". Expected "${PACK_FORMAT}".` };
|
|
69
|
+
}
|
|
70
|
+
if ((raw.version || 0) > PACK_VERSION) {
|
|
71
|
+
return { valid: false, error: `Pack version ${raw.version} is newer than supported version ${PACK_VERSION}. Please update the DB Code plugin.` };
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(raw.routines)) {
|
|
74
|
+
return { valid: false, error: "Pack has no routines array." };
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < raw.routines.length; i++) {
|
|
77
|
+
const r = raw.routines[i];
|
|
78
|
+
if (!r.name) return { valid: false, error: `Routine #${i + 1} has no name.` };
|
|
79
|
+
if (!r.kind) return { valid: false, error: `Routine "${r.name}" has no kind.` };
|
|
80
|
+
if (!r.ddl) return { valid: false, error: `Routine "${r.name}" has no DDL.` };
|
|
81
|
+
}
|
|
82
|
+
return { valid: true, pack: raw };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remap schema references in a DDL string from oldSchema to newSchema.
|
|
87
|
+
* Simple string replacement of "oldSchema". and oldSchema. patterns.
|
|
88
|
+
*/
|
|
89
|
+
function remapSchemaInDdl(ddl, oldSchema, newSchema) {
|
|
90
|
+
if (!ddl || oldSchema === newSchema) return ddl;
|
|
91
|
+
const escapedOld = oldSchema.replace(/"/g, '""');
|
|
92
|
+
// Replace quoted and unquoted schema references
|
|
93
|
+
let result = ddl;
|
|
94
|
+
result = result.replace(new RegExp(`"${escapedOld.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\.`, "g"), `"${newSchema.replace(/"/g, '""')}".`);
|
|
95
|
+
result = result.replace(new RegExp(`(?<!")\\b${oldSchema.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.(?!")`, "g"), `${newSchema}.`);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Render an HTML table previewing the routines in a pack.
|
|
101
|
+
*/
|
|
102
|
+
function renderPackPreview(pack) {
|
|
103
|
+
const routineRows = pack.routines.map((r, i) => {
|
|
104
|
+
const kindIcon = r.kind === "procedure" ? "fas fa-cogs" : "fas fa-cube";
|
|
105
|
+
const kindLabel = r.kind === "procedure" ? "Stored procedure" : "Function";
|
|
106
|
+
const kindClass = r.kind === "procedure" ? "bg-warning text-dark" : "bg-primary";
|
|
107
|
+
return `<tr>
|
|
108
|
+
<td><input class="form-check-input" type="checkbox" name="import_${i}" value="1" checked></td>
|
|
109
|
+
<td><span class="badge ${kindClass}"><i class="${kindIcon} me-1"></i>${escapeHtml(kindLabel)}</span></td>
|
|
110
|
+
<td><code>${escapeHtml(r.name)}</code></td>
|
|
111
|
+
<td><code>${escapeHtml(r.identity_arguments || "none")}</code></td>
|
|
112
|
+
<td><code>${escapeHtml(r.result_type || "—")}</code></td>
|
|
113
|
+
<td><span class="badge bg-light text-dark border">${escapeHtml(r.language || "")}</span></td>
|
|
114
|
+
</tr>`;
|
|
115
|
+
}).join("");
|
|
116
|
+
|
|
117
|
+
return `<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0">
|
|
118
|
+
<thead><tr>
|
|
119
|
+
<th><input class="form-check-input" type="checkbox" id="importToggleAll" checked></th>
|
|
120
|
+
<th>Type</th><th>Name</th><th>Arguments</th><th>Returns</th><th>Language</th>
|
|
121
|
+
</tr></thead>
|
|
122
|
+
<tbody>${routineRows}</tbody>
|
|
123
|
+
</table></div>
|
|
124
|
+
<div class="small text-muted mt-2">${pack.routines.length} routine${pack.routines.length !== 1 ? "s" : ""} · exported from schema <code>${escapeHtml(pack.source_schema || "?")}</code></div>
|
|
125
|
+
<script>
|
|
126
|
+
(function(){
|
|
127
|
+
var toggle = document.getElementById('importToggleAll');
|
|
128
|
+
if (!toggle) return;
|
|
129
|
+
var boxes = document.querySelectorAll('input[name^="import_"]');
|
|
130
|
+
toggle.addEventListener('change', function(){
|
|
131
|
+
boxes.forEach(function(b){ b.checked = toggle.checked; });
|
|
132
|
+
});
|
|
133
|
+
})();
|
|
134
|
+
</script>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { PACK_FORMAT, PACK_VERSION, buildExportPack, validateImportPack, remapSchemaInDdl, renderPackPreview };
|
package/lib/html.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function escapeHtml(value) {
|
|
2
|
+
return String(value ?? "")
|
|
3
|
+
.replace(/&/g, "&")
|
|
4
|
+
.replace(/</g, "<")
|
|
5
|
+
.replace(/>/g, ">")
|
|
6
|
+
.replace(/"/g, """)
|
|
7
|
+
.replace(/'/g, "'");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function page(req, res, title, body) {
|
|
11
|
+
const html = `<div class="container mt-4 db-code-plugin"><h1>${escapeHtml(title)}</h1>${body}</div>`;
|
|
12
|
+
if (res && typeof res.sendWrap === "function") return res.sendWrap(title, html);
|
|
13
|
+
return res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><link rel="stylesheet" href="/static_assets/bootstrap.min.css"></head><body>${html}</body></html>`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { escapeHtml, page };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const db = require("@saltcorn/data/db");
|
|
2
|
+
|
|
3
|
+
const ROUTINE_SELECT = `
|
|
4
|
+
SELECT
|
|
5
|
+
p.oid,
|
|
6
|
+
n.nspname AS schema,
|
|
7
|
+
p.proname AS name,
|
|
8
|
+
p.prokind,
|
|
9
|
+
CASE p.prokind
|
|
10
|
+
WHEN 'f' THEN 'function'
|
|
11
|
+
WHEN 'p' THEN 'procedure'
|
|
12
|
+
WHEN 'a' THEN 'aggregate'
|
|
13
|
+
WHEN 'w' THEN 'window'
|
|
14
|
+
END AS kind,
|
|
15
|
+
pg_get_function_identity_arguments(p.oid) AS identity_arguments,
|
|
16
|
+
pg_get_function_arguments(p.oid) AS arguments,
|
|
17
|
+
pg_get_function_result(p.oid) AS result_type,
|
|
18
|
+
pg_get_functiondef(p.oid) AS definition,
|
|
19
|
+
l.lanname AS language,
|
|
20
|
+
p.provolatile,
|
|
21
|
+
p.pronargs,
|
|
22
|
+
p.pronargdefaults,
|
|
23
|
+
COALESCE(argmeta.input_args, '[]'::json) AS input_args,
|
|
24
|
+
CASE p.provolatile
|
|
25
|
+
WHEN 'i' THEN 'IMMUTABLE'
|
|
26
|
+
WHEN 's' THEN 'STABLE'
|
|
27
|
+
WHEN 'v' THEN 'VOLATILE'
|
|
28
|
+
END AS volatility,
|
|
29
|
+
p.prosecdef,
|
|
30
|
+
obj_description(p.oid, 'pg_proc') AS description
|
|
31
|
+
FROM pg_proc p
|
|
32
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
33
|
+
JOIN pg_language l ON l.oid = p.prolang
|
|
34
|
+
LEFT JOIN LATERAL (
|
|
35
|
+
SELECT json_agg(
|
|
36
|
+
json_build_object(
|
|
37
|
+
'position', u.ord,
|
|
38
|
+
'name', CASE
|
|
39
|
+
WHEN p.proargnames IS NOT NULL AND array_length(p.proargnames, 1) >= u.ord THEN p.proargnames[u.ord]
|
|
40
|
+
ELSE NULL
|
|
41
|
+
END,
|
|
42
|
+
'type', format_type(u.type_oid, NULL)
|
|
43
|
+
)
|
|
44
|
+
ORDER BY u.ord
|
|
45
|
+
) AS input_args
|
|
46
|
+
FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS u(type_oid, ord)
|
|
47
|
+
) argmeta ON true
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
function ensurePostgres() {
|
|
51
|
+
if (db.isSQLite) {
|
|
52
|
+
throw new Error("DB Code only supports PostgreSQL. SQLite does not support persistent stored routines.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function currentTenantSchema() {
|
|
57
|
+
if (typeof db.getTenantSchema !== "function") throw new Error("Cannot resolve the current Saltcorn tenant schema.");
|
|
58
|
+
return db.getTenantSchema();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listRoutines() {
|
|
62
|
+
ensurePostgres();
|
|
63
|
+
const schema = currentTenantSchema();
|
|
64
|
+
const { rows } = await db.query(
|
|
65
|
+
`${ROUTINE_SELECT}
|
|
66
|
+
WHERE n.nspname = $1
|
|
67
|
+
ORDER BY p.proname, identity_arguments`,
|
|
68
|
+
[schema]
|
|
69
|
+
);
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function getRoutineByOid(oid) {
|
|
74
|
+
ensurePostgres();
|
|
75
|
+
const schema = currentTenantSchema();
|
|
76
|
+
const parsedOid = Number.parseInt(oid, 10);
|
|
77
|
+
if (!Number.isInteger(parsedOid) || parsedOid <= 0) throw new Error("Invalid routine OID.");
|
|
78
|
+
const { rows } = await db.query(
|
|
79
|
+
`${ROUTINE_SELECT}
|
|
80
|
+
WHERE n.nspname = $1 AND p.oid = $2
|
|
81
|
+
LIMIT 1`,
|
|
82
|
+
[schema, parsedOid]
|
|
83
|
+
);
|
|
84
|
+
return rows[0] || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { ensurePostgres, currentTenantSchema, listRoutines, getRoutineByOid };
|