diff --git a/README.md b/README.md index 8747e34..6244dc3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A schema-driven MariaDB manager for [Bun](https://bun.sh), featuring automatic s - [TypeScript Type Generation](#typescript-type-generation) - [Default Fields](#default-fields) - [Project Structure](#project-structure) +- [Testing](#testing) --- @@ -212,6 +213,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`. @@ -375,9 +377,11 @@ CLI binary: `bun-mariadb`. bunx bun-mariadb schema [options] ``` -| Option | Description | -| ----------------- | ------------------------------------------------------- | -| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing | +| Option | Description | +| ----------------- | ------------------------------------------------------------------------ | +| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing | +| `--dry-run` | Print the planned schema changes without applying them | +| `-y`, `--yes` | Apply destructive changes (dropped tables / columns) without confirmation | **Examples:** @@ -387,8 +391,13 @@ bunx bun-mariadb schema # Sync schema and regenerate types bunx bun-mariadb schema --typedef + +# Review planned changes first +bunx bun-mariadb schema --dry-run ``` +Changes that discard data (dropping a table or column, or recreating a `VECTOR` column whose size changed) are listed and need confirmation before they run. Non-interactive runs (CI, scripts) refuse them unless `--yes` is passed. + What it does: 1. Ensures the internal `__db_schema_manager__` tracking table exists @@ -469,11 +478,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:** @@ -663,9 +673,11 @@ await BunMariaDB.delete({ ### Raw SQL ```ts -BunMariaDB.sql({ sql, values? }) +BunMariaDB.sql({ sql, values?, config? }) ``` +`success` reflects whether the query ran; a `SELECT` with no rows returns `success: true` and an empty `payload`. + ```ts const res = await BunMariaDB.sql({ sql: "SELECT * FROM users WHERE id = ?", @@ -837,6 +849,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 @@ -937,12 +951,28 @@ bun-mariadb/ │ ├── sql-insert-generator.ts │ ├── sanitize-html-fields.ts │ └── ... -└── test/ - └── coderank/ # Example project +└── tests/ # bun test suite ``` --- +## Testing + +```bash +bun run test +``` + +Starts a throwaway MariaDB container (Docker required, image `mariadb:11.8` by default), runs every test file against it, then removes the container. Extra arguments are passed to `bun test`, e.g. `bun run test tests/db.test.ts`. + +| Variable | Description | +| ------------------------ | --------------------------------------------------------------------------- | +| `BUN_MARIADB_TEST_IMAGE` | Docker image to use (MariaDB 11.7+ for `VECTOR`) | +| `BUN_MARIADB_TEST_DB` | Skip Docker and use this existing database (with `BUN_MARIADB_SERVER_*`). The tests create and drop tables, so use a throwaway database. | + +`bun run test:unit` (or plain `bun test`) runs without a database; the integration tests are skipped. + +--- + ## License MIT diff --git a/bun.lock b/bun.lock index 5457e00..599506e 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "sanitize-html": "^2.17.6", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.3.14", "@types/lodash": "^4.17.24", "@types/node": "^25.3.3", "@types/sanitize-html": "^2.16.1", diff --git a/dist/commands/import.js b/dist/commands/import.js index d19a06c..2ed0dec 100644 --- a/dist/commands/import.js +++ b/dist/commands/import.js @@ -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"; @@ -23,6 +23,7 @@ export default function () { .argument("[file]", "Path to .sql file or export archive (.tar.gz / .tar / .zip)") .option("--sql-only", "When importing an archive, restore SQL only (skip writing schema)") .option("--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, opts) => { console.log(`Importing database ...`); const config = global.CONFIG; @@ -67,6 +68,26 @@ export default function () { process.exit(1); } 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/dist/commands/index.js b/dist/commands/index.js index e648b1b..a9ce0f8 100755 --- a/dist/commands/index.js +++ b/dist/commands/index.js @@ -8,14 +8,23 @@ import exportCmd from "./export"; import importCmd from "./import"; import admin from "./admin"; import init from "../functions/init"; -init(); +import { readFileSync } from "fs"; +try { + init(); +} +catch (error) { + console.error(`Initialization ERROR => ${error.message}`); + process.exit(1); +} +/** Resolves to the package root from both src/commands and dist/commands */ +const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")); /** * # Describe Program */ program .name(`bun-mariadb`) .description(`MariaDB manager for Bun`) - .version(`1.0.0`); + .version(version); /** * # Declare Commands */ diff --git a/dist/commands/schema.js b/dist/commands/schema.js index d2417df..a645219 100644 --- a/dist/commands/schema.js +++ b/dist/commands/schema.js @@ -6,10 +6,28 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db- import chalk from "chalk"; import { writeLiveSchema } from "../functions/live-schema"; import createDBSchema from "../lib/schema/create-db-schema"; +import { isDestructiveSchemaStatement, planSchemaQueries, runSchemaQueriesGuarded, } from "../lib/schema/run-schema-query"; +import { confirm } from "@inquirer/prompts"; +import { AppData } from "../data/app-data"; +/** Silence progress logs while computing a plan */ +async function quietly(fn) { + const { log, warn } = console; + console.log = () => { }; + console.warn = () => { }; + try { + return await fn(); + } + finally { + console.log = log; + console.warn = warn; + } +} export default function () { return new Command("schema") .description("Build DB From Schema") .option("-t, --typedef", "Generate typescript type definitions") + .option("--dry-run", "Print the schema changes that would be applied, without applying them") + .option("-y, --yes", "Apply destructive changes (dropped tables / columns) without confirmation") .action(async (opts) => { console.log(`Starting process ...`); const config = global.CONFIG; @@ -20,10 +38,48 @@ export default function () { const finaldbSchema = appendDefaultFieldsToDbSchema({ dbSchema, }); - await createDBSchema({ - db_schema: finaldbSchema, - config, - }); + const params = { db_schema: finaldbSchema, config }; + // Hide internal bookkeeping (schema manager rows, FK check toggles) + const plan = (await quietly(() => planSchemaQueries(() => createDBSchema(params)))).filter((statement) => !statement.includes(AppData.DbSchemaManagerTableName) && + !/^SET FOREIGN_KEY_CHECKS/i.test(statement)); + const destructive = plan.filter(isDestructiveSchemaStatement); + if (opts.dryRun) { + if (!plan.length) { + console.log(chalk.green(`Schema is up to date.`)); + } + else { + console.log(chalk.bold(`Planned schema changes:\n`)); + for (const statement of plan) { + console.log(isDestructiveSchemaStatement(statement) + ? chalk.red(` ${statement}`) + : ` ${statement}`); + } + if (destructive.length) { + console.log(chalk.red(`\n${destructive.length} destructive change(s) (in red) would discard data.`)); + } + } + process.exit(0); + } + if (destructive.length && !opts.yes) { + console.log(chalk.yellow(`\nThese changes will permanently discard data:\n`)); + for (const statement of destructive) { + console.log(chalk.red(` ${statement}`)); + } + console.log(); + if (!process.stdin.isTTY) { + console.error(chalk.red(`Refusing to apply destructive changes non-interactively. Re-run with --yes to apply them.`)); + process.exit(1); + } + const proceed = await confirm({ + message: `Apply ${destructive.length} destructive change(s)?`, + default: false, + }); + if (!proceed) { + console.log(chalk.dim(`Schema sync cancelled.`)); + process.exit(1); + } + } + await runSchemaQueriesGuarded(() => createDBSchema(params), opts.yes ? true : new Set(destructive)); if (isTypeDef && config.typedef_file_path) { const out_file = path.resolve(ROOT_DIR, config.typedef_file_path); dbSchemaToTypeDef({ diff --git a/dist/functions/grab-mariadb-client.d.ts b/dist/functions/grab-mariadb-client.d.ts index c48b525..be86e90 100644 --- a/dist/functions/grab-mariadb-client.d.ts +++ b/dist/functions/grab-mariadb-client.d.ts @@ -2,5 +2,10 @@ import { type BunMariaDBConfig } from "../types"; type Params = { config: BunMariaDBConfig; }; +/** + * Cache key covering everything that affects the connection, not just the + * database name. + */ +export declare function mariadbClientCacheKey(config?: BunMariaDBConfig): string; export default function grabMariaDBClient({ config, }: Params): Bun.SQL | undefined; export {}; diff --git a/dist/functions/grab-mariadb-client.js b/dist/functions/grab-mariadb-client.js index ba50638..8f73a1f 100644 --- a/dist/functions/grab-mariadb-client.js +++ b/dist/functions/grab-mariadb-client.js @@ -2,29 +2,44 @@ import path from "path"; import grabDirNames from "../data/grab-dir-names"; import {} from "../types"; import { SQL } from "bun"; -/** Reuse one client per database name to avoid exhausting MariaDB connections. */ +/** Reuse one client per distinct connection config to avoid exhausting MariaDB connections. */ const clientCache = new Map(); +/** + * Cache key covering everything that affects the connection, not just the + * database name. + */ +export function mariadbClientCacheKey(config) { + return JSON.stringify({ + db_name: config?.db_name || "__default__", + db_config: config?.db_config || null, + ssl_ca: config?.ssl_ca || null, + ssl_reject_unauthorized: config?.ssl_reject_unauthorized ?? null, + }); +} export default function grabMariaDBClient({ config, }) { - const cacheKey = config.db_name || "__default__"; + const cacheKey = mariadbClientCacheKey(config); const cached = clientCache.get(cacheKey); if (cached) { return cached; } - // Prefer the process-wide client when it matches the requested database + // Prefer the process-wide client when it was created from the same config if (global.MARIADB_CLIENT && - (!global.CONFIG?.db_name || global.CONFIG.db_name === config.db_name)) { + global.CONFIG && + mariadbClientCacheKey(global.CONFIG) === cacheKey) { clientCache.set(cacheKey, global.MARIADB_CLIENT); return global.MARIADB_CLIENT; } 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({ hostname: process.env.BUN_MARIADB_SERVER_HOST, @@ -34,8 +49,8 @@ export default function grabMariaDBClient({ config, }) { port: process.env.BUN_MARIADB_SERVER_PORT ? Number(process.env.BUN_MARIADB_SERVER_PORT) : undefined, - ...config.db_config, tls, + ...config.db_config, adapter: "mariadb", }); clientCache.set(cacheKey, MariaDBClient); diff --git a/dist/functions/init.d.ts b/dist/functions/init.d.ts index f254fa9..3ee4554 100644 --- a/dist/functions/init.d.ts +++ b/dist/functions/init.d.ts @@ -9,4 +9,12 @@ declare global { var CONFIG_FILE_PATH: string; var SCHEMA_FILE_PATH: string; } +/** + * Load config + schema and set up the shared MariaDB client. + * Throws on any problem (never exits the process). + */ export default function init(): void; +/** + * Initialize once, on first use. + */ +export declare function ensureInit(): void; diff --git a/dist/functions/init.js b/dist/functions/init.js index ff85a09..3a4ca53 100644 --- a/dist/functions/init.js +++ b/dist/functions/init.js @@ -5,81 +5,69 @@ import grabDirNames from "../data/grab-dir-names"; import { RequiredENVs, } from "../types"; import setMariaDBClient from "./set-mariadb-client"; import { resolveAndLoadDataFile, supportedDataFileNames, } from "../utils/resolve-and-load-data-file"; +/** + * Load config + schema and set up the shared MariaDB client. + * Throws on any problem (never exits the process). + */ export default function init() { - try { - const { ROOT_DIR } = grabDirNames(); - const { ConfigFileName } = AppData; - const loadedConfig = resolveAndLoadDataFile(ROOT_DIR, ConfigFileName); - if (!loadedConfig) { - console.error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`); - process.exit(1); - } - const Config = loadedConfig.data; - if (!Config || typeof Config !== "object") { - console.error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`); - process.exit(1); - } - if (!Config.db_name) { - console.error(`\`db_name\` is required in your config`); - process.exit(1); - } - RequiredENVs.forEach((env_key) => { - if (env_key == "BUN_MARIADB_SERVER_PORT" || - env_key == "BUN_MARIADB_SERVER_SSL_KEY_PATH") { - return; - } - if (!process.env[env_key]) { - console.error(`\`${env_key}\` env variable is required.`); - process.exit(1); - } - }); - if (!Config.db_dir) { - console.error(`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`); - process.exit(1); - } - const db_dir = path.resolve(ROOT_DIR, Config.db_dir); - if (!fs.existsSync(db_dir)) { - fs.mkdirSync(db_dir, { recursive: true }); - } - const loadedSchema = resolveAndLoadDataFile(db_dir, AppData.DbSchemaFileName); - if (!loadedSchema) { - console.error(`Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`); - process.exit(1); - } - const DbSchema = loadedSchema.data; - if (!DbSchema || typeof DbSchema !== "object") { - console.error(`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`); - process.exit(1); - } - 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 }); - } - const ExportDir = path.resolve(db_dir, AppData["DefaultExportDirName"]); - if (!fs.existsSync(ExportDir)) { - fs.mkdirSync(ExportDir, { recursive: true }); - } - global.CONFIG = Config; - global.DB_SCHEMA = DbSchema; - global.CONFIG_FILE_PATH = loadedConfig.path; - global.SCHEMA_FILE_PATH = loadedSchema.path; - if (!global.CONFIG) { - console.error(`Couldn't grab global Config.`); - process.exit(1); - } - if (!global.DB_SCHEMA) { - console.error(`Couldn't grab Database Schema.`); - process.exit(1); - } - setMariaDBClient({ config: Config }); - if (!global.MARIADB_CLIENT) { - console.error(`Couldn't set MariaDB Client.`); - process.exit(1); - } + const { ROOT_DIR } = grabDirNames(); + const { ConfigFileName } = AppData; + const loadedConfig = resolveAndLoadDataFile(ROOT_DIR, ConfigFileName); + if (!loadedConfig) { + throw new Error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`); } - catch (error) { - console.error(`Initialization ERROR => ` + error.message); - process.exit(1); + const Config = loadedConfig.data; + if (!Config || typeof Config !== "object") { + throw new Error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`); } + if (!Config.db_name) { + throw new Error(`\`db_name\` is required in your config`); + } + RequiredENVs.forEach((env_key) => { + if (env_key == "BUN_MARIADB_SERVER_PORT" || + env_key == "BUN_MARIADB_SERVER_SSL_KEY_PATH") { + return; + } + if (!process.env[env_key]) { + throw new Error(`\`${env_key}\` env variable is required.`); + } + }); + if (!Config.db_dir) { + throw new Error(`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`); + } + const db_dir = path.resolve(ROOT_DIR, Config.db_dir); + if (!fs.existsSync(db_dir)) { + fs.mkdirSync(db_dir, { recursive: true }); + } + const loadedSchema = resolveAndLoadDataFile(db_dir, AppData.DbSchemaFileName); + if (!loadedSchema) { + throw new Error(`Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`); + } + const DbSchema = loadedSchema.data; + if (!DbSchema || typeof DbSchema !== "object") { + throw new Error(`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`); + } + 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 }); + } + const ExportDir = path.resolve(db_dir, AppData["DefaultExportDirName"]); + if (!fs.existsSync(ExportDir)) { + fs.mkdirSync(ExportDir, { recursive: true }); + } + global.CONFIG = Config; + global.DB_SCHEMA = DbSchema; + global.CONFIG_FILE_PATH = loadedConfig.path; + global.SCHEMA_FILE_PATH = loadedSchema.path; + setMariaDBClient({ config: Config }); +} +/** + * Initialize once, on first use. + */ +export function ensureInit() { + if (global.CONFIG && global.DB_SCHEMA && global.MARIADB_CLIENT !== undefined) { + return; + } + init(); } diff --git a/dist/functions/set-mariadb-client.js b/dist/functions/set-mariadb-client.js index 54fe132..5bed87f 100644 --- a/dist/functions/set-mariadb-client.js +++ b/dist/functions/set-mariadb-client.js @@ -1,21 +1,9 @@ import {} from "../types"; import grabMariaDBClient from "./grab-mariadb-client"; export default function setMariaDBClient({ config }) { - try { - const MariaDBClient = grabMariaDBClient({ config }); - if (!MariaDBClient) { - throw new Error(`Couldn't grab MariaDB Client`); - } - global.MARIADB_CLIENT = MariaDBClient; - // const test = await MariaDBClient.unsafe(`SHOW DATABASES`); - // if (!test.count) { - // console.error(`MariaDBClient Error: Database not ready.`); - // console.log(test); - // process.exit(1); - // } - } - catch (error) { - console.error(`ERROR setting MariaDB Client => ` + error.message); - process.exit(1); + const MariaDBClient = grabMariaDBClient({ config }); + if (!MariaDBClient) { + throw new Error(`Couldn't grab MariaDB Client`); } + global.MARIADB_CLIENT = MariaDBClient; } diff --git a/dist/index.js b/dist/index.js index 18af77f..f2bb14f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,4 +1,4 @@ -import init from "./functions/init"; +import { ensureInit } from "./functions/init"; import DbDelete from "./lib/mariadb/db-delete"; import DbInsert from "./lib/mariadb/db-insert"; import DbSelect from "./lib/mariadb/db-select"; @@ -6,15 +6,24 @@ import DbSQL from "./lib/mariadb/db-sql"; import DbUpdate from "./lib/mariadb/db-update"; import grabDbSchema from "./utils/grab-db-schema"; import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object"; -init(); +/** + * Initialize lazily on first call, so importing the library never exits or + * throws. Setup errors (missing config / env) reject the call instead. + */ +function withInit(fn) { + return (async (...args) => { + ensureInit(); + return fn(...args); + }); +} const BunMariaDB = { - select: DbSelect, - insert: DbInsert, - update: DbUpdate, - delete: DbDelete, - sql: DbSQL, + select: withInit(DbSelect), + insert: withInit(DbInsert), + update: withInit(DbUpdate), + delete: withInit(DbDelete), + sql: withInit(DbSQL), utils: { - grab_db_schema: grabDbSchema, + grab_db_schema: withInit(grabDbSchema), grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject, }, }; diff --git a/dist/lib/db-handler.d.ts b/dist/lib/db-handler.d.ts index dacda5b..aea8e7c 100644 --- a/dist/lib/db-handler.d.ts +++ b/dist/lib/db-handler.d.ts @@ -1,9 +1,20 @@ import type { BunMariaDBConfig, DBResponseObject } from "../types"; +/** Anything that can run a raw query: a client or a transaction handle */ +export type DBQueryRunner = { + unsafe: (query: string, values?: any[]) => Promise; +}; type Param = { query: string; values?: any[]; config?: BunMariaDBConfig; + /** Run on this client / transaction instead of the shared client */ + client?: DBQueryRunner; }; +/** + * Resolve a shared MariaDB client. Prefer the global client so schema sync + * and CRUD do not open a new connection per query. + */ +export declare function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined; /** * # Main DB Handler Function */ @@ -11,5 +22,5 @@ export default function dbHandler({ query, values, config }: Param): Promise>; +}>({ query, values, config, client }: Param): Promise>; export {}; diff --git a/dist/lib/db-handler.js b/dist/lib/db-handler.js index d7aed6a..1b2a065 100644 --- a/dist/lib/db-handler.js +++ b/dist/lib/db-handler.js @@ -1,41 +1,62 @@ -import grabMariaDBClient from "../functions/grab-mariadb-client"; +import grabMariaDBClient, { mariadbClientCacheKey, } from "../functions/grab-mariadb-client"; /** * Resolve a shared MariaDB client. Prefer the global client so schema sync * and CRUD do not open a new connection per query. */ -function resolveClient(config) { - const resolvedConfig = config || global.CONFIG; - // Same database as the global client → reuse it - if (global.MARIADB_CLIENT && - (!resolvedConfig?.db_name || - !global.CONFIG?.db_name || - resolvedConfig.db_name === global.CONFIG.db_name)) { +export function resolveClient(config) { + if (!config) { return global.MARIADB_CLIENT; } - if (resolvedConfig) { - return grabMariaDBClient({ config: resolvedConfig }); + // Same connection config as the global client → reuse it + if (global.MARIADB_CLIENT && + (!global.CONFIG || + mariadbClientCacheKey(config) === + mariadbClientCacheKey(global.CONFIG))) { + return global.MARIADB_CLIENT; } - return global.MARIADB_CLIENT; + return grabMariaDBClient({ config }); +} +/** + * Convert driver rows to plain objects. + * - BigInt -> number (or string when outside the safe integer range) + * - Date -> ISO string (matches the generated `string` typedefs) + * - Buffers / typed arrays (BLOB, VECTOR) are kept as-is + */ +function normalizeValue(value) { + if (typeof value == "bigint") { + const num = Number(value); + return Number.isSafeInteger(num) ? num : value.toString(); + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + return value; +} +function normalizeRows(res) { + if (!Array.isArray(res)) + return undefined; + return res.map((row) => { + if (!row || typeof row != "object") + return row; + const normalized = {}; + for (const [key, value] of Object.entries(row)) { + normalized[key] = normalizeValue(value); + } + return normalized; + }); } /** * # Main DB Handler Function */ -export default async function dbHandler({ query, values, config }) { +export default async function dbHandler({ query, values, config, client }) { try { - const CLIENT = resolveClient(config); + const CLIENT = client || resolveClient(config); if (!CLIENT) { throw new Error(`Couldn't grab MariaDB Client.`); } const res = await CLIENT.unsafe(query, values); const count = res.count; - const res_array = (() => { - try { - return JSON.parse(JSON.stringify(res)); - } - catch (error) { - return undefined; - } - })(); + const res_array = normalizeRows(res); const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined; const affected_rows = res_array?.[0] ? res_array.length : undefined; const insert_return = { diff --git a/dist/lib/grab-duplicate-safe-insert-sql.js b/dist/lib/grab-duplicate-safe-insert-sql.js index 5b92e7c..53995b0 100644 --- a/dist/lib/grab-duplicate-safe-insert-sql.js +++ b/dist/lib/grab-duplicate-safe-insert-sql.js @@ -1,6 +1,4 @@ -function quoteIdentifier(identifier) { - return `\`${identifier.replace(/`/g, "``")}\``; -} +import { quoteIdentifier } from "../utils/sql-identifier"; export default async function ({ sql: passedSql, table, data }) { const config = global.CONFIG; const dbSchema = global.DB_SCHEMA; diff --git a/dist/lib/mariadb/db-delete.js b/dist/lib/mariadb/db-delete.js index a777f07..d44f137 100644 --- a/dist/lib/mariadb/db-delete.js +++ b/dist/lib/mariadb/db-delete.js @@ -1,27 +1,17 @@ import dbHandler from "../db-handler"; import _ from "lodash"; import sqlGenerator from "../../utils/sql-generator"; -function quoteIdentifier(identifier) { - return `\`${identifier.replace(/`/g, "``")}\``; -} +import { quoteIdentifier } from "../../utils/sql-identifier"; export default async function DbDelete({ table, query, targetId, config, }) { let sqlObj = null; try { - let finalQuery = query || {}; - if (targetId) { - finalQuery = _.merge(finalQuery, { - query: { - id: { - value: Number(targetId), - }, - }, - }); - } + const finalQuery = query || {}; sqlObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, }); - const whereClause = sqlObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlObj.where; if (!whereClause) { return { success: false, @@ -34,6 +24,7 @@ export default async function DbDelete({ table, query, targetId, config, }) { let sql = `DELETE FROM ${quoteIdentifier(table)} ${whereClause}`; sql += ` RETURNING *`; sqlObj.string = sql; + sqlObj.values = sqlObj.whereValues; const res = await dbHandler({ query: sql, values: sqlObj.values, diff --git a/dist/lib/mariadb/db-schema-to-typedef.js b/dist/lib/mariadb/db-schema-to-typedef.js index 93df003..12adf5b 100644 --- a/dist/lib/mariadb/db-schema-to-typedef.js +++ b/dist/lib/mariadb/db-schema-to-typedef.js @@ -1,5 +1,6 @@ import _ from "lodash"; import generateTypeDefinition from "./db-generate-type-defs"; +import resolveTable from "../schema/resolve-table"; export default function dbSchemaToType({ config, dbSchema, }) { let datasquirelSchema = dbSchema; if (!datasquirelSchema) @@ -14,16 +15,12 @@ export default function dbSchemaToType({ config, dbSchema, }) { const defNames = []; const schemas = dbTablesSchemas .map((table) => { - let final_table = _.cloneDeep(table); - 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, - }); - } - } + const has_parent = Boolean(table.parentTableName && + dbTablesSchemas.find((t) => t.tableName === table.parentTableName)); + // 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", table: final_table, diff --git a/dist/lib/mariadb/db-select.js b/dist/lib/mariadb/db-select.js index cfc6562..4866794 100644 --- a/dist/lib/mariadb/db-select.js +++ b/dist/lib/mariadb/db-select.js @@ -1,25 +1,13 @@ import dbHandler from "../db-handler"; -import _ from "lodash"; import sqlGenerator from "../../utils/sql-generator"; -function quoteIdentifier(identifier) { - return `\`${identifier.replace(/`/g, "``")}\``; -} export default async function DbSelect({ table, query, count, targetId, config, }) { let sqlObj = null; try { - let finalQuery = query || {}; - if (targetId) { - finalQuery = _.merge(finalQuery, { - query: { - id: { - value: Number(targetId), - }, - }, - }); - } + const finalQuery = query || {}; sqlObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, }); const res = await dbHandler({ query: sqlObj.string, @@ -49,8 +37,9 @@ export default async function DbSelect({ table, query, count, targetId, config, }; if (count) { const countSqlObject = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, count, }); const countSql = `SELECT COUNT(*) AS count FROM (${countSqlObject.string}) AS c`; diff --git a/dist/lib/mariadb/db-sql.d.ts b/dist/lib/mariadb/db-sql.d.ts index 3984d51..050a7ae 100644 --- a/dist/lib/mariadb/db-sql.d.ts +++ b/dist/lib/mariadb/db-sql.d.ts @@ -1,11 +1,12 @@ -import type { DBResponseObject, SQLInsertGenValueType } from "../../types"; +import type { BunMariaDBConfig, DBResponseObject, SQLInsertGenValueType } from "../../types"; type Params = { sql: string; values?: SQLInsertGenValueType[]; + config?: BunMariaDBConfig; }; export default function DbSQL({ sql, values }: Params): Promise>; +}>({ sql, values, config }: Params): Promise>; export {}; diff --git a/dist/lib/mariadb/db-sql.js b/dist/lib/mariadb/db-sql.js index be02edf..fa60479 100644 --- a/dist/lib/mariadb/db-sql.js +++ b/dist/lib/mariadb/db-sql.js @@ -1,11 +1,12 @@ import dbHandler from "../db-handler"; -export default async function DbSQL({ sql, values }) { +export default async function DbSQL({ sql, values, config }) { try { const trimmedSql = sql.trim(); const isSelect = trimmedSql.match(/^select/i); const res = await dbHandler({ query: trimmedSql, values: values, + config, }); if (!res.success) { return { @@ -23,12 +24,9 @@ export default async function DbSQL({ sql, values }) { } const payload = isSelect ? (res.payload || []) : undefined; const single_res = isSelect ? payload?.[0] : res.single_res; - const singleRaw = res.single_res; return { ...res, - success: isSelect - ? Boolean(single_res) || Boolean(payload?.[0]) - : true, + success: true, payload, single_res, debug: { diff --git a/dist/lib/mariadb/db-update.js b/dist/lib/mariadb/db-update.js index ea15724..b7ed54d 100644 --- a/dist/lib/mariadb/db-update.js +++ b/dist/lib/mariadb/db-update.js @@ -1,37 +1,38 @@ -import dbHandler from "../db-handler"; -import _ from "lodash"; +import dbHandler, { resolveClient } from "../db-handler"; import sqlGenerator from "../../utils/sql-generator"; import sanitizeHtmlFields from "../../utils/sanitize-html-fields"; -function quoteIdentifier(identifier) { - return `\`${identifier.replace(/`/g, "``")}\``; +import { quoteIdentifier } from "../../utils/sql-identifier"; +import sqlValuePlaceholder from "../../utils/sql-value-placeholder"; +import grabTableFields from "../../utils/grab-table-fields"; +/** Thrown inside the transaction to roll back and return a response */ +class UpdateAbort extends Error { + response; + constructor(response) { + super(response.msg || response.error || "Update aborted"); + this.response = response; + } } export default async function DbUpdate({ table, data, query, targetId, config, }) { - let sqlObj = { string: "", values: [] }; + let sqlObj = { + string: "", + values: [], + where: "", + whereValues: [], + }; try { - let finalQuery = query || {}; - if (targetId) { - finalQuery = _.merge(finalQuery, { - query: { - id: { - value: Number(targetId), - }, - }, - }); - } + const finalQuery = query || {}; const sqlQueryObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, }); - const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlQueryObj.where; if (!whereClause) { return { success: false, msg: `No WHERE clause`, }; } - let values = []; - let sql = ``; - sql += `UPDATE ${quoteIdentifier(table)} SET`; const sanitizedData = sanitizeHtmlFields({ table, data, @@ -41,48 +42,81 @@ export default async function DbUpdate({ table, data, query, targetId, config, } updated_at: Date.now(), ...sanitizedData, }; - const keys = Object.keys(finalData); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - if (!key) - continue; - const isLast = i == keys.length - 1; - sql += ` ${quoteIdentifier(key)}=?`; - values.push(finalData[key] ?? null); - if (!isLast) { - sql += `,`; + const fieldsMap = new Map(grabTableFields(table).map((f) => [f.fieldName, f])); + const setParts = []; + const setValues = []; + for (const key of Object.keys(finalData)) { + const { placeholder, value } = sqlValuePlaceholder({ + value: finalData[key], + field: fieldsMap.get(key), + }); + setParts.push(`${quoteIdentifier(key)}=${placeholder}`); + setValues.push(value); + } + const CLIENT = resolveClient(config); + if (!CLIENT) { + throw new Error(`Couldn't grab MariaDB Client.`); + } + /** + * Lock the matching rows, update them by id, and re-fetch them by id, + * all in one transaction. Re-fetching by id still works when the update + * changes a column used in the WHERE clause. + */ + return await CLIENT.begin(async (tx) => { + const matched_res = await dbHandler({ + query: `SELECT id FROM ${quoteIdentifier(table)} ${whereClause} FOR UPDATE`, + values: sqlQueryObj.whereValues, + client: tx, + }); + if (!matched_res.success) { + throw new UpdateAbort({ + ...matched_res, + success: false, + debug: { sqlObj: sqlQueryObj }, + }); } - } - sql += ` ${whereClause}`; - values = [...values, ...sqlQueryObj.values]; - const res = await dbHandler({ - query: sql, - values: values, - config, + const matched_ids = (matched_res.payload || []) + .map((row) => row.id) + .filter((id) => id !== undefined && id !== null); + if (matched_ids.length === 0) { + throw new UpdateAbort({ + success: false, + msg: "No rows matched the update query", + payload: [], + debug: { sqlObj: sqlQueryObj }, + }); + } + const idPlaceholders = matched_ids.map(() => "?").join(","); + const sql = `UPDATE ${quoteIdentifier(table)} SET ${setParts.join(", ")} WHERE id IN (${idPlaceholders})`; + const values = [...setValues, ...matched_ids]; + sqlObj.string = sql; + sqlObj.values = values; + const res = await dbHandler({ + query: sql, + values: values, + client: tx, + }); + if (!res.success) { + throw new UpdateAbort({ ...res, debug: { sqlObj } }); + } + const updated_res = await dbHandler({ + query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`, + values: matched_ids, + client: tx, + }); + return { + ...updated_res, + debug: { + sqlObj, + }, + db_res: res.db_res, + }; }); - if (res.error) { - return res; - } - sqlObj.string = sql; - sqlObj.values = values; - let updated_sql = ``; - let updated_sql_values = []; - updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`; - updated_sql_values = [...sqlQueryObj.values]; - const updated_res = await dbHandler({ - query: updated_sql, - values: updated_sql_values, - config, - }); - return { - ...updated_res, - debug: { - sqlObj, - }, - db_res: res.db_res, - }; } catch (error) { + if (error instanceof UpdateAbort) { + return error.response; + } return { success: false, error: error.message, diff --git a/dist/lib/schema/get-table-columns-gemnini.d.ts b/dist/lib/schema/get-table-columns-gemnini.d.ts deleted file mode 100644 index d0fb6ae..0000000 --- a/dist/lib/schema/get-table-columns-gemnini.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { BunMariaDBConfig } from "../../types"; -export type ColumnInfoRow = { - name: string; - type: string; - comment?: string; - isNullable: boolean; - columnDefault: string | null; - extra: string; -}; -export default function getTableColumnsGemini({ tableName, config, }: { - tableName: string; - config?: BunMariaDBConfig; -}): Promise; diff --git a/dist/lib/schema/get-table-columns-gemnini.js b/dist/lib/schema/get-table-columns-gemnini.js deleted file mode 100644 index d4088a4..0000000 --- a/dist/lib/schema/get-table-columns-gemnini.js +++ /dev/null @@ -1,45 +0,0 @@ -import { querySchemaRows } from "./run-schema-query"; -import schemaCondition from "./schema-condition"; -export default async function getTableColumnsGemini({ tableName, config, }) { - const schemaCond = schemaCondition(config); - const rows = await querySchemaRows({ - query: ` - SELECT - c.COLUMN_NAME, - c.COLUMN_TYPE, - c.COLUMN_COMMENT, - c.IS_NULLABLE, - c.COLUMN_DEFAULT, - c.EXTRA, - EXISTS ( - SELECT 1 - FROM information_schema.CHECK_CONSTRAINTS cc - WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA - AND cc.TABLE_NAME = c.TABLE_NAME - AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%') - ) AS IS_JSON - FROM information_schema.COLUMNS c - WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")} - AND c.TABLE_NAME = ? - ORDER BY c.ORDINAL_POSITION - `, - values: [...schemaCond.values, tableName], - config, - }); - return rows.map((row) => { - const isJson = Number(row.IS_JSON) === 1; - // Normalize longtext with a json_valid constraint to "json" - let resolvedType = row.COLUMN_TYPE; - if (isJson && row.COLUMN_TYPE.toLowerCase() === "longtext") { - resolvedType = "json"; - } - return { - name: row.COLUMN_NAME, - type: resolvedType, - comment: row.COLUMN_COMMENT, - isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", - columnDefault: row.COLUMN_DEFAULT, - extra: row.EXTRA || "", - }; - }); -} diff --git a/dist/lib/schema/get-table-columns.d.ts b/dist/lib/schema/get-table-columns.d.ts index 5369915..1922674 100644 --- a/dist/lib/schema/get-table-columns.d.ts +++ b/dist/lib/schema/get-table-columns.d.ts @@ -7,6 +7,9 @@ export type ColumnInfoRow = { columnDefault: string | null; extra: string; }; +/** + * Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`. + */ export default function getTableColumns({ tableName, config, }: { tableName: string; config?: BunMariaDBConfig; diff --git a/dist/lib/schema/get-table-columns.js b/dist/lib/schema/get-table-columns.js index a0fcbff..33d5602 100644 --- a/dist/lib/schema/get-table-columns.js +++ b/dist/lib/schema/get-table-columns.js @@ -1,18 +1,48 @@ import { querySchemaRows } from "./run-schema-query"; import schemaCondition from "./schema-condition"; +/** + * Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`. + */ export default async function getTableColumns({ tableName, config, }) { const schemaCond = schemaCondition(config); const rows = await querySchemaRows({ - query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, + query: ` + SELECT + c.COLUMN_NAME, + c.COLUMN_TYPE, + c.COLUMN_COMMENT, + c.IS_NULLABLE, + c.COLUMN_DEFAULT, + c.EXTRA, + EXISTS ( + SELECT 1 + FROM information_schema.CHECK_CONSTRAINTS cc + WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA + AND cc.TABLE_NAME = c.TABLE_NAME + AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%') + ) AS IS_JSON + FROM information_schema.COLUMNS c + WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")} + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION + `, values: [...schemaCond.values, tableName], config, }); - return rows.map((row) => ({ - name: row.COLUMN_NAME, - type: row.COLUMN_TYPE, - comment: row.COLUMN_COMMENT, - isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", - columnDefault: row.COLUMN_DEFAULT, - extra: row.EXTRA || "", - })); + return rows.map((row) => { + const isJson = Number(row.IS_JSON) === 1; + // Normalize longtext with a json_valid constraint to "json" + let resolvedType = row.COLUMN_TYPE; + if (isJson && row.COLUMN_TYPE.toLowerCase() === "longtext") { + resolvedType = "json"; + } + return { + name: row.COLUMN_NAME, + type: resolvedType, + comment: row.COLUMN_COMMENT, + isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", + columnDefault: row.COLUMN_DEFAULT, + extra: row.EXTRA || "", + }; + }); } diff --git a/dist/lib/schema/mariadb-quote-gen.js b/dist/lib/schema/mariadb-quote-gen.js index cfbd780..968d45a 100644 --- a/dist/lib/schema/mariadb-quote-gen.js +++ b/dist/lib/schema/mariadb-quote-gen.js @@ -1,3 +1,4 @@ +import { quoteIdentifier } from "../../utils/sql-identifier"; export default function MariaDBQuoteGen(str) { - return `\`${str.replace(/`/g, "``")}\``; + return quoteIdentifier(str); } diff --git a/dist/lib/schema/run-schema-query.d.ts b/dist/lib/schema/run-schema-query.d.ts index ec18e5c..d8dd34b 100644 --- a/dist/lib/schema/run-schema-query.d.ts +++ b/dist/lib/schema/run-schema-query.d.ts @@ -1,4 +1,18 @@ import type { BunMariaDBConfig } from "../../types"; +/** + * Statements that discard stored data (tables or column values). + */ +export declare function isDestructiveSchemaStatement(query: string): boolean; +/** + * Run `fn` with schema statements recorded but not executed. + * Reads (information_schema) still run, so the plan is a close approximation: + * steps that depend on earlier changes (e.g. after a table rename) may differ. + */ +export declare function planSchemaQueries(fn: () => Promise): Promise; +/** + * Run `fn`, refusing any destructive statement that was not approved. + */ +export declare function runSchemaQueriesGuarded(fn: () => Promise, allowDestructive: true | Set): Promise; export default function runSchemaQuery({ query, values, config, }: { query: string; values?: any[]; diff --git a/dist/lib/schema/run-schema-query.js b/dist/lib/schema/run-schema-query.js index 7073374..80c475a 100644 --- a/dist/lib/schema/run-schema-query.js +++ b/dist/lib/schema/run-schema-query.js @@ -1,5 +1,53 @@ import dbHandler from "../db-handler"; +let schemaRunState = null; +/** + * Statements that discard stored data (tables or column values). + */ +export function isDestructiveSchemaStatement(query) { + return /^\s*DROP\s+TABLE\b/i.test(query) || /\bDROP\s+COLUMN\b/i.test(query); +} +/** + * Run `fn` with schema statements recorded but not executed. + * Reads (information_schema) still run, so the plan is a close approximation: + * steps that depend on earlier changes (e.g. after a table rename) may differ. + */ +export async function planSchemaQueries(fn) { + const previous = schemaRunState; + schemaRunState = { dryRun: true, statements: [], allowDestructive: true }; + try { + await fn(); + return schemaRunState.statements; + } + finally { + schemaRunState = previous; + } +} +/** + * Run `fn`, refusing any destructive statement that was not approved. + */ +export async function runSchemaQueriesGuarded(fn, allowDestructive) { + const previous = schemaRunState; + schemaRunState = { dryRun: false, statements: [], allowDestructive }; + try { + await fn(); + } + finally { + schemaRunState = previous; + } +} export default async function runSchemaQuery({ query, values, config, }) { + if (schemaRunState) { + schemaRunState.statements.push(query); + if (schemaRunState.dryRun) { + return; + } + const allowed = schemaRunState.allowDestructive; + if (allowed !== true && + isDestructiveSchemaStatement(query) && + !allowed.has(query)) { + throw new Error(`Refusing unapproved destructive statement: ${query}. Re-run with --dry-run to review, or --yes to allow it.`); + } + } const res = await dbHandler({ query, values, diff --git a/dist/lib/schema/update-table.js b/dist/lib/schema/update-table.js index 653901c..24eb8ff 100644 --- a/dist/lib/schema/update-table.js +++ b/dist/lib/schema/update-table.js @@ -1,7 +1,6 @@ import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition"; import createTable from "./create-table"; import getTableColumns, {} from "./get-table-columns"; -import getTableColumnsGemini from "./get-table-columns-gemnini"; import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; @@ -188,7 +187,7 @@ async function recreateColumn({ tableName, field, config, }) { await addColumn({ tableName, field, config }); } export default async function updateTable({ table, config, }) { - const existingColumns = await getTableColumnsGemini({ + const existingColumns = await getTableColumns({ tableName: table.tableName, config, }); diff --git a/dist/types/index.d.ts b/dist/types/index.d.ts index f568448..d4d615f 100644 --- a/dist/types/index.d.ts +++ b/dist/types/index.d.ts @@ -1392,6 +1392,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/dist/utils/export-archive.js b/dist/utils/export-archive.js index f5ddf0a..12a8c3b 100644 --- a/dist/utils/export-archive.js +++ b/dist/utils/export-archive.js @@ -165,10 +165,11 @@ function findFileContents(dir, predicate) { 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"); } } @@ -184,7 +185,7 @@ function findSchemaFile(dir) { 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: entry.name === AppData.DbSchemaFileName diff --git a/dist/utils/grab-table-fields.d.ts b/dist/utils/grab-table-fields.d.ts new file mode 100644 index 0000000..1d64e4f --- /dev/null +++ b/dist/utils/grab-table-fields.d.ts @@ -0,0 +1,5 @@ +import type { BUN_MARIADB_FieldSchemaType } from "../types"; +/** + * Grab a table's schema fields from the loaded (or last synced) schema. + */ +export default function grabTableFields(tableName: string): BUN_MARIADB_FieldSchemaType[]; diff --git a/dist/utils/grab-table-fields.js b/dist/utils/grab-table-fields.js new file mode 100644 index 0000000..24a74fb --- /dev/null +++ b/dist/utils/grab-table-fields.js @@ -0,0 +1,9 @@ +import { readLiveSchema } from "../functions/live-schema"; +/** + * Grab a table's schema fields from the loaded (or last synced) schema. + */ +export default function grabTableFields(tableName) { + const dbSchema = global.DB_SCHEMA || readLiveSchema(); + const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName); + return tableSchema?.fields || []; +} diff --git a/dist/utils/sanitize-html-fields.js b/dist/utils/sanitize-html-fields.js index 18e11f3..0fff7e7 100644 --- a/dist/utils/sanitize-html-fields.js +++ b/dist/utils/sanitize-html-fields.js @@ -1,11 +1,6 @@ import sanitizeHtml from "sanitize-html"; -import { readLiveSchema } from "../functions/live-schema"; import getSanitizeHtmlOptions from "./sanitize-html-options"; -function grabTableFields(tableName) { - const dbSchema = global.DB_SCHEMA || readLiveSchema(); - const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName); - return tableSchema?.fields || []; -} +import grabTableFields from "./grab-table-fields"; /** * Only fields with an explicit `html: true` flag are sanitized. * Missing / falsy / non-true values are never sanitized. diff --git a/dist/utils/sanitize-html-options.js b/dist/utils/sanitize-html-options.js index 2ba207b..a52d182 100644 --- a/dist/utils/sanitize-html-options.js +++ b/dist/utils/sanitize-html-options.js @@ -18,7 +18,6 @@ export const defaultSanitizeHtmlOptions = { "h6", "img", "div", - "button", "pre", "code", "br", @@ -35,10 +34,12 @@ export const defaultSanitizeHtmlOptions = { "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"], }, }; function uniqueStrings(values) { diff --git a/dist/utils/sql-gen-operator-gen.d.ts b/dist/utils/sql-gen-operator-gen.d.ts index 50496ba..edac181 100644 --- a/dist/utils/sql-gen-operator-gen.d.ts +++ b/dist/utils/sql-gen-operator-gen.d.ts @@ -1,11 +1,13 @@ import type { ServerQueryEqualities, ServerQueryObject, SQLInsertGenValueType } from "../types"; type Params = { + /** Already-quoted column reference */ fieldName: string; value?: SQLInsertGenValueType; equality?: (typeof ServerQueryEqualities)[number]; queryObj: ServerQueryObject<{ [key: string]: any; }, string>; + /** `value` is an already-quoted column reference, not a parameter */ isValueFieldValue?: boolean; }; type Return = { diff --git a/dist/utils/sql-gen-operator-gen.js b/dist/utils/sql-gen-operator-gen.js index 26fefa1..fad0cd1 100644 --- a/dist/utils/sql-gen-operator-gen.js +++ b/dist/utils/sql-gen-operator-gen.js @@ -10,9 +10,12 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj if (queryObj.notNullValue) { 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 { str: `MATCH(${fieldName}) AGAINST(${finalValue} IN NATURAL LANGUAGE MODE)`, @@ -28,7 +31,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj else if (equality == "LIKE_LOWER") { return { str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`, - param: `%${finalParams}%`, + param: likeParams, }; } else if (equality == "LIKE_LOWER_RAW") { @@ -40,7 +43,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj else if (equality == "LIKE") { return { str: `${fieldName} LIKE ${finalValue}`, - param: `%${finalParams}%`, + param: likeParams, }; } else if (equality == "LIKE_RAW") { @@ -52,7 +55,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj 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") { @@ -64,7 +67,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj else if (equality == "NOT LIKE") { return { str: `${fieldName} NOT LIKE ${finalValue}`, - param: finalParams, + param: likeParams, }; } else if (equality == "NOT LIKE_RAW") { @@ -97,6 +100,12 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj param: finalParams, }; } + else if (equality == "IN" || equality == "NOT IN") { + return { + str: `${fieldName} ${equality} (${finalValue})`, + param: finalParams, + }; + } else if (equality) { return { str: `${fieldName} ${sqlEqualityParser(equality)} ${finalValue}`, @@ -120,13 +129,13 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj else if (equality) { return { str: `${fieldName} ${sqlEqualityParser(equality)} ?`, - param: value, + param: value ?? "", }; } else { return { str: `${fieldName} = ?`, - param: value, + param: value ?? "", }; } } diff --git a/dist/utils/sql-generator-gen-join-str.d.ts b/dist/utils/sql-generator-gen-join-str.d.ts index 5dbf8cb..6bfdc3b 100644 --- a/dist/utils/sql-generator-gen-join-str.d.ts +++ b/dist/utils/sql-generator-gen-join-str.d.ts @@ -2,6 +2,7 @@ import type { ServerQueryParamsJoin, ServerQueryParamsJoinMatchObject, SQLInsert type Param = { mtch: ServerQueryParamsJoinMatchObject; join: ServerQueryParamsJoin; + /** Already-quoted root table name */ table_name: string; }; export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param): { diff --git a/dist/utils/sql-generator-gen-join-str.js b/dist/utils/sql-generator-gen-join-str.js index 0120afa..cbd6f1c 100644 --- a/dist/utils/sql-generator-gen-join-str.js +++ b/dist/utils/sql-generator-gen-join-str.js @@ -1,3 +1,4 @@ +import { columnRef, sqlLogicalOperator, sqlRawComparison, } from "./sql-identifier"; export default function sqlGenGenJoinStr({ join, mtch, table_name }) { let values = []; if (mtch.__batch) { @@ -13,7 +14,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) { btch_mtch += str; values.push(...batch_values); if (i < mtch.__batch.matches.length - 1) { - btch_mtch += ` ${mtch.__batch.operator || "OR"} `; + btch_mtch += ` ${sqlLogicalOperator(mtch.__batch.operator || "OR")} `; } } btch_mtch += `)`; @@ -22,28 +23,12 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) { values, }; } - const equality = 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 equality = mtch.raw_equality + ? sqlRawComparison(mtch.raw_equality) + : "="; + 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); return { @@ -58,6 +43,16 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) { values, }; } + 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/dist/utils/sql-generator-gen-query-str.d.ts b/dist/utils/sql-generator-gen-query-str.d.ts index 886d00a..4be6ece 100644 --- a/dist/utils/sql-generator-gen-query-str.d.ts +++ b/dist/utils/sql-generator-gen-query-str.d.ts @@ -7,6 +7,7 @@ type Param; 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; diff --git a/dist/utils/sql-generator-gen-query-str.js b/dist/utils/sql-generator-gen-query-str.js index d99076a..3424f9f 100644 --- a/dist/utils/sql-generator-gen-query-str.js +++ b/dist/utils/sql-generator-gen-query-str.js @@ -2,6 +2,7 @@ import _, { isUndefined } from "lodash"; 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"; export default function sqlGenGenQueryStr(params) { let str = "SELECT"; const genObject = params.genObject; @@ -42,106 +43,108 @@ export default function sqlGenGenQueryStr(params) { 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 (`; for (let j = 0; j < countSubQuery.srcTrgMap.length; j++) { 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) { subQStr += ` AND `; } } - subQStr += ` )) AS ${countSubQuery.alias}`; + subQStr += ` )) AS ${quoteIdentifier(countSubQuery.alias)}`; countSqls.push(subQStr); } str += `, ${countSqls.join(",")}`; } if (genObject?.join) { const existingJoinTableNames = [table_name]; - str += - "," + - genObject.join - .flat() - .filter((j) => !isUndefined(j)) - .map((joinObj) => { - const joinTableName = joinObj.alias - ? joinObj.alias - : joinObj.tableName; - if (existingJoinTableNames.includes(joinTableName)) - return null; - existingJoinTableNames.push(joinTableName); - if (joinObj.group_concat) { - return sqlGenGrabConcatStr({ - field: `${joinTableName}.${joinObj.group_concat.field}`, - alias: joinObj.group_concat.alias, - separator: joinObj.group_concat.separator, - }); + 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(joinTableRef)) + return null; + existingJoinTableNames.push(joinTableRef); + 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 (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 (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; - } - }) - .join(","); - } - else { - return `${joinTableName}.*`; + else if (typeof selectField == "object") { + const fieldRef = columnRef(String(selectField.field), joinTableName); + let aliasSelectField = fieldRef; + 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}`; + } + if (selectField.alias) + aliasSelectField += ` AS ${quoteIdentifier(selectField.alias)}`; + return aliasSelectField; } }) - .filter((_) => Boolean(_)) .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); } str += ` FROM ${table_name}`; @@ -152,11 +155,11 @@ export default function sqlGenGenQueryStr(params) { .flat() .filter((j) => !isUndefined(j)) .map((join) => { - return (join.joinType + + return (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)) { @@ -172,9 +175,7 @@ export default function sqlGenGenQueryStr(params) { sqlSearhValues.push(...values); return str; }) - .join(join.operator - ? ` ${join.operator} ` - : " AND ") + + .join(` ${sqlLogicalOperator(join.operator)} `) + ")"); } else if (typeof join.match == "object") { diff --git a/dist/utils/sql-generator-gen-search-str.d.ts b/dist/utils/sql-generator-gen-search-str.d.ts index 563daf8..736dcc4 100644 --- a/dist/utils/sql-generator-gen-search-str.d.ts +++ b/dist/utils/sql-generator-gen-search-str.d.ts @@ -3,6 +3,7 @@ type Param = { queryObj: ServerQueryQueryObject[string]; join?: (ServerQueryParamsJoin | ServerQueryParamsJoin[] | undefined)[]; field?: string; + /** Already-quoted root table name */ table_name: string; }; export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }: Param): { diff --git a/dist/utils/sql-generator-gen-search-str.js b/dist/utils/sql-generator-gen-search-str.js index e7cf269..eef7cad 100644 --- a/dist/utils/sql-generator-gen-search-str.js +++ b/dist/utils/sql-generator-gen-search-str.js @@ -1,92 +1,112 @@ import sqlGenOperatorGen from "./sql-gen-operator-gen"; +import { columnRef, sqlLogicalOperator, sqlRawComparison, } from "./sql-identifier"; export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }) { let sqlSearhValues = []; 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) { - const valueParsed = val; - if (!valueParsed) + if (val === undefined || val === null) return; - const valueString = typeof valueParsed == "string" || typeof valueParsed == "number" - ? valueParsed - : valueParsed - ? valueParsed.fieldName && valueParsed.tableName - ? `${valueParsed.tableName}.${valueParsed.fieldName}` - : valueParsed.value - : undefined; - const valueEquality = typeof valueParsed == "object" - ? valueParsed.equality || queryObj.equality + const isFieldRef = Boolean(typeof val == "object" && val.fieldName && val.tableName); + const valueString = typeof val == "string" || typeof val == "number" + ? val + : isFieldRef + ? columnRef(val.fieldName, val.tableName) + : val.value; + const valueEquality = 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)) { + const listEquality = queryObj.equality == "IN" || queryObj.equality == "NOT IN" + ? queryObj.equality + : undefined; + if (listEquality && + !queryObj.nullValue && + !queryObj.notNullValue && + queryObj.value !== undefined && + queryObj.value !== null) { + const rawValues = Array.isArray(queryObj.value) + ? queryObj.value + : [queryObj.value]; + const listValues = rawValues + .map((val) => val !== null && typeof val == "object" ? val.value : val) + .filter((val) => val !== undefined && val !== null); + if (listValues.length === 0) { + // Empty IN () is invalid SQL: IN matches nothing, NOT IN matches everything + str = listEquality == "IN" ? "1 = 0" : "1 = 1"; + } + else { + str = `${finalFieldName} ${listEquality} (${listValues.map(() => "?").join(",")})`; + sqlSearhValues.push(...listValues); + } + } + else if (Array.isArray(queryObj.value)) { const strArray = []; queryObj.value.forEach((val) => { const operatorStrParam = grabValue(val); - if (!operatorStrParam) + 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"} `) + ")"; + str = + "(" + + strArray.join(` ${sqlLogicalOperator(queryObj.operator)} `) + + ")"; } - else if (typeof queryObj.value == "object") { + 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 || ""); - } } return { str, values: sqlSearhValues }; } diff --git a/dist/utils/sql-generator-grab-concat-str.d.ts b/dist/utils/sql-generator-grab-concat-str.d.ts index 3eb39c7..ab342dd 100644 --- a/dist/utils/sql-generator-grab-concat-str.d.ts +++ b/dist/utils/sql-generator-grab-concat-str.d.ts @@ -1,4 +1,5 @@ type Param = { + /** Already-quoted column reference */ field: string; alias: string; separator?: string; diff --git a/dist/utils/sql-generator-grab-concat-str.js b/dist/utils/sql-generator-grab-concat-str.js index 8993f00..47043f7 100644 --- a/dist/utils/sql-generator-grab-concat-str.js +++ b/dist/utils/sql-generator-grab-concat-str.js @@ -1,3 +1,4 @@ +import { escapeStringLiteral, quoteIdentifier } from "./sql-identifier"; export default function sqlGenGrabConcatStr({ alias, field, separator = ",", distinct, }) { let gc = `GROUP_CONCAT(`; if (distinct) { @@ -5,9 +6,9 @@ export default function sqlGenGrabConcatStr({ alias, field, separator = ",", dis } gc += `${field}`; if (!distinct) { - gc += `, '${separator}'`; + gc += `, '${escapeStringLiteral(separator)}'`; } gc += `)`; - gc += ` AS ${alias}`; + gc += ` AS ${quoteIdentifier(alias)}`; return gc; } diff --git a/dist/utils/sql-generator-grab-select-field-sql.js b/dist/utils/sql-generator-grab-select-field-sql.js index 4056788..a56fd60 100644 --- a/dist/utils/sql-generator-grab-select-field-sql.js +++ b/dist/utils/sql-generator-grab-select-field-sql.js @@ -1,55 +1,48 @@ import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; +import { columnRef, quoteIdentifier } from "./sql-identifier"; export default function sqlGenGrabSelectFieldSQL({ selectFields, append_table_names, table_name }) { - let str = ""; - str += ` ${selectFields - ?.map((fld) => { + const table = append_table_names ? table_name : undefined; + 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/dist/utils/sql-generator.d.ts b/dist/utils/sql-generator.d.ts index 21eedbb..8ac50f0 100644 --- a/dist/utils/sql-generator.d.ts +++ b/dist/utils/sql-generator.d.ts @@ -5,13 +5,23 @@ type Param = { genObject?: ServerQueryParam; + /** Raw (unquoted) table name */ tableName: string; dbFullName?: string; count?: boolean; + /** + * Restrict to a single row id. Always combined with AND, regardless of + * `searchOperator`. + */ + targetId?: number | string; }; type Return = { string: string; values: SQLInsertGenValueType[]; + /** `WHERE ...` clause alone (empty string if none) */ + where: string; + /** Values for the `where` clause placeholders only */ + whereValues: SQLInsertGenValueType[]; }; /** * # SQL Query Generator @@ -21,5 +31,5 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }: Param): Return; +}>({ tableName: rawTableName, genObject, dbFullName, count, targetId, }: Param): Return; export {}; diff --git a/dist/utils/sql-generator.js b/dist/utils/sql-generator.js index c93b0ef..6fef38b 100644 --- a/dist/utils/sql-generator.js +++ b/dist/utils/sql-generator.js @@ -1,16 +1,20 @@ 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"; +/** 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({ tableName, genObject, dbFullName, count }) { +export default function sqlGenerator({ tableName: rawTableName, genObject, dbFullName, count, targetId, }) { + const tableName = quoteQualified(rawTableName); const finalQuery = genObject?.query ? genObject.query : undefined; const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined; - const sqlSearhValues = []; + const whereValues = []; let fullTextMatchStr = genObject?.fullTextSearch ? ` MATCH(${genObject.fullTextSearch.fields - .map((f) => genObject.join ? `${tableName}.${String(f)}` : `${String(f)}`) + .map((f) => columnRef(String(f), genObject.join ? tableName : undefined)) .join(",")}) AGAINST (? IN BOOLEAN MODE)` : undefined; const fullTextSearchStr = genObject?.fullTextSearch @@ -19,14 +23,13 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count } .map((t) => `${t}`) .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, full_text_search_str: fullTextSearchStr, genObject, }); - sqlSearhValues.push(...values); const sqlSearhString = queryKeys?.map((field) => { const queryObj = finalQuery?.[field]; if (!queryObj) @@ -34,7 +37,8 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count } if (queryObj.__query) { const subQueryGroup = queryObj.__query; const subSearchKeys = Object.keys(subQueryGroup); - const subSearchString = subSearchKeys.map((_field) => { + const subSearchString = subSearchKeys + .map((_field) => { const newSubQueryObj = subQueryGroup?.[_field]; if (newSubQueryObj) { const { str, values } = sqlGenGenSearchStr({ @@ -43,12 +47,13 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count } join: genObject?.join, table_name: tableName, }); - sqlSearhValues.push(...values); + whereValues.push(...values); return str; } - }); + }) + .filter((str) => typeof str == "string"); return ("(" + - subSearchString.join(` ${queryObj.operator || "AND"} `) + + subSearchString.join(` ${sqlLogicalOperator(queryObj.operator)} `) + ")"); } const { str, values } = sqlGenGenSearchStr({ @@ -57,247 +62,92 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count } join: genObject?.join, table_name: tableName, }); - sqlSearhValues.push(...values); + whereValues.push(...values); return str; }); const cleanedUpSearchStr = sqlSearhString?.filter((str) => typeof str == "string"); const isSearchStr = cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str); + const whereConditions = []; if (isSearchStr) { - const stringOperator = genObject?.searchOperator || "AND"; - queryString += ` WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`; + const stringOperator = sqlLogicalOperator(genObject?.searchOperator); + whereConditions.push(`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`); } if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) { - queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`; - sqlSearhValues.push(fullTextSearchStr); + whereConditions.push(fullTextMatchStr.trim()); + whereValues.push(fullTextSearchStr); + } + if (targetId !== undefined && targetId !== null && targetId !== "") { + const id = Number(targetId); + if (!Number.isFinite(id)) { + throw new Error(`Invalid targetId: ${String(targetId)}`); + } + whereConditions.push(`${columnRef("id", genObject?.join ? tableName : undefined)} = ?`); + whereValues.push(id); + } + const where = whereConditions.length + ? `WHERE ${whereConditions.join(" AND ")}` + : ``; + if (where) { + queryString += ` ${where}`; } if (genObject?.group) { + const groupRef = (group) => 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; + if (Array.isArray(genObject.group)) { + group_by_txt = genObject.group + .map((group) => groupRef(group)) + .join(","); } - 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)}`; - } + else { + group_by_txt = groupRef(genObject.group); } queryString += ` GROUP BY ${group_by_txt}`; } function grabOrderString(order) { - let orderFields = []; - let orderSrt = ``; - if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) { - orderFields.push(genObject.fullTextSearch.scoreAlias); - } - else if (genObject?.join) { - orderFields.push(`${tableName}.${String(order.field)}`); + const scoreAlias = genObject?.fullTextSearch?.scoreAlias; + let orderField = ``; + 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`; - 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 ? `,` : ""); - } - } + const orders = (Array.isArray(genObject.order) ? genObject.order : [genObject.order]).filter(Boolean); + if (orders.length) { + queryString += ` ORDER BY${orders.map(grabOrderString).join(",")}`; } - else { - orderSrt += grabOrderString(genObject.order); + } + if (!count) { + const limit = genObject?.limit !== undefined && genObject.limit !== null + ? sqlNonNegativeInt(genObject.limit, "limit") + : undefined; + let offset; + 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}`; } - queryString += ` ${orderSrt}`; - } - if (genObject?.limit && !count) - queryString += ` LIMIT ${genObject.limit}`; - if (genObject?.offset) { - queryString += ` OFFSET ${genObject.offset}`; - } - else if (genObject?.page && genObject.limit && !count) { - queryString += ` OFFSET ${(genObject.page - 1) * genObject.limit}`; } return { string: queryString, - values: sqlSearhValues, + values: [...selectValues, ...whereValues], + where, + whereValues, }; } -// let queryString = (() => { -// let str = "SELECT"; -// if (genObject?.select_sql) { -// str += ` ${genObject.select_sql}`; -// } else if (genObject?.selectFields?.[0]) { -// if (genObject.join) { -// str += sqlGenGrabSelectFieldSQL({ -// selectFields: genObject.selectFields, -// append_table_names: true, -// table_name: tableName, -// }); -// } else { -// str += sqlGenGrabSelectFieldSQL({ -// selectFields: genObject.selectFields, -// table_name: tableName, -// }); -// } -// } else { -// if (genObject?.join) { -// str += ` ${tableName}.*`; -// } else { -// str += " *"; -// } -// } -// if (genObject?.countSubQueries) { -// let countSqls: string[] = []; -// for (let i = 0; i < genObject.countSubQueries.length; i++) { -// const countSubQuery = genObject.countSubQueries[i]; -// if (!countSubQuery) continue; -// const tableAlias = countSubQuery.table_alias; -// let subQStr = `(SELECT COUNT(*)`; -// subQStr += ` FROM ${countSubQuery.table}${ -// tableAlias ? ` ${tableAlias}` : "" -// }`; -// subQStr += ` WHERE (`; -// for (let j = 0; j < countSubQuery.srcTrgMap.length; j++) { -// const csqSrc = countSubQuery.srcTrgMap[j]; -// if (!csqSrc) continue; -// subQStr += ` ${tableAlias || countSubQuery.table}.${ -// 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}`; -// } -// if (j < countSubQuery.srcTrgMap.length - 1) { -// subQStr += ` AND `; -// } -// } -// subQStr += ` )) AS ${countSubQuery.alias}`; -// countSqls.push(subQStr); -// } -// str += `, ${countSqls.join(",")}`; -// } -// if (genObject?.join) { -// const existingJoinTableNames: string[] = [tableName]; -// str += -// "," + -// genObject.join -// .flat() -// .filter((j) => !isUndefined(j)) -// .map((joinObj) => { -// const joinTableName = joinObj.alias -// ? joinObj.alias -// : joinObj.tableName; -// if (existingJoinTableNames.includes(joinTableName)) -// return null; -// existingJoinTableNames.push(joinTableName); -// if (joinObj.group_concat) { -// return sqlGenGrabConcatStr({ -// field: `${joinTableName}.${joinObj.group_concat.field}`, -// alias: joinObj.group_concat.alias, -// separator: joinObj.group_concat.separator, -// }); -// } else if (joinObj.selectFields) { -// return joinObj.selectFields -// .map((selectField) => { -// if (typeof selectField == "string") { -// return `${joinTableName}.${selectField}`; -// } else if (typeof selectField == "object") { -// let aliasSelectField = selectField.count -// ? `COUNT(${joinTableName}.${selectField.field})` -// : `${joinTableName}.${selectField.field}`; -// if (selectField.alias) -// aliasSelectField += ` AS ${selectField.alias}`; -// return aliasSelectField; -// } -// }) -// .join(","); -// } else { -// return `${joinTableName}.*`; -// } -// }) -// .filter((_) => Boolean(_)) -// .join(","); -// } -// if ( -// genObject?.fullTextSearch && -// fullTextMatchStr && -// fullTextSearchStr -// ) { -// str += `, ${fullTextMatchStr} AS ${genObject.fullTextSearch.scoreAlias}`; -// sqlSearhValues.push(fullTextSearchStr); -// } -// str += ` FROM ${tableName}`; -// if (genObject?.join) { -// str += -// " " + -// genObject.join -// .flat() -// .filter((j) => !isUndefined(j)) -// .map((join) => { -// return ( -// join.joinType + -// " " + -// (join.alias -// ? `${join.tableName}` + " " + join.alias -// : `${join.tableName}`) + -// " ON " + -// (() => { -// if (Array.isArray(join.match)) { -// return ( -// "(" + -// join.match -// .map((mtch) => -// sqlGenGenJoinStr({ -// mtch, -// join, -// table_name: tableName, -// }), -// ) -// .join( -// join.operator -// ? ` ${join.operator} ` -// : " AND ", -// ) + -// ")" -// ); -// } else if (typeof join.match == "object") { -// return sqlGenGenJoinStr({ -// mtch: join.match, -// join, -// table_name: tableName, -// }); -// } -// })() -// ); -// }) -// .join(" "); -// } -// return str; -// })(); diff --git a/dist/utils/sql-identifier.d.ts b/dist/utils/sql-identifier.d.ts new file mode 100644 index 0000000..15da6cf --- /dev/null +++ b/dist/utils/sql-identifier.d.ts @@ -0,0 +1,27 @@ +/** + * Quote a single identifier (table, column, alias) with backticks. + * Already-quoted identifiers (e.g. "`users`") are returned unchanged. + */ +export declare function quoteIdentifier(identifier: string): string; +/** + * Quote a possibly qualified identifier: "table.field" -> `table`.`field`. + * A bare "*" (or "table.*") is kept as a wildcard. + */ +export declare function quoteQualified(identifier: string): string; +/** + * Build a column reference, prefixing the table when one is given and the + * field is not already qualified. + */ +export declare function columnRef(field: string, table?: string): string; +/** + * Escape a value for use inside a single-quoted SQL string literal. + */ +export declare function escapeStringLiteral(value: string): string; +export declare function sqlLogicalOperator(operator?: string): "AND" | "OR"; +export declare function sqlOrderStrategy(strategy?: string): "ASC" | "DESC"; +export declare function sqlRawComparison(comparison: string): string; +export declare function sqlJoinType(joinType: string): string; +/** + * Validate a LIMIT / OFFSET / page value as a non-negative integer. + */ +export declare function sqlNonNegativeInt(value: unknown, name: string): number; diff --git a/dist/utils/sql-identifier.js b/dist/utils/sql-identifier.js new file mode 100644 index 0000000..856010c --- /dev/null +++ b/dist/utils/sql-identifier.js @@ -0,0 +1,80 @@ +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) { + 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) { + 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, table) { + 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) { + return String(value).replace(/\\/g, "\\\\").replace(/'/g, "''"); +} +export function sqlLogicalOperator(operator) { + if (!operator) + return "AND"; + const upper = String(operator).toUpperCase(); + if (ServerQueryOperators.includes(upper)) { + return upper; + } + throw new Error(`Invalid logical operator: ${operator}`); +} +export function sqlOrderStrategy(strategy) { + 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) { + const upper = String(comparison).toUpperCase(); + if (SQlComparisons.includes(upper)) + return upper; + throw new Error(`Invalid comparison operator: ${comparison}`); +} +const JoinTypes = ["INNER JOIN", "JOIN", "LEFT JOIN", "RIGHT JOIN"]; +export function sqlJoinType(joinType) { + const upper = String(joinType).toUpperCase(); + if (JoinTypes.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, name) { + 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/dist/utils/sql-insert-generator.js b/dist/utils/sql-insert-generator.js index bf3bbd5..760367c 100644 --- a/dist/utils/sql-insert-generator.js +++ b/dist/utils/sql-insert-generator.js @@ -1,6 +1,6 @@ -function quoteIdentifier(identifier) { - return `\`${identifier.replace(/`/g, "``")}\``; -} +import { quoteIdentifier } from "./sql-identifier"; +import sqlValuePlaceholder from "./sql-value-placeholder"; +import grabTableFields from "./grab-table-fields"; /** * # SQL Insert Generator */ @@ -17,37 +17,19 @@ export default function sqlInsertGenerator({ tableName, data, dbFullName, }) { } }); }); + const fieldsMap = new Map(grabTableFields(tableName).map((f) => [f.fieldName, f])); let queryBatches = []; let queryValues = []; data.forEach((item) => { queryBatches.push(`(${insertKeys .map((ky) => { - const value = item[ky]; - const is_embedding = Array.isArray(value) && - typeof value[0] == "number"; - const finalValue = typeof value == "string" || - typeof value == "number" - ? value - : typeof value == "function" - ? value().value - : is_embedding - ? JSON.stringify(value) - : value - ? value - : null; - if (!finalValue && typeof value !== "number") { - queryValues.push(null); - return "?"; - } - queryValues.push(finalValue); - const placeholder = typeof value == "function" - ? value().placeholder - : is_embedding - ? "VEC_FromText(?)" - : "?"; + const { placeholder, value } = sqlValuePlaceholder({ + value: item[ky], + field: fieldsMap.get(ky), + }); + queryValues.push(value); return placeholder; }) - .filter((k) => Boolean(k)) .join(",")})`); }); const insertColumns = insertKeys.map(quoteIdentifier).join(","); diff --git a/dist/utils/sql-value-placeholder.d.ts b/dist/utils/sql-value-placeholder.d.ts new file mode 100644 index 0000000..c1e3bc5 --- /dev/null +++ b/dist/utils/sql-value-placeholder.d.ts @@ -0,0 +1,19 @@ +import type { BUN_MARIADB_FieldSchemaType, SQLInsertGenValueType } from "../types"; +type Return = { + placeholder: string; + value: SQLInsertGenValueType; +}; +/** + * Map a JS value to a bound parameter + placeholder for INSERT / UPDATE. + * + * - `false` / `true` -> `0` / `1` + * - number arrays (embeddings) -> `VEC_FromText(?)` with a JSON string + * - `{ value, placeholder }` factories are called once + * - `""` stays `""` for text columns and becomes `NULL` for everything else + * (e.g. the `number | ""` typedefs for numeric columns) + */ +export default function sqlValuePlaceholder({ value, field, }: { + value: any; + field?: BUN_MARIADB_FieldSchemaType; +}): Return; +export {}; diff --git a/dist/utils/sql-value-placeholder.js b/dist/utils/sql-value-placeholder.js new file mode 100644 index 0000000..d98e59f --- /dev/null +++ b/dist/utils/sql-value-placeholder.js @@ -0,0 +1,36 @@ +function isTextField(field) { + if (!field || field.isVector) + return false; + return /char|text|enum|set|json/i.test(String(field.dataType || "")); +} +/** + * Map a JS value to a bound parameter + placeholder for INSERT / UPDATE. + * + * - `false` / `true` -> `0` / `1` + * - number arrays (embeddings) -> `VEC_FromText(?)` with a JSON string + * - `{ value, placeholder }` factories are called once + * - `""` stays `""` for text columns and becomes `NULL` for everything else + * (e.g. the `number | ""` typedefs for numeric columns) + */ +export default function sqlValuePlaceholder({ value, field, }) { + if (typeof value == "function") { + const fnValue = value(); + return { + placeholder: fnValue?.placeholder || "?", + value: fnValue?.value ?? null, + }; + } + if (typeof value == "boolean") { + return { placeholder: "?", value: value ? 1 : 0 }; + } + if (value === undefined || value === null) { + return { placeholder: "?", value: null }; + } + if (value === "") { + return { placeholder: "?", value: isTextField(field) ? "" : null }; + } + if (Array.isArray(value) && typeof value[0] == "number") { + return { placeholder: "VEC_FromText(?)", value: JSON.stringify(value) }; + } + return { placeholder: "?", value }; +} diff --git a/dist/utils/trim-backups.js b/dist/utils/trim-backups.js index a9bd3a7..cc07acb 100644 --- a/dist/utils/trim-backups.js +++ b/dist/utils/trim-backups.js @@ -3,9 +3,13 @@ import fs from "fs"; import grabSortedBackups from "./grab-sorted-backups"; import { AppData } from "../data/app-data"; import path from "path"; +import _ from "lodash"; export default function trimBackups({ config }) { 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"]; for (let i = 0; i < backups.length; i++) { const backup_name = backups[i]; diff --git a/dist/utils/trim-exports.js b/dist/utils/trim-exports.js index ac4fb63..1285ff7 100644 --- a/dist/utils/trim-exports.js +++ b/dist/utils/trim-exports.js @@ -1,11 +1,15 @@ import fs from "fs"; import path from "path"; +import _ from "lodash"; import { AppData } from "../data/app-data"; import grabDBDir from "./grab-db-dir"; import grabSortedExports from "./grab-sorted-exports"; export default function trimExports({ config }) { 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++) { const export_name = exports[i]; diff --git a/package.json b/package.json index e18b4c4..d012c07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moduletrace/bun-mariadb", - "version": "1.0.22", + "version": "1.0.21", "description": "Schema-driven MariaDB manager for Bun", "author": "Benjamin Toby", "license": "MIT", @@ -27,12 +27,14 @@ }, "scripts": { "dev": "tsc --watch", - "compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json", + "compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && chmod +x dist/commands/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun run scripts/test.ts", + "test:unit": "bun test", "prepublishOnly": "bun run compile" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.3.14", "@types/lodash": "^4.17.24", "@types/node": "^25.3.3", "@types/sanitize-html": "^2.16.1", diff --git a/scripts/test.ts b/scripts/test.ts new file mode 100644 index 0000000..3e361dd --- /dev/null +++ b/scripts/test.ts @@ -0,0 +1,157 @@ +#!/usr/bin/env bun +/** + * Run the full test suite against a throwaway MariaDB Docker container. + * + * bun run test # start container, run all tests, remove container + * bun run test tests/db # extra args are passed to `bun test` + * + * Env: + * BUN_MARIADB_TEST_IMAGE Docker image (default: mariadb:11.8, needs 11.7+ for VECTOR) + * BUN_MARIADB_TEST_DB If already set, skip Docker and use the configured + * BUN_MARIADB_SERVER_* connection instead + */ +import { SQL } from "bun"; + +const IMAGE = process.env.BUN_MARIADB_TEST_IMAGE || "mariadb:11.8"; +const DB_NAME = "bun_mariadb_test"; +const PASSWORD = "bun_mariadb_test_pw"; +const CONTAINER = `bun-mariadb-test-${process.pid}`; +const READY_TIMEOUT_MS = 120_000; + +const testArgs = process.argv.slice(2); + +async function run(cmd: string[], opts: { quiet?: boolean } = {}) { + const proc = Bun.spawn(cmd, { + stdout: opts.quiet ? "pipe" : "inherit", + stderr: opts.quiet ? "pipe" : "inherit", + }); + const stdout = opts.quiet ? await new Response(proc.stdout).text() : ""; + const exitCode = await proc.exited; + return { exitCode, stdout: stdout.trim() }; +} + +async function runTests(env: Record): Promise { + const proc = Bun.spawn(["bun", "test", ...testArgs], { + stdout: "inherit", + stderr: "inherit", + env: { ...process.env, ...env }, + }); + return proc.exited; +} + +// Already pointed at a database: just run the tests +if (process.env.BUN_MARIADB_TEST_DB) { + process.exit(await runTests({})); +} + +if ((await run(["docker", "info"], { quiet: true })).exitCode !== 0) { + console.error( + "Docker is not available. Start Docker, or set BUN_MARIADB_TEST_DB and BUN_MARIADB_SERVER_* to use an existing database.", + ); + process.exit(1); +} + +let removed = false; +async function removeContainer() { + if (removed) return; + removed = true; + await run(["docker", "rm", "-f", CONTAINER], { quiet: true }); +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, async () => { + await removeContainer(); + process.exit(130); + }); +} + +let exitCode = 1; + +try { + console.log(`Starting ${IMAGE} (${CONTAINER}) ...`); + + const started = await run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + CONTAINER, + "-e", + `MARIADB_ROOT_PASSWORD=${PASSWORD}`, + "-e", + `MARIADB_DATABASE=${DB_NAME}`, + // Random free host port, bound to localhost only + "-p", + "127.0.0.1::3306", + IMAGE, + ], + { quiet: true }, + ); + + if (started.exitCode !== 0) { + throw new Error(`docker run failed (exit ${started.exitCode})`); + } + + const portOut = await run(["docker", "port", CONTAINER, "3306/tcp"], { + quiet: true, + }); + const port = portOut.stdout.split("\n")[0]?.split(":").pop(); + + if (!port) { + throw new Error(`Could not read the container's mapped port`); + } + + /** + * Wait for a TCP connection. The image's init phase runs a temporary + * server with networking disabled, so a successful TCP query means the + * real server is up. + */ + const deadline = Date.now() + READY_TIMEOUT_MS; + let ready = false; + + while (Date.now() < deadline) { + const client = new SQL({ + adapter: "mariadb", + hostname: "127.0.0.1", + port: Number(port), + username: "root", + password: PASSWORD, + database: DB_NAME, + connectionTimeout: 2, + }); + + try { + await client.unsafe("SELECT 1"); + ready = true; + } catch { + await Bun.sleep(1000); + } finally { + await client.close().catch(() => {}); + } + + if (ready) break; + } + + if (!ready) { + throw new Error(`MariaDB did not become ready within ${READY_TIMEOUT_MS / 1000}s`); + } + + console.log(`MariaDB ready on 127.0.0.1:${port}\n`); + + exitCode = await runTests({ + BUN_MARIADB_TEST_DB: DB_NAME, + BUN_MARIADB_SERVER_HOST: "127.0.0.1", + BUN_MARIADB_SERVER_PORT: port, + BUN_MARIADB_SERVER_USERNAME: "root", + BUN_MARIADB_SERVER_PASSWORD: PASSWORD, + }); +} catch (error: any) { + console.error(`Test setup failed: ${error.message}`); + exitCode = 1; +} finally { + await removeContainer(); +} + +process.exit(exitCode); 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/commands/index.ts b/src/commands/index.ts index 58728fe..9fb37ee 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -9,8 +9,19 @@ import exportCmd from "./export"; import importCmd from "./import"; import admin from "./admin"; import init from "../functions/init"; +import { readFileSync } from "fs"; -init(); +try { + init(); +} catch (error: any) { + console.error(`Initialization ERROR => ${error.message}`); + process.exit(1); +} + +/** Resolves to the package root from both src/commands and dist/commands */ +const { version } = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf-8"), +); /** * # Describe Program @@ -18,7 +29,7 @@ init(); program .name(`bun-mariadb`) .description(`MariaDB manager for Bun`) - .version(`1.0.0`); + .version(version); /** * # Declare Commands diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 1ddc5b7..8f57298 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -6,11 +6,39 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db- import chalk from "chalk"; import { writeLiveSchema } from "../functions/live-schema"; import createDBSchema from "../lib/schema/create-db-schema"; +import { + isDestructiveSchemaStatement, + planSchemaQueries, + runSchemaQueriesGuarded, +} from "../lib/schema/run-schema-query"; +import { confirm } from "@inquirer/prompts"; +import { AppData } from "../data/app-data"; + +/** Silence progress logs while computing a plan */ +async function quietly(fn: () => Promise): Promise { + const { log, warn } = console; + console.log = () => {}; + console.warn = () => {}; + try { + return await fn(); + } finally { + console.log = log; + console.warn = warn; + } +} export default function () { return new Command("schema") .description("Build DB From Schema") .option("-t, --typedef", "Generate typescript type definitions") + .option( + "--dry-run", + "Print the schema changes that would be applied, without applying them", + ) + .option( + "-y, --yes", + "Apply destructive changes (dropped tables / columns) without confirmation", + ) .action(async (opts) => { console.log(`Starting process ...`); @@ -26,10 +54,78 @@ export default function () { dbSchema, }); - await createDBSchema({ - db_schema: finaldbSchema, - config, - }); + const params = { db_schema: finaldbSchema, config }; + + // Hide internal bookkeeping (schema manager rows, FK check toggles) + const plan = ( + await quietly(() => + planSchemaQueries(() => createDBSchema(params)), + ) + ).filter( + (statement) => + !statement.includes(AppData.DbSchemaManagerTableName) && + !/^SET FOREIGN_KEY_CHECKS/i.test(statement), + ); + const destructive = plan.filter(isDestructiveSchemaStatement); + + if (opts.dryRun) { + if (!plan.length) { + console.log(chalk.green(`Schema is up to date.`)); + } else { + console.log(chalk.bold(`Planned schema changes:\n`)); + for (const statement of plan) { + console.log( + isDestructiveSchemaStatement(statement) + ? chalk.red(` ${statement}`) + : ` ${statement}`, + ); + } + if (destructive.length) { + console.log( + chalk.red( + `\n${destructive.length} destructive change(s) (in red) would discard data.`, + ), + ); + } + } + process.exit(0); + } + + if (destructive.length && !opts.yes) { + console.log( + chalk.yellow( + `\nThese changes will permanently discard data:\n`, + ), + ); + for (const statement of destructive) { + console.log(chalk.red(` ${statement}`)); + } + console.log(); + + if (!process.stdin.isTTY) { + console.error( + chalk.red( + `Refusing to apply destructive changes non-interactively. Re-run with --yes to apply them.`, + ), + ); + process.exit(1); + } + + const proceed = await confirm({ + message: `Apply ${destructive.length} destructive change(s)?`, + default: false, + }); + + if (!proceed) { + console.log(chalk.dim(`Schema sync cancelled.`)); + process.exit(1); + } + } + + await runSchemaQueriesGuarded( + () => createDBSchema(params), + opts.yes ? true : new Set(destructive), + ); if (isTypeDef && config.typedef_file_path) { const out_file = path.resolve( diff --git a/src/functions/grab-mariadb-client.ts b/src/functions/grab-mariadb-client.ts index 2b8760c..762d6cd 100644 --- a/src/functions/grab-mariadb-client.ts +++ b/src/functions/grab-mariadb-client.ts @@ -7,23 +7,37 @@ type Params = { config: BunMariaDBConfig; }; -/** Reuse one client per database name to avoid exhausting MariaDB connections. */ +/** Reuse one client per distinct connection config to avoid exhausting MariaDB connections. */ const clientCache = new Map(); +/** + * Cache key covering everything that affects the connection, not just the + * database name. + */ +export function mariadbClientCacheKey(config?: BunMariaDBConfig): string { + return JSON.stringify({ + db_name: config?.db_name || "__default__", + db_config: config?.db_config || null, + ssl_ca: config?.ssl_ca || null, + ssl_reject_unauthorized: config?.ssl_reject_unauthorized ?? null, + }); +} + export default function grabMariaDBClient({ config, }: Params): Bun.SQL | undefined { - const cacheKey = config.db_name || "__default__"; + const cacheKey = mariadbClientCacheKey(config); const cached = clientCache.get(cacheKey); if (cached) { return cached; } - // Prefer the process-wide client when it matches the requested database + // Prefer the process-wide client when it was created from the same config if ( global.MARIADB_CLIENT && - (!global.CONFIG?.db_name || global.CONFIG.db_name === config.db_name) + global.CONFIG && + mariadbClientCacheKey(global.CONFIG) === cacheKey ) { clientCache.set(cacheKey, global.MARIADB_CLIENT); return global.MARIADB_CLIENT; @@ -32,13 +46,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 +66,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/functions/init.ts b/src/functions/init.ts index de97c99..1a5ecf6 100644 --- a/src/functions/init.ts +++ b/src/functions/init.ts @@ -24,121 +24,112 @@ declare global { var SCHEMA_FILE_PATH: string; } +/** + * Load config + schema and set up the shared MariaDB client. + * Throws on any problem (never exits the process). + */ export default function init(): void { - try { - const { ROOT_DIR } = grabDirNames(); - const { ConfigFileName } = AppData; + const { ROOT_DIR } = grabDirNames(); + const { ConfigFileName } = AppData; - const loadedConfig = resolveAndLoadDataFile( - ROOT_DIR, - ConfigFileName, + const loadedConfig = resolveAndLoadDataFile( + ROOT_DIR, + ConfigFileName, + ); + + if (!loadedConfig) { + throw new Error( + `Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`, + ); + } + + const Config = loadedConfig.data; + + if (!Config || typeof Config !== "object") { + throw new Error( + `Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`, + ); + } + + if (!Config.db_name) { + throw new Error( + `\`db_name\` is required in your config`, + ); + } + + RequiredENVs.forEach((env_key) => { + if ( + env_key == "BUN_MARIADB_SERVER_PORT" || + env_key == "BUN_MARIADB_SERVER_SSL_KEY_PATH" + ) { + return; + } + + if (!process.env[env_key]) { + throw new Error( + `\`${env_key}\` env variable is required.`, + ); + } + }); + + if (!Config.db_dir) { + throw new Error( + `\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`, + ); + } + + const db_dir = path.resolve(ROOT_DIR, Config.db_dir); + if (!fs.existsSync(db_dir)) { + fs.mkdirSync(db_dir, { recursive: true }); + } + + const loadedSchema = + resolveAndLoadDataFile( + db_dir, + AppData.DbSchemaFileName, ); - if (!loadedConfig) { - console.error( - `Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`, - ); - process.exit(1); - } - - const Config = loadedConfig.data; - - if (!Config || typeof Config !== "object") { - console.error( - `Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`, - ); - process.exit(1); - } - - if (!Config.db_name) { - console.error(`\`db_name\` is required in your config`); - process.exit(1); - } - - RequiredENVs.forEach((env_key) => { - if ( - env_key == "BUN_MARIADB_SERVER_PORT" || - env_key == "BUN_MARIADB_SERVER_SSL_KEY_PATH" - ) { - return; - } - - if (!process.env[env_key]) { - console.error(`\`${env_key}\` env variable is required.`); - process.exit(1); - } - }); - - if (!Config.db_dir) { - console.error( - `\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`, - ); - process.exit(1); - } - - const db_dir = path.resolve(ROOT_DIR, Config.db_dir); - if (!fs.existsSync(db_dir)) { - fs.mkdirSync(db_dir, { recursive: true }); - } - - const loadedSchema = - resolveAndLoadDataFile( - db_dir, - AppData.DbSchemaFileName, - ); - - if (!loadedSchema) { - console.error( - `Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`, - ); - process.exit(1); - } - - const DbSchema = loadedSchema.data; - - if (!DbSchema || typeof DbSchema !== "object") { - console.error( - `Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`, - ); - process.exit(1); - } - - 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 }); - } - - const ExportDir = path.resolve(db_dir, AppData["DefaultExportDirName"]); - if (!fs.existsSync(ExportDir)) { - fs.mkdirSync(ExportDir, { recursive: true }); - } - - global.CONFIG = Config; - global.DB_SCHEMA = DbSchema; - global.CONFIG_FILE_PATH = loadedConfig.path; - global.SCHEMA_FILE_PATH = loadedSchema.path; - - if (!global.CONFIG) { - console.error(`Couldn't grab global Config.`); - process.exit(1); - } - - if (!global.DB_SCHEMA) { - console.error(`Couldn't grab Database Schema.`); - process.exit(1); - } - - setMariaDBClient({ config: Config }); - - if (!global.MARIADB_CLIENT) { - console.error(`Couldn't set MariaDB Client.`); - process.exit(1); - } - } catch (error: any) { - console.error(`Initialization ERROR => ` + error.message); - process.exit(1); + if (!loadedSchema) { + throw new Error( + `Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`, + ); } + + const DbSchema = loadedSchema.data; + + if (!DbSchema || typeof DbSchema !== "object") { + throw new Error( + `Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`, + ); + } + + 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 }); + } + + const ExportDir = path.resolve(db_dir, AppData["DefaultExportDirName"]); + if (!fs.existsSync(ExportDir)) { + fs.mkdirSync(ExportDir, { recursive: true }); + } + + global.CONFIG = Config; + global.DB_SCHEMA = DbSchema; + global.CONFIG_FILE_PATH = loadedConfig.path; + global.SCHEMA_FILE_PATH = loadedSchema.path; + + setMariaDBClient({ config: Config }); +} + +/** + * Initialize once, on first use. + */ +export function ensureInit(): void { + if (global.CONFIG && global.DB_SCHEMA && global.MARIADB_CLIENT !== undefined) { + return; + } + init(); } diff --git a/src/functions/set-mariadb-client.ts b/src/functions/set-mariadb-client.ts index b8e4801..fae773c 100644 --- a/src/functions/set-mariadb-client.ts +++ b/src/functions/set-mariadb-client.ts @@ -6,24 +6,11 @@ type Params = { }; export default function setMariaDBClient({ config }: Params): void { - try { - const MariaDBClient = grabMariaDBClient({ config }); + const MariaDBClient = grabMariaDBClient({ config }); - if (!MariaDBClient) { - throw new Error(`Couldn't grab MariaDB Client`); - } - - global.MARIADB_CLIENT = MariaDBClient; - - // const test = await MariaDBClient.unsafe(`SHOW DATABASES`); - - // if (!test.count) { - // console.error(`MariaDBClient Error: Database not ready.`); - // console.log(test); - // process.exit(1); - // } - } catch (error: any) { - console.error(`ERROR setting MariaDB Client => ` + error.message); - process.exit(1); + if (!MariaDBClient) { + throw new Error(`Couldn't grab MariaDB Client`); } + + global.MARIADB_CLIENT = MariaDBClient; } diff --git a/src/index.ts b/src/index.ts index fcf0cb7..db08514 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import init from "./functions/init"; +import { ensureInit } from "./functions/init"; import DbDelete from "./lib/mariadb/db-delete"; import DbInsert from "./lib/mariadb/db-insert"; import DbSelect from "./lib/mariadb/db-select"; @@ -7,16 +7,25 @@ import DbUpdate from "./lib/mariadb/db-update"; import grabDbSchema from "./utils/grab-db-schema"; import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object"; -init(); +/** + * Initialize lazily on first call, so importing the library never exits or + * throws. Setup errors (missing config / env) reject the call instead. + */ +function withInit Promise>(fn: F): F { + return (async (...args: Parameters) => { + ensureInit(); + return fn(...args); + }) as F; +} const BunMariaDB = { - select: DbSelect, - insert: DbInsert, - update: DbUpdate, - delete: DbDelete, - sql: DbSQL, + select: withInit(DbSelect), + insert: withInit(DbInsert), + update: withInit(DbUpdate), + delete: withInit(DbDelete), + sql: withInit(DbSQL), utils: { - grab_db_schema: grabDbSchema, + grab_db_schema: withInit(grabDbSchema), grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject, }, } as const; diff --git a/src/lib/db-handler.ts b/src/lib/db-handler.ts index 4779adc..ce1adf3 100644 --- a/src/lib/db-handler.ts +++ b/src/lib/db-handler.ts @@ -1,38 +1,78 @@ -import grabMariaDBClient from "../functions/grab-mariadb-client"; +import grabMariaDBClient, { + mariadbClientCacheKey, +} from "../functions/grab-mariadb-client"; import type { BunMariaDBConfig, DBInsertReturn, DBResponseObject, } from "../types"; +/** Anything that can run a raw query: a client or a transaction handle */ +export type DBQueryRunner = { + unsafe: (query: string, values?: any[]) => Promise; +}; + type Param = { query: string; values?: any[]; config?: BunMariaDBConfig; + /** Run on this client / transaction instead of the shared client */ + client?: DBQueryRunner; }; /** * Resolve a shared MariaDB client. Prefer the global client so schema sync * and CRUD do not open a new connection per query. */ -function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined { - const resolvedConfig = config || global.CONFIG; +export function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined { + if (!config) { + return global.MARIADB_CLIENT; + } - // Same database as the global client → reuse it + // Same connection config as the global client → reuse it if ( global.MARIADB_CLIENT && - (!resolvedConfig?.db_name || - !global.CONFIG?.db_name || - resolvedConfig.db_name === global.CONFIG.db_name) + (!global.CONFIG || + mariadbClientCacheKey(config) === + mariadbClientCacheKey(global.CONFIG)) ) { return global.MARIADB_CLIENT; } - if (resolvedConfig) { - return grabMariaDBClient({ config: resolvedConfig }); + return grabMariaDBClient({ config }); +} + +/** + * Convert driver rows to plain objects. + * - BigInt -> number (or string when outside the safe integer range) + * - Date -> ISO string (matches the generated `string` typedefs) + * - Buffers / typed arrays (BLOB, VECTOR) are kept as-is + */ +function normalizeValue(value: unknown): unknown { + if (typeof value == "bigint") { + const num = Number(value); + return Number.isSafeInteger(num) ? num : value.toString(); } - return global.MARIADB_CLIENT; + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value.toISOString(); + } + + return value; +} + +function normalizeRows(res: unknown): T[] | undefined { + if (!Array.isArray(res)) return undefined; + + return res.map((row) => { + if (!row || typeof row != "object") return row; + + const normalized: Record = {}; + for (const [key, value] of Object.entries(row)) { + normalized[key] = normalizeValue(value); + } + return normalized; + }) as T[]; } /** @@ -40,9 +80,9 @@ function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined { */ export default async function dbHandler< T extends { [k: string]: any } = { [k: string]: any }, ->({ query, values, config }: Param): Promise> { +>({ query, values, config, client }: Param): Promise> { try { - const CLIENT = resolveClient(config); + const CLIENT = client || resolveClient(config); if (!CLIENT) { throw new Error(`Couldn't grab MariaDB Client.`); @@ -52,13 +92,7 @@ export default async function dbHandler< const count = res.count; - const res_array = (() => { - try { - return JSON.parse(JSON.stringify(res)) as T[]; - } catch (error) { - return undefined; - } - })(); + const res_array = normalizeRows(res); const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined; const affected_rows = res_array?.[0] ? res_array.length : undefined; diff --git a/src/lib/grab-duplicate-safe-insert-sql.ts b/src/lib/grab-duplicate-safe-insert-sql.ts index 501cca0..86137e1 100644 --- a/src/lib/grab-duplicate-safe-insert-sql.ts +++ b/src/lib/grab-duplicate-safe-insert-sql.ts @@ -4,9 +4,7 @@ type Params = { data: any | any[]; }; -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; -} +import { quoteIdentifier } from "../utils/sql-identifier"; export default async function ({ sql: passedSql, table, data }: Params) { const config = global.CONFIG; diff --git a/src/lib/mariadb/db-delete.ts b/src/lib/mariadb/db-delete.ts index 18d9440..8ac5455 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, @@ -33,27 +30,15 @@ export default async function DbDelete< let sqlObj: ReturnType | null = null; try { - let finalQuery = query || {}; - - if (targetId) { - finalQuery = _.merge, ServerQueryParam>( - finalQuery, - { - query: { - id: { - value: Number(targetId), - }, - }, - }, - ); - } + const finalQuery = query || {}; sqlObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, }); - const whereClause = sqlObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlObj.where; if (!whereClause) { return { @@ -68,6 +53,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..e7e101c 100644 --- a/src/lib/mariadb/db-select.ts +++ b/src/lib/mariadb/db-select.ts @@ -1,5 +1,4 @@ import dbHandler from "../db-handler"; -import _ from "lodash"; import type { BunMariaDBConfig, DBResponseObject, @@ -18,10 +17,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, @@ -35,24 +30,12 @@ export default async function DbSelect< let sqlObj: ReturnType | null = null; try { - let finalQuery = query || {}; - - if (targetId) { - finalQuery = _.merge, ServerQueryParam>( - finalQuery, - { - query: { - id: { - value: Number(targetId), - }, - }, - }, - ); - } + const finalQuery = query || {}; sqlObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, }); const res = await dbHandler({ @@ -87,8 +70,9 @@ export default async function DbSelect< if (count) { const countSqlObject = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, count, }); diff --git a/src/lib/mariadb/db-sql.ts b/src/lib/mariadb/db-sql.ts index 40a99e9..6513ede 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) { @@ -35,13 +41,10 @@ export default async function DbSQL< const payload = isSelect ? ((res.payload || []) as T[]) : undefined; const single_res = isSelect ? payload?.[0] : (res.single_res as T); - const singleRaw = res.single_res as any; return { ...res, - success: isSelect - ? Boolean(single_res) || Boolean(payload?.[0]) - : true, + success: true, payload, single_res, debug: { diff --git a/src/lib/mariadb/db-update.ts b/src/lib/mariadb/db-update.ts index 6c78e99..22c7f7e 100644 --- a/src/lib/mariadb/db-update.ts +++ b/src/lib/mariadb/db-update.ts @@ -1,5 +1,4 @@ -import dbHandler from "../db-handler"; -import _ from "lodash"; +import dbHandler, { resolveClient } from "../db-handler"; import type { BunMariaDBConfig, DBResponseObject, @@ -8,6 +7,9 @@ import type { } from "../../types"; import sqlGenerator from "../../utils/sql-generator"; import sanitizeHtmlFields from "../../utils/sanitize-html-fields"; +import { quoteIdentifier } from "../../utils/sql-identifier"; +import sqlValuePlaceholder from "../../utils/sql-value-placeholder"; +import grabTableFields from "../../utils/grab-table-fields"; type Params< Schema extends { [k: string]: any } = { [k: string]: any }, @@ -20,8 +22,11 @@ type Params< config?: BunMariaDBConfig; }; -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; +/** Thrown inside the transaction to roll back and return a response */ +class UpdateAbort extends Error { + constructor(public response: DBResponseObject) { + super(response.msg || response.error || "Update aborted"); + } } export default async function DbUpdate< @@ -34,30 +39,23 @@ 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 || {}; - - if (targetId) { - finalQuery = _.merge, ServerQueryParam>( - finalQuery, - { - query: { - id: { - value: Number(targetId), - }, - }, - }, - ); - } + const finalQuery = query || {}; const sqlQueryObj = sqlGenerator({ - tableName: quoteIdentifier(table), + tableName: table, genObject: finalQuery, + targetId, }); - const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0]; + const whereClause = sqlQueryObj.where; if (!whereClause) { return { @@ -66,73 +64,110 @@ export default async function DbUpdate< }; } - let values: SQLInsertGenValueType[] = []; - let sql = ``; - sql += `UPDATE ${quoteIdentifier(table)} SET`; - const sanitizedData = sanitizeHtmlFields({ table, data, config, }); - const finalData: { [k: string]: SQLInsertGenValueType } = { + const finalData: { [k: string]: any } = { updated_at: Date.now(), ...sanitizedData, }; - const keys = Object.keys(finalData); + const fieldsMap = new Map( + grabTableFields(table).map((f) => [f.fieldName, f]), + ); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - if (!key) continue; + const setParts: string[] = []; + const setValues: SQLInsertGenValueType[] = []; - const isLast = i == keys.length - 1; + for (const key of Object.keys(finalData)) { + const { placeholder, value } = sqlValuePlaceholder({ + value: finalData[key], + field: fieldsMap.get(key), + }); - sql += ` ${quoteIdentifier(key)}=?`; - values.push(finalData[key] ?? null); + setParts.push(`${quoteIdentifier(key)}=${placeholder}`); + setValues.push(value); + } - if (!isLast) { - sql += `,`; + const CLIENT = resolveClient(config); + + if (!CLIENT) { + throw new Error(`Couldn't grab MariaDB Client.`); + } + + /** + * Lock the matching rows, update them by id, and re-fetch them by id, + * all in one transaction. Re-fetching by id still works when the update + * changes a column used in the WHERE clause. + */ + return await CLIENT.begin(async (tx) => { + const matched_res = await dbHandler<{ id: number }>({ + query: `SELECT id FROM ${quoteIdentifier(table)} ${whereClause} FOR UPDATE`, + values: sqlQueryObj.whereValues as any, + client: tx, + }); + + if (!matched_res.success) { + throw new UpdateAbort({ + ...matched_res, + success: false, + debug: { sqlObj: sqlQueryObj }, + }); } - } - sql += ` ${whereClause}`; - values = [...values, ...sqlQueryObj.values]; + const matched_ids = (matched_res.payload || []) + .map((row) => row.id) + .filter((id) => id !== undefined && id !== null); - const res = await dbHandler({ - query: sql, - values: values as any, - config, + if (matched_ids.length === 0) { + throw new UpdateAbort({ + success: false, + msg: "No rows matched the update query", + payload: [], + debug: { sqlObj: sqlQueryObj }, + }); + } + + const idPlaceholders = matched_ids.map(() => "?").join(","); + + const sql = `UPDATE ${quoteIdentifier(table)} SET ${setParts.join(", ")} WHERE id IN (${idPlaceholders})`; + const values = [...setValues, ...matched_ids]; + + sqlObj.string = sql; + sqlObj.values = values; + + const res = await dbHandler({ + query: sql, + values: values as any, + client: tx, + }); + + if (!res.success) { + throw new UpdateAbort({ ...res, debug: { sqlObj } }); + } + + const updated_res = await dbHandler({ + query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`, + values: matched_ids, + client: tx, + }); + + return { + ...updated_res, + debug: { + sqlObj, + }, + db_res: res.db_res, + } as DBResponseObject; }); - - 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]; - - const updated_res = await dbHandler({ - query: updated_sql, - values: updated_sql_values, - config, - }); - - return { - ...updated_res, - debug: { - sqlObj, - }, - db_res: res.db_res, - }; } catch (error: any) { + if (error instanceof UpdateAbort) { + return error.response; + } + return { success: false, error: error.message, diff --git a/src/lib/schema/get-table-columns-gemnini.ts b/src/lib/schema/get-table-columns-gemnini.ts deleted file mode 100644 index dedbc03..0000000 --- a/src/lib/schema/get-table-columns-gemnini.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { BunMariaDBConfig } from "../../types"; -import { querySchemaRows } from "./run-schema-query"; -import schemaCondition from "./schema-condition"; - -export type ColumnInfoRow = { - name: string; - type: string; - comment?: string; - isNullable: boolean; - columnDefault: string | null; - extra: string; -}; - -export default async function getTableColumnsGemini({ - tableName, - config, -}: { - tableName: string; - config?: BunMariaDBConfig; -}): Promise { - const schemaCond = schemaCondition(config); - const rows = await querySchemaRows<{ - COLUMN_NAME: string; - COLUMN_TYPE: string; - COLUMN_COMMENT: string; - IS_NULLABLE: string; - COLUMN_DEFAULT: string | null; - EXTRA: string; - IS_JSON: number | boolean; - }>({ - query: ` - SELECT - c.COLUMN_NAME, - c.COLUMN_TYPE, - c.COLUMN_COMMENT, - c.IS_NULLABLE, - c.COLUMN_DEFAULT, - c.EXTRA, - EXISTS ( - SELECT 1 - FROM information_schema.CHECK_CONSTRAINTS cc - WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA - AND cc.TABLE_NAME = c.TABLE_NAME - AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%') - ) AS IS_JSON - FROM information_schema.COLUMNS c - WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")} - AND c.TABLE_NAME = ? - ORDER BY c.ORDINAL_POSITION - `, - values: [...schemaCond.values, tableName], - config, - }); - - return rows.map((row) => { - const isJson = Number(row.IS_JSON) === 1; - - // Normalize longtext with a json_valid constraint to "json" - let resolvedType = row.COLUMN_TYPE; - if (isJson && row.COLUMN_TYPE.toLowerCase() === "longtext") { - resolvedType = "json"; - } - - return { - name: row.COLUMN_NAME, - type: resolvedType, - comment: row.COLUMN_COMMENT, - isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", - columnDefault: row.COLUMN_DEFAULT, - extra: row.EXTRA || "", - }; - }); -} diff --git a/src/lib/schema/get-table-columns.ts b/src/lib/schema/get-table-columns.ts index 5c07781..4d5d27e 100644 --- a/src/lib/schema/get-table-columns.ts +++ b/src/lib/schema/get-table-columns.ts @@ -11,6 +11,9 @@ export type ColumnInfoRow = { extra: string; }; +/** + * Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`. + */ export default async function getTableColumns({ tableName, config, @@ -26,18 +29,48 @@ export default async function getTableColumns({ IS_NULLABLE: string; COLUMN_DEFAULT: string | null; EXTRA: string; + IS_JSON: number | boolean; }>({ - query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, + query: ` + SELECT + c.COLUMN_NAME, + c.COLUMN_TYPE, + c.COLUMN_COMMENT, + c.IS_NULLABLE, + c.COLUMN_DEFAULT, + c.EXTRA, + EXISTS ( + SELECT 1 + FROM information_schema.CHECK_CONSTRAINTS cc + WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA + AND cc.TABLE_NAME = c.TABLE_NAME + AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%') + ) AS IS_JSON + FROM information_schema.COLUMNS c + WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")} + AND c.TABLE_NAME = ? + ORDER BY c.ORDINAL_POSITION + `, values: [...schemaCond.values, tableName], config, }); - return rows.map((row) => ({ - name: row.COLUMN_NAME, - type: row.COLUMN_TYPE, - comment: row.COLUMN_COMMENT, - isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", - columnDefault: row.COLUMN_DEFAULT, - extra: row.EXTRA || "", - })); + return rows.map((row) => { + const isJson = Number(row.IS_JSON) === 1; + + // Normalize longtext with a json_valid constraint to "json" + let resolvedType = row.COLUMN_TYPE; + if (isJson && row.COLUMN_TYPE.toLowerCase() === "longtext") { + resolvedType = "json"; + } + + return { + name: row.COLUMN_NAME, + type: resolvedType, + comment: row.COLUMN_COMMENT, + isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", + columnDefault: row.COLUMN_DEFAULT, + extra: row.EXTRA || "", + }; + }); } diff --git a/src/lib/schema/mariadb-quote-gen.ts b/src/lib/schema/mariadb-quote-gen.ts index bd7d104..fd155d2 100644 --- a/src/lib/schema/mariadb-quote-gen.ts +++ b/src/lib/schema/mariadb-quote-gen.ts @@ -1,3 +1,5 @@ +import { quoteIdentifier } from "../../utils/sql-identifier"; + export default function MariaDBQuoteGen(str: string) { - return `\`${str.replace(/`/g, "``")}\``; + return quoteIdentifier(str); } diff --git a/src/lib/schema/run-schema-query.ts b/src/lib/schema/run-schema-query.ts index 018fe22..5a4ae5c 100644 --- a/src/lib/schema/run-schema-query.ts +++ b/src/lib/schema/run-schema-query.ts @@ -1,6 +1,62 @@ import type { BunMariaDBConfig } from "../../types"; import dbHandler from "../db-handler"; +type SchemaRunState = { + /** Record statements instead of executing them */ + dryRun: boolean; + statements: string[]; + /** + * Destructive statements that may run. `true` allows all of them; + * otherwise only exact matches are allowed. + */ + allowDestructive: true | Set; +}; + +let schemaRunState: SchemaRunState | null = null; + +/** + * Statements that discard stored data (tables or column values). + */ +export function isDestructiveSchemaStatement(query: string): boolean { + return /^\s*DROP\s+TABLE\b/i.test(query) || /\bDROP\s+COLUMN\b/i.test(query); +} + +/** + * Run `fn` with schema statements recorded but not executed. + * Reads (information_schema) still run, so the plan is a close approximation: + * steps that depend on earlier changes (e.g. after a table rename) may differ. + */ +export async function planSchemaQueries( + fn: () => Promise, +): Promise { + const previous = schemaRunState; + schemaRunState = { dryRun: true, statements: [], allowDestructive: true }; + + try { + await fn(); + return schemaRunState.statements; + } finally { + schemaRunState = previous; + } +} + +/** + * Run `fn`, refusing any destructive statement that was not approved. + */ +export async function runSchemaQueriesGuarded( + fn: () => Promise, + allowDestructive: true | Set, +): Promise { + const previous = schemaRunState; + schemaRunState = { dryRun: false, statements: [], allowDestructive }; + + try { + await fn(); + } finally { + schemaRunState = previous; + } +} + export default async function runSchemaQuery({ query, values, @@ -10,6 +66,25 @@ export default async function runSchemaQuery({ values?: any[]; config?: BunMariaDBConfig; }): Promise { + if (schemaRunState) { + schemaRunState.statements.push(query); + + if (schemaRunState.dryRun) { + return; + } + + const allowed = schemaRunState.allowDestructive; + if ( + allowed !== true && + isDestructiveSchemaStatement(query) && + !allowed.has(query) + ) { + throw new Error( + `Refusing unapproved destructive statement: ${query}. Re-run with --dry-run to review, or --yes to allow it.`, + ); + } + } + const res = await dbHandler({ query, values, diff --git a/src/lib/schema/update-table.ts b/src/lib/schema/update-table.ts index 84169ec..31ce9aa 100644 --- a/src/lib/schema/update-table.ts +++ b/src/lib/schema/update-table.ts @@ -8,7 +8,6 @@ import buildColumnDefinition, { } from "./build-column-definition"; import createTable from "./create-table"; import getTableColumns, { type ColumnInfoRow } from "./get-table-columns"; -import getTableColumnsGemini from "./get-table-columns-gemnini"; import isVectorField from "./is-vector-field"; import mapDataType from "./map-data-types"; import MariaDBQuoteGen from "./mariadb-quote-gen"; @@ -278,7 +277,7 @@ export default async function updateTable({ table: BUN_MARIADB_TableSchemaType; config?: BunMariaDBConfig; }): Promise { - const existingColumns = await getTableColumnsGemini({ + const existingColumns = await getTableColumns({ tableName: table.tableName, 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/grab-table-fields.ts b/src/utils/grab-table-fields.ts new file mode 100644 index 0000000..dd8112e --- /dev/null +++ b/src/utils/grab-table-fields.ts @@ -0,0 +1,19 @@ +import type { + BUN_MARIADB_DatabaseSchemaType, + BUN_MARIADB_FieldSchemaType, +} from "../types"; +import { readLiveSchema } from "../functions/live-schema"; + +/** + * Grab a table's schema fields from the loaded (or last synced) schema. + */ +export default function grabTableFields( + tableName: string, +): BUN_MARIADB_FieldSchemaType[] { + const dbSchema: BUN_MARIADB_DatabaseSchemaType | undefined = + global.DB_SCHEMA || readLiveSchema(); + + const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName); + + return tableSchema?.fields || []; +} diff --git a/src/utils/sanitize-html-fields.ts b/src/utils/sanitize-html-fields.ts index e83c34c..e77827b 100644 --- a/src/utils/sanitize-html-fields.ts +++ b/src/utils/sanitize-html-fields.ts @@ -1,20 +1,7 @@ import sanitizeHtml from "sanitize-html"; -import type { - BUN_MARIADB_DatabaseSchemaType, - BUN_MARIADB_FieldSchemaType, - BunMariaDBConfig, -} from "../types"; -import { readLiveSchema } from "../functions/live-schema"; +import type { BUN_MARIADB_FieldSchemaType, BunMariaDBConfig } from "../types"; import getSanitizeHtmlOptions from "./sanitize-html-options"; - -function grabTableFields(tableName: string): BUN_MARIADB_FieldSchemaType[] { - const dbSchema: BUN_MARIADB_DatabaseSchemaType | undefined = - global.DB_SCHEMA || readLiveSchema(); - - const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName); - - return tableSchema?.fields || []; -} +import grabTableFields from "./grab-table-fields"; /** * Only fields with an explicit `html: true` flag are sanitized. 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..e452c78 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 { @@ -116,6 +122,11 @@ export default function sqlGenOperatorGen({ str: `${fieldName} IS NOT ${finalValue}`, param: finalParams, }; + } else if (equality == "IN" || equality == "NOT IN") { + return { + str: `${fieldName} ${equality} (${finalValue})`, + param: finalParams, + }; } else if (equality) { return { str: `${fieldName} ${sqlEqualityParser( @@ -137,12 +148,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..23a97e8 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,98 +29,129 @@ 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)) { + const listEquality = + queryObj.equality == "IN" || queryObj.equality == "NOT IN" + ? queryObj.equality + : undefined; + + if ( + listEquality && + !queryObj.nullValue && + !queryObj.notNullValue && + queryObj.value !== undefined && + queryObj.value !== null + ) { + const rawValues = Array.isArray(queryObj.value) + ? queryObj.value + : [queryObj.value]; + + const listValues = rawValues + .map((val) => + val !== null && typeof val == "object" ? val.value : val, + ) + .filter( + (val): val is string | number => + val !== undefined && val !== null, + ); + + if (listValues.length === 0) { + // Empty IN () is invalid SQL: IN matches nothing, NOT IN matches everything + str = listEquality == "IN" ? "1 = 0" : "1 = 1"; + } else { + str = `${finalFieldName} ${listEquality} (${listValues.map(() => "?").join(",")})`; + sqlSearhValues.push(...listValues); + } + } else if (Array.isArray(queryObj.value)) { const strArray: string[] = []; 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..14a4679 100644 --- a/src/utils/sql-generator.ts +++ b/src/utils/sql-generator.ts @@ -5,36 +5,68 @@ 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; + /** + * Restrict to a single row id. Always combined with AND, regardless of + * `searchOperator`. + */ + targetId?: number | string; }; 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, + targetId, +}: 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 +78,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 +86,6 @@ export default function sqlGenerator< genObject, }); - sqlSearhValues.push(...values); - const sqlSearhString = queryKeys?.map((field) => { const queryObj = finalQuery?.[field]; if (!queryObj) return; @@ -64,26 +94,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 +129,7 @@ export default function sqlGenerator< table_name: tableName, }); - sqlSearhValues.push(...values); + whereValues.push(...values); return str; }); @@ -107,272 +141,116 @@ export default function sqlGenerator< const isSearchStr = cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str); + const whereConditions: string[] = []; + if (isSearchStr) { - const stringOperator = genObject?.searchOperator || "AND"; - queryString += ` WHERE ${cleanedUpSearchStr.join( - ` ${stringOperator} `, - )}`; + const stringOperator = sqlLogicalOperator(genObject?.searchOperator); + whereConditions.push( + `(${cleanedUpSearchStr.join(` ${stringOperator} `)})`, + ); } if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) { - queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`; - sqlSearhValues.push(fullTextSearchStr); + whereConditions.push(fullTextMatchStr.trim()); + whereValues.push(fullTextSearchStr); + } + + if (targetId !== undefined && targetId !== null && targetId !== "") { + const id = Number(targetId); + if (!Number.isFinite(id)) { + throw new Error(`Invalid targetId: ${String(targetId)}`); + } + whereConditions.push( + `${columnRef("id", genObject?.join ? tableName : undefined)} = ?`, + ); + whereValues.push(id); + } + + const where = whereConditions.length + ? `WHERE ${whereConditions.join(" AND ")}` + : ``; + + 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, }; } - -// let queryString = (() => { -// let str = "SELECT"; - -// if (genObject?.select_sql) { -// str += ` ${genObject.select_sql}`; -// } else if (genObject?.selectFields?.[0]) { -// if (genObject.join) { -// str += sqlGenGrabSelectFieldSQL({ -// selectFields: genObject.selectFields, -// append_table_names: true, -// table_name: tableName, -// }); -// } else { -// str += sqlGenGrabSelectFieldSQL({ -// selectFields: genObject.selectFields, -// table_name: tableName, -// }); -// } -// } else { -// if (genObject?.join) { -// str += ` ${tableName}.*`; -// } else { -// str += " *"; -// } -// } - -// if (genObject?.countSubQueries) { -// let countSqls: string[] = []; - -// for (let i = 0; i < genObject.countSubQueries.length; i++) { -// const countSubQuery = genObject.countSubQueries[i]; -// if (!countSubQuery) continue; - -// const tableAlias = countSubQuery.table_alias; - -// let subQStr = `(SELECT COUNT(*)`; - -// subQStr += ` FROM ${countSubQuery.table}${ -// tableAlias ? ` ${tableAlias}` : "" -// }`; - -// subQStr += ` WHERE (`; - -// for (let j = 0; j < countSubQuery.srcTrgMap.length; j++) { -// const csqSrc = countSubQuery.srcTrgMap[j]; -// if (!csqSrc) continue; - -// subQStr += ` ${tableAlias || countSubQuery.table}.${ -// 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}`; -// } - -// if (j < countSubQuery.srcTrgMap.length - 1) { -// subQStr += ` AND `; -// } -// } - -// subQStr += ` )) AS ${countSubQuery.alias}`; -// countSqls.push(subQStr); -// } - -// str += `, ${countSqls.join(",")}`; -// } - -// if (genObject?.join) { -// const existingJoinTableNames: string[] = [tableName]; - -// str += -// "," + -// genObject.join -// .flat() -// .filter((j) => !isUndefined(j)) -// .map((joinObj) => { -// const joinTableName = joinObj.alias -// ? joinObj.alias -// : joinObj.tableName; - -// if (existingJoinTableNames.includes(joinTableName)) -// return null; -// existingJoinTableNames.push(joinTableName); - -// if (joinObj.group_concat) { -// return sqlGenGrabConcatStr({ -// field: `${joinTableName}.${joinObj.group_concat.field}`, -// alias: joinObj.group_concat.alias, -// separator: joinObj.group_concat.separator, -// }); -// } else if (joinObj.selectFields) { -// return joinObj.selectFields -// .map((selectField) => { -// if (typeof selectField == "string") { -// return `${joinTableName}.${selectField}`; -// } else if (typeof selectField == "object") { -// let aliasSelectField = selectField.count -// ? `COUNT(${joinTableName}.${selectField.field})` -// : `${joinTableName}.${selectField.field}`; -// if (selectField.alias) -// aliasSelectField += ` AS ${selectField.alias}`; -// return aliasSelectField; -// } -// }) -// .join(","); -// } else { -// return `${joinTableName}.*`; -// } -// }) -// .filter((_) => Boolean(_)) -// .join(","); -// } - -// if ( -// genObject?.fullTextSearch && -// fullTextMatchStr && -// fullTextSearchStr -// ) { -// str += `, ${fullTextMatchStr} AS ${genObject.fullTextSearch.scoreAlias}`; -// sqlSearhValues.push(fullTextSearchStr); -// } - -// str += ` FROM ${tableName}`; - -// if (genObject?.join) { -// str += -// " " + -// genObject.join -// .flat() -// .filter((j) => !isUndefined(j)) -// .map((join) => { -// return ( -// join.joinType + -// " " + -// (join.alias -// ? `${join.tableName}` + " " + join.alias -// : `${join.tableName}`) + -// " ON " + -// (() => { -// if (Array.isArray(join.match)) { -// return ( -// "(" + -// join.match -// .map((mtch) => -// sqlGenGenJoinStr({ -// mtch, -// join, -// table_name: tableName, -// }), -// ) -// .join( -// join.operator -// ? ` ${join.operator} ` -// : " AND ", -// ) + -// ")" -// ); -// } else if (typeof join.match == "object") { -// return sqlGenGenJoinStr({ -// mtch: join.match, -// join, -// table_name: tableName, -// }); -// } -// })() -// ); -// }) -// .join(" "); -// } - -// return str; -// })(); 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..89d32ad 100644 --- a/src/utils/sql-insert-generator.ts +++ b/src/utils/sql-insert-generator.ts @@ -3,10 +3,9 @@ import type { SQLInsertGenReturn, SQLInsertGenValueType, } from "../types"; - -function quoteIdentifier(identifier: string): string { - return `\`${identifier.replace(/`/g, "``")}\``; -} +import { quoteIdentifier } from "./sql-identifier"; +import sqlValuePlaceholder from "./sql-value-placeholder"; +import grabTableFields from "./grab-table-fields"; /** * # SQL Insert Generator @@ -31,6 +30,10 @@ export default function sqlInsertGenerator({ }); }); + const fieldsMap = new Map( + grabTableFields(tableName).map((f) => [f.fieldName, f]), + ); + let queryBatches: string[] = []; let queryValues: SQLInsertGenValueType[] = []; @@ -38,41 +41,13 @@ export default function sqlInsertGenerator({ queryBatches.push( `(${insertKeys .map((ky) => { - const value = item[ky]; - - const is_embedding = - Array.isArray(value) && - typeof value[0] == "number"; - - const finalValue = - typeof value == "string" || - typeof value == "number" - ? value - : typeof value == "function" - ? value().value - : is_embedding - ? JSON.stringify(value) - : value - ? value - : null; - - if (!finalValue && typeof value !== "number") { - queryValues.push(null); - return "?"; - } - - queryValues.push(finalValue); - - const placeholder = - typeof value == "function" - ? value().placeholder - : is_embedding - ? "VEC_FromText(?)" - : "?"; - + const { placeholder, value } = sqlValuePlaceholder({ + value: item[ky], + field: fieldsMap.get(ky), + }); + queryValues.push(value); return placeholder; }) - .filter((k) => Boolean(k)) .join(",")})`, ); }); diff --git a/src/utils/sql-value-placeholder.ts b/src/utils/sql-value-placeholder.ts new file mode 100644 index 0000000..1baaa37 --- /dev/null +++ b/src/utils/sql-value-placeholder.ts @@ -0,0 +1,57 @@ +import type { + BUN_MARIADB_FieldSchemaType, + SQLInsertGenValueType, +} from "../types"; + +type Return = { + placeholder: string; + value: SQLInsertGenValueType; +}; + +function isTextField(field?: BUN_MARIADB_FieldSchemaType): boolean { + if (!field || field.isVector) return false; + return /char|text|enum|set|json/i.test(String(field.dataType || "")); +} + +/** + * Map a JS value to a bound parameter + placeholder for INSERT / UPDATE. + * + * - `false` / `true` -> `0` / `1` + * - number arrays (embeddings) -> `VEC_FromText(?)` with a JSON string + * - `{ value, placeholder }` factories are called once + * - `""` stays `""` for text columns and becomes `NULL` for everything else + * (e.g. the `number | ""` typedefs for numeric columns) + */ +export default function sqlValuePlaceholder({ + value, + field, +}: { + value: any; + field?: BUN_MARIADB_FieldSchemaType; +}): Return { + if (typeof value == "function") { + const fnValue = value(); + return { + placeholder: fnValue?.placeholder || "?", + value: fnValue?.value ?? null, + }; + } + + if (typeof value == "boolean") { + return { placeholder: "?", value: value ? 1 : 0 }; + } + + if (value === undefined || value === null) { + return { placeholder: "?", value: null }; + } + + if (value === "") { + return { placeholder: "?", value: isTextField(field) ? "" : null }; + } + + if (Array.isArray(value) && typeof value[0] == "number") { + return { placeholder: "VEC_FromText(?)", value: JSON.stringify(value) }; + } + + return { placeholder: "?", value }; +} 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++) { diff --git a/tests/db.test.ts b/tests/db.test.ts new file mode 100644 index 0000000..ee29b30 --- /dev/null +++ b/tests/db.test.ts @@ -0,0 +1,149 @@ +/** + * Integration tests against a real MariaDB. + * + * Skipped unless BUN_MARIADB_TEST_DB is set. Uses the usual + * BUN_MARIADB_SERVER_* env vars to connect. Point BUN_MARIADB_TEST_DB at a + * throwaway database: the tests create and drop tables in it. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { BUN_MARIADB_DatabaseSchemaType } from "../src/types"; + +const TEST_DB = process.env.BUN_MARIADB_TEST_DB; + +const schema: BUN_MARIADB_DatabaseSchemaType = { + tables: [ + { + tableName: "bmt_items", + fields: [ + { fieldName: "name", dataType: "VARCHAR" }, + { fieldName: "qty", dataType: "INT" }, + { fieldName: "active", dataType: "TINYINT" }, + { fieldName: "body", dataType: "TEXT", html: true }, + ], + }, + ], +} as BUN_MARIADB_DatabaseSchemaType; + +describe.skipIf(!TEST_DB)("MariaDB integration", () => { + let DB: typeof import("../src/index").default; + let schemaLib: typeof import("../src/lib/schema/run-schema-query"); + let createDBSchema: typeof import("../src/lib/schema/create-db-schema").default; + let appendDefaults: typeof import("../src/utils/append-default-fields-to-db-schema").default; + + beforeAll(async () => { + const setMariaDBClient = (await import("../src/functions/set-mariadb-client")).default; + createDBSchema = (await import("../src/lib/schema/create-db-schema")).default; + appendDefaults = (await import("../src/utils/append-default-fields-to-db-schema")).default; + schemaLib = await import("../src/lib/schema/run-schema-query"); + + global.CONFIG = { db_name: TEST_DB! }; + global.DB_SCHEMA = schema; + setMariaDBClient({ config: global.CONFIG }); + + DB = (await import("../src/index")).default; + + await createDBSchema({ + db_schema: appendDefaults({ dbSchema: schema }), + config: global.CONFIG, + }); + await DB.sql({ sql: "DELETE FROM bmt_items" }); + + const res = await DB.insert({ + table: "bmt_items", + data: [ + { name: "a", qty: 0, active: false, body: '

hi

' }, + { name: "b", qty: 5, active: true, body: "" }, + { name: "c", qty: 0, active: true }, + ], + }); + expect(res.success).toBe(true); + }); + + afterAll(async () => { + await DB?.sql({ sql: "DROP TABLE IF EXISTS bmt_items" }); + await DB?.sql({ + sql: "DELETE FROM __db_schema_manager__ WHERE table_name = 'bmt_items'", + }); + await global.MARIADB_CLIENT?.close(); + }); + + test("insert stores false as 0, keeps '' for text, sanitizes html fields", async () => { + const { payload } = await DB.select({ table: "bmt_items", query: { order: { field: "id", strategy: "ASC" } } }); + expect(payload?.map((r) => r.active)).toEqual([0, 1, 1]); + expect(payload?.[0].body).toBe("

hi

b"); + expect(payload?.[1].body).toBe(""); + expect(typeof payload?.[0].id).toBe("number"); + }); + + test("select matches 0", async () => { + const res = await DB.select({ table: "bmt_items", query: { query: { qty: { value: 0 } } } }); + expect(res.payload?.map((r) => r.name).sort()).toEqual(["a", "c"]); + }); + + test("select with IN and paged count", async () => { + const res = await DB.select({ + table: "bmt_items", + count: true, + query: { query: { name: { value: ["a", "b"], equality: "IN" } }, limit: 1, offset: 1 }, + }); + expect(res.payload?.length).toBe(1); + expect(res.count).toBe(2); + }); + + test("sql() reports success for empty selects", async () => { + const res = await DB.sql({ sql: "SELECT * FROM bmt_items WHERE name = ?", values: ["nope"] }); + expect(res.success).toBe(true); + expect(res.payload).toEqual([]); + }); + + test("update returns rows even when the WHERE column changes", async () => { + const res = await DB.update({ table: "bmt_items", data: { qty: 9 }, query: { query: { qty: { value: 0 } } } }); + expect(res.success).toBe(true); + expect(res.payload?.map((r: any) => r.qty)).toEqual([9, 9]); + }); + + test("update with no matches fails", async () => { + const res = await DB.update({ table: "bmt_items", data: { qty: 1 }, query: { query: { name: { value: "zzz" } } } }); + expect(res.success).toBe(false); + }); + + test("delete with targetId is ANDed with an OR query", async () => { + const { payload } = await DB.select({ table: "bmt_items", query: { query: { name: { value: "b" } } } }); + const idB = payload![0].id; + + const res = await DB.delete({ + table: "bmt_items", + targetId: idB, + query: { query: { name: { value: "a" }, qty: { value: 9 } }, searchOperator: "OR" }, + }); + // Row "b" (qty 5) matches neither condition, so nothing is deleted + expect(res.success).toBe(false); + + const remaining = await DB.select({ table: "bmt_items" }); + expect(remaining.payload?.length).toBe(3); + }); + + test("delete refuses an empty WHERE clause", async () => { + const res = await DB.delete({ table: "bmt_items", query: {} }); + expect(res.success).toBe(false); + expect(res.msg).toBe("No WHERE clause"); + }); + + test("schema plan flags dropped columns, guarded run refuses them", async () => { + const reduced = { + tables: [{ ...schema.tables[0]!, fields: schema.tables[0]!.fields.filter((f) => f.fieldName !== "body") }], + } as BUN_MARIADB_DatabaseSchemaType; + const params = { db_schema: appendDefaults({ dbSchema: reduced }), config: global.CONFIG }; + + const plan = await schemaLib.planSchemaQueries(() => createDBSchema(params)); + const destructive = plan.filter(schemaLib.isDestructiveSchemaStatement); + expect(destructive).toEqual(["ALTER TABLE `bmt_items` DROP COLUMN `body`"]); + + await expect( + schemaLib.runSchemaQueriesGuarded(() => createDBSchema(params), new Set()), + ).rejects.toThrow(/unapproved destructive/); + + const { payload } = await DB.select({ table: "bmt_items", query: { limit: 1 } }); + expect(payload?.[0]).toHaveProperty("body"); + }); +}); diff --git a/tests/sql-generator.test.ts b/tests/sql-generator.test.ts new file mode 100644 index 0000000..8696e9e --- /dev/null +++ b/tests/sql-generator.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, test } from "bun:test"; +import sqlGenerator from "../src/utils/sql-generator"; + +const gen = (genObject: any, extra: Record = {}) => + sqlGenerator({ tableName: "users", genObject, ...extra }); + +describe("identifier quoting", () => { + test("quotes table and field names", () => { + const { string, values } = gen({ query: { email: { value: "a@b.c" } } }); + expect(string).toBe("SELECT * FROM `users` WHERE (`email` = ?)"); + expect(values).toEqual(["a@b.c"]); + }); + + test("injected field names stay inside the identifier", () => { + const { string } = gen({ query: { "1=1 OR id": { value: 1 } } }); + expect(string).toBe("SELECT * FROM `users` WHERE (`1=1 OR id` = ?)"); + }); + + test("backticks in identifiers are escaped", () => { + const { string } = gen({ query: { "a` OR 1=1 -- ": { value: 1 } } }); + expect(string).toContain("`a`` OR 1=1 -- ` = ?"); + }); + + test("qualified names are quoted per part", () => { + const { string } = gen({ selectFields: ["p.name"] }); + expect(string).toBe("SELECT `p`.`name` FROM `users`"); + }); +}); + +describe("input validation", () => { + test.each([ + ["order strategy", { order: { field: "id", strategy: "DESC; DROP TABLE x" } }], + ["limit", { limit: "1; --" }], + ["negative offset", { offset: -1 }], + ["page", { limit: 10, page: "2 OR 1" }], + ["search operator", { query: { a: { value: 1 } }, searchOperator: "OR 1=1" }], + ["raw equality", { query: { a: { value: 1, raw_equality: "= 1 OR" } } }], + ["join type", { join: [{ tableName: "p", joinType: "CROSS JOIN x;", match: { source: "id", target: "uid" } }] }], + ])("rejects invalid %s", (_name, genObject) => { + expect(() => gen(genObject)).toThrow(); + }); + + test("rejects non-numeric targetId", () => { + expect(() => gen({}, { targetId: "abc" })).toThrow(); + }); +}); + +describe("values", () => { + test("0 is a real value", () => { + expect(gen({ query: { age: { value: 0 } } }).values).toEqual([0]); + }); + + test("0 is kept in arrays", () => { + const { string, values } = gen({ + query: { id: { value: [0, 1], operator: "OR" } }, + }); + expect(string).toBe("SELECT * FROM `users` WHERE ((`id` = ? OR `id` = ?))"); + expect(values).toEqual([0, 1]); + }); + + test("0 in value objects", () => { + const { string, values } = gen({ + query: { n: { value: { value: 0, equality: "GREATER THAN" } } }, + }); + expect(string).toContain("`n` > ?"); + expect(values).toEqual([0]); + }); + + test("null does not leave an unbound placeholder", () => { + const { string, values } = gen({ query: { email: { value: null } } }); + expect(string.split("?").length - 1).toBe(values.length); + }); + + test("LIKE wraps the value", () => { + expect(gen({ query: { n: { value: "x", equality: "LIKE" } } }).values).toEqual(["%x%"]); + expect(gen({ query: { n: { value: "x", equality: "NOT LIKE" } } }).values).toEqual(["%x%"]); + }); + + test("BETWEEN / NOT BETWEEN", () => { + expect(gen({ query: { n: { between: { min: 0, max: 5 } } } }).values).toEqual([0, 5]); + const notBetween = gen({ query: { n: { not_between: { min: 1, max: 2 } } } }); + expect(notBetween.string).toContain("`n` NOT BETWEEN ? AND ?"); + expect(notBetween.values).toEqual([1, 2]); + }); +}); + +describe("IN / NOT IN", () => { + test("array values become an IN list", () => { + const { string, values } = gen({ query: { id: { value: [1, 2, 3], equality: "IN" } } }); + expect(string).toBe("SELECT * FROM `users` WHERE (`id` IN (?,?,?))"); + expect(values).toEqual([1, 2, 3]); + }); + + test("NOT IN with a single value", () => { + const { string, values } = gen({ query: { id: { value: 4, equality: "NOT IN" } } }); + expect(string).toContain("`id` NOT IN (?)"); + expect(values).toEqual([4]); + }); + + test("empty IN matches nothing", () => { + const { string, values } = gen({ query: { id: { value: [], equality: "IN" } } }); + expect(string).toContain("1 = 0"); + expect(values).toEqual([]); + }); +}); + +describe("targetId", () => { + test("is always combined with AND", () => { + const { where, whereValues } = gen( + { query: { a: { value: 1 }, b: { value: 2 } }, searchOperator: "OR" }, + { targetId: 5 }, + ); + expect(where).toBe("WHERE (`a` = ? OR `b` = ?) AND `id` = ?"); + expect(whereValues).toEqual([1, 2, 5]); + }); + + test("works without other conditions", () => { + expect(gen({}, { targetId: "7" }).where).toBe("WHERE `id` = ?"); + }); +}); + +describe("where clause", () => { + test("is separate from select-part values", () => { + const res = gen({ + countSubQueries: [ + { table: "c", alias: "cc", srcTrgMap: [{ src: "uid", trg: "x" }] }, + ], + query: { id: { value: 1 } }, + }); + expect(res.values).toEqual(["x", 1]); + expect(res.where).toBe("WHERE (`id` = ?)"); + expect(res.whereValues).toEqual([1]); + }); + + test("is empty without conditions", () => { + expect(gen({ limit: 5 }).where).toBe(""); + }); +}); + +describe("select fields", () => { + test("alias is applied once", () => { + expect(gen({ selectFields: [{ fieldName: "name", alias: "n" }] }).string).toBe( + "SELECT `name` AS `n` FROM `users`", + ); + }); + + test("object fields with joins use the field name", () => { + const { string } = gen({ + selectFields: [{ fieldName: "name" }], + join: [{ tableName: "p", joinType: "LEFT JOIN", match: { source: "id", target: "uid" } }], + }); + expect(string).toBe( + "SELECT `users`.`name`,`p`.* FROM `users` LEFT JOIN `p` ON `users`.`id` = `p`.`uid`", + ); + }); + + test("average uses AVG", () => { + expect(gen({ selectFields: [{ fieldName: "n", average: true }] }).string).toContain("AVG(`n`)"); + }); + + test("group_concat separator is escaped", () => { + const { string } = gen({ + selectFields: [{ fieldName: "n", group_concat: { alias: "g", separator: "'); DROP" } }], + }); + expect(string).toContain("GROUP_CONCAT(`n`, '''); DROP') AS `g`"); + }); +}); + +describe("pagination and ordering", () => { + test("limit, page and order", () => { + const { string } = gen({ + group: ["a", { table: "p", field: "b" }], + order: [ + { field: "a", strategy: "asc" }, + { field: "b", strategy: "DESC" }, + ], + limit: 10, + page: 3, + }); + expect(string).toBe( + "SELECT * FROM `users` GROUP BY `a`,`p`.`b` ORDER BY `a` ASC, `b` DESC LIMIT 10 OFFSET 20", + ); + }); + + test("offset without limit gets a LIMIT", () => { + expect(gen({ offset: 5 }).string).toBe( + "SELECT * FROM `users` LIMIT 18446744073709551615 OFFSET 5", + ); + }); + + test("count queries skip LIMIT and OFFSET", () => { + expect(gen({ limit: 10, offset: 5 }, { count: true }).string).toBe("SELECT * FROM `users`"); + }); + + test("full-text score ordering only when ordering by the score alias", () => { + const fts = { fields: ["title"], searchTerm: "hi", scoreAlias: "score" }; + expect(gen({ fullTextSearch: fts, order: { field: "score", strategy: "DESC" } }).string).toContain( + "ORDER BY `score` DESC", + ); + expect(gen({ fullTextSearch: fts, order: { field: "id", strategy: "ASC" } }).string).toContain( + "ORDER BY `id` ASC", + ); + }); +}); diff --git a/tests/sql-values.test.ts b/tests/sql-values.test.ts new file mode 100644 index 0000000..ec5f1e7 --- /dev/null +++ b/tests/sql-values.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import sqlInsertGenerator from "../src/utils/sql-insert-generator"; +import sqlValuePlaceholder from "../src/utils/sql-value-placeholder"; + +describe("sqlValuePlaceholder", () => { + test("booleans become 0 / 1", () => { + expect(sqlValuePlaceholder({ value: false })).toEqual({ placeholder: "?", value: 0 }); + expect(sqlValuePlaceholder({ value: true })).toEqual({ placeholder: "?", value: 1 }); + }); + + test("0 is kept", () => { + expect(sqlValuePlaceholder({ value: 0 }).value).toBe(0); + }); + + test("embeddings use VEC_FromText", () => { + expect(sqlValuePlaceholder({ value: [0.1, 0.2] })).toEqual({ + placeholder: "VEC_FromText(?)", + value: "[0.1,0.2]", + }); + }); + + test("empty string: kept for text columns, NULL otherwise", () => { + expect(sqlValuePlaceholder({ value: "", field: { fieldName: "t", dataType: "VARCHAR" } }).value).toBe(""); + expect(sqlValuePlaceholder({ value: "", field: { fieldName: "n", dataType: "INT" } }).value).toBeNull(); + expect(sqlValuePlaceholder({ value: "" }).value).toBeNull(); + }); + + test("function values are called once", () => { + let calls = 0; + const res = sqlValuePlaceholder({ + value: () => { + calls++; + return { value: "POINT(1 2)", placeholder: "ST_GeomFromText(?)" }; + }, + }); + expect(calls).toBe(1); + expect(res).toEqual({ placeholder: "ST_GeomFromText(?)", value: "POINT(1 2)" }); + }); +}); + +describe("sqlInsertGenerator", () => { + test("builds a multi-row insert with quoted identifiers", () => { + const res = sqlInsertGenerator({ + tableName: "items", + data: [ + { name: "a", active: false, qty: 0 }, + { name: "b", emb: [1, 2] }, + ], + }); + expect(res?.query).toBe( + "INSERT INTO `items` (`name`,`active`,`qty`,`emb`) VALUES (?,?,?,?),(?,?,?,VEC_FromText(?))", + ); + expect(res?.values).toEqual(["a", 0, 0, null, "b", null, null, "[1,2]"]); + }); +});