@saltcorn/sql 0.5.7 → 0.6.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/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(
67
- "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" },
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:
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>",
70
75
  },
71
76
  ],
72
77
  });
@@ -82,7 +87,7 @@ const run = async (
82
87
  viewname,
83
88
  { sql, output_type, state_parameters, html_code },
84
89
  state,
85
- { req }
90
+ { req },
86
91
  ) => {
87
92
  const is_sqlite = db.isSQLite;
88
93
 
@@ -104,7 +109,7 @@ const run = async (
104
109
  if (!is_sqlite) {
105
110
  await client.query(`SET LOCAL search_path TO "${db.getTenantSchema()}";`);
106
111
  await client.query(
107
- `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`
112
+ `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`,
108
113
  );
109
114
  }
110
115
  qres = await client.query(sql, phValues);
@@ -114,26 +119,23 @@ 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 },
125
125
  req.user,
126
- `HTML code interpolation in view ${viewname}`
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
 
133
135
  default: //Table
134
136
  return mkTable(
135
137
  qres.fields.map((field) => ({ label: field.name, key: field.name })),
136
- qres.rows
138
+ qres.rows,
137
139
  );
138
140
  }
139
141
  };
@@ -157,21 +159,26 @@ module.exports = {
157
159
  if (!is_sqlite) {
158
160
  db.sql_log(`SET LOCAL search_path TO "${db.getTenantSchema()}";`);
159
161
  await client.query(
160
- `SET LOCAL search_path TO "${db.getTenantSchema()}";`
162
+ `SET LOCAL search_path TO "${db.getTenantSchema()}";`,
161
163
  );
162
164
  db.sql_log(`SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`);
163
165
  await client.query(
164
- `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`
166
+ `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`,
165
167
  );
166
168
  }
167
169
  db.sql_log(query, parameters || []);
168
170
  const qres = await client.query(query, parameters || []);
169
171
  db.sql_log("ROLLBACK;");
170
172
  await client.query(`ROLLBACK;`);
173
+ if (!is_sqlite) client.release();
171
174
  return qres;
172
175
  },
173
176
  isAsync: true,
174
177
  description: "Run an SQL query",
178
+ arguments: [
179
+ { name: "sql_query", type: "String", required: true },
180
+ { name: "parameters", type: "JSON", tstype: "any[]" },
181
+ ],
175
182
  },
176
183
  },
177
184
  viewtemplates: [
package/package.json CHANGED
@@ -1,15 +1,22 @@
1
1
  {
2
2
  "name": "@saltcorn/sql",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
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,24 @@ 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
+ },
74
+ ]
75
+ : []),
49
76
  {
50
77
  label: "Ignore where/order",
51
78
  sublabel:
52
79
  "Always use this SQL directly without attempting to modify it",
53
80
  type: "Bool",
54
81
  name: "ignore_where",
82
+ showIf: features.table_create_callback
83
+ ? { sql_view: false }
84
+ : undefined,
55
85
  },
56
86
  ],
57
87
  });
@@ -66,10 +96,10 @@ const configuration_workflow = (req) =>
66
96
  label: field.name,
67
97
  key: field.name,
68
98
  })),
69
- qres.rows?.slice?.(0, 5)
99
+ qres.rows?.slice?.(0, 5),
70
100
  );
71
101
  const pkey_options = getState().type_names.filter(
72
- (tnm) => getState().types[tnm]?.primaryKey
102
+ (tnm) => getState().types[tnm]?.primaryKey,
73
103
  );
74
104
  const tables = await Table.find({});
75
105
 
@@ -197,7 +227,7 @@ const getSqlQuery = (sql, cfg, where, opts) => {
197
227
  as: null,
198
228
  }
199
229
  : (ast[0].columns || []).find(
200
- (c) => k === c.as || (!c.as && k === c.expr?.column)
230
+ (c) => k === c.as || (!c.as && k === c.expr?.column),
201
231
  );
202
232
  const sqlExprCol =
203
233
  ast[0].columns == "*"
@@ -210,22 +240,22 @@ const getSqlQuery = (sql, cfg, where, opts) => {
210
240
  const sqlAggrCol = (ast[0].columns || []).find(
211
241
  (c) =>
212
242
  c.expr?.type === "aggr_func" &&
213
- c.expr?.name?.toUpperCase() === k.toUpperCase()
243
+ c.expr?.name?.toUpperCase() === k.toUpperCase(),
214
244
  );
215
245
 
216
246
  let left = sqlExprCol
217
247
  ? { ...sqlExprCol.expr, as: null }
218
248
  : sqlAggrCol
219
- ? { ...sqlAggrCol.expr }
220
- : {
221
- type: "column_ref",
222
- table: sqlCol?.expr?.table,
223
- column: sqlCol?.expr?.column || db.sqlsanitize(k),
224
- };
249
+ ? { ...sqlAggrCol.expr }
250
+ : {
251
+ type: "column_ref",
252
+ table: sqlCol?.expr?.table,
253
+ column: sqlCol?.expr?.column || db.sqlsanitize(k),
254
+ };
225
255
  //console.log({ k, sqlCol, sqlExprCol });
226
256
  if (!sqlCol) {
227
257
  const starCol = (ast[0].columns || []).find(
228
- (c) => c.type === "star_ref"
258
+ (c) => c.type === "star_ref",
229
259
  );
230
260
  if (starCol)
231
261
  left = {
@@ -241,14 +271,14 @@ const getSqlQuery = (sql, cfg, where, opts) => {
241
271
  wherek?.ilike && !sqlAggrCol
242
272
  ? "ILIKE"
243
273
  : wherek?.gt && !sqlAggrCol
244
- ? wherek.equal
245
- ? ">="
246
- : ">"
247
- : wherek?.lt && !sqlAggrCol
248
- ? wherek.equal
249
- ? "<="
250
- : "<"
251
- : "=",
274
+ ? wherek.equal
275
+ ? ">="
276
+ : ">"
277
+ : wherek?.lt && !sqlAggrCol
278
+ ? wherek.equal
279
+ ? "<="
280
+ : "<"
281
+ : "=",
252
282
  left,
253
283
  right:
254
284
  wherek?.ilike && !sqlAggrCol
@@ -469,33 +499,56 @@ const countRows = async (cfg, where, opts) => {
469
499
  //console.trace({ sqlQ, phValues, opts });
470
500
  db.sql_log(
471
501
  `select count(*) from (${ensure_no_final_semicolon(sqlQ)})`,
472
- phValues
502
+ phValues,
473
503
  );
474
504
  const qres = await client.query(
475
505
  `select count(*) from (${ensure_no_final_semicolon(sqlQ)})`,
476
- phValues
506
+ phValues,
477
507
  );
478
508
  qres.query = sqlQ;
479
509
  db.sql_log("ROLLBACK;");
480
510
  await client.query(`ROLLBACK;`);
481
511
 
482
512
  if (!is_sqlite) client.release(true);
483
- return qres.rows[0].count;
513
+ return +qres.rows[0].count;
484
514
  };
485
515
 
486
516
  module.exports = {
487
517
  "SQL query": {
488
518
  configuration_workflow,
489
519
  fields: (cfg) => cfg?.columns || [],
490
- get_table: (cfg) => {
520
+ on_create,
521
+ get_table: (cfg, table) => {
522
+ let syntheticTable;
523
+ if (cfg?.sql_view && table)
524
+ syntheticTable = new Table({
525
+ ...table,
526
+ provider_name: undefined,
527
+ provider_cfg: undefined,
528
+ });
491
529
  return {
530
+ disableFiltering: true,
492
531
  getRows: async (where, opts) => {
493
- const qres = await runQuery(cfg, where, opts);
532
+ if (syntheticTable) return await syntheticTable.getRows(where, opts);
533
+ const qres = await runQuery(cfg, where || {}, opts || {});
494
534
  return qres.rows;
495
535
  },
496
536
  countRows: async (where, opts) => {
497
- return await countRows(cfg, where, opts);
537
+ if (syntheticTable)
538
+ return await syntheticTable.countRows(where, opts);
539
+
540
+ return await countRows(cfg, where || {}, opts || {});
498
541
  },
542
+ ...(syntheticTable
543
+ ? {
544
+ distinctValues: async (fldNm, opts) => {
545
+ return await syntheticTable.distinctValues(fldNm, opts);
546
+ },
547
+ getJoinedRows: async (opts) => {
548
+ return await syntheticTable.getJoinedRows(opts);
549
+ },
550
+ }
551
+ : {}),
499
552
  };
500
553
  },
501
554
  },
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
+ });