From b81b09324a3a2091bb45ccad38ccddc436bf1c96 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 14 Oct 2026 02:10:35 +0100 Subject: [PATCH] Fix SQL injection, query value handling, and other security issues - Quote all identifiers and validate operators, order strategies, join types, raw comparisons, and limit/offset/page in the query generator - Treat 0 as a real query value; stop null from leaving unbound placeholders - Return the WHERE clause and its values separately from sqlGenerator so delete/update no longer regex-parse generated SQL - update(): target matched row ids, re-fetch reliably, and return success: false when no rows match - Fix duplicated aliases, [object Object] select fields, AVERAGE -> AVG, escape GROUP_CONCAT separators - Fix OFFSET without LIMIT and OFFSET on count queries - Store boolean false as 0 on insert; call function values once - Verify TLS certificates by default (new ssl_reject_unauthorized option) - Confirm before importing an executable schema from an archive (--yes) - Skip symlinks when reading extracted zip archives - Only trim files matching the backup/export naming pattern - Tighten default HTML sanitizer (no style/id attributes, no button) - Stop typedef generation from mutating parent table schemas - Pass config through DbSQL; handle BigInt values in results Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- src/commands/import.ts | 39 +++- src/functions/grab-mariadb-client.ts | 9 +- src/lib/db-handler.ts | 10 +- src/lib/mariadb/db-delete.ts | 10 +- src/lib/mariadb/db-schema-to-typedef.ts | 24 +-- src/lib/mariadb/db-select.ts | 8 +- src/lib/mariadb/db-sql.ts | 10 +- src/lib/mariadb/db-update.ts | 70 +++++-- src/types/index.ts | 6 + src/utils/export-archive.ts | 5 +- src/utils/sanitize-html-options.ts | 9 +- src/utils/sql-gen-operator-gen.ts | 20 +- src/utils/sql-generator-gen-join-str.ts | 65 +++--- src/utils/sql-generator-gen-query-str.ts | 170 ++++++++-------- src/utils/sql-generator-gen-search-str.ts | 92 +++++---- src/utils/sql-generator-grab-concat-str.ts | 7 +- .../sql-generator-grab-select-field-sql.ts | 77 ++++--- src/utils/sql-generator.ts | 188 ++++++++++-------- src/utils/sql-identifier.ts | 82 ++++++++ src/utils/sql-insert-generator.ts | 54 +++-- src/utils/trim-backups.ts | 12 +- src/utils/trim-exports.ts | 12 +- 23 files changed, 603 insertions(+), 382 deletions(-) create mode 100644 src/utils/sql-identifier.ts diff --git a/README.md b/README.md index 8747e34..f08a82d 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,7 @@ The config file must be named `bun-mariadb.config.ts` and placed at the project | `charset` | `string` | No | Database charset (default: `utf8mb4`) | | `connection_timeout` | `number` | No | Connection timeout in ms (default: `10000`) | | `ssl_ca` | `string` | No | Path to SSL CA certificate (relative to project root) | +| `ssl_reject_unauthorized` | `boolean` | No | Verify the server TLS certificate (default: `true`). Set `false` only for trusted self-signed setups | | `html_sanitize` | `object` | No | Extra HTML sanitizer allowlists (see [HTML Sanitization](#html-sanitization)) | Schema file path is always: `{db_dir}/schema.ts`. @@ -469,11 +470,12 @@ bunx bun-mariadb import [file] [options] | `[file]` | Path to a `.sql` dump or export archive (`.tar.gz` / `.tar` / `.zip`) | | `--sql-only` | Archive only: restore SQL, do not overwrite `schema.ts` | | `--schema-only` | Archive only: write `schema.ts`, do not restore SQL | +| `-y, --yes` | Archive only: skip the confirmation before writing a `.ts`/`.js` schema | Behavior: - **`.sql` file** — restores SQL into the configured database (same as restore, but non-interactive) -- **Export archive** — restores `dump.sql` and writes `schema.ts` into `db_dir` +- **Export archive** — restores `dump.sql` and writes `schema.ts` into `db_dir`. A `.ts`/`.js` schema is executed when loaded, so you are asked to confirm first (non-interactive runs require `--yes`). Only import archives from sources you trust. - **No file argument** — interactive picker of archives in `{db_dir}/.exports` **Examples:** @@ -837,6 +839,8 @@ const config: BunMariaDBConfig = { Config values are **appended** to the built-in defaults (safe rich-text tags/attributes). +`style` and `id` attributes (and the `button` tag) are **not** allowed by default, since they enable CSS overlays and DOM clobbering. Add them through `allowed_attributes` / `allowed_tags` if you need them. + --- ## TypeScript Type Generation diff --git a/src/commands/import.ts b/src/commands/import.ts index 4a35cd5..a11ed63 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import path from "path"; import fs from "fs"; import chalk from "chalk"; -import { select } from "@inquirer/prompts"; +import { confirm, select } from "@inquirer/prompts"; import grabDBDir from "../utils/grab-db-dir"; import grabSortedExports from "../utils/grab-sorted-exports"; import grabBackupData from "../utils/grab-backup-data"; @@ -40,6 +40,10 @@ export default function () { "--schema-only", "When importing an archive, write schema only (skip SQL restore)", ) + .option( + "-y, --yes", + "Do not ask for confirmation before writing an executable (.ts/.js) schema from the archive", + ) .action(async (fileArg: string | undefined, opts) => { console.log(`Importing database ...`); @@ -117,6 +121,39 @@ export default function () { const { sql, schema, schemaFileName } = await readExportArchive(filePath); + /** + * A .ts/.js schema is executed (require) on the next run. + * Confirm before writing code from an archive into the project. + */ + const isExecutableSchema = /\.(ts|js)$/i.test(schemaFileName); + + if (!opts.sqlOnly && isExecutableSchema && !opts.yes) { + if (!process.stdin.isTTY) { + console.error( + chalk.red( + `Archive contains an executable schema (${schemaFileName}). Re-run with --yes to accept it, or --sql-only to skip it.`, + ), + ); + process.exit(1); + } + + console.log( + chalk.yellow( + `\nThis archive contains an executable schema file (${schemaFileName}).\nIt will run as code the next time bun-mariadb or your app loads it.\nOnly continue if you trust the source of this archive.\n`, + ), + ); + + const proceed = await confirm({ + message: `Write ${schemaFileName} into ${db_dir}?`, + default: false, + }); + + if (!proceed) { + console.log(chalk.dim(`Import cancelled.`)); + process.exit(1); + } + } + if (!opts.schemaOnly) { await restoreDatabase(config, sql); console.log(chalk.green(`SQL restored from archive`)); diff --git a/src/functions/grab-mariadb-client.ts b/src/functions/grab-mariadb-client.ts index 2b8760c..eccc54f 100644 --- a/src/functions/grab-mariadb-client.ts +++ b/src/functions/grab-mariadb-client.ts @@ -32,13 +32,16 @@ export default function grabMariaDBClient({ const { ROOT_DIR } = grabDirNames(); try { + // Verify the server certificate unless explicitly disabled + const rejectUnauthorized = config.ssl_reject_unauthorized !== false; + const tls = config.ssl_ca ? { ca: Bun.file(path.resolve(ROOT_DIR, config.ssl_ca)), - rejectUnauthorized: false, + rejectUnauthorized, } : { - rejectUnauthorized: false, + rejectUnauthorized, }; const MariaDBClient = new SQL({ @@ -49,8 +52,8 @@ export default function grabMariaDBClient({ port: process.env.BUN_MARIADB_SERVER_PORT ? Number(process.env.BUN_MARIADB_SERVER_PORT) : undefined, - ...config.db_config, tls, + ...config.db_config, adapter: "mariadb", } as Bun.SQL.Options); diff --git a/src/lib/db-handler.ts b/src/lib/db-handler.ts index 4779adc..ac94ceb 100644 --- a/src/lib/db-handler.ts +++ b/src/lib/db-handler.ts @@ -54,7 +54,15 @@ export default async function dbHandler< const res_array = (() => { try { - return JSON.parse(JSON.stringify(res)) as T[]; + return JSON.parse( + JSON.stringify(res, (_key, value) => + typeof value == "bigint" + ? Number.isSafeInteger(Number(value)) + ? Number(value) + : value.toString() + : value, + ), + ) as T[]; } catch (error) { return undefined; } diff --git a/src/lib/mariadb/db-delete.ts b/src/lib/mariadb/db-delete.ts index 18d9440..de3f9be 100644 --- a/src/lib/mariadb/db-delete.ts +++ b/src/lib/mariadb/db-delete.ts @@ -6,6 +6,7 @@ import type { ServerQueryParam, } from "../../types"; import sqlGenerator from "../../utils/sql-generator"; +import { quoteIdentifier } from "../../utils/sql-identifier"; type Params< Schema extends { [k: string]: any } = { [k: string]: any }, @@ -17,10 +18,6 @@ type Params< config?: BunMariaDBConfig; }; -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; -} - export default async function DbDelete< Schema extends { [k: string]: any } = { [k: string]: any }, Table extends string = string, @@ -49,11 +46,11 @@ export default async function DbDelete< } sqlObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, }); - const whereClause = sqlObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlObj.where; if (!whereClause) { return { @@ -68,6 +65,7 @@ export default async function DbDelete< let sql = `DELETE FROM ${quoteIdentifier(table)} ${whereClause}`; sql += ` RETURNING *`; sqlObj.string = sql; + sqlObj.values = sqlObj.whereValues; const res = await dbHandler({ query: sql, diff --git a/src/lib/mariadb/db-schema-to-typedef.ts b/src/lib/mariadb/db-schema-to-typedef.ts index 547beef..461315d 100644 --- a/src/lib/mariadb/db-schema-to-typedef.ts +++ b/src/lib/mariadb/db-schema-to-typedef.ts @@ -4,6 +4,7 @@ import type { BunMariaDBConfig, } from "../../types"; import generateTypeDefinition from "./db-generate-type-defs"; +import resolveTable from "../schema/resolve-table"; type Params = { dbSchema: BUN_MARIADB_DatabaseSchemaType; @@ -32,20 +33,17 @@ export default function dbSchemaToType({ const schemas = dbTablesSchemas .map((table) => { - let final_table = _.cloneDeep(table); + const has_parent = Boolean( + table.parentTableName && + dbTablesSchemas.find( + (t) => t.tableName === table.parentTableName, + ), + ); - if (final_table.parentTableName) { - const parent_table = dbTablesSchemas.find( - (t) => t.tableName === final_table.parentTableName, - ); - - if (parent_table) { - final_table = _.merge(parent_table, { - tableName: final_table.tableName, - tableDescription: final_table.tableDescription, - }); - } - } + // Merge parent fields into a copy; never mutate the parent schema + const final_table = has_parent + ? resolveTable(table, datasquirelSchema) + : _.cloneDeep(table); const defObj = generateTypeDefinition({ paradigm: "TypeScript", diff --git a/src/lib/mariadb/db-select.ts b/src/lib/mariadb/db-select.ts index fe4a241..5998035 100644 --- a/src/lib/mariadb/db-select.ts +++ b/src/lib/mariadb/db-select.ts @@ -18,10 +18,6 @@ type Params< config?: BunMariaDBConfig; }; -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; -} - export default async function DbSelect< Schema extends { [k: string]: any } = { [k: string]: any }, Table extends string = string, @@ -51,7 +47,7 @@ export default async function DbSelect< } sqlObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, }); @@ -87,7 +83,7 @@ export default async function DbSelect< if (count) { const countSqlObject = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, count, }); diff --git a/src/lib/mariadb/db-sql.ts b/src/lib/mariadb/db-sql.ts index 40a99e9..6e3c8ff 100644 --- a/src/lib/mariadb/db-sql.ts +++ b/src/lib/mariadb/db-sql.ts @@ -1,14 +1,19 @@ import dbHandler from "../db-handler"; -import type { DBResponseObject, SQLInsertGenValueType } from "../../types"; +import type { + BunMariaDBConfig, + DBResponseObject, + SQLInsertGenValueType, +} from "../../types"; type Params = { sql: string; values?: SQLInsertGenValueType[]; + config?: BunMariaDBConfig; }; export default async function DbSQL< T extends { [k: string]: any } = { [k: string]: any }, ->({ sql, values }: Params): Promise> { +>({ sql, values, config }: Params): Promise> { try { const trimmedSql = sql.trim(); const isSelect = trimmedSql.match(/^select/i); @@ -16,6 +21,7 @@ export default async function DbSQL< const res = await dbHandler({ query: trimmedSql, values: values as any, + config, }); if (!res.success) { diff --git a/src/lib/mariadb/db-update.ts b/src/lib/mariadb/db-update.ts index 6c78e99..ce3693c 100644 --- a/src/lib/mariadb/db-update.ts +++ b/src/lib/mariadb/db-update.ts @@ -8,6 +8,7 @@ import type { } from "../../types"; import sqlGenerator from "../../utils/sql-generator"; import sanitizeHtmlFields from "../../utils/sanitize-html-fields"; +import { quoteIdentifier } from "../../utils/sql-identifier"; type Params< Schema extends { [k: string]: any } = { [k: string]: any }, @@ -20,10 +21,6 @@ type Params< config?: BunMariaDBConfig; }; -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; -} - export default async function DbUpdate< Schema extends { [k: string]: any } = { [k: string]: any }, Table extends string = string, @@ -34,7 +31,12 @@ export default async function DbUpdate< targetId, config, }: Params): Promise { - let sqlObj: ReturnType = { string: "", values: [] }; + let sqlObj: ReturnType = { + string: "", + values: [], + where: "", + whereValues: [], + }; try { let finalQuery = query || {}; @@ -53,11 +55,11 @@ export default async function DbUpdate< } const sqlQueryObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, }); - const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlQueryObj.where; if (!whereClause) { return { @@ -66,6 +68,38 @@ export default async function DbUpdate< }; } + /** + * Capture the matching row ids first, so the updated rows can be + * re-fetched even if the update changes a column used in the WHERE + * clause. + */ + const matched_res = await dbHandler<{ id: number }>({ + query: `SELECT id FROM ${quoteIdentifier(table)} ${whereClause}`, + values: sqlQueryObj.whereValues as any, + config, + }); + + if (!matched_res.success) { + return { + ...matched_res, + success: false, + debug: { sqlObj: sqlQueryObj }, + }; + } + + const matched_ids = (matched_res.payload || []) + .map((row) => row.id) + .filter((id) => id !== undefined && id !== null); + + if (matched_ids.length === 0) { + return { + success: false, + msg: "No rows matched the update query", + payload: [], + debug: { sqlObj: sqlQueryObj }, + }; + } + let values: SQLInsertGenValueType[] = []; let sql = ``; sql += `UPDATE ${quoteIdentifier(table)} SET`; @@ -97,8 +131,10 @@ export default async function DbUpdate< } } - sql += ` ${whereClause}`; - values = [...values, ...sqlQueryObj.values]; + const idPlaceholders = matched_ids.map(() => "?").join(","); + + sql += ` WHERE id IN (${idPlaceholders})`; + values = [...values, ...matched_ids]; const res = await dbHandler({ query: sql, @@ -106,22 +142,16 @@ export default async function DbUpdate< config, }); - if (res.error) { - return res; - } - sqlObj.string = sql; sqlObj.values = values as any[]; - let updated_sql = ``; - let updated_sql_values: any[] = []; - - updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`; - updated_sql_values = [...sqlQueryObj.values]; + if (res.error) { + return { ...res, debug: { sqlObj } }; + } const updated_res = await dbHandler({ - query: updated_sql, - values: updated_sql_values, + query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`, + values: matched_ids, config, }); diff --git a/src/types/index.ts b/src/types/index.ts index 383df69..9adae99 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1582,6 +1582,12 @@ export type BunMariaDBConfig = { * File path to the SSL certificate */ ssl_ca?: string; + /** + * Verify the server's TLS certificate. Defaults to `true`. + * Set to `false` only for trusted networks with self-signed certificates + * (prefer providing `ssl_ca` instead). + */ + ssl_reject_unauthorized?: boolean; /** * Extra HTML sanitization allowlists appended to the defaults * when inserting/updating fields marked `html: true`. diff --git a/src/utils/export-archive.ts b/src/utils/export-archive.ts index 57a48d9..7baa66b 100644 --- a/src/utils/export-archive.ts +++ b/src/utils/export-archive.ts @@ -253,9 +253,10 @@ function findFileContents( const current = stack.pop()!; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); + // Never follow symlinks from an extracted (untrusted) archive if (entry.isDirectory()) { stack.push(full); - } else if (predicate(entry.name)) { + } else if (entry.isFile() && predicate(entry.name)) { return fs.readFileSync(full, "utf-8"); } } @@ -273,7 +274,7 @@ function findSchemaFile( const full = path.join(current, entry.name); if (entry.isDirectory()) { stack.push(full); - } else if (isSchemaFileName(entry.name)) { + } else if (entry.isFile() && isSchemaFileName(entry.name)) { return { content: fs.readFileSync(full, "utf-8"), fileName: diff --git a/src/utils/sanitize-html-options.ts b/src/utils/sanitize-html-options.ts index f673b96..e078a32 100644 --- a/src/utils/sanitize-html-options.ts +++ b/src/utils/sanitize-html-options.ts @@ -21,7 +21,6 @@ export const defaultSanitizeHtmlOptions: IOptions = { "h6", "img", "div", - "button", "pre", "code", "br", @@ -38,10 +37,12 @@ export const defaultSanitizeHtmlOptions: IOptions = { "colgroup", "col", ], + // `style` / `id` are intentionally not allowed by default (CSS overlays, + // DOM clobbering). Add them via `html_sanitize.allowed_attributes`. allowedAttributes: { - a: ["href", "title", "class", "style", "target", "rel"], - img: ["src", "alt", "width", "height", "class", "style"], - "*": ["style", "class", "title", "id"], + a: ["href", "title", "class", "target", "rel"], + img: ["src", "alt", "width", "height", "class"], + "*": ["class", "title"], }, }; diff --git a/src/utils/sql-gen-operator-gen.ts b/src/utils/sql-gen-operator-gen.ts index 94f0479..1ef9739 100644 --- a/src/utils/sql-gen-operator-gen.ts +++ b/src/utils/sql-gen-operator-gen.ts @@ -6,6 +6,7 @@ import type { import sqlEqualityParser from "./sql-equality-parser"; type Params = { + /** Already-quoted column reference */ fieldName: string; value?: SQLInsertGenValueType; equality?: (typeof ServerQueryEqualities)[number]; @@ -15,6 +16,7 @@ type Params = { }, string >; + /** `value` is an already-quoted column reference, not a parameter */ isValueFieldValue?: boolean; }; @@ -42,9 +44,13 @@ export default function sqlGenOperatorGen({ return { str: `${fieldName} IS NOT NULL` }; } - if (value) { + // `0` is a real value; only null / undefined / "" count as "no value" + const hasValue = value !== undefined && value !== null && value !== ""; + + if (hasValue) { const finalValue = isValueFieldValue ? value : "?"; const finalParams = isValueFieldValue ? undefined : value; + const likeParams = isValueFieldValue ? undefined : `%${value}%`; if (equality == "MATCH") { return { @@ -59,7 +65,7 @@ export default function sqlGenOperatorGen({ } else if (equality == "LIKE_LOWER") { return { str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`, - param: `%${finalParams}%`, + param: likeParams, }; } else if (equality == "LIKE_LOWER_RAW") { return { @@ -69,7 +75,7 @@ export default function sqlGenOperatorGen({ } else if (equality == "LIKE") { return { str: `${fieldName} LIKE ${finalValue}`, - param: `%${finalParams}%`, + param: likeParams, }; } else if (equality == "LIKE_RAW") { return { @@ -79,7 +85,7 @@ export default function sqlGenOperatorGen({ } else if (equality == "NOT_LIKE_LOWER") { return { str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`, - param: `%${finalParams}%`, + param: likeParams, }; } else if (equality == "NOT_LIKE_LOWER_RAW") { return { @@ -89,7 +95,7 @@ export default function sqlGenOperatorGen({ } else if (equality == "NOT LIKE") { return { str: `${fieldName} NOT LIKE ${finalValue}`, - param: finalParams, + param: likeParams, }; } else if (equality == "NOT LIKE_RAW") { return { @@ -137,12 +143,12 @@ export default function sqlGenOperatorGen({ } else if (equality) { return { str: `${fieldName} ${sqlEqualityParser(equality)} ?`, - param: value, + param: value ?? "", }; } else { return { str: `${fieldName} = ?`, - param: value, + param: value ?? "", }; } } diff --git a/src/utils/sql-generator-gen-join-str.ts b/src/utils/sql-generator-gen-join-str.ts index be646de..ad00a55 100644 --- a/src/utils/sql-generator-gen-join-str.ts +++ b/src/utils/sql-generator-gen-join-str.ts @@ -3,10 +3,16 @@ import type { ServerQueryParamsJoinMatchObject, SQLInsertGenValueType, } from "../types"; +import { + columnRef, + sqlLogicalOperator, + sqlRawComparison, +} from "./sql-identifier"; type Param = { mtch: ServerQueryParamsJoinMatchObject; join: ServerQueryParamsJoin; + /** Already-quoted root table name */ table_name: string; }; @@ -33,7 +39,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) { values.push(...batch_values); if (i < mtch.__batch.matches.length - 1) { - btch_mtch += ` ${mtch.__batch.operator || "OR"} `; + btch_mtch += ` ${sqlLogicalOperator(mtch.__batch.operator || "OR")} `; } } @@ -45,44 +51,14 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) { }; } - const equality = mtch.raw_equality || "="; + const equality = mtch.raw_equality + ? sqlRawComparison(mtch.raw_equality) + : "="; - const lhs = `${ - typeof mtch.source == "object" ? mtch.source.tableName : table_name - }.${typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source}`; - - const rhs = `${(() => { - if (mtch.targetLiteral) { - values.push(mtch.targetLiteral); - - // if (typeof mtch.targetLiteral == "number") { - // return `${mtch.targetLiteral}`; - // } - // return `'${mtch.targetLiteral}'`; - - return `?`; - } - - if (join.alias) { - return `${ - typeof mtch.target == "object" - ? mtch.target.tableName - : join.alias - }.${ - typeof mtch.target == "object" - ? mtch.target.fieldName - : mtch.target - }`; - } - - return `${ - typeof mtch.target == "object" - ? mtch.target.tableName - : join.tableName - }.${ - typeof mtch.target == "object" ? mtch.target.fieldName : mtch.target - }`; - })()}`; + const lhs = + typeof mtch.source == "object" + ? columnRef(mtch.source.fieldName, mtch.source.tableName) + : columnRef(String(mtch.source), table_name); if (mtch.between) { values.push(mtch.between.min, mtch.between.max); @@ -102,6 +78,19 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) { }; } + const rhs = (() => { + if (mtch.targetLiteral !== undefined && mtch.targetLiteral !== null) { + values.push(mtch.targetLiteral); + return `?`; + } + + if (typeof mtch.target == "object") { + return columnRef(mtch.target.fieldName, mtch.target.tableName); + } + + return columnRef(String(mtch.target), join.alias || join.tableName); + })(); + return { str: `${lhs} ${equality} ${rhs}`, values, diff --git a/src/utils/sql-generator-gen-query-str.ts b/src/utils/sql-generator-gen-query-str.ts index 200f2da..224364f 100644 --- a/src/utils/sql-generator-gen-query-str.ts +++ b/src/utils/sql-generator-gen-query-str.ts @@ -3,11 +3,19 @@ import type { ServerQueryParam, TableSelectFieldsObject } from "../types"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; import sqlGenGenJoinStr from "./sql-generator-gen-join-str"; import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql"; +import { + columnRef, + quoteIdentifier, + quoteQualified, + sqlJoinType, + sqlLogicalOperator, +} from "./sql-identifier"; type Param = { genObject?: ServerQueryParam; selectFields?: (keyof T | TableSelectFieldsObject)[]; append_table_names?: boolean; + /** Already-quoted table name */ table_name: string; full_text_match_str?: string; full_text_search_str?: string; @@ -56,11 +64,14 @@ export default function sqlGenGenQueryStr< if (!countSubQuery) continue; const tableAlias = countSubQuery.table_alias; + const countTableRef = quoteQualified( + tableAlias || countSubQuery.table, + ); let subQStr = `(SELECT COUNT(*)`; - subQStr += ` FROM ${countSubQuery.table}${ - tableAlias ? ` ${tableAlias}` : "" + subQStr += ` FROM ${quoteQualified(countSubQuery.table)}${ + tableAlias ? ` ${quoteIdentifier(tableAlias)}` : "" }`; subQStr += ` WHERE (`; @@ -69,15 +80,13 @@ export default function sqlGenGenQueryStr< const csqSrc = countSubQuery.srcTrgMap[j]; if (!csqSrc) continue; - subQStr += ` ${tableAlias || countSubQuery.table}.${ - csqSrc.src - }`; + subQStr += ` ${countTableRef}.${quoteIdentifier(csqSrc.src)}`; if (typeof csqSrc.trg == "string") { subQStr += ` = ?`; sqlSearhValues.push(csqSrc.trg); } else if (typeof csqSrc.trg == "object") { - subQStr += ` = ${csqSrc.trg.table}.${csqSrc.trg.field}`; + subQStr += ` = ${columnRef(csqSrc.trg.field, csqSrc.trg.table)}`; } if (j < countSubQuery.srcTrgMap.length - 1) { @@ -85,7 +94,7 @@ export default function sqlGenGenQueryStr< } } - subQStr += ` )) AS ${countSubQuery.alias}`; + subQStr += ` )) AS ${quoteIdentifier(countSubQuery.alias)}`; countSqls.push(subQStr); } @@ -95,82 +104,89 @@ export default function sqlGenGenQueryStr< if (genObject?.join) { const existingJoinTableNames: string[] = [table_name]; - str += - "," + - genObject.join - .flat() - .filter((j) => !isUndefined(j)) - .map((joinObj) => { - const joinTableName = joinObj.alias - ? joinObj.alias - : joinObj.tableName; + const joinSelects = genObject.join + .flat() + .filter((j) => !isUndefined(j)) + .map((joinObj) => { + const joinTableName = joinObj.alias + ? joinObj.alias + : joinObj.tableName; + const joinTableRef = quoteQualified(joinTableName); - if (existingJoinTableNames.includes(joinTableName)) - return null; - existingJoinTableNames.push(joinTableName); + if (existingJoinTableNames.includes(joinTableRef)) return null; + existingJoinTableNames.push(joinTableRef); - if (joinObj.group_concat) { - return sqlGenGrabConcatStr({ - field: `${joinTableName}.${joinObj.group_concat.field}`, - alias: joinObj.group_concat.alias, - separator: joinObj.group_concat.separator, - }); - } else if (joinObj.selectFields) { - return joinObj.selectFields - .map((selectField) => { - if (typeof selectField == "string") { - return `${joinTableName}.${selectField}`; - } else if (typeof selectField == "object") { - let aliasSelectField = `${joinTableName}.${selectField.field}`; + if (joinObj.group_concat) { + return sqlGenGrabConcatStr({ + field: columnRef( + joinObj.group_concat.field, + joinTableName, + ), + alias: joinObj.group_concat.alias, + separator: joinObj.group_concat.separator, + }); + } else if (joinObj.selectFields) { + return joinObj.selectFields + .map((selectField) => { + if (typeof selectField == "string") { + return columnRef(selectField, joinTableName); + } else if (typeof selectField == "object") { + const fieldRef = columnRef( + String(selectField.field), + joinTableName, + ); + let aliasSelectField = fieldRef; - if (selectField.count) { - aliasSelectField = `COUNT(${joinTableName}.${selectField.field})`; - } else if (selectField.sum) { - aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${joinTableName}.${selectField.field})`; - } else if (selectField.average) { - aliasSelectField = `AVERAGE(${joinTableName}.${selectField.field})`; - } else if (selectField.max) { - aliasSelectField = `MAX(${joinTableName}.${selectField.field})`; - } else if (selectField.min) { - aliasSelectField = `MIN(${joinTableName}.${selectField.field})`; - } else if ( - selectField.group_concat && - selectField.alias - ) { - return sqlGenGrabConcatStr({ - field: `${joinTableName}.${selectField.field}`, - alias: selectField.alias, - separator: - selectField.group_concat - .separator, - distinct: - selectField.group_concat - .distinct, - }); - } else if (selectField.distinct) { - aliasSelectField = `DISTINCT ${joinTableName}.${selectField.field}`; - } - - if (selectField.alias) - aliasSelectField += ` AS ${selectField.alias}`; - return aliasSelectField; + if (selectField.count) { + aliasSelectField = `COUNT(${fieldRef})`; + } else if (selectField.sum) { + aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${fieldRef})`; + } else if (selectField.average) { + aliasSelectField = `AVG(${fieldRef})`; + } else if (selectField.max) { + aliasSelectField = `MAX(${fieldRef})`; + } else if (selectField.min) { + aliasSelectField = `MIN(${fieldRef})`; + } else if ( + selectField.group_concat && + selectField.alias + ) { + return sqlGenGrabConcatStr({ + field: fieldRef, + alias: selectField.alias, + separator: + selectField.group_concat.separator, + distinct: + selectField.group_concat.distinct, + }); + } else if (selectField.distinct) { + aliasSelectField = `DISTINCT ${fieldRef}`; } - }) - .join(","); - } else { - return `${joinTableName}.*`; - } - }) - .filter((_) => Boolean(_)) - .join(","); + + if (selectField.alias) + aliasSelectField += ` AS ${quoteIdentifier(selectField.alias)}`; + return aliasSelectField; + } + }) + .join(","); + } else { + return `${joinTableRef}.*`; + } + }) + .filter((_) => Boolean(_)); + + if (joinSelects.length) { + str += "," + joinSelects.join(","); + } } if ( genObject?.fullTextSearch && + genObject.fullTextSearch.scoreAlias && full_text_match_str && full_text_search_str ) { - str += `, ${full_text_match_str} AS ${genObject.fullTextSearch.scoreAlias}`; + str += `, ${full_text_match_str} AS ${quoteIdentifier(genObject.fullTextSearch.scoreAlias)}`; sqlSearhValues.push(full_text_search_str); } @@ -184,11 +200,11 @@ export default function sqlGenGenQueryStr< .filter((j) => !isUndefined(j)) .map((join) => { return ( - join.joinType + + sqlJoinType(join.joinType) + " " + (join.alias - ? `${join.tableName}` + " " + join.alias - : `${join.tableName}`) + + ? `${quoteQualified(join.tableName)} ${quoteIdentifier(join.alias)}` + : `${quoteQualified(join.tableName)}`) + " ON " + (() => { if (Array.isArray(join.match)) { @@ -209,9 +225,7 @@ export default function sqlGenGenQueryStr< return str; }) .join( - join.operator - ? ` ${join.operator} ` - : " AND ", + ` ${sqlLogicalOperator(join.operator)} `, ) + ")" ); diff --git a/src/utils/sql-generator-gen-search-str.ts b/src/utils/sql-generator-gen-search-str.ts index 07cc0e9..c7bb0ce 100644 --- a/src/utils/sql-generator-gen-search-str.ts +++ b/src/utils/sql-generator-gen-search-str.ts @@ -1,16 +1,21 @@ import type { - QueryRawValueType, ServerQueryParamsJoin, ServerQueryQueryObject, ServerQueryValuesObject, SQLInsertGenValueType, } from "../types"; import sqlGenOperatorGen from "./sql-gen-operator-gen"; +import { + columnRef, + sqlLogicalOperator, + sqlRawComparison, +} from "./sql-identifier"; type Param = { queryObj: ServerQueryQueryObject[string]; join?: (ServerQueryParamsJoin | ServerQueryParamsJoin[] | undefined)[]; field?: string; + /** Already-quoted root table name */ table_name: string; }; @@ -24,48 +29,42 @@ export default function sqlGenGenSearchStr({ const finalFieldName = (() => { if (queryObj?.tableName) { - return `${queryObj.tableName}.${field}`; + return columnRef(String(field), queryObj.tableName); } if (join) { - return `${table_name}.${field}`; + return columnRef(String(field), table_name); } - return field; + return columnRef(String(field)); })(); let str = `${finalFieldName}=?`; function grabValue(val?: string | number | ServerQueryValuesObject | null) { - const valueParsed = val; + if (val === undefined || val === null) return; - if (!valueParsed) return; + const isFieldRef = Boolean( + typeof val == "object" && val.fieldName && val.tableName, + ); const valueString = - typeof valueParsed == "string" || typeof valueParsed == "number" - ? valueParsed - : valueParsed - ? valueParsed.fieldName && valueParsed.tableName - ? `${valueParsed.tableName}.${valueParsed.fieldName}` - : valueParsed.value - : undefined; + typeof val == "string" || typeof val == "number" + ? val + : isFieldRef + ? columnRef(val.fieldName!, val.tableName) + : val.value; const valueEquality = - typeof valueParsed == "object" - ? valueParsed.equality || queryObj.equality + typeof val == "object" + ? val.equality || queryObj.equality : queryObj.equality; - const operatorStrParam = sqlGenOperatorGen({ + return sqlGenOperatorGen({ queryObj, equality: valueEquality, - fieldName: finalFieldName || "", - value: valueString || "", - isValueFieldValue: Boolean( - typeof valueParsed == "object" && - valueParsed.fieldName && - valueParsed.tableName, - ), + fieldName: finalFieldName, + value: valueString ?? "", + isValueFieldValue: isFieldRef, }); - - return operatorStrParam; } if (Array.isArray(queryObj.value)) { @@ -74,48 +73,53 @@ export default function sqlGenGenSearchStr({ queryObj.value.forEach((val) => { const operatorStrParam = grabValue(val); - if (!operatorStrParam) return; + if (!operatorStrParam?.str) return; - if (operatorStrParam.str && operatorStrParam.param) { - strArray.push(operatorStrParam.str); + strArray.push(operatorStrParam.str); + if (operatorStrParam.param !== undefined) { sqlSearhValues.push(operatorStrParam.param); - } else if (operatorStrParam.str) { - strArray.push(operatorStrParam.str); } }); - str = "(" + strArray.join(` ${queryObj.operator || "AND"} `) + ")"; - } else if (typeof queryObj.value == "object") { + str = + "(" + + strArray.join(` ${sqlLogicalOperator(queryObj.operator)} `) + + ")"; + } else if (queryObj.value !== null && typeof queryObj.value == "object") { const operatorStrParam = grabValue(queryObj.value); if (operatorStrParam?.str) { str = operatorStrParam.str; - if (operatorStrParam.param) { + if (operatorStrParam.param !== undefined) { sqlSearhValues.push(operatorStrParam.param); } } - } else if (queryObj.raw_equality && queryObj.value) { - str = `${finalFieldName} ${queryObj.raw_equality} ?`; + } else if ( + queryObj.raw_equality && + queryObj.value !== undefined && + queryObj.value !== null + ) { + str = `${finalFieldName} ${sqlRawComparison(queryObj.raw_equality)} ?`; sqlSearhValues.push(queryObj.value); } else if (queryObj.between) { str = `${finalFieldName} BETWEEN ? AND ?`; sqlSearhValues.push(queryObj.between.min, queryObj.between.max); + } else if (queryObj.not_between) { + str = `${finalFieldName} NOT BETWEEN ? AND ?`; + sqlSearhValues.push(queryObj.not_between.min, queryObj.not_between.max); } else { - const valueParsed = queryObj.value ? queryObj.value : undefined; - const operatorStrParam = sqlGenOperatorGen({ equality: queryObj.equality, - fieldName: finalFieldName || "", - value: valueParsed, + fieldName: finalFieldName, + value: queryObj.value ?? undefined, queryObj, }); - if (operatorStrParam.str && operatorStrParam.param) { + if (operatorStrParam.str) { str = operatorStrParam.str; + } + + if (operatorStrParam.param !== undefined) { sqlSearhValues.push(operatorStrParam.param); - } else if (operatorStrParam.str && !operatorStrParam.str.match(/\?/)) { - str = operatorStrParam.str; - } else { - sqlSearhValues.push(valueParsed || ""); } } diff --git a/src/utils/sql-generator-grab-concat-str.ts b/src/utils/sql-generator-grab-concat-str.ts index 30ff581..a3366a0 100644 --- a/src/utils/sql-generator-grab-concat-str.ts +++ b/src/utils/sql-generator-grab-concat-str.ts @@ -1,4 +1,7 @@ +import { escapeStringLiteral, quoteIdentifier } from "./sql-identifier"; + type Param = { + /** Already-quoted column reference */ field: string; alias: string; separator?: string; @@ -20,11 +23,11 @@ export default function sqlGenGrabConcatStr({ gc += `${field}`; if (!distinct) { - gc += `, '${separator}'`; + gc += `, '${escapeStringLiteral(separator)}'`; } gc += `)`; - gc += ` AS ${alias}`; + gc += ` AS ${quoteIdentifier(alias)}`; return gc; } diff --git a/src/utils/sql-generator-grab-select-field-sql.ts b/src/utils/sql-generator-grab-select-field-sql.ts index 668570b..3602088 100644 --- a/src/utils/sql-generator-grab-select-field-sql.ts +++ b/src/utils/sql-generator-grab-select-field-sql.ts @@ -1,5 +1,6 @@ import type { TableSelectFieldsObject } from "../types"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; +import { columnRef, quoteIdentifier } from "./sql-identifier"; type Param = { selectFields: (keyof T | TableSelectFieldsObject)[]; @@ -10,56 +11,46 @@ type Param = { export default function sqlGenGrabSelectFieldSQL< T extends { [key: string]: any } = { [key: string]: any }, >({ selectFields, append_table_names, table_name }: Param) { - let str = ""; + const table = append_table_names ? table_name : undefined; - str += ` ${selectFields - ?.map((fld) => { + return ` ${selectFields + .map((fld) => { + if (typeof fld != "object") { + return columnRef(String(fld), table); + } + + const fld_name = columnRef(String(fld.fieldName), table); let fld_str = ``; - const final_fld_name = - typeof fld == "object" - ? append_table_names - ? `${table_name}.${String(fld)}` - : `${String(fld.fieldName)}` - : `${String(fld)}`; - - if (typeof fld == "object") { - const fld_name = `${String(fld.fieldName)}`; - - if (fld.count) { - fld_str += `COUNT(${fld_name})`; - } else if (fld.sum) { - fld_str += `SUM(${fld_name})`; - } else if (fld.average) { - fld_str += `AVERAGE(${fld_name})`; - } else if (fld.max) { - fld_str += `MAX(${fld_name})`; - } else if (fld.min) { - fld_str += `MIN(${fld_name})`; - } else if (fld.distinct) { - fld_str += `DISTINCT ${fld_name}`; - } else if (fld.group_concat) { - fld_str += sqlGenGrabConcatStr({ - field: fld_name, - alias: fld.group_concat.alias, - separator: fld.group_concat.separator, - distinct: fld.group_concat.distinct, - }); - } else { - fld_str += - final_fld_name + (fld.alias ? ` as ${fld.alias}` : ``); - } - - if (fld.alias) { - fld_str += ` AS ${fld.alias}`; - } + if (fld.count) { + fld_str = `COUNT(${fld_name})`; + } else if (fld.sum) { + fld_str = `SUM(${fld_name})`; + } else if (fld.average) { + fld_str = `AVG(${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) { + // Carries its own alias + return 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_str = fld_name; + } + + if (fld.alias) { + fld_str += ` AS ${quoteIdentifier(fld.alias)}`; } return fld_str; }) .join(",")}`; - - return str; } diff --git a/src/utils/sql-generator.ts b/src/utils/sql-generator.ts index 20fc350..96e6f77 100644 --- a/src/utils/sql-generator.ts +++ b/src/utils/sql-generator.ts @@ -5,9 +5,18 @@ import type { } from "../types"; import sqlGenGenSearchStr from "./sql-generator-gen-search-str"; import sqlGenGenQueryStr from "./sql-generator-gen-query-str"; +import { + columnRef, + quoteIdentifier, + quoteQualified, + sqlLogicalOperator, + sqlNonNegativeInt, + sqlOrderStrategy, +} from "./sql-identifier"; type Param = { genObject?: ServerQueryParam; + /** Raw (unquoted) table name */ tableName: string; dbFullName?: string; count?: boolean; @@ -16,25 +25,37 @@ type Param = { type Return = { string: string; values: SQLInsertGenValueType[]; + /** `WHERE ...` clause alone (empty string if none) */ + where: string; + /** Values for the `where` clause placeholders only */ + whereValues: SQLInsertGenValueType[]; }; +/** MariaDB requires LIMIT before OFFSET; this is the documented "no limit" value */ +const MAX_LIMIT = "18446744073709551615"; + /** * # SQL Query Generator * @description Builds parameterized SELECT SQL for MariaDB */ export default function sqlGenerator< T extends { [key: string]: any } = { [key: string]: any }, ->({ tableName, genObject, dbFullName, count }: Param): Return { +>({ tableName: rawTableName, genObject, dbFullName, count }: Param): Return { + const tableName = quoteQualified(rawTableName); + const finalQuery = genObject?.query ? genObject.query : undefined; const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined; - const sqlSearhValues: SQLInsertGenValueType[] = []; + const whereValues: SQLInsertGenValueType[] = []; let fullTextMatchStr = genObject?.fullTextSearch ? ` MATCH(${genObject.fullTextSearch.fields .map((f) => - genObject.join ? `${tableName}.${String(f)}` : `${String(f)}`, + columnRef( + String(f), + genObject.join ? tableName : undefined, + ), ) .join(",")}) AGAINST (? IN BOOLEAN MODE)` : undefined; @@ -46,7 +67,7 @@ export default function sqlGenerator< .join(" ") : undefined; - let { str: queryString, values } = sqlGenGenQueryStr({ + let { str: queryString, values: selectValues } = sqlGenGenQueryStr({ table_name: tableName, append_table_names: true, full_text_match_str: fullTextMatchStr, @@ -54,8 +75,6 @@ export default function sqlGenerator< genObject, }); - sqlSearhValues.push(...values); - const sqlSearhString = queryKeys?.map((field) => { const queryObj = finalQuery?.[field]; if (!queryObj) return; @@ -64,26 +83,30 @@ export default function sqlGenerator< const subQueryGroup = queryObj.__query; const subSearchKeys = Object.keys(subQueryGroup); - const subSearchString = subSearchKeys.map((_field) => { - const newSubQueryObj = subQueryGroup?.[_field]; + const subSearchString = subSearchKeys + .map((_field) => { + const newSubQueryObj = subQueryGroup?.[_field]; - if (newSubQueryObj) { - const { str, values } = sqlGenGenSearchStr({ - queryObj: newSubQueryObj, - field: newSubQueryObj.fieldName || _field, - join: genObject?.join, - table_name: tableName, - }); + if (newSubQueryObj) { + const { str, values } = sqlGenGenSearchStr({ + queryObj: newSubQueryObj, + field: newSubQueryObj.fieldName || _field, + join: genObject?.join, + table_name: tableName, + }); - sqlSearhValues.push(...values); + whereValues.push(...values); - return str; - } - }); + return str; + } + }) + .filter((str) => typeof str == "string"); return ( "(" + - subSearchString.join(` ${queryObj.operator || "AND"} `) + + subSearchString.join( + ` ${sqlLogicalOperator(queryObj.operator)} `, + ) + ")" ); } @@ -95,7 +118,7 @@ export default function sqlGenerator< table_name: tableName, }); - sqlSearhValues.push(...values); + whereValues.push(...values); return str; }); @@ -107,97 +130,100 @@ export default function sqlGenerator< const isSearchStr = cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str); + let where = ``; + if (isSearchStr) { - const stringOperator = genObject?.searchOperator || "AND"; - queryString += ` WHERE ${cleanedUpSearchStr.join( - ` ${stringOperator} `, - )}`; + const stringOperator = sqlLogicalOperator(genObject?.searchOperator); + where += `WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`; } if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) { - queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`; - sqlSearhValues.push(fullTextSearchStr); + where += `${isSearchStr ? " AND" : "WHERE"} ${fullTextMatchStr}`; + whereValues.push(fullTextSearchStr); + } + + if (where) { + queryString += ` ${where}`; } if (genObject?.group) { + const groupRef = ( + group: string | { field: unknown; table?: string }, + ) => + typeof group == "object" + ? columnRef(String(group.field), group.table) + : columnRef(String(group)); + let group_by_txt = ``; - if (typeof genObject.group == "string") { - group_by_txt = genObject.group; - } else if (Array.isArray(genObject.group)) { - for (let i = 0; i < genObject.group.length; i++) { - const group = genObject.group[i]; - - if (typeof group == "string") { - group_by_txt += `\`${group.toString()}\``; - } else if (typeof group == "object" && group.table) { - group_by_txt += `${group.table}.${String(group.field)}`; - } else if (typeof group == "object") { - group_by_txt += `${String(group.field)}`; - } - - if (i < genObject.group.length - 1) { - group_by_txt += ","; - } - } - } else if (typeof genObject.group == "object") { - if (genObject.group.table) { - group_by_txt = `${genObject.group.table}.${String(genObject.group.field)}`; - } else { - group_by_txt = `${String(genObject.group.field)}`; - } + if (Array.isArray(genObject.group)) { + group_by_txt = genObject.group + .map((group) => groupRef(group as any)) + .join(","); + } else { + group_by_txt = groupRef(genObject.group as any); } queryString += ` GROUP BY ${group_by_txt}`; } function grabOrderString(order: ServerQueryParamOrder) { - let orderFields = []; - let orderSrt = ``; + const scoreAlias = genObject?.fullTextSearch?.scoreAlias; + let orderField = ``; - if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) { - orderFields.push(genObject.fullTextSearch.scoreAlias); - } else if (genObject?.join) { - orderFields.push(`${tableName}.${String(order.field)}`); + if (scoreAlias && String(order.field) === scoreAlias) { + orderField = quoteIdentifier(scoreAlias); } else { - orderFields.push(order.field); + orderField = columnRef( + String(order.field), + genObject?.join ? tableName : undefined, + ); } - orderSrt += ` ${orderFields.join(", ")} ${order.strategy}`; - - return orderSrt; + return ` ${orderField} ${sqlOrderStrategy(order.strategy)}`; } if (genObject?.order) { - let orderSrt = ` ORDER BY`; + const orders = ( + Array.isArray(genObject.order) ? genObject.order : [genObject.order] + ).filter(Boolean); - if (Array.isArray(genObject.order)) { - for (let i = 0; i < genObject.order.length; i++) { - const order = genObject.order[i]; - if (order) { - orderSrt += - grabOrderString(order) + - (i < genObject.order.length - 1 ? `,` : ""); - } - } - } else { - orderSrt += grabOrderString(genObject.order); + if (orders.length) { + queryString += ` ORDER BY${orders.map(grabOrderString).join(",")}`; } - - queryString += ` ${orderSrt}`; } - if (genObject?.limit && !count) queryString += ` LIMIT ${genObject.limit}`; + if (!count) { + const limit = + genObject?.limit !== undefined && genObject.limit !== null + ? sqlNonNegativeInt(genObject.limit, "limit") + : undefined; - if (genObject?.offset) { - queryString += ` OFFSET ${genObject.offset}`; - } else if (genObject?.page && genObject.limit && !count) { - queryString += ` OFFSET ${(genObject.page - 1) * genObject.limit}`; + let offset: number | undefined; + + if (genObject?.offset !== undefined && genObject.offset !== null) { + offset = sqlNonNegativeInt(genObject.offset, "offset"); + } else if (genObject?.page && limit) { + const page = sqlNonNegativeInt(genObject.page, "page"); + offset = Math.max(page - 1, 0) * limit; + } + + if (limit) { + queryString += ` LIMIT ${limit}`; + } else if (offset) { + queryString += ` LIMIT ${MAX_LIMIT}`; + } + + if (offset) { + queryString += ` OFFSET ${offset}`; + } } return { string: queryString, - values: sqlSearhValues, + values: [...selectValues, ...whereValues], + where, + whereValues, }; } diff --git a/src/utils/sql-identifier.ts b/src/utils/sql-identifier.ts new file mode 100644 index 0000000..ba42feb --- /dev/null +++ b/src/utils/sql-identifier.ts @@ -0,0 +1,82 @@ +import { ServerQueryOperators, SQlComparisons } from "../types"; + +/** + * Quote a single identifier (table, column, alias) with backticks. + * Already-quoted identifiers (e.g. "`users`") are returned unchanged. + */ +export function quoteIdentifier(identifier: string): string { + const str = String(identifier); + if (/^`(?:[^`]|``)+`$/.test(str)) return str; + return `\`${str.replace(/`/g, "``")}\``; +} + +/** + * Quote a possibly qualified identifier: "table.field" -> `table`.`field`. + * A bare "*" (or "table.*") is kept as a wildcard. + */ +export function quoteQualified(identifier: string): string { + const str = String(identifier); + if (/^`(?:[^`]|``)+`$/.test(str)) return str; + return str + .split(".") + .map((part) => (part === "*" ? "*" : quoteIdentifier(part))) + .join("."); +} + +/** + * Build a column reference, prefixing the table when one is given and the + * field is not already qualified. + */ +export function columnRef(field: string, table?: string): string { + const fieldStr = String(field); + if (!table || fieldStr.includes(".")) return quoteQualified(fieldStr); + return `${quoteQualified(table)}.${fieldStr === "*" ? "*" : quoteIdentifier(fieldStr)}`; +} + +/** + * Escape a value for use inside a single-quoted SQL string literal. + */ +export function escapeStringLiteral(value: string): string { + return String(value).replace(/\\/g, "\\\\").replace(/'/g, "''"); +} + +export function sqlLogicalOperator(operator?: string): "AND" | "OR" { + if (!operator) return "AND"; + const upper = String(operator).toUpperCase(); + if ((ServerQueryOperators as readonly string[]).includes(upper)) { + return upper as "AND" | "OR"; + } + throw new Error(`Invalid logical operator: ${operator}`); +} + +export function sqlOrderStrategy(strategy?: string): "ASC" | "DESC" { + if (!strategy) return "ASC"; + const upper = String(strategy).toUpperCase(); + if (upper === "ASC" || upper === "DESC") return upper; + throw new Error(`Invalid order strategy: ${strategy}`); +} + +export function sqlRawComparison(comparison: string): string { + const upper = String(comparison).toUpperCase(); + if ((SQlComparisons as readonly string[]).includes(upper)) return upper; + throw new Error(`Invalid comparison operator: ${comparison}`); +} + +const JoinTypes = ["INNER JOIN", "JOIN", "LEFT JOIN", "RIGHT JOIN"] as const; + +export function sqlJoinType(joinType: string): string { + const upper = String(joinType).toUpperCase(); + if ((JoinTypes as readonly string[]).includes(upper)) return upper; + throw new Error(`Invalid join type: ${joinType}`); +} + +/** + * Validate a LIMIT / OFFSET / page value as a non-negative integer. + */ +export function sqlNonNegativeInt(value: unknown, name: string): number { + const num = typeof value == "string" && value.trim() !== "" ? Number(value) : value; + if (typeof num != "number" || !Number.isInteger(num) || num < 0) { + throw new Error(`Invalid ${name}: ${String(value)}`); + } + return num; +} diff --git a/src/utils/sql-insert-generator.ts b/src/utils/sql-insert-generator.ts index 6a67da1..40f815a 100644 --- a/src/utils/sql-insert-generator.ts +++ b/src/utils/sql-insert-generator.ts @@ -3,10 +3,7 @@ import type { SQLInsertGenReturn, SQLInsertGenValueType, } from "../types"; - -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; -} +import { quoteIdentifier } from "./sql-identifier"; /** * # SQL Insert Generator @@ -40,39 +37,40 @@ export default function sqlInsertGenerator({ .map((ky) => { const value = item[ky]; - const is_embedding = - Array.isArray(value) && - typeof value[0] == "number"; + if (typeof value == "function") { + const fnValue = value(); + queryValues.push(fnValue.value ?? null); + return fnValue.placeholder || "?"; + } - const finalValue = - typeof value == "string" || - typeof value == "number" - ? value - : typeof value == "function" - ? value().value - : is_embedding - ? JSON.stringify(value) - : value - ? value - : null; + if (typeof value == "boolean") { + queryValues.push(value ? 1 : 0); + return "?"; + } - if (!finalValue && typeof value !== "number") { + if (typeof value == "number") { + queryValues.push(value); + return "?"; + } + + // Empty strings / null / undefined are stored as NULL + if (value === undefined || value === null || value === "") { queryValues.push(null); return "?"; } - queryValues.push(finalValue); + const is_embedding = + Array.isArray(value) && + typeof value[0] == "number"; - const placeholder = - typeof value == "function" - ? value().placeholder - : is_embedding - ? "VEC_FromText(?)" - : "?"; + if (is_embedding) { + queryValues.push(JSON.stringify(value)); + return "VEC_FromText(?)"; + } - return placeholder; + queryValues.push(value); + return "?"; }) - .filter((k) => Boolean(k)) .join(",")})`, ); }); diff --git a/src/utils/trim-backups.ts b/src/utils/trim-backups.ts index 46f170b..1e82a61 100644 --- a/src/utils/trim-backups.ts +++ b/src/utils/trim-backups.ts @@ -4,6 +4,7 @@ import type { BunMariaDBConfig } from "../types"; import grabSortedBackups from "./grab-sorted-backups"; import { AppData } from "../data/app-data"; import path from "path"; +import _ from "lodash"; type Params = { config: BunMariaDBConfig; @@ -12,7 +13,16 @@ type Params = { export default function trimBackups({ config }: Params) { const { backup_dir } = grabDBDir({ config }); - const backups = grabSortedBackups({ config }); + // Only consider files written by `backup` (`{db_name}-{timestamp}.sql`) + const backupNamePattern = new RegExp( + `^${_.escapeRegExp(config.db_name)}-\\d+\\.sql$`, + ); + + const backups = grabSortedBackups({ config }).filter( + (name) => + backupNamePattern.test(name) && + fs.statSync(path.join(backup_dir, name)).isFile(), + ); const max_backups = config.max_backups || AppData["MaxBackups"]; diff --git a/src/utils/trim-exports.ts b/src/utils/trim-exports.ts index 7b2c289..dfda644 100644 --- a/src/utils/trim-exports.ts +++ b/src/utils/trim-exports.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import _ from "lodash"; import type { BunMariaDBConfig } from "../types"; import { AppData } from "../data/app-data"; import grabDBDir from "./grab-db-dir"; @@ -11,7 +12,16 @@ type Params = { export default function trimExports({ config }: Params) { const { export_dir } = grabDBDir({ config }); - const exports = grabSortedExports({ config }); + // Only consider archives written by `export` (`{db_name}-{timestamp}.tar.gz|zip`) + const exportNamePattern = new RegExp( + `^${_.escapeRegExp(config.db_name)}-\\d+\\.(tar\\.gz|zip)$`, + ); + + const exports = grabSortedExports({ config }).filter( + (name) => + exportNamePattern.test(name) && + fs.statSync(path.join(export_dir, name)).isFile(), + ); const max_exports = config.max_exports || AppData["MaxExports"]; for (let i = 0; i < exports.length; i++) {