@saltcorn/sql 0.5.8 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -20,8 +20,8 @@ const {
20
20
  } = require("@saltcorn/markup/tags");
21
21
  const { mkTable } = require("@saltcorn/markup");
22
22
  const { readState } = require("@saltcorn/data/plugin-helper");
23
-
24
- const _ = require("underscore");
23
+ const { features } = require("@saltcorn/data/db/state");
24
+ const Handlebars = require("handlebars");
25
25
 
26
26
  const configuration_workflow = () =>
27
27
  new Workflow({
@@ -51,7 +51,9 @@ const configuration_workflow = () =>
51
51
  label: "Output type",
52
52
  type: "String",
53
53
  required: true,
54
- attributes: { options: ["Table", "JSON", "HTML"] },
54
+ attributes: {
55
+ options: ["Table", "JSON", "HTML", "HTML with handlebars"],
56
+ },
55
57
  },
56
58
  {
57
59
  name: "html_code",
@@ -59,14 +61,17 @@ const configuration_workflow = () =>
59
61
  input_type: "code",
60
62
  attributes: { mode: "text/html" },
61
63
  showIf: { output_type: "HTML" },
64
+ sublabel:
65
+ "Use interpolations (<code>{{ }}</code>) to access query result in he <code>rows</code> variable. Example: <code>&lt;script&gt;const rows = {{ JSON.stringify(rows) }}&lt;/script&gt;</code>",
62
66
  },
63
67
  {
64
- input_type: "section_header",
65
- label: " ",
66
- sublabel: div(
68
+ name: "html_code",
69
+ label: "HTML Code",
70
+ input_type: "code",
71
+ attributes: { mode: "text/html" },
72
+ showIf: { output_type: "HTML with handlebars" },
73
+ sublabel:
67
74
  "Use handlebars to access query result in the <code>rows</code> variable. Example: <code>{{#each rows}}&lt;h1&gt;{{this.name}}&lt;/h1&gt;{{/each}}</code>",
68
- ),
69
- showIf: { row_count: "Many" },
70
75
  },
71
76
  ],
72
77
  });
@@ -114,11 +119,6 @@ const run = async (
114
119
  }
115
120
  switch (output_type) {
116
121
  case "HTML":
117
- /*const template = _.template(html_code || "", {
118
- evaluate: /\{\{#(.+?)\}\}/g,
119
- interpolate: /\{\{([^#].+?)\}\}/g,
120
- });
121
- console.log("template", viewname, state, html_code);*/
122
122
  return interpolate(
123
123
  html_code,
124
124
  { rows: qres.rows },
@@ -126,7 +126,9 @@ const run = async (
126
126
  `HTML code interpolation in view ${viewname}`,
127
127
  );
128
128
  //return template();
129
-
129
+ case "HTML with handlebars":
130
+ const template = Handlebars.compile(html_code || "");
131
+ return template({ rows: qres.rows });
130
132
  case "JSON":
131
133
  return `<pre>${JSON.stringify(qres.rows, null, 2)}</pre>`;
132
134
 
@@ -184,6 +186,29 @@ module.exports = {
184
186
  name: "SQLView",
185
187
  display_state_form: false,
186
188
  tableless: true,
189
+ copilot_generate_view_prompt: async ({ table }) => {
190
+ const tableLines = [];
191
+ const tables = await Table.find({});
192
+ tables.forEach((table) => {
193
+ const fieldLines = table.fields.map(
194
+ (f) =>
195
+ ` * ${f.name} with type: ${f.pretty_type.replace(
196
+ "Key to",
197
+ "ForeignKey referencing",
198
+ )}.${f.description ? ` ${f.description}` : ""}`,
199
+ );
200
+ tableLines.push(
201
+ `${table.name}${
202
+ table.description ? `: ${table.description}.` : "."
203
+ } Contains the following fields:\n${fieldLines.join("\n")}`,
204
+ );
205
+ });
206
+ return `The database already contains the following tables:
207
+
208
+ ${tableLines.join("\n\n")}
209
+
210
+ You can reference these tables when generating SQL`;
211
+ },
187
212
  get_state_fields,
188
213
  configuration_workflow,
189
214
  run,
package/package.json CHANGED
@@ -1,15 +1,22 @@
1
1
  {
2
2
  "name": "@saltcorn/sql",
3
- "version": "0.5.8",
3
+ "version": "0.6.1",
4
4
  "description": "Actions and views based on SQL",
5
5
  "main": "index.js",
6
6
  "dependencies": {
7
7
  "@saltcorn/markup": "^0.6.0",
8
8
  "@saltcorn/data": "^0.6.0",
9
9
  "underscore": "1.13.6",
10
+ "handlebars": "4.7.7",
10
11
  "node-sql-parser": "5.3.10",
11
12
  "sqlstring": "^2.3.3"
12
13
  },
14
+ "devDependencies": {
15
+ "jest": "^29.7.0"
16
+ },
17
+ "scripts": {
18
+ "test": "jest tests --runInBand"
19
+ },
13
20
  "author": "Tom Nielsen",
14
21
  "license": "MIT",
15
22
  "eslintConfig": {
@@ -19,7 +26,8 @@
19
26
  },
20
27
  "env": {
21
28
  "node": true,
22
- "es6": true
29
+ "es6": true,
30
+ "jest/globals": true
23
31
  },
24
32
  "rules": {
25
33
  "no-unused-vars": "off",
package/table-provider.js CHANGED
@@ -12,9 +12,27 @@ const { mkTable } = require("@saltcorn/markup");
12
12
  const { pre, code } = require("@saltcorn/markup/tags");
13
13
  const parser = new Parser();
14
14
  const _ = require("underscore");
15
+ const { features } = require("@saltcorn/data/db/state");
16
+
17
+ const on_create = async (table) => {
18
+ if (table?.provider_cfg?.sql_view) {
19
+ await db.query(
20
+ `CREATE OR REPLACE VIEW "${db.sqlsanitize(table.name)}" AS ${table?.provider_cfg?.sql}`,
21
+ );
22
+ }
23
+ };
15
24
 
16
25
  const configuration_workflow = (req) =>
17
26
  new Workflow({
27
+ onDone: async (ctx) => {
28
+ const table = Table.findOne(ctx.table_id);
29
+ if (table && ctx?.sql_view) {
30
+ await db.query(
31
+ `CREATE OR REPLACE VIEW "${db.sqlsanitize(table.name)}" AS ${ctx.sql}`,
32
+ );
33
+ }
34
+ return ctx;
35
+ },
18
36
  steps: [
19
37
  {
20
38
  name: "query",
@@ -46,12 +64,25 @@ const configuration_workflow = (req) =>
46
64
  }
47
65
  },
48
66
  },
67
+ ...(features.table_create_callback
68
+ ? [
69
+ {
70
+ label: "Create SQL VIEW",
71
+ type: "Bool",
72
+ name: "sql_view",
73
+ default: true
74
+ },
75
+ ]
76
+ : []),
49
77
  {
50
78
  label: "Ignore where/order",
51
79
  sublabel:
52
80
  "Always use this SQL directly without attempting to modify it",
53
81
  type: "Bool",
54
82
  name: "ignore_where",
83
+ showIf: features.table_create_callback
84
+ ? { sql_view: false }
85
+ : undefined,
55
86
  },
56
87
  ],
57
88
  });
@@ -66,10 +97,10 @@ const configuration_workflow = (req) =>
66
97
  label: field.name,
67
98
  key: field.name,
68
99
  })),
69
- qres.rows?.slice?.(0, 5)
100
+ qres.rows?.slice?.(0, 5),
70
101
  );
71
102
  const pkey_options = getState().type_names.filter(
72
- (tnm) => getState().types[tnm]?.primaryKey
103
+ (tnm) => getState().types[tnm]?.primaryKey,
73
104
  );
74
105
  const tables = await Table.find({});
75
106
 
@@ -197,7 +228,7 @@ const getSqlQuery = (sql, cfg, where, opts) => {
197
228
  as: null,
198
229
  }
199
230
  : (ast[0].columns || []).find(
200
- (c) => k === c.as || (!c.as && k === c.expr?.column)
231
+ (c) => k === c.as || (!c.as && k === c.expr?.column),
201
232
  );
202
233
  const sqlExprCol =
203
234
  ast[0].columns == "*"
@@ -210,22 +241,22 @@ const getSqlQuery = (sql, cfg, where, opts) => {
210
241
  const sqlAggrCol = (ast[0].columns || []).find(
211
242
  (c) =>
212
243
  c.expr?.type === "aggr_func" &&
213
- c.expr?.name?.toUpperCase() === k.toUpperCase()
244
+ c.expr?.name?.toUpperCase() === k.toUpperCase(),
214
245
  );
215
246
 
216
247
  let left = sqlExprCol
217
248
  ? { ...sqlExprCol.expr, as: null }
218
249
  : sqlAggrCol
219
- ? { ...sqlAggrCol.expr }
220
- : {
221
- type: "column_ref",
222
- table: sqlCol?.expr?.table,
223
- column: sqlCol?.expr?.column || db.sqlsanitize(k),
224
- };
250
+ ? { ...sqlAggrCol.expr }
251
+ : {
252
+ type: "column_ref",
253
+ table: sqlCol?.expr?.table,
254
+ column: sqlCol?.expr?.column || db.sqlsanitize(k),
255
+ };
225
256
  //console.log({ k, sqlCol, sqlExprCol });
226
257
  if (!sqlCol) {
227
258
  const starCol = (ast[0].columns || []).find(
228
- (c) => c.type === "star_ref"
259
+ (c) => c.type === "star_ref",
229
260
  );
230
261
  if (starCol)
231
262
  left = {
@@ -241,14 +272,14 @@ const getSqlQuery = (sql, cfg, where, opts) => {
241
272
  wherek?.ilike && !sqlAggrCol
242
273
  ? "ILIKE"
243
274
  : wherek?.gt && !sqlAggrCol
244
- ? wherek.equal
245
- ? ">="
246
- : ">"
247
- : wherek?.lt && !sqlAggrCol
248
- ? wherek.equal
249
- ? "<="
250
- : "<"
251
- : "=",
275
+ ? wherek.equal
276
+ ? ">="
277
+ : ">"
278
+ : wherek?.lt && !sqlAggrCol
279
+ ? wherek.equal
280
+ ? "<="
281
+ : "<"
282
+ : "=",
252
283
  left,
253
284
  right:
254
285
  wherek?.ilike && !sqlAggrCol
@@ -469,33 +500,59 @@ const countRows = async (cfg, where, opts) => {
469
500
  //console.trace({ sqlQ, phValues, opts });
470
501
  db.sql_log(
471
502
  `select count(*) from (${ensure_no_final_semicolon(sqlQ)})`,
472
- phValues
503
+ phValues,
473
504
  );
474
505
  const qres = await client.query(
475
506
  `select count(*) from (${ensure_no_final_semicolon(sqlQ)})`,
476
- phValues
507
+ phValues,
477
508
  );
478
509
  qres.query = sqlQ;
479
510
  db.sql_log("ROLLBACK;");
480
511
  await client.query(`ROLLBACK;`);
481
512
 
482
513
  if (!is_sqlite) client.release(true);
483
- return qres.rows[0].count;
514
+ return +qres.rows[0].count;
484
515
  };
485
516
 
486
517
  module.exports = {
487
518
  "SQL query": {
488
519
  configuration_workflow,
489
520
  fields: (cfg) => cfg?.columns || [],
490
- get_table: (cfg) => {
521
+ on_create,
522
+ get_table: (cfg, table) => {
523
+ let syntheticTable;
524
+ if (cfg?.sql_view && table)
525
+ syntheticTable = new Table({
526
+ ...table,
527
+ provider_name: undefined,
528
+ provider_cfg: undefined,
529
+ });
491
530
  return {
531
+ disableFiltering: true,
492
532
  getRows: async (where, opts) => {
493
- const qres = await runQuery(cfg, where, opts);
533
+ if (syntheticTable) return await syntheticTable.getRows(where, opts);
534
+ const qres = await runQuery(cfg, where || {}, opts || {});
494
535
  return qres.rows;
495
536
  },
496
537
  countRows: async (where, opts) => {
497
- return await countRows(cfg, where, opts);
538
+ if (syntheticTable)
539
+ return await syntheticTable.countRows(where, opts);
540
+
541
+ return await countRows(cfg, where || {}, opts || {});
498
542
  },
543
+ ...(syntheticTable
544
+ ? {
545
+ distinctValues: async (fldNm, opts) => {
546
+ return await syntheticTable.distinctValues(fldNm, opts);
547
+ },
548
+ getJoinedRows: async (opts) => {
549
+ return await syntheticTable.getJoinedRows(opts);
550
+ },
551
+ aggregationQuery: async (aggs, opts) => {
552
+ return await syntheticTable.aggregationQuery(aggs, opts);
553
+ },
554
+ }
555
+ : {}),
499
556
  };
500
557
  },
501
558
  },
package/tests/data.js ADDED
@@ -0,0 +1,85 @@
1
+ const sqlusers = {
2
+ min_role_read: 1,
3
+ min_role_write: 1,
4
+ provider_name: "SQL query",
5
+ provider_cfg: {
6
+ sql: "select * from users;",
7
+ columns: [
8
+ {
9
+ name: "id",
10
+ type: "Integer",
11
+ label: "id",
12
+ primary_key: true,
13
+ },
14
+ {
15
+ name: "email",
16
+ type: "String",
17
+ label: "email",
18
+ },
19
+ {
20
+ name: "password",
21
+ type: "String",
22
+ label: "password",
23
+ },
24
+ {
25
+ name: "role_id",
26
+ type: "Integer",
27
+ label: "role id",
28
+ },
29
+ {
30
+ name: "reset_password_token",
31
+ type: "String",
32
+ label: "reset password token",
33
+ },
34
+ {
35
+ name: "reset_password_expiry",
36
+ type: "String",
37
+ label: "reset password expiry",
38
+ },
39
+ {
40
+ name: "language",
41
+ type: "String",
42
+ label: "language",
43
+ },
44
+ {
45
+ name: "disabled",
46
+ type: "Bool",
47
+ label: "disabled",
48
+ },
49
+ {
50
+ name: "api_token",
51
+ type: "String",
52
+ label: "api token",
53
+ },
54
+ {
55
+ name: "_attributes",
56
+ type: "JSON",
57
+ label: " attributes",
58
+ },
59
+ {
60
+ name: "verification_token",
61
+ type: "String",
62
+ label: "verification token",
63
+ },
64
+ {
65
+ name: "verified_on",
66
+ type: "String",
67
+ label: "verified on",
68
+ },
69
+ {
70
+ name: "last_mobile_login",
71
+ type: "String",
72
+ label: "last mobile login",
73
+ },
74
+ ],
75
+ //ignore_where: true,
76
+ },
77
+ ownership_formula: null,
78
+ };
79
+
80
+ const sqlusers_view = {
81
+ ...sqlusers,
82
+ provider_cfg: { ...sqlusers.provider_cfg, sql_view: true },
83
+ };
84
+
85
+ module.exports = { sqlusers, sqlusers_view };
@@ -0,0 +1,81 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const View = require("@saltcorn/data/models/view");
3
+ const Table = require("@saltcorn/data/models/table");
4
+ const Plugin = require("@saltcorn/data/models/plugin");
5
+
6
+ const { mockReqRes } = require("@saltcorn/data/tests/mocks");
7
+ const { afterAll, beforeAll, describe, it, expect } = require("@jest/globals");
8
+ const db = require("@saltcorn/data/db");
9
+
10
+ afterAll(require("@saltcorn/data/db").close);
11
+ beforeAll(async () => {
12
+ await require("@saltcorn/data/db/reset_schema")();
13
+ await require("@saltcorn/data/db/fixtures")();
14
+
15
+ getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
16
+ getState().registerPlugin("@saltcorn/sql", require(".."));
17
+ //db.set_sql_logging(true);
18
+ });
19
+
20
+ describe("sql view", () => {
21
+ it("runs", async () => {
22
+ const view = new View({
23
+ name: "BookSQLView",
24
+ description: "",
25
+ viewtemplate: "SQLView",
26
+ configuration: {
27
+ sql: "select id, author, pages from books order by id;",
28
+ html_code: `<script>const boooks = {{ JSON.stringify(rows) }}<script>`,
29
+ output_type: "HTML",
30
+ state_parameters: "",
31
+ },
32
+ min_role: 1,
33
+ table: null,
34
+ });
35
+ const result = await view.run({}, mockReqRes);
36
+
37
+ expect(result).toBe(
38
+ '<script>const boooks = [{"id":1,"author":"Herman Melville","pages":967},{"id":2,"author":"Leo Tolstoy","pages":728}]<script>',
39
+ );
40
+ });
41
+ it("run with state params", async () => {
42
+ const view = new View({
43
+ name: "BookSQLView",
44
+ description: "",
45
+ viewtemplate: "SQLView",
46
+ configuration: {
47
+ sql: "select id, author, pages from books where id = $1",
48
+ html_code: `<script>const boooks = {{ JSON.stringify(rows) }}<script>`,
49
+ output_type: "HTML",
50
+ state_parameters: "id",
51
+ },
52
+ min_role: 1,
53
+ table: null,
54
+ });
55
+ const result = await view.run({id:2}, mockReqRes);
56
+
57
+ expect(result).toBe(
58
+ '<script>const boooks = [{"id":2,"author":"Leo Tolstoy","pages":728}]<script>',
59
+ );
60
+ });
61
+ it("runs with handlebars", async () => {
62
+ const view = new View({
63
+ name: "BookSQLView",
64
+ description: "",
65
+ viewtemplate: "SQLView",
66
+ configuration: {
67
+ sql: "select id, author, pages from books order by id;",
68
+ html_code: `{{#each rows}}<h1>{{this.author}}</h1>{{/each}}`,
69
+ output_type: "HTML with handlebars",
70
+ state_parameters: "",
71
+ },
72
+ min_role: 1,
73
+ table: null,
74
+ });
75
+ const result = await view.run({}, mockReqRes);
76
+
77
+ expect(result).toBe(
78
+ '<h1>Herman Melville</h1><h1>Leo Tolstoy</h1>',
79
+ );
80
+ });
81
+ });
@@ -0,0 +1,107 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const View = require("@saltcorn/data/models/view");
3
+ const Table = require("@saltcorn/data/models/table");
4
+ const Plugin = require("@saltcorn/data/models/plugin");
5
+
6
+ const { mockReqRes } = require("@saltcorn/data/tests/mocks");
7
+ const { afterAll, beforeAll, describe, it, expect } = require("@jest/globals");
8
+ const db = require("@saltcorn/data/db");
9
+
10
+ afterAll(require("@saltcorn/data/db").close);
11
+ beforeAll(async () => {
12
+ await require("@saltcorn/data/db/reset_schema")();
13
+ await require("@saltcorn/data/db/fixtures")();
14
+
15
+ getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
16
+ getState().registerPlugin("@saltcorn/sql", require(".."));
17
+ //db.set_sql_logging(true);
18
+ });
19
+
20
+ // run with:
21
+ // saltcorn dev:plugin-test -d ~/sql/
22
+
23
+ //jest.setTimeout(30000);
24
+
25
+ describe("sql table provider", () => {
26
+ it("creates table", async () => {
27
+ await Table.create("sqlusers", require("./data").sqlusers);
28
+ await getState().refresh_tables(false);
29
+ });
30
+ it("counts table", async () => {
31
+ const table = Table.findOne("sqlusers");
32
+ const nus = await table.countRows({});
33
+ expect(nus).toBe(3);
34
+ const nadmin = await table.countRows({ role_id: 1 });
35
+ expect(nadmin).toBe(1);
36
+ });
37
+ it("gets rows from table", async () => {
38
+ const table = Table.findOne("sqlusers");
39
+ const us = await table.getRows({}, { orderBy: "id" });
40
+ expect(us.length).toBe(3);
41
+ expect(us[0].id).toBe(1);
42
+
43
+ const twous = await table.getRows({}, { limit: 2 });
44
+ expect(twous.length).toBe(2);
45
+ const twous1 = await table.getRows(
46
+ {},
47
+ { limit: 2, orderBy: "id", orderDesc: true },
48
+ );
49
+ expect(twous1.length).toBe(2);
50
+ expect(twous1[0].id).toBe(3);
51
+
52
+ const admins = await table.getRows({ role_id: 1 });
53
+ expect(admins.length).toBe(1);
54
+ expect(admins[0].email).toBe("admin@foo.com");
55
+ const admin = await table.getRow({ role_id: 1 });
56
+ expect(admin.email).toBe("admin@foo.com");
57
+ });
58
+ });
59
+ describe("view-based sql table provider", () => {
60
+ it("creates table", async () => {
61
+ await Table.create("sqlusersv", require("./data").sqlusers_view);
62
+ await getState().refresh_tables(false);
63
+ });
64
+ it("counts table", async () => {
65
+ const table = Table.findOne("sqlusersv");
66
+ const nus = await table.countRows({});
67
+ expect(nus).toBe(3);
68
+ const nadmin = await table.countRows({ role_id: 1 });
69
+ expect(nadmin).toBe(1);
70
+ });
71
+ it("gets rows from table", async () => {
72
+ const table = Table.findOne("sqlusersv");
73
+ const us = await table.getRows({}, { orderBy: "id" });
74
+ expect(us.length).toBe(3);
75
+ expect(us[0].id).toBe(1);
76
+
77
+ const twous = await table.getRows({}, { limit: 2 });
78
+ expect(twous.length).toBe(2);
79
+ const twous1 = await table.getRows(
80
+ {},
81
+ { limit: 2, orderBy: "id", orderDesc: true },
82
+ );
83
+ expect(twous1.length).toBe(2);
84
+ expect(twous1[0].id).toBe(3);
85
+
86
+ const admins = await table.getRows({ role_id: 1 });
87
+ expect(admins.length).toBe(1);
88
+ expect(admins[0].email).toBe("admin@foo.com");
89
+ const admin = await table.getRow({ role_id: 1 });
90
+ expect(admin.email).toBe("admin@foo.com");
91
+ });
92
+ it("has distinct values", async () => {
93
+ const table = Table.findOne("sqlusersv");
94
+ const vs = await table.distinctValues("role_id");
95
+ expect(vs.length).toBe(3);
96
+ expect(vs).toContain(80);
97
+ });
98
+ it("gets joined values", async () => {
99
+ const table = Table.findOne("sqlusersv");
100
+ const vs = await table.getJoinedRows({});
101
+ expect(vs.length).toBe(3);
102
+ const vs2 = await table.getJoinedRows({ limit: 2 });
103
+ expect(vs2.length).toBe(2);
104
+ const admins = await table.getJoinedRows({ where: { role_id: 1 } });
105
+ expect(admins.length).toBe(1);
106
+ });
107
+ });