@saltcorn/mysql-tables 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.
@@ -0,0 +1,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:raw.githubusercontent.com)",
5
+ "WebFetch(domain:github.com)",
6
+ "WebSearch",
7
+ "WebFetch(domain:saltcorn.github.io)",
8
+ "WebFetch(domain:wiki.saltcorn.com)",
9
+ "Bash(npx eslint:*)"
10
+ ]
11
+ }
12
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saltcorn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # mysql-tables
2
+ Table provider for remote MySQL and MariaDB tables
3
+
4
+ This module contains a Saltcorn table provider for remote MySQL and MariaDB tables. Use this to access a table on a different database as if it were a normal Saltcorn table.
5
+
6
+ There are two ways of setting up a remote table:
7
+
8
+ * The standard way is to create a table and select the "MySQL remote table" as the table provider. In the configuration, you will on the first screen select the database connection parameters (host URL, port, username, password, database name, and table name). On the second page you can adjust the fields that have been guessed from connecting to the database. This way works fine if you are only importing a single table.
9
+
10
+ * If you are importing multiple tables and there are relations between them, it is easier to run the "MySQL Database Explorer" view, which is only available to the administrator and will be in your list of views. Here you also enter the database connection parameters, but not the table name. When you have entered the other connection parameters, press "Look up tables" and a list of the tables will appear. Here, select all of the tables you would like to import and then click "Import tables". This means you don't have to enter the connection parameters multiple times, and it will also correctly set up any relations as foreign key fields between the imported tables.
11
+
12
+ If you have added fields to the remote table after doing an import, simply use the "MySQL Database Explorer" to import these tables again. The list of fields will be updated.
13
+
14
+ If you would prefer not to have your database connection password stored in the database you can set this up as an environment variable, as indicated on the sublabel for the password connection parameter. You should set the `SC_EXTMYSQL_PASS_{database name}` environment variable. For instance, if your database is called `testdb1` then set the `SC_EXTMYSQL_PASS_testdb1` environment variable.
15
+
16
+ ## Query function
17
+
18
+ This module also provides the `extMySqlQuery` function for use in code actions. It takes a connection object, an SQL query string, and optionally an array of parameters. Queries are executed in a read-only transaction for safety. Example usage:
19
+
20
+ ```javascript
21
+ const result = await extMySqlQuery(
22
+ { host: "db.example.com", port: 3306, user: "reader", password: "secret", database: "mydb" },
23
+ "SELECT * FROM orders WHERE status = ?",
24
+ ["pending"]
25
+ );
26
+ // result.rows contains the query results
27
+ ```
package/connections.js ADDED
@@ -0,0 +1,24 @@
1
+ const mysql = require("mysql2/promise");
2
+
3
+ const pools = {};
4
+
5
+ const getConnection = async (connObj) => {
6
+ if (!connObj) return null;
7
+ const key = `${connObj.host}:${connObj.port || 3306}:${connObj.database}:${connObj.user}`;
8
+ if (!pools[key]) {
9
+ const password =
10
+ connObj.password || process.env[`SC_EXTMYSQL_PASS_${connObj.database}`];
11
+ pools[key] = mysql.createPool({
12
+ host: connObj.host,
13
+ port: connObj.port || 3306,
14
+ user: connObj.user,
15
+ password,
16
+ database: connObj.database,
17
+ waitForConnections: true,
18
+ connectionLimit: 10,
19
+ });
20
+ }
21
+ return pools[key];
22
+ };
23
+
24
+ module.exports = { getConnection };
@@ -0,0 +1,204 @@
1
+ const Table = require("@saltcorn/data/models/table");
2
+ const Form = require("@saltcorn/data/models/form");
3
+ const Workflow = require("@saltcorn/data/models/workflow");
4
+ const { renderForm } = require("@saltcorn/markup");
5
+ const { script } = require("@saltcorn/markup/tags");
6
+
7
+ const { getConnection } = require("./connections");
8
+ const { discover_tables, discoverable_tables } = require("./discovery");
9
+
10
+ const configuration_workflow = () =>
11
+ new Workflow({
12
+ steps: [],
13
+ });
14
+
15
+ const getForm = async ({ viewname }) => {
16
+ const fields = [
17
+ {
18
+ name: "host",
19
+ label: "Database host",
20
+ type: "String",
21
+ required: true,
22
+ attributes: { asideNext: true },
23
+ },
24
+ {
25
+ name: "port",
26
+ label: "Port",
27
+ type: "Integer",
28
+ required: true,
29
+ default: 3306,
30
+ },
31
+ {
32
+ name: "user",
33
+ label: "User",
34
+ type: "String",
35
+ required: true,
36
+ attributes: { asideNext: true },
37
+ },
38
+ {
39
+ name: "password",
40
+ label: "Password",
41
+ type: "String",
42
+ fieldview: "password",
43
+ sublabel:
44
+ "If blank, use environment variable <code>SC_EXTMYSQL_PASS_{database name}</code>",
45
+ required: true,
46
+ },
47
+ {
48
+ name: "database",
49
+ label: "Database",
50
+ type: "String",
51
+ required: true,
52
+ },
53
+ {
54
+ name: "tables",
55
+ label: "Tables",
56
+ type: "String",
57
+ class: "table-selector",
58
+ attributes: { options: [] },
59
+ },
60
+ ];
61
+
62
+ const form = new Form({
63
+ action: `/view/${viewname}`,
64
+ fields,
65
+ noSubmitButton: true,
66
+ additionalButtons: [
67
+ {
68
+ label: "Look up tables",
69
+ onclick: "look_up_tables(this)",
70
+ class: "btn btn-primary",
71
+ },
72
+ {
73
+ label: "Import tables",
74
+ onclick: "import_tables(this)",
75
+ class: "btn btn-primary",
76
+ },
77
+ ],
78
+ });
79
+ return form;
80
+ };
81
+
82
+ const js = (viewname) =>
83
+ script(`
84
+ function look_up_tables(that) {
85
+ const form = $(that).closest('form');
86
+ view_post("${viewname}", "lookup_tables", $(form).serialize(), (r)=>{
87
+ $(".table-selector").attr("multiple", true).html(r.tables.map(t=>'<option>'+t+'</option>').join(""))
88
+ })
89
+ }
90
+ function import_tables(that) {
91
+ const form = $(that).closest('form');
92
+ view_post("${viewname}", "import_tables", $(form).serialize())
93
+ }
94
+ `);
95
+
96
+ const run = async (table_id, viewname, cfg, state, { res, req }) => {
97
+ const form = await getForm({ viewname });
98
+ return renderForm(form, req.csrfToken()) + js(viewname);
99
+ };
100
+
101
+ const runPost = async (
102
+ table_id,
103
+ viewname,
104
+ config,
105
+ state,
106
+ body,
107
+ { req, res },
108
+ ) => {
109
+ const form = await getForm({ viewname, body });
110
+ form.validate(body);
111
+ let plot = "";
112
+ if (!form.hasErrors) {
113
+ const table = await Table.findOne({ name: form.values.table });
114
+ }
115
+ form.hasErrors = false;
116
+ form.errors = {};
117
+ res.sendWrap("Data explorer", [
118
+ renderForm(form, req.csrfToken()),
119
+ js(viewname),
120
+ plot,
121
+ ]);
122
+ };
123
+
124
+ const lookup_tables = async (table_id, viewname, config, body, { req }) => {
125
+ const form = await getForm({ viewname, body });
126
+ form.validate(body);
127
+ if (!form.hasErrors) {
128
+ const cfg = form.values;
129
+ const pool = await getConnection(cfg);
130
+ const tbls = await discoverable_tables(cfg.database, pool);
131
+
132
+ return { json: { success: "ok", tables: tbls.map((t) => t.table_name) } };
133
+ }
134
+ return { json: { error: "Form incomplete" } };
135
+ };
136
+
137
+ const import_tables = async (table_id, viewname, config, body, { req }) => {
138
+ const form = await getForm({ viewname, body });
139
+ form.validate(body);
140
+ if (!form.hasErrors) {
141
+ const { _csrf, tables, ...cfg } = form.values;
142
+
143
+ const pool = await getConnection(cfg);
144
+ const pack = await discover_tables(
145
+ Array.isArray(body.tables) ? body.tables : [body.tables],
146
+ cfg.database,
147
+ pool,
148
+ );
149
+ const imported = [],
150
+ updated = [],
151
+ skipped = [];
152
+ for (const tableCfg of pack.tables) {
153
+ const existing = Table.findOne({ name: tableCfg.name });
154
+ if (existing?.provider_name === "MySQL remote table") {
155
+ updated.push(tableCfg.name);
156
+ await existing.update({
157
+ provider_cfg: {
158
+ ...cfg,
159
+ table_name: tableCfg.name,
160
+ fields: tableCfg.fields,
161
+ },
162
+ });
163
+ } else if (existing) {
164
+ skipped.push(tableCfg.name);
165
+ } else {
166
+ imported.push(tableCfg.name);
167
+ await Table.create(tableCfg.name, {
168
+ provider_name: "MySQL remote table",
169
+ provider_cfg: {
170
+ ...cfg,
171
+ table_name: tableCfg.name,
172
+ fields: tableCfg.fields,
173
+ },
174
+ });
175
+ }
176
+ }
177
+ return {
178
+ json: {
179
+ success: "ok",
180
+ notify: `${
181
+ imported.length ? `Imported tables: ${imported.join(",")}. ` : ""
182
+ }${updated.length ? `Updated tables: ${updated.join(",")}. ` : ""}${
183
+ skipped.length
184
+ ? `Skipped tables (name clash): ${skipped.join(",")}. `
185
+ : ""
186
+ }`,
187
+ },
188
+ };
189
+ }
190
+ return { json: { error: "Form incomplete" } };
191
+ };
192
+
193
+ module.exports = {
194
+ name: "MySQL Database Explorer",
195
+ display_state_form: false,
196
+ tableless: true,
197
+ singleton: true,
198
+ description: "Explore and import MySQL/MariaDB databases",
199
+ get_state_fields: () => [],
200
+ configuration_workflow,
201
+ run,
202
+ runPost,
203
+ routes: { lookup_tables, import_tables },
204
+ };
package/discovery.js ADDED
@@ -0,0 +1,138 @@
1
+ const findType = (dataType, columnType) => {
2
+ const dt = dataType.toLowerCase();
3
+
4
+ if (
5
+ dt === "tinyint" &&
6
+ columnType &&
7
+ columnType.toLowerCase() === "tinyint(1)"
8
+ ) {
9
+ return "Bool";
10
+ }
11
+
12
+ if (
13
+ [
14
+ "int",
15
+ "integer",
16
+ "smallint",
17
+ "mediumint",
18
+ "bigint",
19
+ "tinyint",
20
+ ].includes(dt)
21
+ ) {
22
+ return "Integer";
23
+ }
24
+
25
+ if (["float", "double", "decimal", "numeric", "real"].includes(dt)) {
26
+ return "Float";
27
+ }
28
+
29
+ if (
30
+ [
31
+ "varchar",
32
+ "char",
33
+ "text",
34
+ "tinytext",
35
+ "mediumtext",
36
+ "longtext",
37
+ "enum",
38
+ "set",
39
+ ].includes(dt)
40
+ ) {
41
+ return "String";
42
+ }
43
+
44
+ if (["date", "datetime", "timestamp"].includes(dt)) {
45
+ return "Date";
46
+ }
47
+
48
+ if (dt === "json") {
49
+ return "String";
50
+ }
51
+
52
+ return null;
53
+ };
54
+
55
+ const discoverable_tables = async (database, pool) => {
56
+ const [rows] = await pool.query(
57
+ `SELECT TABLE_NAME as table_name
58
+ FROM INFORMATION_SCHEMA.TABLES
59
+ WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
60
+ ORDER BY TABLE_NAME`,
61
+ [database],
62
+ );
63
+ return rows;
64
+ };
65
+
66
+ const discover_tables = async (tableNames, database, pool) => {
67
+ const packTables = [];
68
+
69
+ for (const tnm of tableNames) {
70
+ const [columns] = await pool.query(
71
+ `SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_TYPE, EXTRA
72
+ FROM INFORMATION_SCHEMA.COLUMNS
73
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
74
+ ORDER BY ORDINAL_POSITION`,
75
+ [database, tnm],
76
+ );
77
+
78
+ const [primaryKeys] = await pool.query(
79
+ `SELECT COLUMN_NAME
80
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
81
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'`,
82
+ [database, tnm],
83
+ );
84
+ const pkColumns = primaryKeys.map((r) => r.COLUMN_NAME);
85
+
86
+ const [foreignKeys] = await pool.query(
87
+ `SELECT COLUMN_NAME, REFERENCED_TABLE_NAME
88
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
89
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
90
+ AND REFERENCED_TABLE_SCHEMA IS NOT NULL`,
91
+ [database, tnm],
92
+ );
93
+ const fkMap = {};
94
+ foreignKeys.forEach((fk) => {
95
+ fkMap[fk.COLUMN_NAME] = fk.REFERENCED_TABLE_NAME;
96
+ });
97
+
98
+ const fields = columns
99
+ .map((c) => {
100
+ const isAutoIncrement =
101
+ c.EXTRA && c.EXTRA.includes("auto_increment");
102
+ const fkTable = fkMap[c.COLUMN_NAME];
103
+
104
+ const field = {
105
+ name: c.COLUMN_NAME,
106
+ label: c.COLUMN_NAME,
107
+ required: c.IS_NULLABLE === "NO" && !isAutoIncrement,
108
+ };
109
+
110
+ if (pkColumns.includes(c.COLUMN_NAME)) {
111
+ field.primary_key = true;
112
+ }
113
+
114
+ if (fkTable) {
115
+ field.type = "Key";
116
+ field.reftable_name = fkTable;
117
+ } else {
118
+ const type = findType(c.DATA_TYPE, c.COLUMN_TYPE);
119
+ if (!type) return null;
120
+ field.type = type;
121
+ }
122
+
123
+ return field;
124
+ })
125
+ .filter((f) => f !== null);
126
+
127
+ packTables.push({
128
+ name: tnm,
129
+ fields,
130
+ min_role_read: 1,
131
+ min_role_write: 1,
132
+ });
133
+ }
134
+
135
+ return { tables: packTables };
136
+ };
137
+
138
+ module.exports = { discover_tables, discoverable_tables, findType };
package/index.js ADDED
@@ -0,0 +1,45 @@
1
+ const { getConnection } = require("./connections");
2
+
3
+ module.exports = {
4
+ sc_plugin_api_version: 1,
5
+ table_providers: require("./table-provider"),
6
+ viewtemplates: [require("./database-browser")],
7
+ functions: {
8
+ extMySqlQuery: {
9
+ run: async (connection, query, parameters) => {
10
+ const sql_log = (...args) => {
11
+ console.log(...args);
12
+ };
13
+ const pool = await getConnection(connection);
14
+ const conn = await pool.getConnection();
15
+ try {
16
+ sql_log("START TRANSACTION READ ONLY;");
17
+ await conn.query("START TRANSACTION READ ONLY;");
18
+
19
+ sql_log(query, parameters || []);
20
+ const [rows, fields] = await conn.query(query, parameters || []);
21
+
22
+ sql_log("ROLLBACK;");
23
+ await conn.query("ROLLBACK");
24
+ return { rows, fields };
25
+ } finally {
26
+ conn.release();
27
+ }
28
+ },
29
+ isAsync: true,
30
+ description:
31
+ "Run a read-only SQL query on an external MySQL/MariaDB database",
32
+ arguments: [
33
+ {
34
+ name: "connection",
35
+ type: "JSON",
36
+ tstype:
37
+ "{host: string, port: number, user: string, password: string, database: string}",
38
+ required: true,
39
+ },
40
+ { name: "sql_query", type: "String", required: true },
41
+ { name: "parameters", type: "JSON", tstype: "any[]" },
42
+ ],
43
+ },
44
+ },
45
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@saltcorn/mysql-tables",
3
+ "version": "0.1.0",
4
+ "description": "Table provider for remote MySQL and MariaDB tables",
5
+ "main": "index.js",
6
+ "dependencies": {
7
+ "@saltcorn/data": "^1.3.0",
8
+ "@saltcorn/markup": "^1.3.0",
9
+ "mysql2": "^3.11.0"
10
+ },
11
+ "author": "Tom Nielsen",
12
+ "license": "MIT",
13
+ "repository": "github:saltcorn/mysql-tables",
14
+ "eslintConfig": {
15
+ "extends": "eslint:recommended",
16
+ "parserOptions": {
17
+ "ecmaVersion": 2020
18
+ },
19
+ "env": {
20
+ "node": true,
21
+ "es6": true
22
+ },
23
+ "rules": {
24
+ "no-unused-vars": "off",
25
+ "no-case-declarations": "off",
26
+ "no-empty": "warn",
27
+ "no-fallthrough": "warn"
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,366 @@
1
+ const db = require("@saltcorn/data/db");
2
+ const Workflow = require("@saltcorn/data/models/workflow");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
5
+ const Field = require("@saltcorn/data/models/field");
6
+ const Table = require("@saltcorn/data/models/table");
7
+ const {
8
+ aggregation_query_fields,
9
+ joinfield_renamer,
10
+ } = require("@saltcorn/data/models/internal/query");
11
+ const { getState } = require("@saltcorn/data/db/state");
12
+ const {
13
+ sqlsanitize,
14
+ mkWhere,
15
+ mkSelectOptions,
16
+ } = require("@saltcorn/db-common/internal");
17
+
18
+ const { getConnection } = require("./connections");
19
+ const { discover_tables } = require("./discovery");
20
+
21
+ const pgToMysql = (sql, values) => {
22
+ if (!sql) return { sql: "", values: values || [] };
23
+
24
+ const newValues = [];
25
+ let converted = sql;
26
+
27
+ if (/\$\d+/.test(sql) && values && values.length > 0) {
28
+ converted = converted.replace(/\$(\d+)/g, (_, num) => {
29
+ newValues.push(values[parseInt(num) - 1]);
30
+ return "?";
31
+ });
32
+ } else if (values) {
33
+ newValues.push(...values);
34
+ }
35
+
36
+ converted = converted
37
+ .replace(/"/g, "`")
38
+ .replace(/\bILIKE\b/gi, "LIKE")
39
+ .replace(/::([\w]+(\[\])?)/g, "")
40
+ .replace(/\bNULLS\s+(FIRST|LAST)\b/gi, "")
41
+ .replace(/\bLIMIT\s+ALL\b/gi, "");
42
+
43
+ return { sql: converted, values: newValues };
44
+ };
45
+
46
+ const configuration_workflow = (req) =>
47
+ new Workflow({
48
+ onDone: (ctx) => {
49
+ (ctx.fields || []).forEach((f) => {
50
+ if (f.summary_field) {
51
+ if (!f.attributes) f.attributes = {};
52
+ f.attributes.summary_field = f.summary_field;
53
+ }
54
+ });
55
+ return ctx;
56
+ },
57
+ steps: [
58
+ {
59
+ name: "table",
60
+ form: async () => {
61
+ return new Form({
62
+ fields: [
63
+ {
64
+ name: "host",
65
+ label: "Database host",
66
+ type: "String",
67
+ required: true,
68
+ exclude_from_mobile: true,
69
+ },
70
+ {
71
+ name: "port",
72
+ label: "Port",
73
+ type: "Integer",
74
+ required: true,
75
+ default: 3306,
76
+ exclude_from_mobile: true,
77
+ },
78
+ {
79
+ name: "user",
80
+ label: "User",
81
+ type: "String",
82
+ required: true,
83
+ exclude_from_mobile: true,
84
+ },
85
+ {
86
+ name: "password",
87
+ label: "Password",
88
+ type: "String",
89
+ fieldview: "password",
90
+ required: true,
91
+ sublabel:
92
+ "If blank, use environment variable <code>SC_EXTMYSQL_PASS_{database name}</code>",
93
+ exclude_from_mobile: true,
94
+ },
95
+ {
96
+ name: "database",
97
+ label: "Database",
98
+ type: "String",
99
+ required: true,
100
+ exclude_from_mobile: true,
101
+ },
102
+ {
103
+ name: "table_name",
104
+ label: "Table name",
105
+ type: "String",
106
+ required: true,
107
+ exclude_from_mobile: true,
108
+ },
109
+ {
110
+ name: "read_only",
111
+ label: "Read-only",
112
+ type: "Bool",
113
+ },
114
+ ],
115
+ });
116
+ },
117
+ },
118
+ {
119
+ name: "fields",
120
+ form: async (ctx) => {
121
+ const pool = await getConnection(ctx);
122
+ const pack = await discover_tables(
123
+ [ctx.table_name],
124
+ ctx.database,
125
+ pool,
126
+ );
127
+ const tables = await Table.find({});
128
+
129
+ const real_fkey_opts = tables.map((t) => `Key to ${t.name}`);
130
+ const fkey_opts = ["File", ...real_fkey_opts];
131
+
132
+ const form = new Form({
133
+ fields: [
134
+ {
135
+ input_type: "section_header",
136
+ label: "Column types",
137
+ },
138
+ new FieldRepeat({
139
+ name: "fields",
140
+ fields: [
141
+ {
142
+ name: "name",
143
+ label: "Name",
144
+ type: "String",
145
+ required: true,
146
+ },
147
+ {
148
+ name: "label",
149
+ label: "Label",
150
+ type: "String",
151
+ required: true,
152
+ },
153
+ {
154
+ name: "type",
155
+ label: "Type",
156
+ type: "String",
157
+ required: true,
158
+ attributes: {
159
+ options: getState().type_names.concat(fkey_opts || []),
160
+ },
161
+ },
162
+ {
163
+ name: "primary_key",
164
+ label: "Primary key",
165
+ type: "Bool",
166
+ },
167
+ {
168
+ name: "summary_field",
169
+ label: "Summary field",
170
+ sublabel:
171
+ "The field name, on the target table, which will be used to pick values for this key",
172
+ type: "String",
173
+ showIf: { type: real_fkey_opts },
174
+ },
175
+ ],
176
+ }),
177
+ ],
178
+ });
179
+ if (!ctx.fields || !ctx.fields.length) {
180
+ if (!form.values) form.values = {};
181
+ form.values.fields = pack.tables[0].fields;
182
+ } else {
183
+ (ctx.fields || []).forEach((f) => {
184
+ if (f.type === "Key" && f.reftable_name)
185
+ f.type = `Key to ${f.reftable_name}`;
186
+ if (f.attributes?.summary_field)
187
+ f.summary_field = f.attributes?.summary_field;
188
+ const reftable_name =
189
+ f.reftable_name || typeof f.type === "string"
190
+ ? f.type.replace("Key to ", "")
191
+ : null;
192
+ const reftable = reftable_name && Table.findOne(reftable_name);
193
+ const repeater = form.fields.find((ff) => ff.isRepeat);
194
+ const sum_form_field = repeater.fields.find(
195
+ (ff) => ff.name === "summary_field",
196
+ );
197
+ if (reftable && sum_form_field) {
198
+ sum_form_field.showIf.type = sum_form_field.showIf.type.filter(
199
+ (t) => t !== f.type,
200
+ );
201
+
202
+ repeater.fields.push(
203
+ new Field({
204
+ name: "summary_field",
205
+ label: "Summary field for " + f.name,
206
+ sublabel: `The field name, on the ${reftable_name} table, which will be used to pick values for this key`,
207
+ type: "String",
208
+ showIf: { type: f.type },
209
+ attributes: {
210
+ options: reftable.fields.map((f) => f.name),
211
+ },
212
+ }),
213
+ );
214
+ }
215
+ });
216
+ }
217
+
218
+ return form;
219
+ },
220
+ },
221
+ ],
222
+ });
223
+
224
+ const getPkName = (cfg) => {
225
+ const pkField = (cfg?.fields || []).find((f) => f.primary_key);
226
+ return pkField ? pkField.name : "id";
227
+ };
228
+
229
+ module.exports = {
230
+ "MySQL remote table": {
231
+ configuration_workflow,
232
+ fields: (cfg) => {
233
+ return cfg?.fields || [];
234
+ },
235
+ get_table: (cfg) => {
236
+ const pkName = getPkName(cfg);
237
+ return {
238
+ disableFiltering: true,
239
+ ...(cfg?.read_only
240
+ ? {}
241
+ : {
242
+ deleteRows: async (where, user) => {
243
+ const pool = await getConnection(cfg);
244
+ const { where: whereClause, values: whereVals } = mkWhere(
245
+ where || {},
246
+ );
247
+ const { sql, values } = pgToMysql(
248
+ `DELETE FROM "${sqlsanitize(cfg.table_name)}" ${whereClause}`,
249
+ whereVals,
250
+ );
251
+ await pool.query(sql, values);
252
+ },
253
+ updateRow: async (updRow, id, user) => {
254
+ const pool = await getConnection(cfg);
255
+ const kvs = Object.entries(updRow);
256
+ const assigns = kvs
257
+ .map(([k]) => `\`${sqlsanitize(k)}\` = ?`)
258
+ .join(", ");
259
+ const values = [...kvs.map(([, v]) => v), id];
260
+ const sql = `UPDATE \`${sqlsanitize(cfg.table_name)}\` SET ${assigns} WHERE \`${sqlsanitize(pkName)}\` = ?`;
261
+ await pool.query(sql, values);
262
+ },
263
+ insertRow: async (rec, user) => {
264
+ const pool = await getConnection(cfg);
265
+ const kvs = Object.entries(rec);
266
+ const fnameList = kvs
267
+ .map(([k]) => `\`${sqlsanitize(k)}\``)
268
+ .join(", ");
269
+ const valPlaceholders = kvs.map(() => "?").join(", ");
270
+ const values = kvs.map(([, v]) => v);
271
+ const sql = `INSERT INTO \`${sqlsanitize(cfg.table_name)}\` (${fnameList}) VALUES (${valPlaceholders})`;
272
+ const [result] = await pool.query(sql, values);
273
+ return result.insertId;
274
+ },
275
+ }),
276
+ countRows: async (where, opts) => {
277
+ const pool = await getConnection(cfg);
278
+ const { where: whereClause, values: whereVals } = mkWhere(
279
+ where || {},
280
+ );
281
+ const { sql, values } = pgToMysql(
282
+ `SELECT COUNT(*) as count FROM "${sqlsanitize(cfg.table_name)}" ${whereClause}`,
283
+ whereVals,
284
+ );
285
+ const [rows] = await pool.query(sql, values);
286
+ return parseInt(rows[0].count);
287
+ },
288
+ aggregationQuery: async (aggregations, options) => {
289
+ const pool = await getConnection(cfg);
290
+ const {
291
+ sql: pgSql,
292
+ values: pgValues,
293
+ groupBy,
294
+ } = aggregation_query_fields(cfg.table_name, aggregations, {
295
+ ...options,
296
+ schema: cfg.database,
297
+ });
298
+ const { sql, values } = pgToMysql(pgSql, pgValues);
299
+
300
+ const [rows] = await pool.query(sql, values);
301
+ if (groupBy) return rows;
302
+ return rows[0];
303
+ },
304
+ distinctValues: async (fieldnm, whereObj) => {
305
+ const pool = await getConnection(cfg);
306
+ if (whereObj) {
307
+ const { where, values: whereVals } = mkWhere(whereObj);
308
+ const { sql, values } = pgToMysql(
309
+ `SELECT DISTINCT "${sqlsanitize(fieldnm)}" FROM "${sqlsanitize(cfg.table_name)}" ${where} ORDER BY "${sqlsanitize(fieldnm)}"`,
310
+ whereVals,
311
+ );
312
+ const [rows] = await pool.query(sql, values);
313
+ return rows.map((r) => r[fieldnm]);
314
+ } else {
315
+ const sql = `SELECT DISTINCT \`${sqlsanitize(fieldnm)}\` FROM \`${sqlsanitize(cfg.table_name)}\` ORDER BY \`${sqlsanitize(fieldnm)}\``;
316
+ const [rows] = await pool.query(sql);
317
+ return rows.map((r) => r[fieldnm]);
318
+ }
319
+ },
320
+ getRows: async (where, opts) => {
321
+ const pool = await getConnection(cfg);
322
+ const { where: whereClause, values: whereVals } = mkWhere(
323
+ where || {},
324
+ );
325
+ const selectOpts = mkSelectOptions(opts || {});
326
+ const { sql, values } = pgToMysql(
327
+ `SELECT * FROM "${sqlsanitize(cfg.table_name)}" ${whereClause} ${selectOpts}`,
328
+ whereVals,
329
+ );
330
+ const [rows] = await pool.query(sql, values);
331
+ return rows;
332
+ },
333
+ getJoinedRows: async (opts) => {
334
+ const pool = await getConnection(cfg);
335
+ const pseudoTable = new Table({
336
+ name: cfg.table_name,
337
+ fields: cfg.fields,
338
+ });
339
+ const {
340
+ sql: pgSql,
341
+ values: pgValues,
342
+ joinFields,
343
+ aggregations,
344
+ } = await pseudoTable.getJoinedQuery({
345
+ schema: cfg.database,
346
+ ...opts,
347
+ ignoreExternal: true,
348
+ });
349
+ const { sql, values } = pgToMysql(pgSql, pgValues);
350
+ if (db.get_sql_logging?.()) console.log(sql, values);
351
+ const [rows] = await pool.query(sql, values);
352
+ let result = joinfield_renamer
353
+ ? joinfield_renamer(joinFields, aggregations)(rows)
354
+ : rows;
355
+ for (const k of Object.keys(joinFields || {})) {
356
+ if (!joinFields?.[k].lookupFunction) continue;
357
+ for (const row of result) {
358
+ row[k] = await joinFields[k].lookupFunction(row);
359
+ }
360
+ }
361
+ return result;
362
+ },
363
+ };
364
+ },
365
+ },
366
+ };