From 66f73c42286a17cdc8fc094e58a7875017f215ea Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 14 Oct 2026 02:06:59 +0100 Subject: [PATCH] Fix SQL injection, schema-sync data loss, and backup safety Security: - select: bind parameters instead of mysql.format (backslash escaping allowed injection on SQLite); drop the mysql dependency - validate all interpolated identifiers, limit/offset/page, order strategy, operators, join types and raw equalities (new sql-safe.ts) - quote identifiers and bind values in schema manager DDL/queries Data loss: - schema sync runs with foreign keys off inside a transaction, with foreign_key_check before commit (DROP TABLE no longer cascades) - parentTableName tables: stop mutating the parent schema and copy inherited columns on rebuild - keep column data on type changes (match columns by name) - backups and schema snapshots use VACUUM INTO (includes WAL data); restore closes the DB, clears -wal/-shm, and swaps atomically - backup listing/trimming only touches - files Correctness: - update/delete use the generator's WHERE clause instead of a regex - keep 0/""/false values; booleans stored as 0/1 - IN/NOT IN and BETWEEN arrays, NOT LIKE wrapping, AVG, select aliases - OFFSET without LIMIT, count ignores pagination - MATCH emits `field MATCH fn(?)` for sqlite-vec - sql() returns rows for any row-producing statement - targetId no longer mutates the caller's query - init throws instead of process.exit; CLI reports errors Co-Authored-By: Claude Opus 5.5 --- bun.lock | 26 --- package.json | 2 - src/commands/backup.ts | 10 +- src/commands/index.ts | 5 +- src/commands/restore.ts | 12 +- src/commands/schema.ts | 12 +- src/functions/init.ts | 95 ++++---- src/lib/grab-duplicate-safe-insert-sql.ts | 19 +- src/lib/sqlite/db-delete.ts | 24 +- src/lib/sqlite/db-schema-manager.ts | 186 +++++++++------ src/lib/sqlite/db-schema-to-typedef.ts | 2 +- src/lib/sqlite/db-select.ts | 43 ++-- src/lib/sqlite/db-sql.ts | 14 +- src/lib/sqlite/db-update.ts | 43 ++-- src/utils/grab-sorted-backups.ts | 21 +- src/utils/replace-db-file.ts | 25 +++ src/utils/snapshot-db.ts | 19 ++ src/utils/sql-gen-operator-gen.ts | 166 +++++++------- src/utils/sql-generator-gen-join-str.ts | 70 +++--- src/utils/sql-generator-gen-query-str.ts | 178 +++++++-------- src/utils/sql-generator-gen-search-str.ts | 166 ++++++++------ src/utils/sql-generator-grab-concat-str.ts | 8 +- .../sql-generator-grab-select-field-sql.ts | 70 +++--- src/utils/sql-generator.ts | 212 +++++++++--------- src/utils/sql-insert-generator.ts | 111 +++++---- src/utils/sql-safe.ts | 106 +++++++++ 26 files changed, 927 insertions(+), 718 deletions(-) create mode 100644 src/utils/replace-db-file.ts create mode 100644 src/utils/snapshot-db.ts create mode 100644 src/utils/sql-safe.ts diff --git a/bun.lock b/bun.lock index 6e0a84e..26ff395 100644 --- a/bun.lock +++ b/bun.lock @@ -10,14 +10,12 @@ "commander": "^14.0.3", "inquirer": "^13.3.2", "lodash": "^4.17.23", - "mysql": "^2.18.1", "sqlite-vec": "^0.1.7-alpha.2", }, "devDependencies": { "@types/bun": "latest", "@types/inquirer": "^9.0.9", "@types/lodash": "^4.17.24", - "@types/mysql": "^2.15.27", "@types/node": "^25.3.3", }, "peerDependencies": { @@ -64,14 +62,10 @@ "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], - "@types/mysql": ["@types/mysql@2.15.27", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="], - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], "@types/through": ["@types/through@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="], - "bignumber.js": ["bignumber.js@9.0.0", "", {}, "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A=="], - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -82,8 +76,6 @@ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -92,28 +84,16 @@ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "inquirer": ["inquirer@13.3.2", "", { "dependencies": { "@inquirer/ansi": "^2.0.4", "@inquirer/core": "^11.1.7", "@inquirer/prompts": "^8.3.2", "@inquirer/type": "^4.0.4", "mute-stream": "^3.0.0", "run-async": "^4.0.6", "rxjs": "^7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-bh/OjBGxNR9qvfQj1n5bxtIF58mbOTp2InN5dKuwUK03dXcDGFsjlDinQRuXMZ4EGiJaFieUWHCAaxH2p7iUBw=="], - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], - "mysql": ["mysql@2.18.1", "", { "dependencies": { "bignumber.js": "9.0.0", "readable-stream": "2.3.7", "safe-buffer": "5.1.2", "sqlstring": "2.3.1" } }, "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig=="], - - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - - "readable-stream": ["readable-stream@2.3.7", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw=="], - "run-async": ["run-async@4.0.6", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="], "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -130,18 +110,12 @@ "sqlite-vec-windows-x64": ["sqlite-vec-windows-x64@0.1.7-alpha.2", "", { "os": "win32", "cpu": "x64" }, "sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ=="], - "sqlstring": ["sqlstring@2.3.1", "", {}, "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ=="], - - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "@inquirer/checkbox/@inquirer/ansi": ["@inquirer/ansi@2.0.3", "", {}, "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw=="], "@inquirer/checkbox/@inquirer/core": ["@inquirer/core@11.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.3", "@inquirer/figures": "^2.0.3", "@inquirer/type": "^4.0.3", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A=="], diff --git a/package.json b/package.json index 2632677..8925273 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "@types/bun": "latest", "@types/inquirer": "^9.0.9", "@types/lodash": "^4.17.24", - "@types/mysql": "^2.15.27", "@types/node": "^25.3.3" }, "peerDependencies": { @@ -36,7 +35,6 @@ "commander": "^14.0.3", "inquirer": "^13.3.2", "lodash": "^4.17.23", - "mysql": "^2.18.1", "sqlite-vec": "^0.1.7-alpha.2" } } diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 7a3d80b..a3b1692 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -2,10 +2,11 @@ import { Command } from "commander"; import init from "../functions/init"; import path from "path"; import grabDBDir from "../utils/grab-db-dir"; -import fs from "fs"; import grabDBBackupFileName from "../utils/grab-db-backup-file-name"; import chalk from "chalk"; import trimBackups from "../utils/trim-backups"; +import DbClient from "../lib/sqlite"; +import snapshotDb from "../utils/snapshot-db"; export default function () { return new Command("backup") @@ -15,11 +16,14 @@ export default function () { const { config } = await init(); - const { backup_dir, db_file_path } = grabDBDir({ config }); + const { backup_dir } = grabDBDir({ config }); const new_db_file_name = grabDBBackupFileName({ config }); - fs.cpSync(db_file_path, path.join(backup_dir, new_db_file_name)); + snapshotDb({ + db: DbClient, + dst: path.join(backup_dir, new_db_file_name), + }); trimBackups({ config }); diff --git a/src/commands/index.ts b/src/commands/index.ts index b44a4ee..d03757a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -43,4 +43,7 @@ program.on("command:*", () => { /** * # Parse Arguments */ -program.parse(Bun.argv); +program.parseAsync(Bun.argv).catch((error: any) => { + console.error(`ERROR => ${error?.message || error}`); + process.exit(1); +}); diff --git a/src/commands/restore.ts b/src/commands/restore.ts index fc8ad0b..4bc4c88 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -1,7 +1,8 @@ import { Command } from "commander"; import init from "../functions/init"; import grabDBDir from "../utils/grab-db-dir"; -import fs from "fs"; +import DbClient from "../lib/sqlite"; +import replaceDbFile from "../utils/replace-db-file"; import chalk from "chalk"; import grabSortedBackups from "../utils/grab-sorted-backups"; import { select } from "@inquirer/prompts"; @@ -41,7 +42,12 @@ export default function () { }), }); - fs.cpSync(path.join(backup_dir, selected_backup), db_file_path); + DbClient.close(); + + replaceDbFile({ + src: path.join(backup_dir, selected_backup), + db_file_path, + }); console.log( `${chalk.bold(chalk.green(`DB Restore Success!`))}`, @@ -50,7 +56,7 @@ export default function () { process.exit(); } catch (error: any) { console.error(`Backup Restore ERROR => ${error.message}`); - process.exit(); + process.exit(1); } }); } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 49a0f2b..5ece199 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -9,7 +9,9 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db- import chalk from "chalk"; import { writeLiveSchema } from "../functions/live-schema"; import grabDBDir from "../utils/grab-db-dir"; -import { cpSync } from "fs"; +import DbClient from "../lib/sqlite"; +import snapshotDb from "../utils/snapshot-db"; +import replaceDbFile from "../utils/replace-db-file"; export default function () { return new Command("schema") @@ -26,7 +28,7 @@ export default function () { const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames(); const { db_file_path } = grabDBDir({ config }); - cpSync(db_file_path, BUN_SQLITE_TEMP_DB_FILE_PATH); + snapshotDb({ db: DbClient, dst: BUN_SQLITE_TEMP_DB_FILE_PATH }); try { const isVector = Boolean(opts.vector || opts.v); @@ -65,7 +67,11 @@ export default function () { process.exit(); } catch (error) { console.log(error); - cpSync(BUN_SQLITE_TEMP_DB_FILE_PATH, db_file_path); + DbClient.close(); + replaceDbFile({ + src: BUN_SQLITE_TEMP_DB_FILE_PATH, + db_file_path, + }); process.exit(1); } }); diff --git a/src/functions/init.ts b/src/functions/init.ts index 7c61093..f5ee5ea 100644 --- a/src/functions/init.ts +++ b/src/functions/init.ts @@ -9,61 +9,48 @@ import type { } from "../types"; export default async function init(): Promise { - try { - const { ROOT_DIR } = grabDirNames(); - const { ConfigFileName } = AppData; + const { ROOT_DIR } = grabDirNames(); + const { ConfigFileName } = AppData; - const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName); + const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName); - if (!fs.existsSync(ConfigFilePath)) { - console.log("ConfigFilePath", ConfigFilePath); - - console.error( - `Please create a \`${ConfigFileName}\` file at the root of your project.`, - ); - process.exit(1); - } - - const ConfigImport = await import(ConfigFilePath); - const Config = ConfigImport["default"] as BunSQLiteConfig; - - if (!Config.db_name) { - console.error(`\`db_name\` is required in your config`); - process.exit(1); - } - - if (!Config.db_schema_file_name) { - console.error(`\`db_schema_file_name\` is required in your config`); - process.exit(1); - } - - let db_dir = ROOT_DIR; - - if (Config.db_dir) { - db_dir = path.resolve(ROOT_DIR, Config.db_dir); - - if (!fs.existsSync(Config.db_dir)) { - fs.mkdirSync(Config.db_dir, { recursive: true }); - } - } - - const DBSchemaFilePath = path.join(db_dir, Config.db_schema_file_name); - const DbSchemaImport = await import(DBSchemaFilePath); - const DbSchema = DbSchemaImport[ - "default" - ] as BUN_SQLITE_DatabaseSchemaType; - - const backup_dir = - Config.db_backup_dir || AppData["DefaultBackupDirName"]; - - const BackupDir = path.resolve(db_dir, backup_dir); - if (!fs.existsSync(BackupDir)) { - fs.mkdirSync(BackupDir, { recursive: true }); - } - - return { config: Config, dbSchema: DbSchema }; - } catch (error: any) { - console.error(`Initialization ERROR => ` + error.message); - process.exit(1); + if (!fs.existsSync(ConfigFilePath)) { + throw new Error( + `Please create a \`${ConfigFileName}\` file at the root of your project. (Looked for ${ConfigFilePath})`, + ); } + + const ConfigImport = await import(ConfigFilePath); + const Config = ConfigImport["default"] as BunSQLiteConfig; + + if (!Config.db_name) { + throw new Error(`\`db_name\` is required in your config`); + } + + if (!Config.db_schema_file_name) { + throw new Error(`\`db_schema_file_name\` is required in your config`); + } + + let db_dir = ROOT_DIR; + + if (Config.db_dir) { + db_dir = path.resolve(ROOT_DIR, Config.db_dir); + + if (!fs.existsSync(db_dir)) { + fs.mkdirSync(db_dir, { recursive: true }); + } + } + + const DBSchemaFilePath = path.join(db_dir, Config.db_schema_file_name); + const DbSchemaImport = await import(DBSchemaFilePath); + const DbSchema = DbSchemaImport["default"] as BUN_SQLITE_DatabaseSchemaType; + + const backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"]; + + const BackupDir = path.resolve(db_dir, backup_dir); + if (!fs.existsSync(BackupDir)) { + fs.mkdirSync(BackupDir, { recursive: true }); + } + + return { config: Config, dbSchema: DbSchema }; } diff --git a/src/lib/grab-duplicate-safe-insert-sql.ts b/src/lib/grab-duplicate-safe-insert-sql.ts index 80c02ef..e638cc9 100644 --- a/src/lib/grab-duplicate-safe-insert-sql.ts +++ b/src/lib/grab-duplicate-safe-insert-sql.ts @@ -1,4 +1,5 @@ import init from "../functions/init"; +import { sqlIdent, sqlQuoteIdent } from "../utils/sql-safe"; type Params = { sql: string; @@ -13,9 +14,12 @@ export default async function ({ sql: passed_sql, table, data }: Params) { const now = Date.now(); if (table_schema?.tableName) { - const set_sql_arr = Object.keys( - Array.isArray(data) ? data[0] : data, - ).map((field) => `${field} = excluded.${field}`); + const set_sql_arr = Object.keys(Array.isArray(data) ? data[0] : data) + .filter((field) => field !== "updated_at") + .map((field) => { + const column = sqlIdent(field, "column name"); + return `${column} = excluded.${column}`; + }); set_sql_arr.push(`updated_at = ${now}`); @@ -25,13 +29,18 @@ export default async function ({ sql: passed_sql, table, data }: Params) { for (let i = 0; i < unique_fields.length; i++) { const field = unique_fields[i]; - sql += ` ON CONFLICT(${field?.fieldName}) DO UPDATE SET ${set_sql}`; + if (!field?.fieldName) continue; + sql += ` ON CONFLICT(${sqlQuoteIdent(field.fieldName)}) DO UPDATE SET ${set_sql}`; } if (table_schema.uniqueConstraints?.[0]) { for (let i = 0; i < table_schema.uniqueConstraints.length; i++) { const constraint = table_schema.uniqueConstraints[i]; - sql += ` ON CONFLICT(${constraint?.constraintTableFields?.map((c) => c.value)?.join(", ")}) DO UPDATE SET ${set_sql}`; + const columns = constraint?.constraintTableFields + ?.map((c) => sqlQuoteIdent(String(c.value))) + ?.join(", "); + if (!columns) continue; + sql += ` ON CONFLICT(${columns}) DO UPDATE SET ${set_sql}`; } } } diff --git a/src/lib/sqlite/db-delete.ts b/src/lib/sqlite/db-delete.ts index e16f22b..1cf31f4 100644 --- a/src/lib/sqlite/db-delete.ts +++ b/src/lib/sqlite/db-delete.ts @@ -25,30 +25,32 @@ export default async function DbDelete< try { let finalQuery = query || {}; - if (targetId) { - finalQuery = _.merge, ServerQueryParam>( - finalQuery, - { - query: { - id: { - value: String(targetId), - }, + if (targetId !== undefined && targetId !== null && targetId !== "") { + finalQuery = _.merge< + ServerQueryParam, + ServerQueryParam, + ServerQueryParam + >({}, finalQuery, { + query: { + id: { + value: String(targetId), }, }, - ); + }); } sqlObj = sqlGenerator({ tableName: table, - genObject: finalQuery, + genObject: _.pick(finalQuery, ["query", "searchOperator"]), }); - const whereClause = sqlObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlObj.where.string; if (whereClause) { let sql = `DELETE FROM ${table} ${whereClause}`; sqlObj.string = sql; + sqlObj.values = sqlObj.where.values; const res = DbClient.run(sql, sqlObj.values); diff --git a/src/lib/sqlite/db-schema-manager.ts b/src/lib/sqlite/db-schema-manager.ts index bd77659..103c497 100644 --- a/src/lib/sqlite/db-schema-manager.ts +++ b/src/lib/sqlite/db-schema-manager.ts @@ -11,6 +11,7 @@ import type { } from "../../types"; import { AppData } from "../../data/app-data"; import { readLiveSchema } from "../../functions/live-schema"; +import { sqlQuoteIdent as q } from "../../utils/sql-safe"; // Schema Manager Class class SQLiteSchemaManager { @@ -64,17 +65,49 @@ class SQLiteSchemaManager { async syncSchema(): Promise { console.log("Starting schema synchronization..."); - const existingTables = this.getExistingTables(); - const schemaTables = this.db_schema.tables.map((t) => t.tableName); + /** + * Tables are rebuilt by copying into a new table and dropping the + * old one. With foreign keys ON, `DROP TABLE` runs an implicit + * `DELETE FROM` that fires `ON DELETE CASCADE` on child tables, so + * keys are disabled for the sync and checked before committing. + * (`PRAGMA foreign_keys` is a no-op inside a transaction.) + */ + this.db.run("PRAGMA foreign_keys = OFF;"); + this.db.run("BEGIN"); - // 2. Create or update tables - for (const table of this.db_schema.tables) { - await this.syncTable(table, existingTables); + try { + const existingTables = this.getExistingTables(); + const schemaTables = this.db_schema.tables.map((t) => t.tableName); + + // 1. Create or update tables + for (const table of this.db_schema.tables) { + await this.syncTable(table, existingTables); + } + + // 2. Drop tables that no longer exist in schema + await this.dropRemovedTables(existingTables, schemaTables); + + const fkViolations = this.db + .query(`PRAGMA foreign_key_check`) + .all() as { table: string; parent: string }[]; + + if (fkViolations.length) { + const tables = _.uniq( + fkViolations.map((v) => `${v.table} -> ${v.parent}`), + ); + throw new Error( + `Foreign key violations after schema sync: ${tables.join(", ")}`, + ); + } + + this.db.run("COMMIT"); + } catch (error) { + if (this.db.inTransaction) this.db.run("ROLLBACK"); + throw error; + } finally { + this.db.run("PRAGMA foreign_keys = ON;"); } - // 1. Drop tables that no longer exist in schema - await this.dropRemovedTables(existingTables, schemaTables); - console.log("Schema synchronization complete!"); } @@ -125,10 +158,8 @@ class SQLiteSchemaManager { for (const tableName of tablesToDrop) { console.log(`Dropping table: ${tableName}`); - this.db.run(`DROP TABLE IF EXISTS "${tableName}"`); - this.db.run( - `DELETE FROM ${this.db_manager_table_name} WHERE table_name = "${tableName}"`, - ); + this.db.run(`DROP TABLE IF EXISTS ${q(tableName)}`); + this.removeDbManagerTable(tableName); } } @@ -148,7 +179,7 @@ class SQLiteSchemaManager { `Renaming table: ${table.tableNameOld} -> ${table.tableName}`, ); this.db.run( - `ALTER TABLE "${table.tableNameOld}" RENAME TO "${table.tableName}"`, + `ALTER TABLE ${q(table.tableNameOld)} RENAME TO ${q(table.tableName)}`, ); this.insertDbManagerTable(table.tableName); this.removeDbManagerTable(table.tableNameOld); @@ -179,33 +210,19 @@ class SQLiteSchemaManager { console.log(`Creating table: ${table.tableName}`); } - let new_table = _.cloneDeep(table); - - if (new_table.parentTableName) { - const parent_table = this.db_schema.tables.find( - (t) => t.tableName === new_table.parentTableName, - ); - - if (!parent_table) { - throw new Error( - `Parent table \`${new_table.parentTableName}\` not found for \`${new_table.tableName}\``, - ); - } - - new_table = _.merge(parent_table, { - tableName: new_table.tableName, - tableDescription: new_table.tableDescription, - }); - } + const new_table = this.resolveTable(table); const columns: string[] = []; const foreignKeys: string[] = []; for (const field of new_table.fields) { - const columnDef = this.buildColumnDefinition(field, table.isVector); + const columnDef = this.buildColumnDefinition( + field, + new_table.isVector, + ); columns.push(columnDef); - if (field.foreignKey && !table.isVector) { + if (field.foreignKey && !new_table.isVector) { foreignKeys.push(this.buildForeignKeyConstraint(field)); } } @@ -217,14 +234,16 @@ class SQLiteSchemaManager { constraint.constraintTableFields && constraint.constraintTableFields.length > 0 ) { - const fields = constraint.constraintTableFields - .map((f) => `"${f.value}"`) - .join(", "); + const fieldNames = constraint.constraintTableFields.map( + (f) => String(f.value), + ); const constraintName = constraint.constraintName || - `unique_${fields.replace(/"/g, "")}`; + `unique_${fieldNames.join(", ")}`; columns.push( - `CONSTRAINT "${constraintName}" UNIQUE (${fields})`, + `CONSTRAINT ${q(constraintName)} UNIQUE (${fieldNames + .map(q) + .join(", ")})`, ); } } @@ -233,12 +252,40 @@ class SQLiteSchemaManager { const allConstraints = [...columns, ...foreignKeys]; const sql = new_table.isVector - ? `CREATE VIRTUAL TABLE "${new_table.tableName}" USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})` - : `CREATE TABLE "${new_table.tableName}" (${allConstraints.join(", ")})`; + ? `CREATE VIRTUAL TABLE ${q(new_table.tableName)} USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})` + : `CREATE TABLE ${q(new_table.tableName)} (${allConstraints.join(", ")})`; this.db.run(sql); } + /** + * Resolve a table's effective definition. Tables with + * `parentTableName` take their structure from the parent. Always + * returns a copy: the schema objects are never mutated. + */ + private resolveTable( + table: BUN_SQLITE_TableSchemaType, + ): BUN_SQLITE_TableSchemaType { + if (!table.parentTableName) { + return _.cloneDeep(table); + } + + const parent_table = this.db_schema.tables.find( + (t) => t.tableName === table.parentTableName, + ); + + if (!parent_table) { + throw new Error( + `Parent table \`${table.parentTableName}\` not found for \`${table.tableName}\``, + ); + } + + return _.merge(_.cloneDeep(parent_table), { + tableName: table.tableName, + tableDescription: table.tableDescription, + }); + } + /** * Update an existing table */ @@ -298,7 +345,7 @@ class SQLiteSchemaManager { private getTableColumns( tableName: string, ): { name: string; type: string }[] { - const query = this.db.query(`PRAGMA table_info("${tableName}")`); + const query = this.db.query(`PRAGMA table_info(${q(tableName)})`); const results = query.all() as { name: string; type: string }[]; return results; } @@ -320,7 +367,7 @@ class SQLiteSchemaManager { .replace(/UNIQUE/gi, "") .trim(); - const sql = `ALTER TABLE "${tableName}" ADD COLUMN ${cleanDef}`; + const sql = `ALTER TABLE ${q(tableName)} ADD COLUMN ${cleanDef}`; this.db.run(sql); } @@ -352,10 +399,10 @@ class SQLiteSchemaManager { if (does_table_exist) { existingRows = this.db - .query(`SELECT * FROM "${table.tableName}"`) + .query(`SELECT * FROM ${q(table.tableName)}`) .all() as { [k: string]: any }[]; - this.db.run(`DROP TABLE "${table.tableName}"`); + this.db.run(`DROP TABLE ${q(table.tableName)}`); } await this.createTable(table); @@ -369,7 +416,7 @@ class SQLiteSchemaManager { const placeholders = columns.map(() => "?").join(", "); this.db.run( - `INSERT INTO "${table.tableName}" (${columns.join(", ")}) VALUES (${placeholders})`, + `INSERT INTO ${q(table.tableName)} (${columns.map(q).join(", ")}) VALUES (${placeholders})`, Object.values(row), ); } @@ -380,19 +427,17 @@ class SQLiteSchemaManager { const tempTableName = `${table.tableName}_temp_${Date.now()}`; - // Get existing data + // Get existing data. Columns are matched by name only: SQLite + // converts values to the new column affinity on copy, so a type + // change keeps the data instead of silently dropping the column. const existingColumns = this.getTableColumns(table.tableName); - const columnsToKeep = table.fields - .filter( + const columnsToKeep = this.resolveTable(table) + .fields.filter( (f) => f.fieldName && - existingColumns.find( - (c) => - c.name == f.fieldName && - c.type == this.mapDataType(f), - ), + existingColumns.find((c) => c.name == f.fieldName), ) - .map((f) => f.fieldName); + .map((f) => f.fieldName as string); // Create temp table with new schema const tempTable = { ...table, tableName: tempTableName }; @@ -400,18 +445,18 @@ class SQLiteSchemaManager { // Copy data if there are common columns if (columnsToKeep.length > 0) { - const columnList = columnsToKeep.map((c) => `"${c}"`).join(", "); + const columnList = columnsToKeep.map(q).join(", "); this.db.run( - `INSERT INTO "${tempTableName}" (${columnList}) SELECT ${columnList} FROM "${table.tableName}"`, + `INSERT INTO ${q(tempTableName)} (${columnList}) SELECT ${columnList} FROM ${q(table.tableName)}`, ); } // Drop old table - this.db.run(`DROP TABLE "${table.tableName}"`); + this.db.run(`DROP TABLE ${q(table.tableName)}`); // Rename temp table this.db.run( - `ALTER TABLE "${tempTableName}" RENAME TO "${table.tableName}"`, + `ALTER TABLE ${q(tempTableName)} RENAME TO ${q(table.tableName)}`, ); } @@ -426,9 +471,12 @@ class SQLiteSchemaManager { throw new Error("Field name is required"); } - const fieldName = field.sideCar - ? `+${field.fieldName}` - : `${field.fieldName}`; + // vec0 can't parse quoted column names + const fieldName = is_vector + ? field.sideCar + ? `+${field.fieldName}` + : `${field.fieldName}` + : q(field.fieldName); const parts: string[] = [fieldName]; @@ -538,7 +586,7 @@ class SQLiteSchemaManager { field: BUN_SQLITE_FieldSchemaType, ): string { const fk = field.foreignKey!; - let constraint = `FOREIGN KEY ("${field.fieldName}") REFERENCES "${fk.destinationTableName}"("${fk.destinationTableColumnName}")`; + let constraint = `FOREIGN KEY (${q(field.fieldName!)}) REFERENCES ${q(String(fk.destinationTableName))}(${q(String(fk.destinationTableColumnName))})`; if (fk.cascadeDelete) { constraint += " ON DELETE CASCADE"; @@ -563,11 +611,11 @@ class SQLiteSchemaManager { // Get existing indexes const query = this.db.query( - `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='${table.tableName}' AND name NOT LIKE 'sqlite_%'`, - ); - const existingIndexes = (query.all() as { name: string }[]).map( - (r) => r.name, + `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'`, ); + const existingIndexes = ( + query.all(table.tableName) as { name: string }[] + ).map((r) => r.name); // Drop indexes not in schema for (const indexName of existingIndexes) { @@ -576,7 +624,7 @@ class SQLiteSchemaManager { ); if (!stillExists) { console.log(`Dropping index: ${indexName}`); - this.db.run(`DROP INDEX IF EXISTS "${indexName}"`); + this.db.run(`DROP INDEX IF EXISTS ${q(indexName)}`); } } @@ -593,10 +641,10 @@ class SQLiteSchemaManager { if (!existingIndexes.includes(index.indexName)) { console.log(`Creating index: ${index.indexName}`); const fields = index.indexTableFields - .map((f) => `"${f}"`) + .map((f) => q(String(f))) .join(", "); this.db.run( - `CREATE INDEX "${index.indexName}" ON "${table.tableName}" (${fields})`, + `CREATE INDEX ${q(index.indexName)} ON ${q(table.tableName)} (${fields})`, ); } } diff --git a/src/lib/sqlite/db-schema-to-typedef.ts b/src/lib/sqlite/db-schema-to-typedef.ts index 0394812..6251092 100644 --- a/src/lib/sqlite/db-schema-to-typedef.ts +++ b/src/lib/sqlite/db-schema-to-typedef.ts @@ -40,7 +40,7 @@ export default function dbSchemaToType({ ); if (parent_table) { - final_table = _.merge(parent_table, { + final_table = _.merge(_.cloneDeep(parent_table), { tableName: final_table.tableName, tableDescription: final_table.tableDescription, }); diff --git a/src/lib/sqlite/db-select.ts b/src/lib/sqlite/db-select.ts index 86c3ca4..cd2b6e1 100644 --- a/src/lib/sqlite/db-select.ts +++ b/src/lib/sqlite/db-select.ts @@ -1,4 +1,3 @@ -import mysql from "mysql"; import DbClient from "."; import _ from "lodash"; import type { APIResponseObject, ServerQueryParam } from "../../types"; @@ -28,17 +27,18 @@ export default async function DbSelect< try { let finalQuery = query || {}; - if (targetId) { - finalQuery = _.merge, ServerQueryParam>( - finalQuery, - { - query: { - id: { - value: String(targetId), - }, + if (targetId !== undefined && targetId !== null && targetId !== "") { + finalQuery = _.merge< + ServerQueryParam, + ServerQueryParam, + ServerQueryParam + >({}, finalQuery, { + query: { + id: { + value: String(targetId), }, }, - ); + }); } sqlObj = sqlGenerator({ @@ -46,10 +46,11 @@ export default async function DbSelect< genObject: finalQuery, }); - let sql = mysql.format(sqlObj.string, sqlObj.values); + const sql = sqlObj.string; - const res = DbClient.query(sql); - const batchRes = res.all(); + const batchRes = DbClient.query(sql).all( + ...sqlObj.values, + ); let resp: APIResponseObject = { success: Boolean(batchRes[0]), @@ -62,23 +63,19 @@ export default async function DbSelect< }; if (count) { - let count_sql_object = sqlGenerator({ + const count_sql_object = sqlGenerator({ tableName: table, genObject: finalQuery, count, }); - let count_sql = mysql.format( - count_sql_object.string, - count_sql_object.values, - ); + const count_sql = `SELECT COUNT(*) AS count FROM (${count_sql_object.string}) AS c`; - count_sql = `SELECT COUNT(*) FROM (${count_sql}) as c`; + const count_res = DbClient.query<{ count: number }, any[]>( + count_sql, + ).get(...count_sql_object.values); - const count_res = DbClient.query(count_sql).all(); - - const count_val = count_res[0]?.["COUNT(*)"]; - resp["count"] = Number(count_val); + resp["count"] = Number(count_res?.count); resp["debug"]["count_sql"] = count_sql; } diff --git a/src/lib/sqlite/db-sql.ts b/src/lib/sqlite/db-sql.ts index eb89ad2..e082d68 100644 --- a/src/lib/sqlite/db-sql.ts +++ b/src/lib/sqlite/db-sql.ts @@ -13,10 +13,20 @@ export default async function DbSQL< try { const trimmed_sql = sql.trim(); - const res = trimmed_sql.match(/^select/i) - ? DbClient.query(trimmed_sql).all(...(values || [])) + /** + * Any statement that produces columns (SELECT, WITH, PRAGMA, + * RETURNING, ...) returns rows. Everything else goes through `run`, + * which also handles multiple statements. + */ + const stmt = DbClient.prepare(trimmed_sql); + const returnsRows = stmt.columnNames.length > 0; + + const res = returnsRows + ? stmt.all(...(values || [])) : DbClient.run(trimmed_sql, values || []); + stmt.finalize(); + return { success: true, payload: Array.isArray(res) ? (res as T[]) : undefined, diff --git a/src/lib/sqlite/db-update.ts b/src/lib/sqlite/db-update.ts index 381184c..92866d9 100644 --- a/src/lib/sqlite/db-update.ts +++ b/src/lib/sqlite/db-update.ts @@ -6,6 +6,8 @@ import type { ServerQueryParam, } from "../../types"; import sqlGenerator from "../../utils/sql-generator"; +import { sqlValue } from "../../utils/sql-insert-generator"; +import { sqlIdent } from "../../utils/sql-safe"; type Params< Schema extends { [k: string]: any } = { [k: string]: any }, @@ -26,35 +28,39 @@ export default async function DbUpdate< query, targetId, }: Params): Promise { - let sqlObj: ReturnType = { string: "", values: [] }; + let sqlObj: Pick, "string" | "values"> = { + string: "", + values: [], + }; try { let finalQuery = query || {}; - if (targetId) { - finalQuery = _.merge, ServerQueryParam>( - finalQuery, - { - query: { - id: { - value: String(targetId), - }, + if (targetId !== undefined && targetId !== null && targetId !== "") { + finalQuery = _.merge< + ServerQueryParam, + ServerQueryParam, + ServerQueryParam + >({}, finalQuery, { + query: { + id: { + value: String(targetId), }, }, - ); + }); } const sqlQueryObj = sqlGenerator({ tableName: table, - genObject: finalQuery, + genObject: _.pick(finalQuery, ["query", "searchOperator"]), }); let values: SQLInsertGenValueType[] = []; - const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlQueryObj.where.string; if (whereClause) { - let sql = `UPDATE ${table} SET`; + let sql = `UPDATE ${sqlIdent(table, "table name")} SET`; const finalData: { [k: string]: SQLInsertGenValueType } = { updated_at: Date.now(), @@ -69,13 +75,8 @@ export default async function DbUpdate< const isLast = i == keys.length - 1; - sql += ` ${key}=?`; - const value = finalData[key]; - values.push( - typeof value == "number" || typeof value == "string" - ? value - : value || null, - ); + sql += ` ${sqlIdent(key, "column name")}=?`; + values.push(sqlValue(finalData[key])); if (!isLast) { sql += `,`; @@ -83,7 +84,7 @@ export default async function DbUpdate< } sql += ` ${whereClause}`; - values = [...values, ...sqlQueryObj.values]; + values = [...values, ...sqlQueryObj.where.values]; sqlObj.string = sql; sqlObj.values = values as any[]; diff --git a/src/utils/grab-sorted-backups.ts b/src/utils/grab-sorted-backups.ts index aa8fb4f..05de4db 100644 --- a/src/utils/grab-sorted-backups.ts +++ b/src/utils/grab-sorted-backups.ts @@ -1,5 +1,6 @@ import grabDBDir from "../utils/grab-db-dir"; import fs from "fs"; +import _ from "lodash"; import type { BunSQLiteConfig } from "../types"; type Params = { @@ -9,7 +10,19 @@ type Params = { export default function grabSortedBackups({ config }: Params) { const { backup_dir } = grabDBDir({ config }); - const backups = fs.readdirSync(backup_dir); + if (!fs.existsSync(backup_dir)) return []; + + /** + * Only files named by `grabDBBackupFileName` for this database. Anything + * else in the directory is never listed (or trimmed). + */ + const backup_name_regex = new RegExp( + `^${_.escapeRegExp(config.db_name)}-\\d+$`, + ); + + const backups = fs + .readdirSync(backup_dir) + .filter((b) => backup_name_regex.test(b)); /** * Order Backups. Most recent first. @@ -18,11 +31,7 @@ export default function grabSortedBackups({ config }: Params) { const a_date = Number(a.split("-").pop()); const b_date = Number(b.split("-").pop()); - if (a_date > b_date) { - return -1; - } - - return 1; + return b_date - a_date; }); return ordered_backups; diff --git a/src/utils/replace-db-file.ts b/src/utils/replace-db-file.ts new file mode 100644 index 0000000..00c7469 --- /dev/null +++ b/src/utils/replace-db-file.ts @@ -0,0 +1,25 @@ +import fs from "fs"; + +type Params = { + src: string; + db_file_path: string; +}; + +/** + * # Replace the database file with a copy of `src` + * @description All connections to `db_file_path` must be closed first. + * Leftover `-wal`/`-shm`/`-journal` files are removed so SQLite doesn't + * replay stale pages onto the new file, and the copy is swapped in with an + * atomic rename. + */ +export default function replaceDbFile({ src, db_file_path }: Params) { + const tmp_file_path = `${db_file_path}.replace-${Date.now()}`; + + fs.copyFileSync(src, tmp_file_path); + + for (const suffix of ["-wal", "-shm", "-journal"]) { + fs.rmSync(`${db_file_path}${suffix}`, { force: true }); + } + + fs.renameSync(tmp_file_path, db_file_path); +} diff --git a/src/utils/snapshot-db.ts b/src/utils/snapshot-db.ts new file mode 100644 index 0000000..23b7619 --- /dev/null +++ b/src/utils/snapshot-db.ts @@ -0,0 +1,19 @@ +import fs from "fs"; +import path from "path"; +import type { Database } from "bun:sqlite"; + +type Params = { + db: Database; + dst: string; +}; + +/** + * # Write a consistent copy of the database to `dst` + * @description Uses `VACUUM INTO`, which includes changes still in the WAL + * file (a plain file copy misses them). + */ +export default function snapshotDb({ db, dst }: Params) { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.rmSync(dst, { force: true }); + db.run("VACUUM INTO ?", [dst]); +} diff --git a/src/utils/sql-gen-operator-gen.ts b/src/utils/sql-gen-operator-gen.ts index 3ffd445..7bc2c83 100644 --- a/src/utils/sql-gen-operator-gen.ts +++ b/src/utils/sql-gen-operator-gen.ts @@ -4,6 +4,7 @@ import type { SQLInsertGenValueType, } from "../types"; import sqlEqualityParser from "./sql-equality-parser"; +import { sqlIdent } from "./sql-safe"; type Params = { fieldName: string; @@ -15,17 +16,22 @@ type Params = { }, string >; + /** + * `value` is an already-validated `table.field` reference rather than a + * literal, so it is inlined instead of bound + */ isValueFieldValue?: boolean; }; type Return = { - str?: string; - param?: SQLInsertGenValueType; + str: string; + params: SQLInsertGenValueType[]; }; /** * # SQL Gen Operator Gen - * @description Generates an SQL operator for node module `mysql` or `serverless-mysql` + * @description Generates an SQL condition for a single field. Literal values + * are always returned in `params` to be bound, never inlined. */ export default function sqlGenOperatorGen({ fieldName, @@ -34,116 +40,96 @@ export default function sqlGenOperatorGen({ queryObj, isValueFieldValue, }: Params): Return { - if (queryObj.nullValue) { - return { str: `${fieldName} IS NULL` }; + if (queryObj.nullValue || equality == "IS NULL") { + return { str: `${fieldName} IS NULL`, params: [] }; } - if (queryObj.notNullValue) { - return { str: `${fieldName} IS NOT NULL` }; + if (queryObj.notNullValue || equality == "IS NOT NULL") { + return { str: `${fieldName} IS NOT NULL`, params: [] }; } - if (value) { - const finalValue = isValueFieldValue ? value : "?"; - const finalParams = isValueFieldValue ? undefined : value; + const finalValue = isValueFieldValue ? String(value) : "?"; + const params: SQLInsertGenValueType[] = isValueFieldValue + ? [] + : [value ?? ""]; - if (equality == "MATCH") { + const wrappedValue = isValueFieldValue ? `'%' || ${value} || '%'` : "?"; + const wrappedParams: SQLInsertGenValueType[] = isValueFieldValue + ? [] + : [`%${value ?? ""}%`]; + + switch (equality) { + case "MATCH": + case "MATCH_BOOLEAN": + case "FULLTEXT": { + const matchValue = + queryObj.vectorFunction && !isValueFieldValue + ? `${sqlIdent(queryObj.vectorFunction, "vector function")}(?)` + : finalValue; + + return { str: `${fieldName} MATCH ${matchValue}`, params }; + } + case "LIKE_LOWER": return { - str: `MATCH(${fieldName}) AGAINST(${finalValue} IN NATURAL LANGUAGE MODE)`, - param: finalParams, + str: `LOWER(${fieldName}) LIKE LOWER(${wrappedValue})`, + params: wrappedParams, }; - } else if (equality == "MATCH_BOOLEAN") { - return { - str: `MATCH(${fieldName}) AGAINST(${finalValue} IN BOOLEAN MODE)`, - param: finalParams, - }; - } else if (equality == "LIKE_LOWER") { + case "LIKE_LOWER_RAW": return { str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`, - param: `%${finalParams}%`, + params, }; - } else if (equality == "LIKE_LOWER_RAW") { + case "LIKE": return { - str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`, - param: finalParams, + str: `${fieldName} LIKE ${wrappedValue}`, + params: wrappedParams, }; - } else if (equality == "LIKE") { + case "LIKE_RAW": + return { str: `${fieldName} LIKE ${finalValue}`, params }; + case "NOT_LIKE_LOWER": return { - str: `${fieldName} LIKE ${finalValue}`, - param: `%${finalParams}%`, + str: `LOWER(${fieldName}) NOT LIKE LOWER(${wrappedValue})`, + params: wrappedParams, }; - } else if (equality == "LIKE_RAW") { - return { - str: `${fieldName} LIKE ${finalValue}`, - param: finalParams, - }; - } else if (equality == "NOT_LIKE_LOWER") { + case "NOT_LIKE_LOWER_RAW": return { str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`, - param: `%${finalParams}%`, + params, }; - } else if (equality == "NOT_LIKE_LOWER_RAW") { + case "NOT LIKE": return { - str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`, - param: finalParams, + str: `${fieldName} NOT LIKE ${wrappedValue}`, + params: wrappedParams, }; - } else if (equality == "NOT LIKE") { - return { - str: `${fieldName} NOT LIKE ${finalValue}`, - param: finalParams, - }; - } else if (equality == "NOT LIKE_RAW") { - return { - str: `${fieldName} NOT LIKE ${finalValue}`, - param: finalParams, - }; - } else if (equality == "REGEXP") { + case "NOT LIKE_RAW": + return { str: `${fieldName} NOT LIKE ${finalValue}`, params }; + case "REGEXP": + /** + * SQLite has no built-in REGEXP function. This only works when + * an extension providing `regexp()` is loaded. + */ return { str: `LOWER(${fieldName}) REGEXP LOWER(${finalValue})`, - param: finalParams, + params, }; - } else if (equality == "FULLTEXT") { + case "NOT EQUAL": + return { str: `${fieldName} != ${finalValue}`, params }; + case "IS NOT": + return { str: `${fieldName} IS NOT ${finalValue}`, params }; + case "IN": + case "NOT IN": + return { str: `${fieldName} ${equality} (${finalValue})`, params }; + case "BETWEEN": + case "NOT BETWEEN": + throw new Error( + `\`${equality}\` on \`${fieldName}\` requires an array of two values`, + ); + case undefined: + return { str: `${fieldName} = ${finalValue}`, params }; + default: return { - str: `MATCH(${fieldName}) AGAINST(${finalValue} IN BOOLEAN MODE)`, - param: finalParams, + str: `${fieldName} ${sqlEqualityParser(equality)} ${finalValue}`, + params, }; - } else if (equality == "NOT EQUAL") { - return { - str: `${fieldName} != ${finalValue}`, - param: finalParams, - }; - } else if (equality == "IS NOT") { - return { - str: `${fieldName} IS NOT ${finalValue}`, - param: finalParams, - }; - } else if (equality) { - return { - str: `${fieldName} ${sqlEqualityParser( - equality, - )} ${finalValue}`, - param: finalParams, - }; - } else { - return { - str: `${fieldName} = ${finalValue}`, - param: finalParams, - }; - } - } else { - if (equality == "IS NULL") { - return { str: `${fieldName} IS NULL` }; - } else if (equality == "IS NOT NULL") { - return { str: `${fieldName} IS NOT NULL` }; - } else if (equality) { - return { - str: `${fieldName} ${sqlEqualityParser(equality)} ?`, - param: value, - }; - } else { - return { - str: `${fieldName} = ?`, - param: value, - }; - } } } diff --git a/src/utils/sql-generator-gen-join-str.ts b/src/utils/sql-generator-gen-join-str.ts index be646de..bbe6513 100644 --- a/src/utils/sql-generator-gen-join-str.ts +++ b/src/utils/sql-generator-gen-join-str.ts @@ -3,6 +3,7 @@ import type { ServerQueryParamsJoinMatchObject, SQLInsertGenValueType, } from "../types"; +import { sqlIdent, sqlOperator, sqlRawEquality } from "./sql-safe"; type Param = { mtch: ServerQueryParamsJoinMatchObject; @@ -33,7 +34,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) { values.push(...batch_values); if (i < mtch.__batch.matches.length - 1) { - btch_mtch += ` ${mtch.__batch.operator || "OR"} `; + btch_mtch += ` ${sqlOperator(mtch.__batch.operator, "OR")} `; } } @@ -45,44 +46,15 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) { }; } - const equality = mtch.raw_equality || "="; + const equality = mtch.raw_equality ? sqlRawEquality(mtch.raw_equality) : "="; - const lhs = `${ - typeof mtch.source == "object" ? mtch.source.tableName : table_name - }.${typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source}`; - - const rhs = `${(() => { - if (mtch.targetLiteral) { - values.push(mtch.targetLiteral); - - // if (typeof mtch.targetLiteral == "number") { - // return `${mtch.targetLiteral}`; - // } - // return `'${mtch.targetLiteral}'`; - - return `?`; - } - - if (join.alias) { - return `${ - typeof mtch.target == "object" - ? mtch.target.tableName - : join.alias - }.${ - typeof mtch.target == "object" - ? mtch.target.fieldName - : mtch.target - }`; - } - - return `${ - typeof mtch.target == "object" - ? mtch.target.tableName - : join.tableName - }.${ - typeof mtch.target == "object" ? mtch.target.fieldName : mtch.target - }`; - })()}`; + const lhs = `${sqlIdent( + typeof mtch.source == "object" ? mtch.source.tableName : table_name, + "table name", + )}.${sqlIdent( + typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source, + "field name", + )}`; if (mtch.between) { values.push(mtch.between.min, mtch.between.max); @@ -102,6 +74,28 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) { }; } + const rhs = (() => { + if (mtch.targetLiteral !== undefined && mtch.targetLiteral !== null) { + values.push(mtch.targetLiteral); + return `?`; + } + + const targetTable = + typeof mtch.target == "object" + ? mtch.target.tableName + : join.alias || join.tableName; + + const targetField = + typeof mtch.target == "object" + ? mtch.target.fieldName + : mtch.target; + + return `${sqlIdent(targetTable, "table name")}.${sqlIdent( + targetField, + "field name", + )}`; + })(); + return { str: `${lhs} ${equality} ${rhs}`, values, diff --git a/src/utils/sql-generator-gen-query-str.ts b/src/utils/sql-generator-gen-query-str.ts index 5492ac1..3153366 100644 --- a/src/utils/sql-generator-gen-query-str.ts +++ b/src/utils/sql-generator-gen-query-str.ts @@ -3,14 +3,13 @@ import type { ServerQueryParam, TableSelectFieldsObject } from "../types"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; import sqlGenGenJoinStr from "./sql-generator-gen-join-str"; import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql"; +import { sqlIdent, sqlJoinType, sqlOperator } from "./sql-safe"; type Param = { genObject?: ServerQueryParam; selectFields?: (keyof T | TableSelectFieldsObject)[]; append_table_names?: boolean; table_name: string; - full_text_match_str?: string; - full_text_search_str?: string; }; export default function sqlGenGenQueryStr< @@ -20,8 +19,6 @@ export default function sqlGenGenQueryStr< const genObject = params.genObject; const table_name = params.table_name; - const full_text_match_str = params.full_text_match_str; - const full_text_search_str = params.full_text_search_str; let sqlSearhValues: any[] = []; @@ -55,13 +52,14 @@ export default function sqlGenGenQueryStr< const countSubQuery = genObject.countSubQueries[i]; if (!countSubQuery) continue; - const tableAlias = countSubQuery.table_alias; + const countTable = sqlIdent(countSubQuery.table, "table name"); + const tableAlias = countSubQuery.table_alias + ? sqlIdent(countSubQuery.table_alias, "alias") + : undefined; let subQStr = `(SELECT COUNT(*)`; - subQStr += ` FROM ${countSubQuery.table}${ - tableAlias ? ` ${tableAlias}` : "" - }`; + subQStr += ` FROM ${countTable}${tableAlias ? ` ${tableAlias}` : ""}`; subQStr += ` WHERE (`; @@ -69,15 +67,16 @@ export default function sqlGenGenQueryStr< const csqSrc = countSubQuery.srcTrgMap[j]; if (!csqSrc) continue; - subQStr += ` ${tableAlias || countSubQuery.table}.${ - csqSrc.src - }`; + subQStr += ` ${tableAlias || countTable}.${sqlIdent( + csqSrc.src, + "field name", + )}`; if (typeof csqSrc.trg == "string") { subQStr += ` = ?`; sqlSearhValues.push(csqSrc.trg); } else if (typeof csqSrc.trg == "object") { - subQStr += ` = ${csqSrc.trg.table}.${csqSrc.trg.field}`; + subQStr += ` = ${sqlIdent(csqSrc.trg.table, "table name")}.${sqlIdent(csqSrc.trg.field, "field name")}`; } if (j < countSubQuery.srcTrgMap.length - 1) { @@ -85,7 +84,7 @@ export default function sqlGenGenQueryStr< } } - subQStr += ` )) AS ${countSubQuery.alias}`; + subQStr += ` )) AS ${sqlIdent(countSubQuery.alias, "alias")}`; countSqls.push(subQStr); } @@ -95,83 +94,80 @@ export default function sqlGenGenQueryStr< if (genObject?.join) { const existingJoinTableNames: string[] = [table_name]; - str += - "," + - genObject.join - .flat() - .filter((j) => !isUndefined(j)) - .map((joinObj) => { - const joinTableName = joinObj.alias - ? joinObj.alias - : joinObj.tableName; + const joinSelectStr = genObject.join + .flat() + .filter((j) => !isUndefined(j)) + .map((joinObj) => { + const joinTableName = sqlIdent( + joinObj.alias ? joinObj.alias : joinObj.tableName, + "join table name", + ); - if (existingJoinTableNames.includes(joinTableName)) - return null; - existingJoinTableNames.push(joinTableName); + if (existingJoinTableNames.includes(joinTableName)) + return null; + existingJoinTableNames.push(joinTableName); - if (joinObj.group_concat) { - return sqlGenGrabConcatStr({ - field: `${joinTableName}.${joinObj.group_concat.field}`, - alias: joinObj.group_concat.alias, - separator: joinObj.group_concat.separator, - }); - } else if (joinObj.selectFields) { - return joinObj.selectFields - .map((selectField) => { - if (typeof selectField == "string") { - return `${joinTableName}.${selectField}`; - } else if (typeof selectField == "object") { - let aliasSelectField = `${joinTableName}.${selectField.field}`; + if (joinObj.group_concat) { + return sqlGenGrabConcatStr({ + field: `${joinTableName}.${sqlIdent(joinObj.group_concat.field, "field name")}`, + alias: joinObj.group_concat.alias, + separator: joinObj.group_concat.separator, + distinct: joinObj.group_concat.distinct, + }); + } else if (joinObj.selectFields) { + return joinObj.selectFields + .map((selectField) => { + if (typeof selectField != "object") { + return `${joinTableName}.${sqlIdent(selectField, "field name")}`; + } - if (selectField.count) { - aliasSelectField = `COUNT(${joinTableName}.${selectField.field})`; - } else if (selectField.sum) { - aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${joinTableName}.${selectField.field})`; - } else if (selectField.average) { - aliasSelectField = `AVERAGE(${joinTableName}.${selectField.field})`; - } else if (selectField.max) { - aliasSelectField = `MAX(${joinTableName}.${selectField.field})`; - } else if (selectField.min) { - aliasSelectField = `MIN(${joinTableName}.${selectField.field})`; - } else if ( - selectField.group_concat && - selectField.alias - ) { - return sqlGenGrabConcatStr({ - field: `${joinTableName}.${selectField.field}`, - alias: selectField.alias, - separator: - selectField.group_concat - .separator, - distinct: - selectField.group_concat - .distinct, - }); - } else if (selectField.distinct) { - aliasSelectField = `DISTINCT ${joinTableName}.${selectField.field}`; - } + const field = `${joinTableName}.${sqlIdent(selectField.field, "field name")}`; - if (selectField.alias) - aliasSelectField += ` AS ${selectField.alias}`; - return aliasSelectField; - } - }) - .join(","); - } else { - return `${joinTableName}.*`; - } - }) - .filter((_) => Boolean(_)) - .join(","); - } + if ( + selectField.group_concat && + selectField.alias + ) { + return sqlGenGrabConcatStr({ + field, + alias: selectField.alias, + separator: + selectField.group_concat.separator, + distinct: + selectField.group_concat.distinct, + }); + } - if ( - genObject?.fullTextSearch && - full_text_match_str && - full_text_search_str - ) { - str += `, ${full_text_match_str} AS ${genObject.fullTextSearch.scoreAlias}`; - sqlSearhValues.push(full_text_search_str); + let aliasSelectField = field; + + if (selectField.count) { + aliasSelectField = `COUNT(${field})`; + } else if (selectField.sum) { + aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${field})`; + } else if (selectField.average) { + aliasSelectField = `AVG(${field})`; + } else if (selectField.max) { + aliasSelectField = `MAX(${field})`; + } else if (selectField.min) { + aliasSelectField = `MIN(${field})`; + } else if (selectField.distinct) { + aliasSelectField = `DISTINCT ${field}`; + } + + if (selectField.alias) + aliasSelectField += ` AS ${sqlIdent(selectField.alias, "alias")}`; + return aliasSelectField; + }) + .join(","); + } else { + return `${joinTableName}.*`; + } + }) + .filter((_) => Boolean(_)) + .join(","); + + if (joinSelectStr) { + str += "," + joinSelectStr; + } } str += ` FROM ${table_name}`; @@ -183,12 +179,14 @@ export default function sqlGenGenQueryStr< .flat() .filter((j) => !isUndefined(j)) .map((join) => { + const joinTable = sqlIdent(join.tableName, "table name"); + return ( - join.joinType + + sqlJoinType(join.joinType) + " " + (join.alias - ? `${join.tableName}` + " " + join.alias - : `${join.tableName}`) + + ? `${joinTable} ${sqlIdent(join.alias, "alias")}` + : joinTable) + " ON " + (() => { if (Array.isArray(join.match)) { @@ -208,9 +206,7 @@ export default function sqlGenGenQueryStr< return str; }) .join( - join.operator - ? ` ${join.operator} ` - : " AND ", + ` ${sqlOperator(join.operator)} `, ) + ")" ); diff --git a/src/utils/sql-generator-gen-search-str.ts b/src/utils/sql-generator-gen-search-str.ts index 07cc0e9..985b6ae 100644 --- a/src/utils/sql-generator-gen-search-str.ts +++ b/src/utils/sql-generator-gen-search-str.ts @@ -1,11 +1,11 @@ import type { - QueryRawValueType, ServerQueryParamsJoin, ServerQueryQueryObject, ServerQueryValuesObject, SQLInsertGenValueType, } from "../types"; import sqlGenOperatorGen from "./sql-gen-operator-gen"; +import { sqlField, sqlIdent, sqlOperator, sqlRawEquality } from "./sql-safe"; type Param = { queryObj: ServerQueryQueryObject[string]; @@ -14,6 +14,21 @@ type Param = { table_name: string; }; +type QueryValue = + | string + | number + | ServerQueryValuesObject + | SQLInsertGenValueType + | undefined; + +function isPresent(val: T): val is NonNullable { + return val !== undefined && val !== null; +} + +function isValuesObject(val: unknown): val is ServerQueryValuesObject { + return typeof val == "object" && val !== null && !ArrayBuffer.isView(val); +} + export default function sqlGenGenSearchStr({ queryObj, join, @@ -22,101 +37,108 @@ export default function sqlGenGenSearchStr({ }: Param) { let sqlSearhValues: SQLInsertGenValueType[] = []; + const safeField = sqlField(field); + const finalFieldName = (() => { if (queryObj?.tableName) { - return `${queryObj.tableName}.${field}`; + return `${sqlIdent(queryObj.tableName, "table name")}.${safeField}`; } if (join) { - return `${table_name}.${field}`; + return `${table_name}.${safeField}`; } - return field; + return safeField; })(); - let str = `${finalFieldName}=?`; + function grabValue(val: QueryValue) { + if (!isPresent(val)) return; - function grabValue(val?: string | number | ServerQueryValuesObject | null) { - const valueParsed = val; + if (isValuesObject(val)) { + const isFieldValue = Boolean(val.fieldName && val.tableName); - if (!valueParsed) return; + return sqlGenOperatorGen({ + queryObj, + equality: val.equality || queryObj.equality, + fieldName: finalFieldName, + value: isFieldValue + ? `${sqlIdent(val.tableName, "table name")}.${sqlIdent(val.fieldName, "field name")}` + : val.value, + isValueFieldValue: isFieldValue, + }); + } - const valueString = - typeof valueParsed == "string" || typeof valueParsed == "number" - ? valueParsed - : valueParsed - ? valueParsed.fieldName && valueParsed.tableName - ? `${valueParsed.tableName}.${valueParsed.fieldName}` - : valueParsed.value - : undefined; - - const valueEquality = - typeof valueParsed == "object" - ? valueParsed.equality || queryObj.equality - : queryObj.equality; - - const operatorStrParam = sqlGenOperatorGen({ + return sqlGenOperatorGen({ queryObj, - equality: valueEquality, - fieldName: finalFieldName || "", - value: valueString || "", - isValueFieldValue: Boolean( - typeof valueParsed == "object" && - valueParsed.fieldName && - valueParsed.tableName, - ), + equality: queryObj.equality, + fieldName: finalFieldName, + value: val, }); - - return operatorStrParam; } + let str: string; + if (Array.isArray(queryObj.value)) { - const strArray: string[] = []; + const items = queryObj.value.filter(isPresent); + const rawValues = items + .map((v) => (isValuesObject(v) ? v.value : v)) + .filter(isPresent); + const equality = queryObj.equality; - queryObj.value.forEach((val) => { - const operatorStrParam = grabValue(val); - - if (!operatorStrParam) return; - - if (operatorStrParam.str && operatorStrParam.param) { - strArray.push(operatorStrParam.str); - sqlSearhValues.push(operatorStrParam.param); - } else if (operatorStrParam.str) { - strArray.push(operatorStrParam.str); + if (equality == "IN" || equality == "NOT IN") { + str = `${finalFieldName} ${equality} (${rawValues + .map(() => "?") + .join(", ")})`; + sqlSearhValues.push(...rawValues); + } else if (equality == "BETWEEN" || equality == "NOT BETWEEN") { + if (rawValues.length !== 2) { + throw new Error( + `\`${equality}\` on \`${finalFieldName}\` requires exactly two values`, + ); } - }); - - str = "(" + strArray.join(` ${queryObj.operator || "AND"} `) + ")"; - } else if (typeof queryObj.value == "object") { - const operatorStrParam = grabValue(queryObj.value); - if (operatorStrParam?.str) { - str = operatorStrParam.str; - if (operatorStrParam.param) { - sqlSearhValues.push(operatorStrParam.param); + str = `${finalFieldName} ${equality} ? AND ?`; + sqlSearhValues.push(...rawValues); + } else { + if (!items.length) { + throw new Error(`Empty value array for \`${finalFieldName}\``); } + + const strArray: string[] = []; + + for (const val of items) { + const operatorStrParam = grabValue(val); + if (!operatorStrParam) continue; + strArray.push(operatorStrParam.str); + sqlSearhValues.push(...operatorStrParam.params); + } + + str = + "(" + + strArray.join(` ${sqlOperator(queryObj.operator)} `) + + ")"; } - } else if (queryObj.raw_equality && queryObj.value) { - str = `${finalFieldName} ${queryObj.raw_equality} ?`; - sqlSearhValues.push(queryObj.value); + } else if (queryObj.raw_equality && isPresent(queryObj.value)) { + const value = isValuesObject(queryObj.value) + ? queryObj.value.value + : queryObj.value; + str = `${finalFieldName} ${sqlRawEquality(queryObj.raw_equality)} ?`; + sqlSearhValues.push(value ?? null); } else if (queryObj.between) { str = `${finalFieldName} BETWEEN ? AND ?`; sqlSearhValues.push(queryObj.between.min, queryObj.between.max); + } else if (queryObj.not_between) { + str = `${finalFieldName} NOT BETWEEN ? AND ?`; + sqlSearhValues.push(queryObj.not_between.min, queryObj.not_between.max); } else { - const valueParsed = queryObj.value ? queryObj.value : undefined; + const operatorStrParam = + grabValue(queryObj.value) || + sqlGenOperatorGen({ + equality: queryObj.equality, + fieldName: finalFieldName, + value: undefined, + queryObj, + }); - const operatorStrParam = sqlGenOperatorGen({ - equality: queryObj.equality, - fieldName: finalFieldName || "", - value: valueParsed, - queryObj, - }); - - if (operatorStrParam.str && operatorStrParam.param) { - str = operatorStrParam.str; - sqlSearhValues.push(operatorStrParam.param); - } else if (operatorStrParam.str && !operatorStrParam.str.match(/\?/)) { - str = operatorStrParam.str; - } else { - sqlSearhValues.push(valueParsed || ""); - } + str = operatorStrParam.str; + sqlSearhValues.push(...operatorStrParam.params); } return { str, values: sqlSearhValues }; diff --git a/src/utils/sql-generator-grab-concat-str.ts b/src/utils/sql-generator-grab-concat-str.ts index 30ff581..ba14481 100644 --- a/src/utils/sql-generator-grab-concat-str.ts +++ b/src/utils/sql-generator-grab-concat-str.ts @@ -1,3 +1,5 @@ +import { sqlField, sqlIdent, sqlStringLiteral } from "./sql-safe"; + type Param = { field: string; alias: string; @@ -17,14 +19,14 @@ export default function sqlGenGrabConcatStr({ gc += `DISTINCT `; } - gc += `${field}`; + gc += sqlField(field); if (!distinct) { - gc += `, '${separator}'`; + gc += `, ${sqlStringLiteral(String(separator))}`; } gc += `)`; - gc += ` AS ${alias}`; + gc += ` AS ${sqlIdent(alias, "alias")}`; return gc; } diff --git a/src/utils/sql-generator-grab-select-field-sql.ts b/src/utils/sql-generator-grab-select-field-sql.ts index 668570b..85ac9cd 100644 --- a/src/utils/sql-generator-grab-select-field-sql.ts +++ b/src/utils/sql-generator-grab-select-field-sql.ts @@ -1,5 +1,6 @@ import type { TableSelectFieldsObject } from "../types"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; +import { sqlField, sqlIdent } from "./sql-safe"; type Param = { selectFields: (keyof T | TableSelectFieldsObject)[]; @@ -14,49 +15,52 @@ export default function sqlGenGrabSelectFieldSQL< str += ` ${selectFields ?.map((fld) => { - let fld_str = ``; + if (typeof fld != "object") { + return sqlField(fld); + } + + const fld_name = sqlField(fld.fieldName); const final_fld_name = - typeof fld == "object" - ? append_table_names - ? `${table_name}.${String(fld)}` - : `${String(fld.fieldName)}` - : `${String(fld)}`; + append_table_names && !fld_name.includes(".") + ? `${table_name}.${fld_name}` + : fld_name; - if (typeof fld == "object") { - const fld_name = `${String(fld.fieldName)}`; + if (fld.group_concat) { + return sqlGenGrabConcatStr({ + field: final_fld_name, + alias: fld.group_concat.alias || fld.alias || fld_name, + separator: fld.group_concat.separator, + distinct: fld.group_concat.distinct, + }); + } - if (fld.count) { - fld_str += `COUNT(${fld_name})`; - } else if (fld.sum) { - fld_str += `SUM(${fld_name})`; - } else if (fld.average) { - fld_str += `AVERAGE(${fld_name})`; - } else if (fld.max) { - fld_str += `MAX(${fld_name})`; - } else if (fld.min) { - fld_str += `MIN(${fld_name})`; - } else if (fld.distinct) { - fld_str += `DISTINCT ${fld_name}`; - } else if (fld.group_concat) { - fld_str += sqlGenGrabConcatStr({ - field: fld_name, - alias: fld.group_concat.alias, - separator: fld.group_concat.separator, - distinct: fld.group_concat.distinct, - }); - } else { - fld_str += - final_fld_name + (fld.alias ? ` as ${fld.alias}` : ``); - } + let fld_str = ``; + let alias = fld.alias; - if (fld.alias) { - fld_str += ` AS ${fld.alias}`; + if (fld.count) { + fld_str += `COUNT(${final_fld_name})`; + if (!alias && typeof fld.count == "object") { + alias = fld.count.alias; } + } else if (fld.sum) { + fld_str += `SUM(${final_fld_name})`; + } else if (fld.average) { + fld_str += `AVG(${final_fld_name})`; + } else if (fld.max) { + fld_str += `MAX(${final_fld_name})`; + } else if (fld.min) { + fld_str += `MIN(${final_fld_name})`; + } else if (fld.distinct) { + fld_str += `DISTINCT ${final_fld_name}`; } else { fld_str += final_fld_name; } + if (alias) { + fld_str += ` AS ${sqlIdent(alias, "alias")}`; + } + return fld_str; }) .join(",")}`; diff --git a/src/utils/sql-generator.ts b/src/utils/sql-generator.ts index 9b6511f..0189bb5 100644 --- a/src/utils/sql-generator.ts +++ b/src/utils/sql-generator.ts @@ -5,6 +5,13 @@ import type { } from "../types"; import sqlGenGenSearchStr from "./sql-generator-gen-search-str"; import sqlGenGenQueryStr from "./sql-generator-gen-query-str"; +import { + sqlField, + sqlIdent, + sqlInt, + sqlOperator, + sqlOrderStrategy, +} from "./sql-safe"; type Param = { genObject?: ServerQueryParam; @@ -16,41 +23,41 @@ type Param = { type Return = { string: string; values: SQLInsertGenValueType[]; + /** + * Just the `WHERE ...` clause (empty if there are no conditions) and its + * bound values. Used by UPDATE and DELETE. + */ + where: { + string: string; + values: SQLInsertGenValueType[]; + }; }; /** * # SQL Query Generator - * @description Generates an SQL Query for node module `mysql` or `serverless-mysql` + * @description Generates a parameterized SQLite SELECT query. All values are + * returned in `values` to be bound; identifiers are validated. */ export default function sqlGenerator< T extends { [key: string]: any } = { [key: string]: any }, ->({ tableName, genObject, dbFullName, count }: Param): Return { +>({ tableName: passedTableName, genObject, count }: Param): Return { + const tableName = sqlIdent(passedTableName, "table name"); const finalQuery = genObject?.query ? genObject.query : undefined; const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined; const sqlSearhValues: SQLInsertGenValueType[] = []; + const whereValues: SQLInsertGenValueType[] = []; - let fullTextMatchStr = genObject?.fullTextSearch - ? ` MATCH(${genObject.fullTextSearch.fields - .map((f) => - genObject.join ? `${tableName}.${String(f)}` : `${String(f)}`, - ) - .join(",")}) AGAINST (? IN BOOLEAN MODE)` - : undefined; - - const fullTextSearchStr = genObject?.fullTextSearch - ? genObject.fullTextSearch.searchTerm - .split(` `) - .map((t) => `${t}`) - .join(" ") - : undefined; + if (genObject?.fullTextSearch) { + throw new Error( + "`fullTextSearch` is not supported by SQLite. Use an FTS5 table and the `MATCH` equality instead.", + ); + } let { str: queryString, values } = sqlGenGenQueryStr({ table_name: tableName, append_table_names: true, - full_text_match_str: fullTextMatchStr, - full_text_search_str: fullTextSearchStr, genObject, }); @@ -64,26 +71,30 @@ export default function sqlGenerator< const subQueryGroup = queryObj.__query; const subSearchKeys = Object.keys(subQueryGroup); - const subSearchString = subSearchKeys.map((_field) => { - const newSubQueryObj = subQueryGroup?.[_field]; + const subSearchString = subSearchKeys + .map((_field) => { + const newSubQueryObj = subQueryGroup?.[_field]; - if (newSubQueryObj) { - const { str, values } = sqlGenGenSearchStr({ - queryObj: newSubQueryObj, - field: newSubQueryObj.fieldName || _field, - join: genObject?.join, - table_name: tableName, - }); + if (newSubQueryObj) { + const { str, values } = sqlGenGenSearchStr({ + queryObj: newSubQueryObj, + field: newSubQueryObj.fieldName || _field, + join: genObject?.join, + table_name: tableName, + }); - sqlSearhValues.push(...values); + whereValues.push(...values); - return str; - } - }); + return str; + } + }) + .filter((str) => typeof str == "string"); + + if (!subSearchString.length) return; return ( "(" + - subSearchString.join(` ${queryObj.operator || "AND"} `) + + subSearchString.join(` ${sqlOperator(queryObj.operator)} `) + ")" ); } @@ -95,7 +106,7 @@ export default function sqlGenerator< table_name: tableName, }); - sqlSearhValues.push(...values); + whereValues.push(...values); return str; }); @@ -104,103 +115,102 @@ export default function sqlGenerator< (str) => typeof str == "string", ); - const isSearchStr = - cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str); + let whereString = ""; - if (isSearchStr) { - const stringOperator = genObject?.searchOperator || "AND"; - queryString += ` WHERE ${cleanedUpSearchStr.join( - ` ${stringOperator} `, - )}`; - } - - if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) { - queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`; - sqlSearhValues.push(fullTextSearchStr); + if (cleanedUpSearchStr?.[0]) { + const stringOperator = sqlOperator(genObject?.searchOperator); + whereString = `WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`; + queryString += ` ${whereString}`; + sqlSearhValues.push(...whereValues); } if (genObject?.group) { - let group_by_txt = ``; - - if (typeof genObject.group == "string") { - group_by_txt = genObject.group; - } else if (Array.isArray(genObject.group)) { - for (let i = 0; i < genObject.group.length; i++) { - const group = genObject.group[i]; - - if (typeof group == "string") { - group_by_txt += `\`${group.toString()}\``; - } else if (typeof group == "object" && group.table) { - group_by_txt += `${group.table}.${String(group.field)}`; - } else if (typeof group == "object") { - group_by_txt += `${String(group.field)}`; - } - - if (i < genObject.group.length - 1) { - group_by_txt += ","; - } + const grabGroupStr = ( + group: string | number | symbol | { field: unknown; table?: string }, + ) => { + if (typeof group == "object") { + return group.table + ? `${sqlIdent(group.table, "table name")}.${sqlIdent(group.field, "field name")}` + : sqlField(group.field); } - } else if (typeof genObject.group == "object") { - if (genObject.group.table) { - group_by_txt = `${genObject.group.table}.${String(genObject.group.field)}`; - } else { - group_by_txt = `${String(genObject.group.field)}`; - } - } + + return sqlField(group); + }; + + const group_by_txt = Array.isArray(genObject.group) + ? genObject.group.map(grabGroupStr).join(",") + : grabGroupStr(genObject.group); queryString += ` GROUP BY ${group_by_txt}`; } function grabOrderString(order: ServerQueryParamOrder) { - let orderFields = []; - let orderSrt = ``; + const field = + genObject?.join && !String(order.field).includes(".") + ? `${tableName}.${sqlIdent(order.field, "order field")}` + : sqlField(order.field, "order field"); - if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) { - orderFields.push(genObject.fullTextSearch.scoreAlias); - } else if (genObject?.join) { - orderFields.push(`${tableName}.${String(order.field)}`); - } else { - orderFields.push(order.field); - } - - orderSrt += ` ${orderFields.join(", ")} ${order.strategy}`; - - return orderSrt; + return ` ${field} ${sqlOrderStrategy(order.strategy)}`; } if (genObject?.order) { let orderSrt = ` ORDER BY`; - if (Array.isArray(genObject.order)) { - for (let i = 0; i < genObject.order.length; i++) { - const order = genObject.order[i]; - if (order) { - orderSrt += - grabOrderString(order) + - (i < genObject.order.length - 1 ? `,` : ""); - } - } - } else { - orderSrt += grabOrderString(genObject.order); - } + const orders = ( + Array.isArray(genObject.order) ? genObject.order : [genObject.order] + ).filter(Boolean); + + orderSrt += orders.map(grabOrderString).join(","); queryString += ` ${orderSrt}`; } - if (genObject?.limit && !count) queryString += ` LIMIT ${genObject.limit}`; + /** + * Pagination is skipped for count queries so the count covers every + * matching row + */ + if (!count) { + const { limit: rawLimit, offset: rawOffset, page } = genObject || {}; - if (genObject?.offset) { - queryString += ` OFFSET ${genObject.offset}`; - } else if (genObject?.page && genObject.limit && !count) { - queryString += ` OFFSET ${(genObject.page - 1) * genObject.limit}`; + /** + * `limit: 0` has always meant "no limit" + */ + const limit = rawLimit ? sqlInt(rawLimit, "limit") : undefined; + + const offset = isSet(rawOffset) + ? sqlInt(rawOffset, "offset") + : isSet(page) && limit !== undefined + ? Math.max(sqlInt(page, "page") - 1, 0) * limit + : undefined; + + if (limit !== undefined) { + queryString += ` LIMIT ${limit}`; + } else if (offset) { + /** + * SQLite doesn't allow OFFSET without LIMIT + */ + queryString += ` LIMIT -1`; + } + + if (offset) { + queryString += ` OFFSET ${offset}`; + } } return { string: queryString, values: sqlSearhValues, + where: { + string: whereString, + values: whereString ? whereValues : [], + }, }; } +function isSet(val: V): val is NonNullable { + return val !== undefined && val !== null; +} + // let queryString = (() => { // let str = "SELECT"; diff --git a/src/utils/sql-insert-generator.ts b/src/utils/sql-insert-generator.ts index 6543573..780d4be 100644 --- a/src/utils/sql-insert-generator.ts +++ b/src/utils/sql-insert-generator.ts @@ -3,6 +3,7 @@ import type { SQLInsertGenReturn, SQLInsertGenValueType, } from "../types"; +import { sqlIdent } from "./sql-safe"; /** * # SQL Insert Generator @@ -12,75 +13,65 @@ export default function sqlInsertGenerator({ data, dbFullName, }: SQLInsertGenParams): SQLInsertGenReturn | undefined { - const finalDbName = dbFullName ? `${dbFullName}.` : ""; + const finalDbName = dbFullName + ? `${sqlIdent(dbFullName, "database name")}.` + : ""; - try { - if (Array.isArray(data) && data?.[0]) { - let insertKeys: string[] = []; + if (Array.isArray(data) && data?.[0]) { + let insertKeys: string[] = []; - data.forEach((dt) => { - const kys = Object.keys(dt); - kys.forEach((ky) => { - if (!insertKeys.includes(ky)) { - insertKeys.push(ky); - } - }); + data.forEach((dt) => { + const kys = Object.keys(dt); + kys.forEach((ky) => { + if (!insertKeys.includes(ky)) { + insertKeys.push(sqlIdent(ky, "column name")); + } }); + }); - let queryBatches: string[] = []; - let queryValues: SQLInsertGenValueType[] = []; + let queryBatches: string[] = []; + let queryValues: SQLInsertGenValueType[] = []; - data.forEach((item) => { - queryBatches.push( - `(${insertKeys - .map((ky) => { - const value = item[ky]; + data.forEach((item) => { + queryBatches.push( + `(${insertKeys + .map((ky) => { + const value = item[ky]; - const finalValue = - typeof value == "string" || - typeof value == "number" - ? value - : typeof value == "function" - ? value().value - : value - ? value - : null; + if (typeof value == "function") { + const fnValue = value(); + queryValues.push(fnValue.value ?? null); + return fnValue.placeholder; + } - if ( - !finalValue && - typeof finalValue !== "number" && - typeof finalValue !== "string" - ) { - queryValues.push(null); - return "?"; - } + queryValues.push(sqlValue(value)); + return "?"; + }) + .join(",")})`, + ); + }); - queryValues.push(finalValue); + let query = `INSERT INTO ${finalDbName}${sqlIdent( + tableName, + "table name", + )} (${insertKeys.join(",")}) VALUES ${queryBatches.join(",")}`; - const placeholder = - typeof value == "function" - ? value().placeholder - : "?"; - - return placeholder; - }) - .filter((k) => Boolean(k)) - .join(",")})`, - ); - }); - let query = `INSERT INTO ${finalDbName}${tableName} (${insertKeys.join( - ",", - )}) VALUES ${queryBatches.join(",")}`; - - return { - query: query, - values: queryValues, - }; - } else { - return undefined; - } - } catch (/** @type {any} */ error: any) { - console.log(`SQL insert gen ERROR: ${error.message}`); + return { + query: query, + values: queryValues, + }; + } else { return undefined; } } + +/** + * # Normalize a JS value for binding + * @description Keeps `0`, `""` and `false` (as `0`) instead of turning them + * into `NULL` + */ +export function sqlValue(value: unknown): SQLInsertGenValueType { + if (value === undefined || value === null) return null; + if (typeof value == "boolean") return value ? 1 : 0; + return value as SQLInsertGenValueType; +} diff --git a/src/utils/sql-safe.ts b/src/utils/sql-safe.ts new file mode 100644 index 0000000..cded203 --- /dev/null +++ b/src/utils/sql-safe.ts @@ -0,0 +1,106 @@ +import { SQlComparisons, ServerQueryOperators } from "../types"; + +const IDENTIFIER_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/; +const FIELD_REGEX = /^([A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*|\*)$/; +const JOIN_TYPES = ["INNER JOIN", "JOIN", "LEFT JOIN", "RIGHT JOIN"] as const; + +/** + * # Validate a bare SQL identifier (table, column, alias) + * @description Identifiers can't be bound as parameters, so anything that + * gets interpolated into SQL must pass this check first. + */ +export function sqlIdent(name: unknown, label = "identifier"): string { + const str = String(name); + + if (typeof name == "symbol" || !IDENTIFIER_REGEX.test(str)) { + throw new Error(`Invalid SQL ${label}: ${JSON.stringify(str)}`); + } + + return str; +} + +/** + * # Validate a column reference + * @description Accepts `field`, `table.field` or `table.*` + */ +export function sqlField(name: unknown, label = "field name"): string { + const str = String(name); + + if (typeof name == "symbol" || !FIELD_REGEX.test(str)) { + throw new Error(`Invalid SQL ${label}: ${JSON.stringify(str)}`); + } + + return str; +} + +/** + * # Quote an identifier for DDL statements + */ +export function sqlQuoteIdent(name: string): string { + return `"${name.replace(/"/g, '""')}"`; +} + +/** + * # Quote a string literal + */ +export function sqlStringLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +/** + * # Validate a non-negative integer (LIMIT, OFFSET, page) + */ +export function sqlInt(value: unknown, label = "integer"): number { + const num = Number(value); + + if (!Number.isSafeInteger(num) || num < 0) { + throw new Error(`Invalid SQL ${label}: ${JSON.stringify(value)}`); + } + + return num; +} + +export function sqlOrderStrategy(strategy: unknown): "ASC" | "DESC" { + if (strategy === undefined || strategy === null) return "ASC"; + + const str = String(strategy).toUpperCase(); + + if (str !== "ASC" && str !== "DESC") { + throw new Error(`Invalid SQL order strategy: ${JSON.stringify(strategy)}`); + } + + return str; +} + +export function sqlOperator( + operator: unknown, + fallback: (typeof ServerQueryOperators)[number] = "AND", +): (typeof ServerQueryOperators)[number] { + if (operator === undefined || operator === null) return fallback; + + const str = String(operator).toUpperCase(); + + if (!(ServerQueryOperators as readonly string[]).includes(str)) { + throw new Error(`Invalid SQL operator: ${JSON.stringify(operator)}`); + } + + return str as (typeof ServerQueryOperators)[number]; +} + +export function sqlRawEquality( + equality: unknown, +): (typeof SQlComparisons)[number] { + if (!(SQlComparisons as readonly unknown[]).includes(equality)) { + throw new Error(`Invalid SQL comparison: ${JSON.stringify(equality)}`); + } + + return equality as (typeof SQlComparisons)[number]; +} + +export function sqlJoinType(joinType: unknown): (typeof JOIN_TYPES)[number] { + if (!(JOIN_TYPES as readonly unknown[]).includes(joinType)) { + throw new Error(`Invalid SQL join type: ${JSON.stringify(joinType)}`); + } + + return joinType as (typeof JOIN_TYPES)[number]; +}