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 <db_name>-<timestamp> 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 <[email protected]>
This commit is contained in:
@@ -10,14 +10,12 @@
|
|||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"inquirer": "^13.3.2",
|
"inquirer": "^13.3.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"mysql": "^2.18.1",
|
|
||||||
"sqlite-vec": "^0.1.7-alpha.2",
|
"sqlite-vec": "^0.1.7-alpha.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/inquirer": "^9.0.9",
|
"@types/inquirer": "^9.0.9",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/mysql": "^2.15.27",
|
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -64,14 +62,10 @@
|
|||||||
|
|
||||||
"@types/lodash": ["@types/[email protected]", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
"@types/lodash": ["@types/[email protected]", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="],
|
||||||
|
|
||||||
"@types/mysql": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||||
|
|
||||||
"@types/through": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
|
"@types/through": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
|
||||||
|
|
||||||
"bignumber.js": ["[email protected]", "", {}, "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A=="],
|
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
@@ -82,8 +76,6 @@
|
|||||||
|
|
||||||
"commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["[email protected]", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
"core-util-is": ["[email protected]", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
|
||||||
|
|
||||||
"fast-string-truncated-width": ["[email protected]", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
"fast-string-truncated-width": ["[email protected]", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||||
|
|
||||||
"fast-string-width": ["[email protected]", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
"fast-string-width": ["[email protected]", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||||
@@ -92,28 +84,16 @@
|
|||||||
|
|
||||||
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||||
|
|
||||||
"inherits": ["[email protected]", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
|
||||||
|
|
||||||
"inquirer": ["[email protected]", "", { "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=="],
|
"inquirer": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
|
||||||
|
|
||||||
"lodash": ["[email protected]", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
"lodash": ["[email protected]", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||||
|
|
||||||
"mute-stream": ["[email protected]", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
|
"mute-stream": ["[email protected]", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
|
||||||
|
|
||||||
"mysql": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
|
||||||
|
|
||||||
"readable-stream": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="],
|
"run-async": ["[email protected]", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="],
|
||||||
|
|
||||||
"rxjs": ["[email protected]", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
"rxjs": ["[email protected]", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||||
|
|
||||||
"safe-buffer": ["[email protected]", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
|
||||||
|
|
||||||
"safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["[email protected]", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
"signal-exit": ["[email protected]", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
@@ -130,18 +110,12 @@
|
|||||||
|
|
||||||
"sqlite-vec-windows-x64": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ=="],
|
"sqlite-vec-windows-x64": ["[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ=="],
|
||||||
|
|
||||||
"sqlstring": ["[email protected]", "", {}, "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ=="],
|
|
||||||
|
|
||||||
"string_decoder": ["[email protected]", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
|
||||||
|
|
||||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
|
||||||
|
|
||||||
"@inquirer/checkbox/@inquirer/ansi": ["@inquirer/[email protected]", "", {}, "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw=="],
|
"@inquirer/checkbox/@inquirer/ansi": ["@inquirer/[email protected]", "", {}, "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw=="],
|
||||||
|
|
||||||
"@inquirer/checkbox/@inquirer/core": ["@inquirer/[email protected]", "", { "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=="],
|
"@inquirer/checkbox/@inquirer/core": ["@inquirer/[email protected]", "", { "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=="],
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/inquirer": "^9.0.9",
|
"@types/inquirer": "^9.0.9",
|
||||||
"@types/lodash": "^4.17.24",
|
"@types/lodash": "^4.17.24",
|
||||||
"@types/mysql": "^2.15.27",
|
|
||||||
"@types/node": "^25.3.3"
|
"@types/node": "^25.3.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"inquirer": "^13.3.2",
|
"inquirer": "^13.3.2",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"mysql": "^2.18.1",
|
|
||||||
"sqlite-vec": "^0.1.7-alpha.2"
|
"sqlite-vec": "^0.1.7-alpha.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { Command } from "commander";
|
|||||||
import init from "../functions/init";
|
import init from "../functions/init";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import grabDBDir from "../utils/grab-db-dir";
|
import grabDBDir from "../utils/grab-db-dir";
|
||||||
import fs from "fs";
|
|
||||||
import grabDBBackupFileName from "../utils/grab-db-backup-file-name";
|
import grabDBBackupFileName from "../utils/grab-db-backup-file-name";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import trimBackups from "../utils/trim-backups";
|
import trimBackups from "../utils/trim-backups";
|
||||||
|
import DbClient from "../lib/sqlite";
|
||||||
|
import snapshotDb from "../utils/snapshot-db";
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
return new Command("backup")
|
return new Command("backup")
|
||||||
@@ -15,11 +16,14 @@ export default function () {
|
|||||||
|
|
||||||
const { config } = await init();
|
const { config } = await init();
|
||||||
|
|
||||||
const { backup_dir, db_file_path } = grabDBDir({ config });
|
const { backup_dir } = grabDBDir({ config });
|
||||||
|
|
||||||
const new_db_file_name = grabDBBackupFileName({ 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 });
|
trimBackups({ config });
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,7 @@ program.on("command:*", () => {
|
|||||||
/**
|
/**
|
||||||
* # Parse Arguments
|
* # Parse Arguments
|
||||||
*/
|
*/
|
||||||
program.parse(Bun.argv);
|
program.parseAsync(Bun.argv).catch((error: any) => {
|
||||||
|
console.error(`ERROR => ${error?.message || error}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import init from "../functions/init";
|
import init from "../functions/init";
|
||||||
import grabDBDir from "../utils/grab-db-dir";
|
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 chalk from "chalk";
|
||||||
import grabSortedBackups from "../utils/grab-sorted-backups";
|
import grabSortedBackups from "../utils/grab-sorted-backups";
|
||||||
import { select } from "@inquirer/prompts";
|
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(
|
console.log(
|
||||||
`${chalk.bold(chalk.green(`DB Restore Success!`))}`,
|
`${chalk.bold(chalk.green(`DB Restore Success!`))}`,
|
||||||
@@ -50,7 +56,7 @@ export default function () {
|
|||||||
process.exit();
|
process.exit();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Backup Restore ERROR => ${error.message}`);
|
console.error(`Backup Restore ERROR => ${error.message}`);
|
||||||
process.exit();
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { writeLiveSchema } from "../functions/live-schema";
|
import { writeLiveSchema } from "../functions/live-schema";
|
||||||
import grabDBDir from "../utils/grab-db-dir";
|
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 () {
|
export default function () {
|
||||||
return new Command("schema")
|
return new Command("schema")
|
||||||
@@ -26,7 +28,7 @@ export default function () {
|
|||||||
const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames();
|
const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames();
|
||||||
const { db_file_path } = grabDBDir({ config });
|
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 {
|
try {
|
||||||
const isVector = Boolean(opts.vector || opts.v);
|
const isVector = Boolean(opts.vector || opts.v);
|
||||||
@@ -65,7 +67,11 @@ export default function () {
|
|||||||
process.exit();
|
process.exit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+41
-54
@@ -9,61 +9,48 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export default async function init(): Promise<BunSQLiteConfigReturn> {
|
export default async function init(): Promise<BunSQLiteConfigReturn> {
|
||||||
try {
|
const { ROOT_DIR } = grabDirNames();
|
||||||
const { ROOT_DIR } = grabDirNames();
|
const { ConfigFileName } = AppData;
|
||||||
const { ConfigFileName } = AppData;
|
|
||||||
|
|
||||||
const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName);
|
const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName);
|
||||||
|
|
||||||
if (!fs.existsSync(ConfigFilePath)) {
|
if (!fs.existsSync(ConfigFilePath)) {
|
||||||
console.log("ConfigFilePath", ConfigFilePath);
|
throw new Error(
|
||||||
|
`Please create a \`${ConfigFileName}\` file at the root of your project. (Looked for ${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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import init from "../functions/init";
|
import init from "../functions/init";
|
||||||
|
import { sqlIdent, sqlQuoteIdent } from "../utils/sql-safe";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
sql: string;
|
sql: string;
|
||||||
@@ -13,9 +14,12 @@ export default async function ({ sql: passed_sql, table, data }: Params) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (table_schema?.tableName) {
|
if (table_schema?.tableName) {
|
||||||
const set_sql_arr = Object.keys(
|
const set_sql_arr = Object.keys(Array.isArray(data) ? data[0] : data)
|
||||||
Array.isArray(data) ? data[0] : data,
|
.filter((field) => field !== "updated_at")
|
||||||
).map((field) => `${field} = excluded.${field}`);
|
.map((field) => {
|
||||||
|
const column = sqlIdent(field, "column name");
|
||||||
|
return `${column} = excluded.${column}`;
|
||||||
|
});
|
||||||
|
|
||||||
set_sql_arr.push(`updated_at = ${now}`);
|
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++) {
|
for (let i = 0; i < unique_fields.length; i++) {
|
||||||
const field = unique_fields[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]) {
|
if (table_schema.uniqueConstraints?.[0]) {
|
||||||
for (let i = 0; i < table_schema.uniqueConstraints.length; i++) {
|
for (let i = 0; i < table_schema.uniqueConstraints.length; i++) {
|
||||||
const constraint = table_schema.uniqueConstraints[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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-11
@@ -25,30 +25,32 @@ export default async function DbDelete<
|
|||||||
try {
|
try {
|
||||||
let finalQuery = query || {};
|
let finalQuery = query || {};
|
||||||
|
|
||||||
if (targetId) {
|
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
||||||
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
|
finalQuery = _.merge<
|
||||||
finalQuery,
|
ServerQueryParam<any>,
|
||||||
{
|
ServerQueryParam<any>,
|
||||||
query: {
|
ServerQueryParam<any>
|
||||||
id: {
|
>({}, finalQuery, {
|
||||||
value: String(targetId),
|
query: {
|
||||||
},
|
id: {
|
||||||
|
value: String(targetId),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlObj = sqlGenerator({
|
sqlObj = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: finalQuery,
|
genObject: _.pick(finalQuery, ["query", "searchOperator"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const whereClause = sqlObj.string.match(/WHERE .*/)?.[0];
|
const whereClause = sqlObj.where.string;
|
||||||
|
|
||||||
if (whereClause) {
|
if (whereClause) {
|
||||||
let sql = `DELETE FROM ${table} ${whereClause}`;
|
let sql = `DELETE FROM ${table} ${whereClause}`;
|
||||||
|
|
||||||
sqlObj.string = sql;
|
sqlObj.string = sql;
|
||||||
|
sqlObj.values = sqlObj.where.values;
|
||||||
|
|
||||||
const res = DbClient.run(sql, sqlObj.values);
|
const res = DbClient.run(sql, sqlObj.values);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
} from "../../types";
|
} from "../../types";
|
||||||
import { AppData } from "../../data/app-data";
|
import { AppData } from "../../data/app-data";
|
||||||
import { readLiveSchema } from "../../functions/live-schema";
|
import { readLiveSchema } from "../../functions/live-schema";
|
||||||
|
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
|
||||||
|
|
||||||
// Schema Manager Class
|
// Schema Manager Class
|
||||||
class SQLiteSchemaManager {
|
class SQLiteSchemaManager {
|
||||||
@@ -64,17 +65,49 @@ class SQLiteSchemaManager {
|
|||||||
async syncSchema(): Promise<void> {
|
async syncSchema(): Promise<void> {
|
||||||
console.log("Starting schema synchronization...");
|
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
|
try {
|
||||||
for (const table of this.db_schema.tables) {
|
const existingTables = this.getExistingTables();
|
||||||
await this.syncTable(table, existingTables);
|
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!");
|
console.log("Schema synchronization complete!");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +158,8 @@ class SQLiteSchemaManager {
|
|||||||
|
|
||||||
for (const tableName of tablesToDrop) {
|
for (const tableName of tablesToDrop) {
|
||||||
console.log(`Dropping table: ${tableName}`);
|
console.log(`Dropping table: ${tableName}`);
|
||||||
this.db.run(`DROP TABLE IF EXISTS "${tableName}"`);
|
this.db.run(`DROP TABLE IF EXISTS ${q(tableName)}`);
|
||||||
this.db.run(
|
this.removeDbManagerTable(tableName);
|
||||||
`DELETE FROM ${this.db_manager_table_name} WHERE table_name = "${tableName}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +179,7 @@ class SQLiteSchemaManager {
|
|||||||
`Renaming table: ${table.tableNameOld} -> ${table.tableName}`,
|
`Renaming table: ${table.tableNameOld} -> ${table.tableName}`,
|
||||||
);
|
);
|
||||||
this.db.run(
|
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.insertDbManagerTable(table.tableName);
|
||||||
this.removeDbManagerTable(table.tableNameOld);
|
this.removeDbManagerTable(table.tableNameOld);
|
||||||
@@ -179,33 +210,19 @@ class SQLiteSchemaManager {
|
|||||||
console.log(`Creating table: ${table.tableName}`);
|
console.log(`Creating table: ${table.tableName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_table = _.cloneDeep(table);
|
const new_table = this.resolveTable(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 columns: string[] = [];
|
const columns: string[] = [];
|
||||||
const foreignKeys: string[] = [];
|
const foreignKeys: string[] = [];
|
||||||
|
|
||||||
for (const field of new_table.fields) {
|
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);
|
columns.push(columnDef);
|
||||||
|
|
||||||
if (field.foreignKey && !table.isVector) {
|
if (field.foreignKey && !new_table.isVector) {
|
||||||
foreignKeys.push(this.buildForeignKeyConstraint(field));
|
foreignKeys.push(this.buildForeignKeyConstraint(field));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,14 +234,16 @@ class SQLiteSchemaManager {
|
|||||||
constraint.constraintTableFields &&
|
constraint.constraintTableFields &&
|
||||||
constraint.constraintTableFields.length > 0
|
constraint.constraintTableFields.length > 0
|
||||||
) {
|
) {
|
||||||
const fields = constraint.constraintTableFields
|
const fieldNames = constraint.constraintTableFields.map(
|
||||||
.map((f) => `"${f.value}"`)
|
(f) => String(f.value),
|
||||||
.join(", ");
|
);
|
||||||
const constraintName =
|
const constraintName =
|
||||||
constraint.constraintName ||
|
constraint.constraintName ||
|
||||||
`unique_${fields.replace(/"/g, "")}`;
|
`unique_${fieldNames.join(", ")}`;
|
||||||
columns.push(
|
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 allConstraints = [...columns, ...foreignKeys];
|
||||||
|
|
||||||
const sql = new_table.isVector
|
const sql = new_table.isVector
|
||||||
? `CREATE VIRTUAL TABLE "${new_table.tableName}" USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})`
|
? `CREATE VIRTUAL TABLE ${q(new_table.tableName)} USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})`
|
||||||
: `CREATE TABLE "${new_table.tableName}" (${allConstraints.join(", ")})`;
|
: `CREATE TABLE ${q(new_table.tableName)} (${allConstraints.join(", ")})`;
|
||||||
|
|
||||||
this.db.run(sql);
|
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
|
* Update an existing table
|
||||||
*/
|
*/
|
||||||
@@ -298,7 +345,7 @@ class SQLiteSchemaManager {
|
|||||||
private getTableColumns(
|
private getTableColumns(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
): { name: string; type: 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 }[];
|
const results = query.all() as { name: string; type: string }[];
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -320,7 +367,7 @@ class SQLiteSchemaManager {
|
|||||||
.replace(/UNIQUE/gi, "")
|
.replace(/UNIQUE/gi, "")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const sql = `ALTER TABLE "${tableName}" ADD COLUMN ${cleanDef}`;
|
const sql = `ALTER TABLE ${q(tableName)} ADD COLUMN ${cleanDef}`;
|
||||||
|
|
||||||
this.db.run(sql);
|
this.db.run(sql);
|
||||||
}
|
}
|
||||||
@@ -352,10 +399,10 @@ class SQLiteSchemaManager {
|
|||||||
|
|
||||||
if (does_table_exist) {
|
if (does_table_exist) {
|
||||||
existingRows = this.db
|
existingRows = this.db
|
||||||
.query(`SELECT * FROM "${table.tableName}"`)
|
.query(`SELECT * FROM ${q(table.tableName)}`)
|
||||||
.all() as { [k: string]: any }[];
|
.all() as { [k: string]: any }[];
|
||||||
|
|
||||||
this.db.run(`DROP TABLE "${table.tableName}"`);
|
this.db.run(`DROP TABLE ${q(table.tableName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.createTable(table);
|
await this.createTable(table);
|
||||||
@@ -369,7 +416,7 @@ class SQLiteSchemaManager {
|
|||||||
const placeholders = columns.map(() => "?").join(", ");
|
const placeholders = columns.map(() => "?").join(", ");
|
||||||
|
|
||||||
this.db.run(
|
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),
|
Object.values(row),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -380,19 +427,17 @@ class SQLiteSchemaManager {
|
|||||||
|
|
||||||
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
|
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 existingColumns = this.getTableColumns(table.tableName);
|
||||||
const columnsToKeep = table.fields
|
const columnsToKeep = this.resolveTable(table)
|
||||||
.filter(
|
.fields.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.fieldName &&
|
f.fieldName &&
|
||||||
existingColumns.find(
|
existingColumns.find((c) => c.name == f.fieldName),
|
||||||
(c) =>
|
|
||||||
c.name == f.fieldName &&
|
|
||||||
c.type == this.mapDataType(f),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.map((f) => f.fieldName);
|
.map((f) => f.fieldName as string);
|
||||||
|
|
||||||
// Create temp table with new schema
|
// Create temp table with new schema
|
||||||
const tempTable = { ...table, tableName: tempTableName };
|
const tempTable = { ...table, tableName: tempTableName };
|
||||||
@@ -400,18 +445,18 @@ class SQLiteSchemaManager {
|
|||||||
|
|
||||||
// Copy data if there are common columns
|
// Copy data if there are common columns
|
||||||
if (columnsToKeep.length > 0) {
|
if (columnsToKeep.length > 0) {
|
||||||
const columnList = columnsToKeep.map((c) => `"${c}"`).join(", ");
|
const columnList = columnsToKeep.map(q).join(", ");
|
||||||
this.db.run(
|
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
|
// Drop old table
|
||||||
this.db.run(`DROP TABLE "${table.tableName}"`);
|
this.db.run(`DROP TABLE ${q(table.tableName)}`);
|
||||||
|
|
||||||
// Rename temp table
|
// Rename temp table
|
||||||
this.db.run(
|
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");
|
throw new Error("Field name is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldName = field.sideCar
|
// vec0 can't parse quoted column names
|
||||||
? `+${field.fieldName}`
|
const fieldName = is_vector
|
||||||
: `${field.fieldName}`;
|
? field.sideCar
|
||||||
|
? `+${field.fieldName}`
|
||||||
|
: `${field.fieldName}`
|
||||||
|
: q(field.fieldName);
|
||||||
|
|
||||||
const parts: string[] = [fieldName];
|
const parts: string[] = [fieldName];
|
||||||
|
|
||||||
@@ -538,7 +586,7 @@ class SQLiteSchemaManager {
|
|||||||
field: BUN_SQLITE_FieldSchemaType,
|
field: BUN_SQLITE_FieldSchemaType,
|
||||||
): string {
|
): string {
|
||||||
const fk = field.foreignKey!;
|
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) {
|
if (fk.cascadeDelete) {
|
||||||
constraint += " ON DELETE CASCADE";
|
constraint += " ON DELETE CASCADE";
|
||||||
@@ -563,11 +611,11 @@ class SQLiteSchemaManager {
|
|||||||
|
|
||||||
// Get existing indexes
|
// Get existing indexes
|
||||||
const query = this.db.query(
|
const query = this.db.query(
|
||||||
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='${table.tableName}' AND name NOT LIKE 'sqlite_%'`,
|
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'`,
|
||||||
);
|
|
||||||
const existingIndexes = (query.all() as { name: string }[]).map(
|
|
||||||
(r) => r.name,
|
|
||||||
);
|
);
|
||||||
|
const existingIndexes = (
|
||||||
|
query.all(table.tableName) as { name: string }[]
|
||||||
|
).map((r) => r.name);
|
||||||
|
|
||||||
// Drop indexes not in schema
|
// Drop indexes not in schema
|
||||||
for (const indexName of existingIndexes) {
|
for (const indexName of existingIndexes) {
|
||||||
@@ -576,7 +624,7 @@ class SQLiteSchemaManager {
|
|||||||
);
|
);
|
||||||
if (!stillExists) {
|
if (!stillExists) {
|
||||||
console.log(`Dropping index: ${indexName}`);
|
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)) {
|
if (!existingIndexes.includes(index.indexName)) {
|
||||||
console.log(`Creating index: ${index.indexName}`);
|
console.log(`Creating index: ${index.indexName}`);
|
||||||
const fields = index.indexTableFields
|
const fields = index.indexTableFields
|
||||||
.map((f) => `"${f}"`)
|
.map((f) => q(String(f)))
|
||||||
.join(", ");
|
.join(", ");
|
||||||
this.db.run(
|
this.db.run(
|
||||||
`CREATE INDEX "${index.indexName}" ON "${table.tableName}" (${fields})`,
|
`CREATE INDEX ${q(index.indexName)} ON ${q(table.tableName)} (${fields})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function dbSchemaToType({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (parent_table) {
|
if (parent_table) {
|
||||||
final_table = _.merge(parent_table, {
|
final_table = _.merge(_.cloneDeep(parent_table), {
|
||||||
tableName: final_table.tableName,
|
tableName: final_table.tableName,
|
||||||
tableDescription: final_table.tableDescription,
|
tableDescription: final_table.tableDescription,
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-23
@@ -1,4 +1,3 @@
|
|||||||
import mysql from "mysql";
|
|
||||||
import DbClient from ".";
|
import DbClient from ".";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import type { APIResponseObject, ServerQueryParam } from "../../types";
|
import type { APIResponseObject, ServerQueryParam } from "../../types";
|
||||||
@@ -28,17 +27,18 @@ export default async function DbSelect<
|
|||||||
try {
|
try {
|
||||||
let finalQuery = query || {};
|
let finalQuery = query || {};
|
||||||
|
|
||||||
if (targetId) {
|
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
||||||
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
|
finalQuery = _.merge<
|
||||||
finalQuery,
|
ServerQueryParam<any>,
|
||||||
{
|
ServerQueryParam<any>,
|
||||||
query: {
|
ServerQueryParam<any>
|
||||||
id: {
|
>({}, finalQuery, {
|
||||||
value: String(targetId),
|
query: {
|
||||||
},
|
id: {
|
||||||
|
value: String(targetId),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlObj = sqlGenerator({
|
sqlObj = sqlGenerator({
|
||||||
@@ -46,10 +46,11 @@ export default async function DbSelect<
|
|||||||
genObject: finalQuery,
|
genObject: finalQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
let sql = mysql.format(sqlObj.string, sqlObj.values);
|
const sql = sqlObj.string;
|
||||||
|
|
||||||
const res = DbClient.query<Schema, Schema[]>(sql);
|
const batchRes = DbClient.query<Schema, any[]>(sql).all(
|
||||||
const batchRes = res.all();
|
...sqlObj.values,
|
||||||
|
);
|
||||||
|
|
||||||
let resp: APIResponseObject<Schema> = {
|
let resp: APIResponseObject<Schema> = {
|
||||||
success: Boolean(batchRes[0]),
|
success: Boolean(batchRes[0]),
|
||||||
@@ -62,23 +63,19 @@ export default async function DbSelect<
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (count) {
|
if (count) {
|
||||||
let count_sql_object = sqlGenerator({
|
const count_sql_object = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: finalQuery,
|
genObject: finalQuery,
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
|
|
||||||
let count_sql = mysql.format(
|
const count_sql = `SELECT COUNT(*) AS count FROM (${count_sql_object.string}) AS c`;
|
||||||
count_sql_object.string,
|
|
||||||
count_sql_object.values,
|
|
||||||
);
|
|
||||||
|
|
||||||
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<Schema, Schema[]>(count_sql).all();
|
resp["count"] = Number(count_res?.count);
|
||||||
|
|
||||||
const count_val = count_res[0]?.["COUNT(*)"];
|
|
||||||
resp["count"] = Number(count_val);
|
|
||||||
resp["debug"]["count_sql"] = count_sql;
|
resp["debug"]["count_sql"] = count_sql;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,20 @@ export default async function DbSQL<
|
|||||||
try {
|
try {
|
||||||
const trimmed_sql = sql.trim();
|
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 || []);
|
: DbClient.run(trimmed_sql, values || []);
|
||||||
|
|
||||||
|
stmt.finalize();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
payload: Array.isArray(res) ? (res as T[]) : undefined,
|
payload: Array.isArray(res) ? (res as T[]) : undefined,
|
||||||
|
|||||||
+22
-21
@@ -6,6 +6,8 @@ import type {
|
|||||||
ServerQueryParam,
|
ServerQueryParam,
|
||||||
} from "../../types";
|
} from "../../types";
|
||||||
import sqlGenerator from "../../utils/sql-generator";
|
import sqlGenerator from "../../utils/sql-generator";
|
||||||
|
import { sqlValue } from "../../utils/sql-insert-generator";
|
||||||
|
import { sqlIdent } from "../../utils/sql-safe";
|
||||||
|
|
||||||
type Params<
|
type Params<
|
||||||
Schema extends { [k: string]: any } = { [k: string]: any },
|
Schema extends { [k: string]: any } = { [k: string]: any },
|
||||||
@@ -26,35 +28,39 @@ export default async function DbUpdate<
|
|||||||
query,
|
query,
|
||||||
targetId,
|
targetId,
|
||||||
}: Params<Schema, Table>): Promise<APIResponseObject> {
|
}: Params<Schema, Table>): Promise<APIResponseObject> {
|
||||||
let sqlObj: ReturnType<typeof sqlGenerator> = { string: "", values: [] };
|
let sqlObj: Pick<ReturnType<typeof sqlGenerator>, "string" | "values"> = {
|
||||||
|
string: "",
|
||||||
|
values: [],
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let finalQuery = query || {};
|
let finalQuery = query || {};
|
||||||
|
|
||||||
if (targetId) {
|
if (targetId !== undefined && targetId !== null && targetId !== "") {
|
||||||
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
|
finalQuery = _.merge<
|
||||||
finalQuery,
|
ServerQueryParam<any>,
|
||||||
{
|
ServerQueryParam<any>,
|
||||||
query: {
|
ServerQueryParam<any>
|
||||||
id: {
|
>({}, finalQuery, {
|
||||||
value: String(targetId),
|
query: {
|
||||||
},
|
id: {
|
||||||
|
value: String(targetId),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sqlQueryObj = sqlGenerator({
|
const sqlQueryObj = sqlGenerator({
|
||||||
tableName: table,
|
tableName: table,
|
||||||
genObject: finalQuery,
|
genObject: _.pick(finalQuery, ["query", "searchOperator"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
let values: SQLInsertGenValueType[] = [];
|
let values: SQLInsertGenValueType[] = [];
|
||||||
|
|
||||||
const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0];
|
const whereClause = sqlQueryObj.where.string;
|
||||||
|
|
||||||
if (whereClause) {
|
if (whereClause) {
|
||||||
let sql = `UPDATE ${table} SET`;
|
let sql = `UPDATE ${sqlIdent(table, "table name")} SET`;
|
||||||
|
|
||||||
const finalData: { [k: string]: SQLInsertGenValueType } = {
|
const finalData: { [k: string]: SQLInsertGenValueType } = {
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
@@ -69,13 +75,8 @@ export default async function DbUpdate<
|
|||||||
|
|
||||||
const isLast = i == keys.length - 1;
|
const isLast = i == keys.length - 1;
|
||||||
|
|
||||||
sql += ` ${key}=?`;
|
sql += ` ${sqlIdent(key, "column name")}=?`;
|
||||||
const value = finalData[key];
|
values.push(sqlValue(finalData[key]));
|
||||||
values.push(
|
|
||||||
typeof value == "number" || typeof value == "string"
|
|
||||||
? value
|
|
||||||
: value || null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
sql += `,`;
|
sql += `,`;
|
||||||
@@ -83,7 +84,7 @@ export default async function DbUpdate<
|
|||||||
}
|
}
|
||||||
|
|
||||||
sql += ` ${whereClause}`;
|
sql += ` ${whereClause}`;
|
||||||
values = [...values, ...sqlQueryObj.values];
|
values = [...values, ...sqlQueryObj.where.values];
|
||||||
|
|
||||||
sqlObj.string = sql;
|
sqlObj.string = sql;
|
||||||
sqlObj.values = values as any[];
|
sqlObj.values = values as any[];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import grabDBDir from "../utils/grab-db-dir";
|
import grabDBDir from "../utils/grab-db-dir";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import _ from "lodash";
|
||||||
import type { BunSQLiteConfig } from "../types";
|
import type { BunSQLiteConfig } from "../types";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -9,7 +10,19 @@ type Params = {
|
|||||||
export default function grabSortedBackups({ config }: Params) {
|
export default function grabSortedBackups({ config }: Params) {
|
||||||
const { backup_dir } = grabDBDir({ config });
|
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.
|
* Order Backups. Most recent first.
|
||||||
@@ -18,11 +31,7 @@ export default function grabSortedBackups({ config }: Params) {
|
|||||||
const a_date = Number(a.split("-").pop());
|
const a_date = Number(a.split("-").pop());
|
||||||
const b_date = Number(b.split("-").pop());
|
const b_date = Number(b.split("-").pop());
|
||||||
|
|
||||||
if (a_date > b_date) {
|
return b_date - a_date;
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return ordered_backups;
|
return ordered_backups;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
SQLInsertGenValueType,
|
SQLInsertGenValueType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import sqlEqualityParser from "./sql-equality-parser";
|
import sqlEqualityParser from "./sql-equality-parser";
|
||||||
|
import { sqlIdent } from "./sql-safe";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
@@ -15,17 +16,22 @@ type Params = {
|
|||||||
},
|
},
|
||||||
string
|
string
|
||||||
>;
|
>;
|
||||||
|
/**
|
||||||
|
* `value` is an already-validated `table.field` reference rather than a
|
||||||
|
* literal, so it is inlined instead of bound
|
||||||
|
*/
|
||||||
isValueFieldValue?: boolean;
|
isValueFieldValue?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Return = {
|
type Return = {
|
||||||
str?: string;
|
str: string;
|
||||||
param?: SQLInsertGenValueType;
|
params: SQLInsertGenValueType[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # SQL Gen Operator Gen
|
* # 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({
|
export default function sqlGenOperatorGen({
|
||||||
fieldName,
|
fieldName,
|
||||||
@@ -34,116 +40,96 @@ export default function sqlGenOperatorGen({
|
|||||||
queryObj,
|
queryObj,
|
||||||
isValueFieldValue,
|
isValueFieldValue,
|
||||||
}: Params): Return {
|
}: Params): Return {
|
||||||
if (queryObj.nullValue) {
|
if (queryObj.nullValue || equality == "IS NULL") {
|
||||||
return { str: `${fieldName} IS NULL` };
|
return { str: `${fieldName} IS NULL`, params: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryObj.notNullValue) {
|
if (queryObj.notNullValue || equality == "IS NOT NULL") {
|
||||||
return { str: `${fieldName} IS NOT NULL` };
|
return { str: `${fieldName} IS NOT NULL`, params: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value) {
|
const finalValue = isValueFieldValue ? String(value) : "?";
|
||||||
const finalValue = isValueFieldValue ? value : "?";
|
const params: SQLInsertGenValueType[] = isValueFieldValue
|
||||||
const finalParams = isValueFieldValue ? undefined : value;
|
? []
|
||||||
|
: [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 {
|
return {
|
||||||
str: `MATCH(${fieldName}) AGAINST(${finalValue} IN NATURAL LANGUAGE MODE)`,
|
str: `LOWER(${fieldName}) LIKE LOWER(${wrappedValue})`,
|
||||||
param: finalParams,
|
params: wrappedParams,
|
||||||
};
|
};
|
||||||
} else if (equality == "MATCH_BOOLEAN") {
|
case "LIKE_LOWER_RAW":
|
||||||
return {
|
|
||||||
str: `MATCH(${fieldName}) AGAINST(${finalValue} IN BOOLEAN MODE)`,
|
|
||||||
param: finalParams,
|
|
||||||
};
|
|
||||||
} else if (equality == "LIKE_LOWER") {
|
|
||||||
return {
|
return {
|
||||||
str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`,
|
str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`,
|
||||||
param: `%${finalParams}%`,
|
params,
|
||||||
};
|
};
|
||||||
} else if (equality == "LIKE_LOWER_RAW") {
|
case "LIKE":
|
||||||
return {
|
return {
|
||||||
str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`,
|
str: `${fieldName} LIKE ${wrappedValue}`,
|
||||||
param: finalParams,
|
params: wrappedParams,
|
||||||
};
|
};
|
||||||
} else if (equality == "LIKE") {
|
case "LIKE_RAW":
|
||||||
|
return { str: `${fieldName} LIKE ${finalValue}`, params };
|
||||||
|
case "NOT_LIKE_LOWER":
|
||||||
return {
|
return {
|
||||||
str: `${fieldName} LIKE ${finalValue}`,
|
str: `LOWER(${fieldName}) NOT LIKE LOWER(${wrappedValue})`,
|
||||||
param: `%${finalParams}%`,
|
params: wrappedParams,
|
||||||
};
|
};
|
||||||
} else if (equality == "LIKE_RAW") {
|
case "NOT_LIKE_LOWER_RAW":
|
||||||
return {
|
|
||||||
str: `${fieldName} LIKE ${finalValue}`,
|
|
||||||
param: finalParams,
|
|
||||||
};
|
|
||||||
} else if (equality == "NOT_LIKE_LOWER") {
|
|
||||||
return {
|
return {
|
||||||
str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`,
|
str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`,
|
||||||
param: `%${finalParams}%`,
|
params,
|
||||||
};
|
};
|
||||||
} else if (equality == "NOT_LIKE_LOWER_RAW") {
|
case "NOT LIKE":
|
||||||
return {
|
return {
|
||||||
str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`,
|
str: `${fieldName} NOT LIKE ${wrappedValue}`,
|
||||||
param: finalParams,
|
params: wrappedParams,
|
||||||
};
|
};
|
||||||
} else if (equality == "NOT LIKE") {
|
case "NOT LIKE_RAW":
|
||||||
return {
|
return { str: `${fieldName} NOT LIKE ${finalValue}`, params };
|
||||||
str: `${fieldName} NOT LIKE ${finalValue}`,
|
case "REGEXP":
|
||||||
param: finalParams,
|
/**
|
||||||
};
|
* SQLite has no built-in REGEXP function. This only works when
|
||||||
} else if (equality == "NOT LIKE_RAW") {
|
* an extension providing `regexp()` is loaded.
|
||||||
return {
|
*/
|
||||||
str: `${fieldName} NOT LIKE ${finalValue}`,
|
|
||||||
param: finalParams,
|
|
||||||
};
|
|
||||||
} else if (equality == "REGEXP") {
|
|
||||||
return {
|
return {
|
||||||
str: `LOWER(${fieldName}) REGEXP LOWER(${finalValue})`,
|
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 {
|
return {
|
||||||
str: `MATCH(${fieldName}) AGAINST(${finalValue} IN BOOLEAN MODE)`,
|
str: `${fieldName} ${sqlEqualityParser(equality)} ${finalValue}`,
|
||||||
param: finalParams,
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ServerQueryParamsJoinMatchObject,
|
ServerQueryParamsJoinMatchObject,
|
||||||
SQLInsertGenValueType,
|
SQLInsertGenValueType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { sqlIdent, sqlOperator, sqlRawEquality } from "./sql-safe";
|
||||||
|
|
||||||
type Param = {
|
type Param = {
|
||||||
mtch: ServerQueryParamsJoinMatchObject;
|
mtch: ServerQueryParamsJoinMatchObject;
|
||||||
@@ -33,7 +34,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) {
|
|||||||
values.push(...batch_values);
|
values.push(...batch_values);
|
||||||
|
|
||||||
if (i < mtch.__batch.matches.length - 1) {
|
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 = `${
|
const lhs = `${sqlIdent(
|
||||||
typeof mtch.source == "object" ? mtch.source.tableName : table_name
|
typeof mtch.source == "object" ? mtch.source.tableName : table_name,
|
||||||
}.${typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source}`;
|
"table name",
|
||||||
|
)}.${sqlIdent(
|
||||||
const rhs = `${(() => {
|
typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source,
|
||||||
if (mtch.targetLiteral) {
|
"field name",
|
||||||
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
|
|
||||||
}`;
|
|
||||||
})()}`;
|
|
||||||
|
|
||||||
if (mtch.between) {
|
if (mtch.between) {
|
||||||
values.push(mtch.between.min, mtch.between.max);
|
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 {
|
return {
|
||||||
str: `${lhs} ${equality} ${rhs}`,
|
str: `${lhs} ${equality} ${rhs}`,
|
||||||
values,
|
values,
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import type { ServerQueryParam, TableSelectFieldsObject } from "../types";
|
|||||||
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
|
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
|
||||||
import sqlGenGenJoinStr from "./sql-generator-gen-join-str";
|
import sqlGenGenJoinStr from "./sql-generator-gen-join-str";
|
||||||
import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql";
|
import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql";
|
||||||
|
import { sqlIdent, sqlJoinType, sqlOperator } from "./sql-safe";
|
||||||
|
|
||||||
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
||||||
genObject?: ServerQueryParam<T>;
|
genObject?: ServerQueryParam<T>;
|
||||||
selectFields?: (keyof T | TableSelectFieldsObject<T>)[];
|
selectFields?: (keyof T | TableSelectFieldsObject<T>)[];
|
||||||
append_table_names?: boolean;
|
append_table_names?: boolean;
|
||||||
table_name: string;
|
table_name: string;
|
||||||
full_text_match_str?: string;
|
|
||||||
full_text_search_str?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function sqlGenGenQueryStr<
|
export default function sqlGenGenQueryStr<
|
||||||
@@ -20,8 +19,6 @@ export default function sqlGenGenQueryStr<
|
|||||||
|
|
||||||
const genObject = params.genObject;
|
const genObject = params.genObject;
|
||||||
const table_name = params.table_name;
|
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[] = [];
|
let sqlSearhValues: any[] = [];
|
||||||
|
|
||||||
@@ -55,13 +52,14 @@ export default function sqlGenGenQueryStr<
|
|||||||
const countSubQuery = genObject.countSubQueries[i];
|
const countSubQuery = genObject.countSubQueries[i];
|
||||||
if (!countSubQuery) continue;
|
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(*)`;
|
let subQStr = `(SELECT COUNT(*)`;
|
||||||
|
|
||||||
subQStr += ` FROM ${countSubQuery.table}${
|
subQStr += ` FROM ${countTable}${tableAlias ? ` ${tableAlias}` : ""}`;
|
||||||
tableAlias ? ` ${tableAlias}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
subQStr += ` WHERE (`;
|
subQStr += ` WHERE (`;
|
||||||
|
|
||||||
@@ -69,15 +67,16 @@ export default function sqlGenGenQueryStr<
|
|||||||
const csqSrc = countSubQuery.srcTrgMap[j];
|
const csqSrc = countSubQuery.srcTrgMap[j];
|
||||||
if (!csqSrc) continue;
|
if (!csqSrc) continue;
|
||||||
|
|
||||||
subQStr += ` ${tableAlias || countSubQuery.table}.${
|
subQStr += ` ${tableAlias || countTable}.${sqlIdent(
|
||||||
csqSrc.src
|
csqSrc.src,
|
||||||
}`;
|
"field name",
|
||||||
|
)}`;
|
||||||
|
|
||||||
if (typeof csqSrc.trg == "string") {
|
if (typeof csqSrc.trg == "string") {
|
||||||
subQStr += ` = ?`;
|
subQStr += ` = ?`;
|
||||||
sqlSearhValues.push(csqSrc.trg);
|
sqlSearhValues.push(csqSrc.trg);
|
||||||
} else if (typeof csqSrc.trg == "object") {
|
} 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) {
|
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);
|
countSqls.push(subQStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,83 +94,80 @@ export default function sqlGenGenQueryStr<
|
|||||||
if (genObject?.join) {
|
if (genObject?.join) {
|
||||||
const existingJoinTableNames: string[] = [table_name];
|
const existingJoinTableNames: string[] = [table_name];
|
||||||
|
|
||||||
str +=
|
const joinSelectStr = genObject.join
|
||||||
"," +
|
.flat()
|
||||||
genObject.join
|
.filter((j) => !isUndefined(j))
|
||||||
.flat()
|
.map((joinObj) => {
|
||||||
.filter((j) => !isUndefined(j))
|
const joinTableName = sqlIdent(
|
||||||
.map((joinObj) => {
|
joinObj.alias ? joinObj.alias : joinObj.tableName,
|
||||||
const joinTableName = joinObj.alias
|
"join table name",
|
||||||
? joinObj.alias
|
);
|
||||||
: joinObj.tableName;
|
|
||||||
|
|
||||||
if (existingJoinTableNames.includes(joinTableName))
|
if (existingJoinTableNames.includes(joinTableName))
|
||||||
return null;
|
return null;
|
||||||
existingJoinTableNames.push(joinTableName);
|
existingJoinTableNames.push(joinTableName);
|
||||||
|
|
||||||
if (joinObj.group_concat) {
|
if (joinObj.group_concat) {
|
||||||
return sqlGenGrabConcatStr({
|
return sqlGenGrabConcatStr({
|
||||||
field: `${joinTableName}.${joinObj.group_concat.field}`,
|
field: `${joinTableName}.${sqlIdent(joinObj.group_concat.field, "field name")}`,
|
||||||
alias: joinObj.group_concat.alias,
|
alias: joinObj.group_concat.alias,
|
||||||
separator: joinObj.group_concat.separator,
|
separator: joinObj.group_concat.separator,
|
||||||
});
|
distinct: joinObj.group_concat.distinct,
|
||||||
} else if (joinObj.selectFields) {
|
});
|
||||||
return joinObj.selectFields
|
} else if (joinObj.selectFields) {
|
||||||
.map((selectField) => {
|
return joinObj.selectFields
|
||||||
if (typeof selectField == "string") {
|
.map((selectField) => {
|
||||||
return `${joinTableName}.${selectField}`;
|
if (typeof selectField != "object") {
|
||||||
} else if (typeof selectField == "object") {
|
return `${joinTableName}.${sqlIdent(selectField, "field name")}`;
|
||||||
let aliasSelectField = `${joinTableName}.${selectField.field}`;
|
}
|
||||||
|
|
||||||
if (selectField.count) {
|
const field = `${joinTableName}.${sqlIdent(selectField.field, "field name")}`;
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectField.alias)
|
if (
|
||||||
aliasSelectField += ` AS ${selectField.alias}`;
|
selectField.group_concat &&
|
||||||
return aliasSelectField;
|
selectField.alias
|
||||||
}
|
) {
|
||||||
})
|
return sqlGenGrabConcatStr({
|
||||||
.join(",");
|
field,
|
||||||
} else {
|
alias: selectField.alias,
|
||||||
return `${joinTableName}.*`;
|
separator:
|
||||||
}
|
selectField.group_concat.separator,
|
||||||
})
|
distinct:
|
||||||
.filter((_) => Boolean(_))
|
selectField.group_concat.distinct,
|
||||||
.join(",");
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
let aliasSelectField = field;
|
||||||
genObject?.fullTextSearch &&
|
|
||||||
full_text_match_str &&
|
if (selectField.count) {
|
||||||
full_text_search_str
|
aliasSelectField = `COUNT(${field})`;
|
||||||
) {
|
} else if (selectField.sum) {
|
||||||
str += `, ${full_text_match_str} AS ${genObject.fullTextSearch.scoreAlias}`;
|
aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${field})`;
|
||||||
sqlSearhValues.push(full_text_search_str);
|
} 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}`;
|
str += ` FROM ${table_name}`;
|
||||||
@@ -183,12 +179,14 @@ export default function sqlGenGenQueryStr<
|
|||||||
.flat()
|
.flat()
|
||||||
.filter((j) => !isUndefined(j))
|
.filter((j) => !isUndefined(j))
|
||||||
.map((join) => {
|
.map((join) => {
|
||||||
|
const joinTable = sqlIdent(join.tableName, "table name");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
join.joinType +
|
sqlJoinType(join.joinType) +
|
||||||
" " +
|
" " +
|
||||||
(join.alias
|
(join.alias
|
||||||
? `${join.tableName}` + " " + join.alias
|
? `${joinTable} ${sqlIdent(join.alias, "alias")}`
|
||||||
: `${join.tableName}`) +
|
: joinTable) +
|
||||||
" ON " +
|
" ON " +
|
||||||
(() => {
|
(() => {
|
||||||
if (Array.isArray(join.match)) {
|
if (Array.isArray(join.match)) {
|
||||||
@@ -208,9 +206,7 @@ export default function sqlGenGenQueryStr<
|
|||||||
return str;
|
return str;
|
||||||
})
|
})
|
||||||
.join(
|
.join(
|
||||||
join.operator
|
` ${sqlOperator(join.operator)} `,
|
||||||
? ` ${join.operator} `
|
|
||||||
: " AND ",
|
|
||||||
) +
|
) +
|
||||||
")"
|
")"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
QueryRawValueType,
|
|
||||||
ServerQueryParamsJoin,
|
ServerQueryParamsJoin,
|
||||||
ServerQueryQueryObject,
|
ServerQueryQueryObject,
|
||||||
ServerQueryValuesObject,
|
ServerQueryValuesObject,
|
||||||
SQLInsertGenValueType,
|
SQLInsertGenValueType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import sqlGenOperatorGen from "./sql-gen-operator-gen";
|
import sqlGenOperatorGen from "./sql-gen-operator-gen";
|
||||||
|
import { sqlField, sqlIdent, sqlOperator, sqlRawEquality } from "./sql-safe";
|
||||||
|
|
||||||
type Param = {
|
type Param = {
|
||||||
queryObj: ServerQueryQueryObject[string];
|
queryObj: ServerQueryQueryObject[string];
|
||||||
@@ -14,6 +14,21 @@ type Param = {
|
|||||||
table_name: string;
|
table_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QueryValue =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| ServerQueryValuesObject
|
||||||
|
| SQLInsertGenValueType
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
function isPresent<T>(val: T): val is NonNullable<T> {
|
||||||
|
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({
|
export default function sqlGenGenSearchStr({
|
||||||
queryObj,
|
queryObj,
|
||||||
join,
|
join,
|
||||||
@@ -22,101 +37,108 @@ export default function sqlGenGenSearchStr({
|
|||||||
}: Param) {
|
}: Param) {
|
||||||
let sqlSearhValues: SQLInsertGenValueType[] = [];
|
let sqlSearhValues: SQLInsertGenValueType[] = [];
|
||||||
|
|
||||||
|
const safeField = sqlField(field);
|
||||||
|
|
||||||
const finalFieldName = (() => {
|
const finalFieldName = (() => {
|
||||||
if (queryObj?.tableName) {
|
if (queryObj?.tableName) {
|
||||||
return `${queryObj.tableName}.${field}`;
|
return `${sqlIdent(queryObj.tableName, "table name")}.${safeField}`;
|
||||||
}
|
}
|
||||||
if (join) {
|
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) {
|
if (isValuesObject(val)) {
|
||||||
const valueParsed = 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 =
|
return sqlGenOperatorGen({
|
||||||
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({
|
|
||||||
queryObj,
|
queryObj,
|
||||||
equality: valueEquality,
|
equality: queryObj.equality,
|
||||||
fieldName: finalFieldName || "",
|
fieldName: finalFieldName,
|
||||||
value: valueString || "",
|
value: val,
|
||||||
isValueFieldValue: Boolean(
|
|
||||||
typeof valueParsed == "object" &&
|
|
||||||
valueParsed.fieldName &&
|
|
||||||
valueParsed.tableName,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return operatorStrParam;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let str: string;
|
||||||
|
|
||||||
if (Array.isArray(queryObj.value)) {
|
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) => {
|
if (equality == "IN" || equality == "NOT IN") {
|
||||||
const operatorStrParam = grabValue(val);
|
str = `${finalFieldName} ${equality} (${rawValues
|
||||||
|
.map(() => "?")
|
||||||
if (!operatorStrParam) return;
|
.join(", ")})`;
|
||||||
|
sqlSearhValues.push(...rawValues);
|
||||||
if (operatorStrParam.str && operatorStrParam.param) {
|
} else if (equality == "BETWEEN" || equality == "NOT BETWEEN") {
|
||||||
strArray.push(operatorStrParam.str);
|
if (rawValues.length !== 2) {
|
||||||
sqlSearhValues.push(operatorStrParam.param);
|
throw new Error(
|
||||||
} else if (operatorStrParam.str) {
|
`\`${equality}\` on \`${finalFieldName}\` requires exactly two values`,
|
||||||
strArray.push(operatorStrParam.str);
|
);
|
||||||
}
|
}
|
||||||
});
|
str = `${finalFieldName} ${equality} ? AND ?`;
|
||||||
|
sqlSearhValues.push(...rawValues);
|
||||||
str = "(" + strArray.join(` ${queryObj.operator || "AND"} `) + ")";
|
} else {
|
||||||
} else if (typeof queryObj.value == "object") {
|
if (!items.length) {
|
||||||
const operatorStrParam = grabValue(queryObj.value);
|
throw new Error(`Empty value array for \`${finalFieldName}\``);
|
||||||
if (operatorStrParam?.str) {
|
|
||||||
str = operatorStrParam.str;
|
|
||||||
if (operatorStrParam.param) {
|
|
||||||
sqlSearhValues.push(operatorStrParam.param);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} else if (queryObj.raw_equality && isPresent(queryObj.value)) {
|
||||||
str = `${finalFieldName} ${queryObj.raw_equality} ?`;
|
const value = isValuesObject(queryObj.value)
|
||||||
sqlSearhValues.push(queryObj.value);
|
? queryObj.value.value
|
||||||
|
: queryObj.value;
|
||||||
|
str = `${finalFieldName} ${sqlRawEquality(queryObj.raw_equality)} ?`;
|
||||||
|
sqlSearhValues.push(value ?? null);
|
||||||
} else if (queryObj.between) {
|
} else if (queryObj.between) {
|
||||||
str = `${finalFieldName} BETWEEN ? AND ?`;
|
str = `${finalFieldName} BETWEEN ? AND ?`;
|
||||||
sqlSearhValues.push(queryObj.between.min, queryObj.between.max);
|
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 {
|
} 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({
|
str = operatorStrParam.str;
|
||||||
equality: queryObj.equality,
|
sqlSearhValues.push(...operatorStrParam.params);
|
||||||
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 || "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { str, values: sqlSearhValues };
|
return { str, values: sqlSearhValues };
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { sqlField, sqlIdent, sqlStringLiteral } from "./sql-safe";
|
||||||
|
|
||||||
type Param = {
|
type Param = {
|
||||||
field: string;
|
field: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -17,14 +19,14 @@ export default function sqlGenGrabConcatStr({
|
|||||||
gc += `DISTINCT `;
|
gc += `DISTINCT `;
|
||||||
}
|
}
|
||||||
|
|
||||||
gc += `${field}`;
|
gc += sqlField(field);
|
||||||
|
|
||||||
if (!distinct) {
|
if (!distinct) {
|
||||||
gc += `, '${separator}'`;
|
gc += `, ${sqlStringLiteral(String(separator))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
gc += `)`;
|
gc += `)`;
|
||||||
gc += ` AS ${alias}`;
|
gc += ` AS ${sqlIdent(alias, "alias")}`;
|
||||||
|
|
||||||
return gc;
|
return gc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TableSelectFieldsObject } from "../types";
|
import type { TableSelectFieldsObject } from "../types";
|
||||||
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
|
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
|
||||||
|
import { sqlField, sqlIdent } from "./sql-safe";
|
||||||
|
|
||||||
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
||||||
selectFields: (keyof T | TableSelectFieldsObject<T>)[];
|
selectFields: (keyof T | TableSelectFieldsObject<T>)[];
|
||||||
@@ -14,49 +15,52 @@ export default function sqlGenGrabSelectFieldSQL<
|
|||||||
|
|
||||||
str += ` ${selectFields
|
str += ` ${selectFields
|
||||||
?.map((fld) => {
|
?.map((fld) => {
|
||||||
let fld_str = ``;
|
if (typeof fld != "object") {
|
||||||
|
return sqlField(fld);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fld_name = sqlField(fld.fieldName);
|
||||||
|
|
||||||
const final_fld_name =
|
const final_fld_name =
|
||||||
typeof fld == "object"
|
append_table_names && !fld_name.includes(".")
|
||||||
? append_table_names
|
? `${table_name}.${fld_name}`
|
||||||
? `${table_name}.${String(fld)}`
|
: fld_name;
|
||||||
: `${String(fld.fieldName)}`
|
|
||||||
: `${String(fld)}`;
|
|
||||||
|
|
||||||
if (typeof fld == "object") {
|
if (fld.group_concat) {
|
||||||
const fld_name = `${String(fld.fieldName)}`;
|
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) {
|
let fld_str = ``;
|
||||||
fld_str += `COUNT(${fld_name})`;
|
let alias = fld.alias;
|
||||||
} 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}` : ``);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fld.alias) {
|
if (fld.count) {
|
||||||
fld_str += ` AS ${fld.alias}`;
|
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 {
|
} else {
|
||||||
fld_str += final_fld_name;
|
fld_str += final_fld_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alias) {
|
||||||
|
fld_str += ` AS ${sqlIdent(alias, "alias")}`;
|
||||||
|
}
|
||||||
|
|
||||||
return fld_str;
|
return fld_str;
|
||||||
})
|
})
|
||||||
.join(",")}`;
|
.join(",")}`;
|
||||||
|
|||||||
+111
-101
@@ -5,6 +5,13 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import sqlGenGenSearchStr from "./sql-generator-gen-search-str";
|
import sqlGenGenSearchStr from "./sql-generator-gen-search-str";
|
||||||
import sqlGenGenQueryStr from "./sql-generator-gen-query-str";
|
import sqlGenGenQueryStr from "./sql-generator-gen-query-str";
|
||||||
|
import {
|
||||||
|
sqlField,
|
||||||
|
sqlIdent,
|
||||||
|
sqlInt,
|
||||||
|
sqlOperator,
|
||||||
|
sqlOrderStrategy,
|
||||||
|
} from "./sql-safe";
|
||||||
|
|
||||||
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
||||||
genObject?: ServerQueryParam<T>;
|
genObject?: ServerQueryParam<T>;
|
||||||
@@ -16,41 +23,41 @@ type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
|
|||||||
type Return = {
|
type Return = {
|
||||||
string: string;
|
string: string;
|
||||||
values: SQLInsertGenValueType[];
|
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
|
* # 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<
|
export default function sqlGenerator<
|
||||||
T extends { [key: string]: any } = { [key: string]: any },
|
T extends { [key: string]: any } = { [key: string]: any },
|
||||||
>({ tableName, genObject, dbFullName, count }: Param<T>): Return {
|
>({ tableName: passedTableName, genObject, count }: Param<T>): Return {
|
||||||
|
const tableName = sqlIdent(passedTableName, "table name");
|
||||||
const finalQuery = genObject?.query ? genObject.query : undefined;
|
const finalQuery = genObject?.query ? genObject.query : undefined;
|
||||||
|
|
||||||
const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined;
|
const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined;
|
||||||
|
|
||||||
const sqlSearhValues: SQLInsertGenValueType[] = [];
|
const sqlSearhValues: SQLInsertGenValueType[] = [];
|
||||||
|
const whereValues: SQLInsertGenValueType[] = [];
|
||||||
|
|
||||||
let fullTextMatchStr = genObject?.fullTextSearch
|
if (genObject?.fullTextSearch) {
|
||||||
? ` MATCH(${genObject.fullTextSearch.fields
|
throw new Error(
|
||||||
.map((f) =>
|
"`fullTextSearch` is not supported by SQLite. Use an FTS5 table and the `MATCH` equality instead.",
|
||||||
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;
|
|
||||||
|
|
||||||
let { str: queryString, values } = sqlGenGenQueryStr<T>({
|
let { str: queryString, values } = sqlGenGenQueryStr<T>({
|
||||||
table_name: tableName,
|
table_name: tableName,
|
||||||
append_table_names: true,
|
append_table_names: true,
|
||||||
full_text_match_str: fullTextMatchStr,
|
|
||||||
full_text_search_str: fullTextSearchStr,
|
|
||||||
genObject,
|
genObject,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,26 +71,30 @@ export default function sqlGenerator<
|
|||||||
const subQueryGroup = queryObj.__query;
|
const subQueryGroup = queryObj.__query;
|
||||||
|
|
||||||
const subSearchKeys = Object.keys(subQueryGroup);
|
const subSearchKeys = Object.keys(subQueryGroup);
|
||||||
const subSearchString = subSearchKeys.map((_field) => {
|
const subSearchString = subSearchKeys
|
||||||
const newSubQueryObj = subQueryGroup?.[_field];
|
.map((_field) => {
|
||||||
|
const newSubQueryObj = subQueryGroup?.[_field];
|
||||||
|
|
||||||
if (newSubQueryObj) {
|
if (newSubQueryObj) {
|
||||||
const { str, values } = sqlGenGenSearchStr({
|
const { str, values } = sqlGenGenSearchStr({
|
||||||
queryObj: newSubQueryObj,
|
queryObj: newSubQueryObj,
|
||||||
field: newSubQueryObj.fieldName || _field,
|
field: newSubQueryObj.fieldName || _field,
|
||||||
join: genObject?.join,
|
join: genObject?.join,
|
||||||
table_name: tableName,
|
table_name: tableName,
|
||||||
});
|
});
|
||||||
|
|
||||||
sqlSearhValues.push(...values);
|
whereValues.push(...values);
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
.filter((str) => typeof str == "string");
|
||||||
|
|
||||||
|
if (!subSearchString.length) return;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"(" +
|
"(" +
|
||||||
subSearchString.join(` ${queryObj.operator || "AND"} `) +
|
subSearchString.join(` ${sqlOperator(queryObj.operator)} `) +
|
||||||
")"
|
")"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,7 +106,7 @@ export default function sqlGenerator<
|
|||||||
table_name: tableName,
|
table_name: tableName,
|
||||||
});
|
});
|
||||||
|
|
||||||
sqlSearhValues.push(...values);
|
whereValues.push(...values);
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
});
|
});
|
||||||
@@ -104,103 +115,102 @@ export default function sqlGenerator<
|
|||||||
(str) => typeof str == "string",
|
(str) => typeof str == "string",
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSearchStr =
|
let whereString = "";
|
||||||
cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str);
|
|
||||||
|
|
||||||
if (isSearchStr) {
|
if (cleanedUpSearchStr?.[0]) {
|
||||||
const stringOperator = genObject?.searchOperator || "AND";
|
const stringOperator = sqlOperator(genObject?.searchOperator);
|
||||||
queryString += ` WHERE ${cleanedUpSearchStr.join(
|
whereString = `WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`;
|
||||||
` ${stringOperator} `,
|
queryString += ` ${whereString}`;
|
||||||
)}`;
|
sqlSearhValues.push(...whereValues);
|
||||||
}
|
|
||||||
|
|
||||||
if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) {
|
|
||||||
queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`;
|
|
||||||
sqlSearhValues.push(fullTextSearchStr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genObject?.group) {
|
if (genObject?.group) {
|
||||||
let group_by_txt = ``;
|
const grabGroupStr = (
|
||||||
|
group: string | number | symbol | { field: unknown; table?: string },
|
||||||
if (typeof genObject.group == "string") {
|
) => {
|
||||||
group_by_txt = genObject.group;
|
if (typeof group == "object") {
|
||||||
} else if (Array.isArray(genObject.group)) {
|
return group.table
|
||||||
for (let i = 0; i < genObject.group.length; i++) {
|
? `${sqlIdent(group.table, "table name")}.${sqlIdent(group.field, "field name")}`
|
||||||
const group = genObject.group[i];
|
: sqlField(group.field);
|
||||||
|
|
||||||
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 += ",";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (typeof genObject.group == "object") {
|
|
||||||
if (genObject.group.table) {
|
return sqlField(group);
|
||||||
group_by_txt = `${genObject.group.table}.${String(genObject.group.field)}`;
|
};
|
||||||
} else {
|
|
||||||
group_by_txt = `${String(genObject.group.field)}`;
|
const group_by_txt = Array.isArray(genObject.group)
|
||||||
}
|
? genObject.group.map(grabGroupStr).join(",")
|
||||||
}
|
: grabGroupStr(genObject.group);
|
||||||
|
|
||||||
queryString += ` GROUP BY ${group_by_txt}`;
|
queryString += ` GROUP BY ${group_by_txt}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function grabOrderString(order: ServerQueryParamOrder<T>) {
|
function grabOrderString(order: ServerQueryParamOrder<T>) {
|
||||||
let orderFields = [];
|
const field =
|
||||||
let orderSrt = ``;
|
genObject?.join && !String(order.field).includes(".")
|
||||||
|
? `${tableName}.${sqlIdent(order.field, "order field")}`
|
||||||
|
: sqlField(order.field, "order field");
|
||||||
|
|
||||||
if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) {
|
return ` ${field} ${sqlOrderStrategy(order.strategy)}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genObject?.order) {
|
if (genObject?.order) {
|
||||||
let orderSrt = ` ORDER BY`;
|
let orderSrt = ` ORDER BY`;
|
||||||
|
|
||||||
if (Array.isArray(genObject.order)) {
|
const orders = (
|
||||||
for (let i = 0; i < genObject.order.length; i++) {
|
Array.isArray(genObject.order) ? genObject.order : [genObject.order]
|
||||||
const order = genObject.order[i];
|
).filter(Boolean);
|
||||||
if (order) {
|
|
||||||
orderSrt +=
|
orderSrt += orders.map(grabOrderString).join(",");
|
||||||
grabOrderString(order) +
|
|
||||||
(i < genObject.order.length - 1 ? `,` : "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
orderSrt += grabOrderString(genObject.order);
|
|
||||||
}
|
|
||||||
|
|
||||||
queryString += ` ${orderSrt}`;
|
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}`;
|
* `limit: 0` has always meant "no limit"
|
||||||
} else if (genObject?.page && genObject.limit && !count) {
|
*/
|
||||||
queryString += ` OFFSET ${(genObject.page - 1) * genObject.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 {
|
return {
|
||||||
string: queryString,
|
string: queryString,
|
||||||
values: sqlSearhValues,
|
values: sqlSearhValues,
|
||||||
|
where: {
|
||||||
|
string: whereString,
|
||||||
|
values: whereString ? whereValues : [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSet<V>(val: V): val is NonNullable<V> {
|
||||||
|
return val !== undefined && val !== null;
|
||||||
|
}
|
||||||
|
|
||||||
// let queryString = (() => {
|
// let queryString = (() => {
|
||||||
// let str = "SELECT";
|
// let str = "SELECT";
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
SQLInsertGenReturn,
|
SQLInsertGenReturn,
|
||||||
SQLInsertGenValueType,
|
SQLInsertGenValueType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { sqlIdent } from "./sql-safe";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* # SQL Insert Generator
|
* # SQL Insert Generator
|
||||||
@@ -12,75 +13,65 @@ export default function sqlInsertGenerator({
|
|||||||
data,
|
data,
|
||||||
dbFullName,
|
dbFullName,
|
||||||
}: SQLInsertGenParams): SQLInsertGenReturn | undefined {
|
}: SQLInsertGenParams): SQLInsertGenReturn | undefined {
|
||||||
const finalDbName = dbFullName ? `${dbFullName}.` : "";
|
const finalDbName = dbFullName
|
||||||
|
? `${sqlIdent(dbFullName, "database name")}.`
|
||||||
|
: "";
|
||||||
|
|
||||||
try {
|
if (Array.isArray(data) && data?.[0]) {
|
||||||
if (Array.isArray(data) && data?.[0]) {
|
let insertKeys: string[] = [];
|
||||||
let insertKeys: string[] = [];
|
|
||||||
|
|
||||||
data.forEach((dt) => {
|
data.forEach((dt) => {
|
||||||
const kys = Object.keys(dt);
|
const kys = Object.keys(dt);
|
||||||
kys.forEach((ky) => {
|
kys.forEach((ky) => {
|
||||||
if (!insertKeys.includes(ky)) {
|
if (!insertKeys.includes(ky)) {
|
||||||
insertKeys.push(ky);
|
insertKeys.push(sqlIdent(ky, "column name"));
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let queryBatches: string[] = [];
|
let queryBatches: string[] = [];
|
||||||
let queryValues: SQLInsertGenValueType[] = [];
|
let queryValues: SQLInsertGenValueType[] = [];
|
||||||
|
|
||||||
data.forEach((item) => {
|
data.forEach((item) => {
|
||||||
queryBatches.push(
|
queryBatches.push(
|
||||||
`(${insertKeys
|
`(${insertKeys
|
||||||
.map((ky) => {
|
.map((ky) => {
|
||||||
const value = item[ky];
|
const value = item[ky];
|
||||||
|
|
||||||
const finalValue =
|
if (typeof value == "function") {
|
||||||
typeof value == "string" ||
|
const fnValue = value();
|
||||||
typeof value == "number"
|
queryValues.push(fnValue.value ?? null);
|
||||||
? value
|
return fnValue.placeholder;
|
||||||
: typeof value == "function"
|
}
|
||||||
? value().value
|
|
||||||
: value
|
|
||||||
? value
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (
|
queryValues.push(sqlValue(value));
|
||||||
!finalValue &&
|
return "?";
|
||||||
typeof finalValue !== "number" &&
|
})
|
||||||
typeof finalValue !== "string"
|
.join(",")})`,
|
||||||
) {
|
);
|
||||||
queryValues.push(null);
|
});
|
||||||
return "?";
|
|
||||||
}
|
|
||||||
|
|
||||||
queryValues.push(finalValue);
|
let query = `INSERT INTO ${finalDbName}${sqlIdent(
|
||||||
|
tableName,
|
||||||
|
"table name",
|
||||||
|
)} (${insertKeys.join(",")}) VALUES ${queryBatches.join(",")}`;
|
||||||
|
|
||||||
const placeholder =
|
return {
|
||||||
typeof value == "function"
|
query: query,
|
||||||
? value().placeholder
|
values: queryValues,
|
||||||
: "?";
|
};
|
||||||
|
} else {
|
||||||
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 undefined;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user