sqlite-hub 0.9.3 → 0.9.6

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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +2 -158
  5. package/frontend/js/app.js +41 -22
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +145 -195
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +81 -99
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +28 -31
  15. package/frontend/js/components/structureGraph.js +10 -9
  16. package/frontend/js/components/tableDesignerEditor.js +91 -116
  17. package/frontend/js/store.js +39 -3
  18. package/frontend/js/utils/dom.js +28 -0
  19. package/frontend/js/utils/tableDesigner.js +8 -2
  20. package/frontend/js/views/charts.js +23 -43
  21. package/frontend/js/views/data.js +116 -132
  22. package/frontend/js/views/mediaTagging.js +131 -164
  23. package/frontend/js/views/structure.js +52 -48
  24. package/frontend/styles/tailwind.css +80 -0
  25. package/frontend/styles/tailwind.generated.css +2 -0
  26. package/frontend/styles/tokens.css +3 -3
  27. package/package.json +19 -5
  28. package/server/routes/mediaTagging.js +2 -10
  29. package/server/routes/sql.js +35 -10
  30. package/server/server.js +24 -0
  31. package/server/services/sqlite/dataBrowserService.js +25 -5
  32. package/server/services/sqlite/exportService.js +4 -2
  33. package/server/services/sqlite/introspection.js +2 -2
  34. package/server/services/sqlite/mediaTaggingService.js +166 -53
  35. package/server/services/sqlite/structureService.js +2 -2
  36. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  37. package/server/services/storage/appStateStore.js +227 -87
  38. package/server/utils/appPaths.js +55 -19
  39. package/server/utils/fileValidation.js +94 -8
  40. package/tailwind.config.cjs +73 -0
  41. package/tests/security-paths.test.js +84 -0
  42. package/tests/sql-identifier-safety.test.js +66 -0
  43. package/.npmingnore +0 -4
  44. package/changelog.md +0 -84
  45. package/docs/DESIGN_GUIDELINES.md +0 -36
  46. package/scripts/publish_brew.sh +0 -466
  47. package/scripts/publish_npm.sh +0 -241
  48. package/shortkeys.md +0 -5
@@ -70,8 +70,10 @@ class ExportService {
70
70
  const orderClause = buildTableOrderClause(tableDetail, sort);
71
71
  const statement = db.prepare(
72
72
  [
73
- `SELECT * FROM ${quoteIdentifier(tableName)}`,
74
- orderClause ? `ORDER BY ${orderClause}` : "",
73
+ "SELECT * FROM",
74
+ quoteIdentifier(tableName),
75
+ orderClause ? "ORDER BY" : "",
76
+ orderClause,
75
77
  ]
76
78
  .filter(Boolean)
77
79
  .join(" ")
@@ -92,7 +92,7 @@ function groupForeignKeys(rows) {
92
92
  function safeCountRows(db, tableName) {
93
93
  try {
94
94
  const row = db
95
- .prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(tableName)}`)
95
+ .prepare(["SELECT COUNT(*) AS count FROM", quoteIdentifier(tableName)].join(" "))
96
96
  .get();
97
97
  return row?.count ?? 0;
98
98
  } catch (error) {
@@ -223,7 +223,7 @@ function getViewDetail(db, viewName) {
223
223
 
224
224
  try {
225
225
  columns = db
226
- .prepare(`SELECT * FROM ${quoteIdentifier(viewName)} LIMIT 0`)
226
+ .prepare(["SELECT * FROM", quoteIdentifier(viewName), "LIMIT 0"].join(" "))
227
227
  .columns()
228
228
  .map((column) => ({
229
229
  name: column.name,
@@ -3,6 +3,7 @@ const path = require("node:path");
3
3
  const { fileURLToPath } = require("node:url");
4
4
  const { detectQueryType } = require("../storage/queryHistoryUtils");
5
5
  const { ConflictError, NotFoundError, ValidationError, mapSqliteError } = require("../../utils/errors");
6
+ const { resolvePathInsideDirectory } = require("../../utils/fileValidation");
6
7
  const { quoteIdentifier } = require("../../utils/identifier");
7
8
  const { getRawStructureEntries, getTableDetail } = require("./introspection");
8
9
  const { serializeRow } = require("../../utils/sqliteTypes");
@@ -314,16 +315,18 @@ function buildDefaultMediaQueries(tableDetail, config = {}) {
314
315
 
315
316
  const selectionPrefix =
316
317
  tableDetail.identityStrategy?.type === "rowid"
317
- ? `SELECT rowid AS ${quoteIdentifier(MEDIA_ROWID_ALIAS)}, *`
318
+ ? ["SELECT rowid AS", quoteIdentifier(MEDIA_ROWID_ALIAS), ", *"].join(" ")
318
319
  : "SELECT *";
319
- const tableSql = `${selectionPrefix} FROM ${quoteIdentifier(tableDetail.name)}`;
320
+ const tableSql = [selectionPrefix, "FROM", quoteIdentifier(tableDetail.name)].join(" ");
320
321
  const taggedColumnSql = quoteIdentifier(config.taggedColumn);
321
322
  const orderByClause = buildOrderByClause(tableDetail, MEDIA_ROWID_ALIAS);
322
- const orderSegment = orderByClause ? `\n${orderByClause}` : "";
323
+ const orderSegment = orderByClause ? "\n" + orderByClause : "";
323
324
 
324
325
  return {
325
- untaggedQuery: `${tableSql}\nWHERE COALESCE(${taggedColumnSql}, 0) = 0${orderSegment}`,
326
- taggedQuery: `${tableSql}\nWHERE COALESCE(${taggedColumnSql}, 0) = 1${orderSegment}`,
326
+ untaggedQuery:
327
+ tableSql + "\nWHERE COALESCE(" + taggedColumnSql + ", 0) = 0" + orderSegment,
328
+ taggedQuery:
329
+ tableSql + "\nWHERE COALESCE(" + taggedColumnSql + ", 0) = 1" + orderSegment,
327
330
  };
328
331
  }
329
332
 
@@ -341,7 +344,11 @@ function buildWrappedQuery(query) {
341
344
 
342
345
  function getQueryOutputColumns(db, query) {
343
346
  const statement = db.prepare(
344
- `SELECT * FROM (${buildWrappedQuery(query)}) AS media_tagging_source LIMIT 0`
347
+ [
348
+ "SELECT * FROM (",
349
+ buildWrappedQuery(query),
350
+ ") AS media_tagging_source LIMIT 0",
351
+ ].join("")
345
352
  );
346
353
 
347
354
  return statement.columns().map((column) => column.name);
@@ -349,7 +356,13 @@ function getQueryOutputColumns(db, query) {
349
356
 
350
357
  function countQueryRows(db, query) {
351
358
  const row = db
352
- .prepare(`SELECT COUNT(*) AS count FROM (${buildWrappedQuery(query)}) AS media_tagging_source`)
359
+ .prepare(
360
+ [
361
+ "SELECT COUNT(*) AS count FROM (",
362
+ buildWrappedQuery(query),
363
+ ") AS media_tagging_source",
364
+ ].join("")
365
+ )
353
366
  .get();
354
367
 
355
368
  return Number(row?.count ?? 0);
@@ -962,16 +975,21 @@ class MediaTaggingService {
962
975
  const labelColumns = getTagLabelColumns(tagTableDetail);
963
976
  const selectionPrefix =
964
977
  tagTableDetail.identityStrategy?.type === "rowid"
965
- ? `SELECT rowid AS ${quoteIdentifier(TAG_ROWID_ALIAS)}, *`
978
+ ? ["SELECT rowid AS", quoteIdentifier(TAG_ROWID_ALIAS), ", *"].join(" ")
966
979
  : "SELECT *";
967
980
  const orderByColumns = labelColumns.length ? labelColumns : keyColumns;
968
981
  const parentTagFeature = getParentTagFeatureColumns(tagTableDetail);
969
982
  const singleIdentityColumn = getSingleTagIdentityColumn(tagTableDetail);
970
983
  const rows = db
971
984
  .prepare(
972
- `${selectionPrefix} FROM ${quoteIdentifier(tagTableDetail.name)} ORDER BY ${orderByColumns
973
- .map((column) => quoteIdentifier(column))
974
- .join(", ")} ASC`
985
+ [
986
+ selectionPrefix,
987
+ "FROM",
988
+ quoteIdentifier(tagTableDetail.name),
989
+ "ORDER BY",
990
+ orderByColumns.map((column) => quoteIdentifier(column)).join(", "),
991
+ "ASC",
992
+ ].join(" ")
975
993
  )
976
994
  .all();
977
995
 
@@ -1171,7 +1189,13 @@ class MediaTaggingService {
1171
1189
 
1172
1190
  loadCurrentMediaRow(db, untaggedQuery, identityColumns, skippedMediaKeys = []) {
1173
1191
  const exclusion = buildExcludedKeyClause(identityColumns, skippedMediaKeys);
1174
- const sqlParts = [`SELECT * FROM (${buildWrappedQuery(untaggedQuery)}) AS media_tagging_source`];
1192
+ const sqlParts = [
1193
+ [
1194
+ "SELECT * FROM (",
1195
+ buildWrappedQuery(untaggedQuery),
1196
+ ") AS media_tagging_source",
1197
+ ].join(""),
1198
+ ];
1175
1199
 
1176
1200
  if (exclusion.sql) {
1177
1201
  sqlParts.push(`WHERE NOT (${exclusion.sql})`);
@@ -1188,13 +1212,20 @@ class MediaTaggingService {
1188
1212
  const whereClause = buildKeyWhereClause(identityColumns, identityValues);
1189
1213
  const selectionPrefix =
1190
1214
  mediaTableDetail.identityStrategy?.type === "rowid"
1191
- ? `SELECT rowid AS ${quoteIdentifier(MEDIA_ROWID_ALIAS)}, *`
1215
+ ? ["SELECT rowid AS", quoteIdentifier(MEDIA_ROWID_ALIAS), ", *"].join(" ")
1192
1216
  : "SELECT *";
1193
1217
 
1194
1218
  return (
1195
1219
  db
1196
1220
  .prepare(
1197
- `${selectionPrefix} FROM ${quoteIdentifier(mediaTableDetail.name)} WHERE ${whereClause.sql} LIMIT 1`
1221
+ [
1222
+ selectionPrefix,
1223
+ "FROM",
1224
+ quoteIdentifier(mediaTableDetail.name),
1225
+ "WHERE",
1226
+ whereClause.sql,
1227
+ "LIMIT 1",
1228
+ ].join(" ")
1198
1229
  )
1199
1230
  .get(...whereClause.params) ?? null
1200
1231
  );
@@ -1209,7 +1240,14 @@ class MediaTaggingService {
1209
1240
  .join(", ");
1210
1241
  const rows = db
1211
1242
  .prepare(
1212
- `SELECT ${tagSelection} FROM ${quoteIdentifier(selectedMapping.tableName)} WHERE ${mediaWhere}`
1243
+ [
1244
+ "SELECT",
1245
+ tagSelection,
1246
+ "FROM",
1247
+ quoteIdentifier(selectedMapping.tableName),
1248
+ "WHERE",
1249
+ mediaWhere,
1250
+ ].join(" ")
1213
1251
  )
1214
1252
  .all(
1215
1253
  selectedMapping.mediaForeignKey.mappings.map((mapping) => currentRow[mapping.to] ?? null)
@@ -1247,16 +1285,30 @@ class MediaTaggingService {
1247
1285
  };
1248
1286
  }
1249
1287
 
1250
- const resolvedPath = this.resolveMediaFilePath(normalizedPath, connection);
1288
+ let resolvedPath = null;
1289
+
1290
+ try {
1291
+ resolvedPath = this.resolveMediaFilePath(normalizedPath, connection);
1292
+ } catch (error) {
1293
+ if (!(error instanceof ValidationError)) {
1294
+ throw error;
1295
+ }
1296
+ }
1251
1297
 
1252
1298
  return {
1253
1299
  previewKind: inferPreviewKind(normalizedPath),
1254
- previewUrl: `/api/media-tagging/media-file?path=${encodeURIComponent(normalizedPath)}`,
1300
+ previewUrl: resolvedPath
1301
+ ? `/api/media-tagging/media-file?path=${encodeURIComponent(normalizedPath)}`
1302
+ : null,
1255
1303
  resolvedPath,
1256
1304
  existsOnDisk: Boolean(resolvedPath && fs.existsSync(resolvedPath)),
1257
1305
  };
1258
1306
  }
1259
1307
 
1308
+ getMediaBaseDirectory(connection = this.getActiveConnection()) {
1309
+ return connection?.path ? path.dirname(connection.path) : null;
1310
+ }
1311
+
1260
1312
  resolveMediaFilePath(rawPath, connection = this.getActiveConnection()) {
1261
1313
  const normalizedPath = String(rawPath ?? "").trim();
1262
1314
 
@@ -1264,16 +1316,46 @@ class MediaTaggingService {
1264
1316
  return null;
1265
1317
  }
1266
1318
 
1267
- if (/^file:\/\//i.test(normalizedPath)) {
1268
- return fileURLToPath(normalizedPath);
1319
+ const mediaBaseDirectory = this.getMediaBaseDirectory(connection);
1320
+
1321
+ if (!mediaBaseDirectory) {
1322
+ return null;
1323
+ }
1324
+
1325
+ const candidatePath = /^file:\/\//i.test(normalizedPath)
1326
+ ? fileURLToPath(normalizedPath)
1327
+ : normalizedPath;
1328
+
1329
+ return resolvePathInsideDirectory(
1330
+ mediaBaseDirectory,
1331
+ candidatePath,
1332
+ "Media file path"
1333
+ );
1334
+ }
1335
+
1336
+ getMediaFileForRequest(rawPath) {
1337
+ const normalizedPath = String(rawPath ?? "").trim();
1338
+ const resolvedPath = this.resolveMediaFilePath(normalizedPath);
1339
+
1340
+ if (!resolvedPath || !fs.existsSync(resolvedPath)) {
1341
+ throw new NotFoundError(`Media file not found: ${normalizedPath}`);
1269
1342
  }
1270
1343
 
1271
- if (path.isAbsolute(normalizedPath) || path.win32.isAbsolute(normalizedPath)) {
1272
- return normalizedPath;
1344
+ const stat = fs.statSync(resolvedPath);
1345
+
1346
+ if (!stat.isFile()) {
1347
+ throw new NotFoundError(`Media file not found: ${normalizedPath}`);
1273
1348
  }
1274
1349
 
1275
- const basePath = connection?.path ? path.dirname(connection.path) : process.cwd();
1276
- return path.resolve(basePath, normalizedPath);
1350
+ return {
1351
+ directory: path.dirname(resolvedPath),
1352
+ fileName: path.basename(resolvedPath),
1353
+ };
1354
+ }
1355
+
1356
+ sendMediaFile(rawPath, res) {
1357
+ const mediaFile = this.getMediaFileForRequest(rawPath);
1358
+ res.sendFile(mediaFile.fileName, { root: mediaFile.directory });
1277
1359
  }
1278
1360
 
1279
1361
  createTag(payload = {}) {
@@ -1329,9 +1411,13 @@ class MediaTaggingService {
1329
1411
 
1330
1412
  const parentTag = db
1331
1413
  .prepare(
1332
- `SELECT * FROM ${quoteIdentifier(tagTableDetail.name)} WHERE ${quoteIdentifier(
1333
- singleIdentityColumn
1334
- )} = ? LIMIT 1`
1414
+ [
1415
+ "SELECT * FROM",
1416
+ quoteIdentifier(tagTableDetail.name),
1417
+ "WHERE",
1418
+ quoteIdentifier(singleIdentityColumn),
1419
+ "= ? LIMIT 1",
1420
+ ].join(" ")
1335
1421
  )
1336
1422
  .get(normalizedValues[parentIdColumnName]);
1337
1423
 
@@ -1353,12 +1439,13 @@ class MediaTaggingService {
1353
1439
  throw new ValidationError("Enter at least one tag value before saving.");
1354
1440
  }
1355
1441
 
1356
- const sql = `
1357
- INSERT INTO ${quoteIdentifier(tagTableDetail.name)} (${providedColumns
1358
- .map((column) => quoteIdentifier(column.name))
1359
- .join(", ")})
1360
- VALUES (${providedColumns.map(() => "?").join(", ")})
1361
- `;
1442
+ const sql = [
1443
+ "INSERT INTO",
1444
+ quoteIdentifier(tagTableDetail.name),
1445
+ "(" + providedColumns.map((column) => quoteIdentifier(column.name)).join(", ") + ")",
1446
+ "VALUES",
1447
+ "(" + providedColumns.map(() => "?").join(", ") + ")",
1448
+ ].join(" ");
1362
1449
 
1363
1450
  const providedColumnNames = providedColumns.map((column) => column.name);
1364
1451
  const insertTag = db.transaction(() => {
@@ -1373,12 +1460,15 @@ class MediaTaggingService {
1373
1460
  // the tag still ends up with a stable key for selection and mapping.
1374
1461
  if (implicitPrimaryKey && !providedColumnNames.includes(implicitPrimaryKey.name)) {
1375
1462
  db.prepare(
1376
- `
1377
- UPDATE ${quoteIdentifier(tagTableDetail.name)}
1378
- SET ${quoteIdentifier(implicitPrimaryKey.name)} = ?
1379
- WHERE rowid = ?
1380
- AND ${quoteIdentifier(implicitPrimaryKey.name)} IS NULL
1381
- `
1463
+ [
1464
+ "UPDATE",
1465
+ quoteIdentifier(tagTableDetail.name),
1466
+ "SET",
1467
+ quoteIdentifier(implicitPrimaryKey.name),
1468
+ "= ? WHERE rowid = ? AND",
1469
+ quoteIdentifier(implicitPrimaryKey.name),
1470
+ "IS NULL",
1471
+ ].join(" ")
1382
1472
  ).run(result.lastInsertRowid, result.lastInsertRowid);
1383
1473
  }
1384
1474
  });
@@ -1426,13 +1516,20 @@ class MediaTaggingService {
1426
1516
  const mappingWhere = buildKeyWhereClause(mappingColumns, keyValues);
1427
1517
 
1428
1518
  db.prepare(
1429
- `DELETE FROM ${quoteIdentifier(selectedMapping.tableName)} WHERE ${mappingWhere.sql}`
1519
+ [
1520
+ "DELETE FROM",
1521
+ quoteIdentifier(selectedMapping.tableName),
1522
+ "WHERE",
1523
+ mappingWhere.sql,
1524
+ ].join(" ")
1430
1525
  ).run(mappingWhere.params);
1431
1526
  }
1432
1527
 
1433
1528
  const tagWhere = buildKeyWhereClause(keyColumns, keyValues);
1434
1529
  const result = db
1435
- .prepare(`DELETE FROM ${quoteIdentifier(tagTableDetail.name)} WHERE ${tagWhere.sql}`)
1530
+ .prepare(
1531
+ ["DELETE FROM", quoteIdentifier(tagTableDetail.name), "WHERE", tagWhere.sql].join(" ")
1532
+ )
1436
1533
  .run(tagWhere.params);
1437
1534
 
1438
1535
  if (!result.changes) {
@@ -1548,12 +1645,13 @@ class MediaTaggingService {
1548
1645
 
1549
1646
  if (!this.mappingExists(db, selectedMapping.tableName, mappingPayload)) {
1550
1647
  const columns = Object.keys(mappingPayload);
1551
- const insertSql = `
1552
- INSERT INTO ${quoteIdentifier(selectedMapping.tableName)} (${columns
1553
- .map((column) => quoteIdentifier(column))
1554
- .join(", ")})
1555
- VALUES (${columns.map(() => "?").join(", ")})
1556
- `;
1648
+ const insertSql = [
1649
+ "INSERT INTO",
1650
+ quoteIdentifier(selectedMapping.tableName),
1651
+ "(" + columns.map((column) => quoteIdentifier(column)).join(", ") + ")",
1652
+ "VALUES",
1653
+ "(" + columns.map(() => "?").join(", ") + ")",
1654
+ ].join(" ");
1557
1655
 
1558
1656
  db.prepare(insertSql).run(columns.map((column) => mappingPayload[column]));
1559
1657
  }
@@ -1637,7 +1735,13 @@ class MediaTaggingService {
1637
1735
  const whereClause = columns.map((column) => `${quoteIdentifier(column)} = ?`).join(" AND ");
1638
1736
  const row = db
1639
1737
  .prepare(
1640
- `SELECT 1 AS exists_flag FROM ${quoteIdentifier(mappingTableName)} WHERE ${whereClause} LIMIT 1`
1738
+ [
1739
+ "SELECT 1 AS exists_flag FROM",
1740
+ quoteIdentifier(mappingTableName),
1741
+ "WHERE",
1742
+ whereClause,
1743
+ "LIMIT 1",
1744
+ ].join(" ")
1641
1745
  )
1642
1746
  .get(columns.map((column) => mappingPayload[column]));
1643
1747
 
@@ -1654,9 +1758,14 @@ class MediaTaggingService {
1654
1758
  const values = identity.columns.map((column) => currentRow[column] ?? null);
1655
1759
 
1656
1760
  db.prepare(
1657
- `UPDATE ${quoteIdentifier(mediaTableDetail.name)} SET ${quoteIdentifier(
1658
- taggedColumn
1659
- )} = 1 WHERE ${whereClause}`
1761
+ [
1762
+ "UPDATE",
1763
+ quoteIdentifier(mediaTableDetail.name),
1764
+ "SET",
1765
+ quoteIdentifier(taggedColumn),
1766
+ "= 1 WHERE",
1767
+ whereClause,
1768
+ ].join(" ")
1660
1769
  ).run(values);
1661
1770
  return;
1662
1771
  }
@@ -1671,9 +1780,13 @@ class MediaTaggingService {
1671
1780
  }
1672
1781
 
1673
1782
  db.prepare(
1674
- `UPDATE ${quoteIdentifier(mediaTableDetail.name)} SET ${quoteIdentifier(
1675
- taggedColumn
1676
- )} = 1 WHERE rowid = ?`
1783
+ [
1784
+ "UPDATE",
1785
+ quoteIdentifier(mediaTableDetail.name),
1786
+ "SET",
1787
+ quoteIdentifier(taggedColumn),
1788
+ "= 1 WHERE rowid = ?",
1789
+ ].join(" ")
1677
1790
  ).run(rowIdValue);
1678
1791
  return;
1679
1792
  }
@@ -54,9 +54,9 @@ class StructureService {
54
54
  const table = getTableDetail(db, tableName);
55
55
  const previewLimit = Math.max(1, this.appStateStore.getSettings().defaultPageSize ?? 50);
56
56
  const previewStatement = db.prepare(
57
- `SELECT * FROM ${quoteIdentifier(tableName)} LIMIT ${previewLimit}`
57
+ ["SELECT * FROM", quoteIdentifier(tableName), "LIMIT ?"].join(" ")
58
58
  );
59
- const previewRows = serializeRows(previewStatement.all());
59
+ const previewRows = serializeRows(previewStatement.all(previewLimit));
60
60
  const previewColumns = previewStatement.columns().map((column) => column.name);
61
61
 
62
62
  return {
@@ -42,16 +42,32 @@ function buildCreateTableSql(draft) {
42
42
  }
43
43
 
44
44
  function buildAlterTableRenameSql(fromName, toName) {
45
- return `ALTER TABLE ${quoteIdentifier(fromName)} RENAME TO ${quoteIdentifier(toName)};`;
45
+ return [
46
+ "ALTER TABLE",
47
+ quoteIdentifier(fromName),
48
+ "RENAME TO",
49
+ quoteIdentifier(toName),
50
+ ].join(" ") + ";";
46
51
  }
47
52
 
48
53
  function buildAlterTableAddColumnSql(tableName, column) {
49
- return `ALTER TABLE ${quoteIdentifier(tableName)} ADD COLUMN ${buildColumnDefinition(column)};`;
54
+ return [
55
+ "ALTER TABLE",
56
+ quoteIdentifier(tableName),
57
+ "ADD COLUMN",
58
+ buildColumnDefinition(column),
59
+ ].join(" ") + ";";
50
60
  }
51
61
 
52
62
  function buildInsertRowsSql(tableName, columns) {
53
63
  const placeholders = columns.map(() => "?").join(", ");
54
- return `INSERT INTO ${quoteIdentifier(tableName)} (${quoteIdentifierList(columns)}) VALUES (${placeholders});`;
64
+ return [
65
+ "INSERT INTO",
66
+ quoteIdentifier(tableName),
67
+ "(" + quoteIdentifierList(columns) + ")",
68
+ "VALUES",
69
+ "(" + placeholders + ");",
70
+ ].join(" ");
55
71
  }
56
72
 
57
73
  module.exports = {