Add tests, schema sync safeguards, and query/handler improvements

- Add bun test suite: SQL generator and value unit tests, plus MariaDB
  integration tests gated on BUN_MARIADB_TEST_DB
- schema: add --dry-run and require confirmation (or --yes) before
  dropping tables/columns; block unapproved destructive statements
- Always AND targetId with the query, regardless of searchOperator
- Initialize lazily on first call; init throws instead of exiting
- Generate IN (?, ?, ...) lists for IN / NOT IN
- Share value binding between insert and update (booleans, embeddings,
  function values, "" -> NULL for non-text columns)
- Keep Buffers and convert BigInt/Date without a JSON round-trip
- DbSQL: report success for empty SELECT results
- Run update() in a transaction with row locking
- Key the client cache on the full connection config
- Consolidate identifier quoting, drop dead code, merge column lookup
- Read CLI version from package.json, pin @types/bun, chmod CLI on compile
- Rebuild dist

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 02:27:59 +01:00
co-authored by Claude Opus 5.5
parent b81b09324a
commit ddf431e866
82 changed files with 2140 additions and 1403 deletions
+27 -4
View File
@@ -35,6 +35,7 @@ A schema-driven MariaDB manager for [Bun](https://bun.sh), featuring automatic s
- [TypeScript Type Generation](#typescript-type-generation) - [TypeScript Type Generation](#typescript-type-generation)
- [Default Fields](#default-fields) - [Default Fields](#default-fields)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Testing](#testing)
--- ---
@@ -377,8 +378,10 @@ bunx bun-mariadb schema [options]
``` ```
| Option | Description | | Option | Description |
| ----------------- | ------------------------------------------------------- | | ----------------- | ------------------------------------------------------------------------ |
| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing | | `-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:** **Examples:**
@@ -388,8 +391,13 @@ bunx bun-mariadb schema
# Sync schema and regenerate types # Sync schema and regenerate types
bunx bun-mariadb schema --typedef 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: What it does:
1. Ensures the internal `__db_schema_manager__` tracking table exists 1. Ensures the internal `__db_schema_manager__` tracking table exists
@@ -665,9 +673,11 @@ await BunMariaDB.delete({
### Raw SQL ### Raw SQL
```ts ```ts
BunMariaDB.sql<T>({ sql, values? }) BunMariaDB.sql<T>({ sql, values?, config? })
``` ```
`success` reflects whether the query ran; a `SELECT` with no rows returns `success: true` and an empty `payload`.
```ts ```ts
const res = await BunMariaDB.sql({ const res = await BunMariaDB.sql({
sql: "SELECT * FROM users WHERE id = ?", sql: "SELECT * FROM users WHERE id = ?",
@@ -941,8 +951,21 @@ bun-mariadb/
│ ├── sql-insert-generator.ts │ ├── sql-insert-generator.ts
│ ├── sanitize-html-fields.ts │ ├── sanitize-html-fields.ts
│ └── ... │ └── ...
└── test/ └── tests/ # bun test suite
└── coderank/ # Example project ```
---
## Testing
```bash
bun run test
```
Unit tests need no database. The MariaDB integration tests run only when `BUN_MARIADB_TEST_DB` is set (plus the usual `BUN_MARIADB_SERVER_*` variables). Point it at a throwaway database: the tests create and drop tables.
```bash
BUN_MARIADB_TEST_DB=bun_mariadb_test bun run test
``` ```
--- ---
+1 -1
View File
@@ -12,7 +12,7 @@
"sanitize-html": "^2.17.6", "sanitize-html": "^2.17.6",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "1.3.14",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
+22 -1
View File
@@ -2,7 +2,7 @@ import { Command } from "commander";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import chalk from "chalk"; import chalk from "chalk";
import { select } from "@inquirer/prompts"; import { confirm, select } from "@inquirer/prompts";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import grabSortedExports from "../utils/grab-sorted-exports"; import grabSortedExports from "../utils/grab-sorted-exports";
import grabBackupData from "../utils/grab-backup-data"; 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)") .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("--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("--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) => { .action(async (fileArg, opts) => {
console.log(`Importing database ...`); console.log(`Importing database ...`);
const config = global.CONFIG; const config = global.CONFIG;
@@ -67,6 +68,26 @@ export default function () {
process.exit(1); process.exit(1);
} }
const { sql, schema, schemaFileName } = await readExportArchive(filePath); 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) { if (!opts.schemaOnly) {
await restoreDatabase(config, sql); await restoreDatabase(config, sql);
console.log(chalk.green(`SQL restored from archive`)); console.log(chalk.green(`SQL restored from archive`));
+10 -1
View File
@@ -8,14 +8,23 @@ import exportCmd from "./export";
import importCmd from "./import"; import importCmd from "./import";
import admin from "./admin"; import admin from "./admin";
import init from "../functions/init"; import init from "../functions/init";
import { readFileSync } from "fs";
try {
init(); 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 * # Describe Program
*/ */
program program
.name(`bun-mariadb`) .name(`bun-mariadb`)
.description(`MariaDB manager for Bun`) .description(`MariaDB manager for Bun`)
.version(`1.0.0`); .version(version);
/** /**
* # Declare Commands * # Declare Commands
*/ */
+59 -3
View File
@@ -6,10 +6,28 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-
import chalk from "chalk"; import chalk from "chalk";
import { writeLiveSchema } from "../functions/live-schema"; import { writeLiveSchema } from "../functions/live-schema";
import createDBSchema from "../lib/schema/create-db-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 () { export default function () {
return new Command("schema") return new Command("schema")
.description("Build DB From Schema") .description("Build DB From Schema")
.option("-t, --typedef", "Generate typescript type definitions") .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) => { .action(async (opts) => {
console.log(`Starting process ...`); console.log(`Starting process ...`);
const config = global.CONFIG; const config = global.CONFIG;
@@ -20,10 +38,48 @@ export default function () {
const finaldbSchema = appendDefaultFieldsToDbSchema({ const finaldbSchema = appendDefaultFieldsToDbSchema({
dbSchema, dbSchema,
}); });
await createDBSchema({ const params = { db_schema: finaldbSchema, config };
db_schema: finaldbSchema, // Hide internal bookkeeping (schema manager rows, FK check toggles)
config, 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) { if (isTypeDef && config.typedef_file_path) {
const out_file = path.resolve(ROOT_DIR, config.typedef_file_path); const out_file = path.resolve(ROOT_DIR, config.typedef_file_path);
dbSchemaToTypeDef({ dbSchemaToTypeDef({
+5
View File
@@ -2,5 +2,10 @@ import { type BunMariaDBConfig } from "../types";
type Params = { type Params = {
config: BunMariaDBConfig; 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 default function grabMariaDBClient({ config, }: Params): Bun.SQL | undefined;
export {}; export {};
+22 -7
View File
@@ -2,29 +2,44 @@ import path from "path";
import grabDirNames from "../data/grab-dir-names"; import grabDirNames from "../data/grab-dir-names";
import {} from "../types"; import {} from "../types";
import { SQL } from "bun"; 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(); 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, }) { export default function grabMariaDBClient({ config, }) {
const cacheKey = config.db_name || "__default__"; const cacheKey = mariadbClientCacheKey(config);
const cached = clientCache.get(cacheKey); const cached = clientCache.get(cacheKey);
if (cached) { if (cached) {
return 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 && 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); clientCache.set(cacheKey, global.MARIADB_CLIENT);
return global.MARIADB_CLIENT; return global.MARIADB_CLIENT;
} }
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
try { try {
// Verify the server certificate unless explicitly disabled
const rejectUnauthorized = config.ssl_reject_unauthorized !== false;
const tls = config.ssl_ca const tls = config.ssl_ca
? { ? {
ca: Bun.file(path.resolve(ROOT_DIR, config.ssl_ca)), ca: Bun.file(path.resolve(ROOT_DIR, config.ssl_ca)),
rejectUnauthorized: false, rejectUnauthorized,
} }
: { : {
rejectUnauthorized: false, rejectUnauthorized,
}; };
const MariaDBClient = new SQL({ const MariaDBClient = new SQL({
hostname: process.env.BUN_MARIADB_SERVER_HOST, hostname: process.env.BUN_MARIADB_SERVER_HOST,
@@ -34,8 +49,8 @@ export default function grabMariaDBClient({ config, }) {
port: process.env.BUN_MARIADB_SERVER_PORT port: process.env.BUN_MARIADB_SERVER_PORT
? Number(process.env.BUN_MARIADB_SERVER_PORT) ? Number(process.env.BUN_MARIADB_SERVER_PORT)
: undefined, : undefined,
...config.db_config,
tls, tls,
...config.db_config,
adapter: "mariadb", adapter: "mariadb",
}); });
clientCache.set(cacheKey, MariaDBClient); clientCache.set(cacheKey, MariaDBClient);
+8
View File
@@ -9,4 +9,12 @@ declare global {
var CONFIG_FILE_PATH: string; var CONFIG_FILE_PATH: string;
var SCHEMA_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; export default function init(): void;
/**
* Initialize once, on first use.
*/
export declare function ensureInit(): void;
+18 -30
View File
@@ -5,23 +5,23 @@ import grabDirNames from "../data/grab-dir-names";
import { RequiredENVs, } from "../types"; import { RequiredENVs, } from "../types";
import setMariaDBClient from "./set-mariadb-client"; import setMariaDBClient from "./set-mariadb-client";
import { resolveAndLoadDataFile, supportedDataFileNames, } from "../utils/resolve-and-load-data-file"; 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() { export default function init() {
try {
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData; const { ConfigFileName } = AppData;
const loadedConfig = resolveAndLoadDataFile(ROOT_DIR, ConfigFileName); const loadedConfig = resolveAndLoadDataFile(ROOT_DIR, ConfigFileName);
if (!loadedConfig) { if (!loadedConfig) {
console.error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`); throw new Error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`);
process.exit(1);
} }
const Config = loadedConfig.data; const Config = loadedConfig.data;
if (!Config || typeof Config !== "object") { if (!Config || typeof Config !== "object") {
console.error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`); throw new 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) { if (!Config.db_name) {
console.error(`\`db_name\` is required in your config`); throw new Error(`\`db_name\` is required in your config`);
process.exit(1);
} }
RequiredENVs.forEach((env_key) => { RequiredENVs.forEach((env_key) => {
if (env_key == "BUN_MARIADB_SERVER_PORT" || if (env_key == "BUN_MARIADB_SERVER_PORT" ||
@@ -29,13 +29,11 @@ export default function init() {
return; return;
} }
if (!process.env[env_key]) { if (!process.env[env_key]) {
console.error(`\`${env_key}\` env variable is required.`); throw new Error(`\`${env_key}\` env variable is required.`);
process.exit(1);
} }
}); });
if (!Config.db_dir) { 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`); 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`);
process.exit(1);
} }
const db_dir = path.resolve(ROOT_DIR, Config.db_dir); const db_dir = path.resolve(ROOT_DIR, Config.db_dir);
if (!fs.existsSync(db_dir)) { if (!fs.existsSync(db_dir)) {
@@ -43,13 +41,11 @@ export default function init() {
} }
const loadedSchema = resolveAndLoadDataFile(db_dir, AppData.DbSchemaFileName); const loadedSchema = resolveAndLoadDataFile(db_dir, AppData.DbSchemaFileName);
if (!loadedSchema) { 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." : "."}`); 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." : "."}`);
process.exit(1);
} }
const DbSchema = loadedSchema.data; const DbSchema = loadedSchema.data;
if (!DbSchema || typeof DbSchema !== "object") { if (!DbSchema || typeof DbSchema !== "object") {
console.error(`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`); throw new 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 backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"];
const BackupDir = path.resolve(db_dir, backup_dir); const BackupDir = path.resolve(db_dir, backup_dir);
@@ -64,22 +60,14 @@ export default function init() {
global.DB_SCHEMA = DbSchema; global.DB_SCHEMA = DbSchema;
global.CONFIG_FILE_PATH = loadedConfig.path; global.CONFIG_FILE_PATH = loadedConfig.path;
global.SCHEMA_FILE_PATH = loadedSchema.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 }); setMariaDBClient({ config: Config });
if (!global.MARIADB_CLIENT) {
console.error(`Couldn't set MariaDB Client.`);
process.exit(1);
} }
/**
* Initialize once, on first use.
*/
export function ensureInit() {
if (global.CONFIG && global.DB_SCHEMA && global.MARIADB_CLIENT !== undefined) {
return;
} }
catch (error) { init();
console.error(`Initialization ERROR => ` + error.message);
process.exit(1);
}
} }
-12
View File
@@ -1,21 +1,9 @@
import {} from "../types"; import {} from "../types";
import grabMariaDBClient from "./grab-mariadb-client"; import grabMariaDBClient from "./grab-mariadb-client";
export default function setMariaDBClient({ config }) { export default function setMariaDBClient({ config }) {
try {
const MariaDBClient = grabMariaDBClient({ config }); const MariaDBClient = grabMariaDBClient({ config });
if (!MariaDBClient) { if (!MariaDBClient) {
throw new Error(`Couldn't grab MariaDB Client`); throw new Error(`Couldn't grab MariaDB Client`);
} }
global.MARIADB_CLIENT = MariaDBClient; 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);
}
} }
+17 -8
View File
@@ -1,4 +1,4 @@
import init from "./functions/init"; import { ensureInit } from "./functions/init";
import DbDelete from "./lib/mariadb/db-delete"; import DbDelete from "./lib/mariadb/db-delete";
import DbInsert from "./lib/mariadb/db-insert"; import DbInsert from "./lib/mariadb/db-insert";
import DbSelect from "./lib/mariadb/db-select"; 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 DbUpdate from "./lib/mariadb/db-update";
import grabDbSchema from "./utils/grab-db-schema"; import grabDbSchema from "./utils/grab-db-schema";
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object"; 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 = { const BunMariaDB = {
select: DbSelect, select: withInit(DbSelect),
insert: DbInsert, insert: withInit(DbInsert),
update: DbUpdate, update: withInit(DbUpdate),
delete: DbDelete, delete: withInit(DbDelete),
sql: DbSQL, sql: withInit(DbSQL),
utils: { utils: {
grab_db_schema: grabDbSchema, grab_db_schema: withInit(grabDbSchema),
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject, grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
}, },
}; };
+12 -1
View File
@@ -1,9 +1,20 @@
import type { BunMariaDBConfig, DBResponseObject } from "../types"; 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<any>;
};
type Param = { type Param = {
query: string; query: string;
values?: any[]; values?: any[];
config?: BunMariaDBConfig; 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 * # Main DB Handler Function
*/ */
@@ -11,5 +22,5 @@ export default function dbHandler<T extends {
[k: string]: any; [k: string]: any;
} = { } = {
[k: string]: any; [k: string]: any;
}>({ query, values, config }: Param): Promise<DBResponseObject<T>>; }>({ query, values, config, client }: Param): Promise<DBResponseObject<T>>;
export {}; export {};
+41 -20
View File
@@ -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 * Resolve a shared MariaDB client. Prefer the global client so schema sync
* and CRUD do not open a new connection per query. * and CRUD do not open a new connection per query.
*/ */
function resolveClient(config) { export function resolveClient(config) {
const resolvedConfig = config || global.CONFIG; if (!config) {
// Same database as the global client → reuse it return global.MARIADB_CLIENT;
}
// Same connection config as the global client → reuse it
if (global.MARIADB_CLIENT && if (global.MARIADB_CLIENT &&
(!resolvedConfig?.db_name || (!global.CONFIG ||
!global.CONFIG?.db_name || mariadbClientCacheKey(config) ===
resolvedConfig.db_name === global.CONFIG.db_name)) { mariadbClientCacheKey(global.CONFIG))) {
return global.MARIADB_CLIENT; return global.MARIADB_CLIENT;
} }
if (resolvedConfig) { return grabMariaDBClient({ config });
return grabMariaDBClient({ config: resolvedConfig });
} }
return global.MARIADB_CLIENT; /**
* 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 * # Main DB Handler Function
*/ */
export default async function dbHandler({ query, values, config }) { export default async function dbHandler({ query, values, config, client }) {
try { try {
const CLIENT = resolveClient(config); const CLIENT = client || resolveClient(config);
if (!CLIENT) { if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`); throw new Error(`Couldn't grab MariaDB Client.`);
} }
const res = await CLIENT.unsafe(query, values); const res = await CLIENT.unsafe(query, values);
const count = res.count; const count = res.count;
const res_array = (() => { const res_array = normalizeRows(res);
try {
return JSON.parse(JSON.stringify(res));
}
catch (error) {
return undefined;
}
})();
const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined; const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined;
const affected_rows = res_array?.[0] ? res_array.length : undefined; const affected_rows = res_array?.[0] ? res_array.length : undefined;
const insert_return = { const insert_return = {
+1 -3
View File
@@ -1,6 +1,4 @@
function quoteIdentifier(identifier) { import { quoteIdentifier } from "../utils/sql-identifier";
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function ({ sql: passedSql, table, data }) { export default async function ({ sql: passedSql, table, data }) {
const config = global.CONFIG; const config = global.CONFIG;
const dbSchema = global.DB_SCHEMA; const dbSchema = global.DB_SCHEMA;
+6 -15
View File
@@ -1,27 +1,17 @@
import dbHandler from "../db-handler"; import dbHandler from "../db-handler";
import _ from "lodash"; import _ from "lodash";
import sqlGenerator from "../../utils/sql-generator"; import sqlGenerator from "../../utils/sql-generator";
function quoteIdentifier(identifier) { import { quoteIdentifier } from "../../utils/sql-identifier";
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function DbDelete({ table, query, targetId, config, }) { export default async function DbDelete({ table, query, targetId, config, }) {
let sqlObj = null; let sqlObj = null;
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId) {
finalQuery = _.merge(finalQuery, {
query: {
id: {
value: Number(targetId),
},
},
});
}
sqlObj = sqlGenerator({ sqlObj = sqlGenerator({
tableName: quoteIdentifier(table), tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const whereClause = sqlObj.string.match(/WHERE .*/)?.[0]; const whereClause = sqlObj.where;
if (!whereClause) { if (!whereClause) {
return { return {
success: false, success: false,
@@ -34,6 +24,7 @@ export default async function DbDelete({ table, query, targetId, config, }) {
let sql = `DELETE FROM ${quoteIdentifier(table)} ${whereClause}`; let sql = `DELETE FROM ${quoteIdentifier(table)} ${whereClause}`;
sql += ` RETURNING *`; sql += ` RETURNING *`;
sqlObj.string = sql; sqlObj.string = sql;
sqlObj.values = sqlObj.whereValues;
const res = await dbHandler({ const res = await dbHandler({
query: sql, query: sql,
values: sqlObj.values, values: sqlObj.values,
+7 -10
View File
@@ -1,5 +1,6 @@
import _ from "lodash"; import _ from "lodash";
import generateTypeDefinition from "./db-generate-type-defs"; import generateTypeDefinition from "./db-generate-type-defs";
import resolveTable from "../schema/resolve-table";
export default function dbSchemaToType({ config, dbSchema, }) { export default function dbSchemaToType({ config, dbSchema, }) {
let datasquirelSchema = dbSchema; let datasquirelSchema = dbSchema;
if (!datasquirelSchema) if (!datasquirelSchema)
@@ -14,16 +15,12 @@ export default function dbSchemaToType({ config, dbSchema, }) {
const defNames = []; const defNames = [];
const schemas = dbTablesSchemas const schemas = dbTablesSchemas
.map((table) => { .map((table) => {
let final_table = _.cloneDeep(table); const has_parent = Boolean(table.parentTableName &&
if (final_table.parentTableName) { dbTablesSchemas.find((t) => t.tableName === table.parentTableName));
const parent_table = dbTablesSchemas.find((t) => t.tableName === final_table.parentTableName); // Merge parent fields into a copy; never mutate the parent schema
if (parent_table) { const final_table = has_parent
final_table = _.merge(parent_table, { ? resolveTable(table, datasquirelSchema)
tableName: final_table.tableName, : _.cloneDeep(table);
tableDescription: final_table.tableDescription,
});
}
}
const defObj = generateTypeDefinition({ const defObj = generateTypeDefinition({
paradigm: "TypeScript", paradigm: "TypeScript",
table: final_table, table: final_table,
+5 -16
View File
@@ -1,25 +1,13 @@
import dbHandler from "../db-handler"; import dbHandler from "../db-handler";
import _ from "lodash";
import sqlGenerator from "../../utils/sql-generator"; import sqlGenerator from "../../utils/sql-generator";
function quoteIdentifier(identifier) {
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function DbSelect({ table, query, count, targetId, config, }) { export default async function DbSelect({ table, query, count, targetId, config, }) {
let sqlObj = null; let sqlObj = null;
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId) {
finalQuery = _.merge(finalQuery, {
query: {
id: {
value: Number(targetId),
},
},
});
}
sqlObj = sqlGenerator({ sqlObj = sqlGenerator({
tableName: quoteIdentifier(table), tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const res = await dbHandler({ const res = await dbHandler({
query: sqlObj.string, query: sqlObj.string,
@@ -49,8 +37,9 @@ export default async function DbSelect({ table, query, count, targetId, config,
}; };
if (count) { if (count) {
const countSqlObject = sqlGenerator({ const countSqlObject = sqlGenerator({
tableName: quoteIdentifier(table), tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
count, count,
}); });
const countSql = `SELECT COUNT(*) AS count FROM (${countSqlObject.string}) AS c`; const countSql = `SELECT COUNT(*) AS count FROM (${countSqlObject.string}) AS c`;
+3 -2
View File
@@ -1,11 +1,12 @@
import type { DBResponseObject, SQLInsertGenValueType } from "../../types"; import type { BunMariaDBConfig, DBResponseObject, SQLInsertGenValueType } from "../../types";
type Params = { type Params = {
sql: string; sql: string;
values?: SQLInsertGenValueType[]; values?: SQLInsertGenValueType[];
config?: BunMariaDBConfig;
}; };
export default function DbSQL<T extends { export default function DbSQL<T extends {
[k: string]: any; [k: string]: any;
} = { } = {
[k: string]: any; [k: string]: any;
}>({ sql, values }: Params): Promise<DBResponseObject<T>>; }>({ sql, values, config }: Params): Promise<DBResponseObject<T>>;
export {}; export {};
+3 -5
View File
@@ -1,11 +1,12 @@
import dbHandler from "../db-handler"; import dbHandler from "../db-handler";
export default async function DbSQL({ sql, values }) { export default async function DbSQL({ sql, values, config }) {
try { try {
const trimmedSql = sql.trim(); const trimmedSql = sql.trim();
const isSelect = trimmedSql.match(/^select/i); const isSelect = trimmedSql.match(/^select/i);
const res = await dbHandler({ const res = await dbHandler({
query: trimmedSql, query: trimmedSql,
values: values, values: values,
config,
}); });
if (!res.success) { if (!res.success) {
return { return {
@@ -23,12 +24,9 @@ export default async function DbSQL({ sql, values }) {
} }
const payload = isSelect ? (res.payload || []) : undefined; const payload = isSelect ? (res.payload || []) : undefined;
const single_res = isSelect ? payload?.[0] : res.single_res; const single_res = isSelect ? payload?.[0] : res.single_res;
const singleRaw = res.single_res;
return { return {
...res, ...res,
success: isSelect success: true,
? Boolean(single_res) || Boolean(payload?.[0])
: true,
payload, payload,
single_res, single_res,
debug: { debug: {
+78 -44
View File
@@ -1,37 +1,38 @@
import dbHandler from "../db-handler"; import dbHandler, { resolveClient } from "../db-handler";
import _ from "lodash";
import sqlGenerator from "../../utils/sql-generator"; import sqlGenerator from "../../utils/sql-generator";
import sanitizeHtmlFields from "../../utils/sanitize-html-fields"; import sanitizeHtmlFields from "../../utils/sanitize-html-fields";
function quoteIdentifier(identifier) { import { quoteIdentifier } from "../../utils/sql-identifier";
return `\`${identifier.replace(/`/g, "``")}\``; 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, }) { export default async function DbUpdate({ table, data, query, targetId, config, }) {
let sqlObj = { string: "", values: [] }; let sqlObj = {
string: "",
values: [],
where: "",
whereValues: [],
};
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId) {
finalQuery = _.merge(finalQuery, {
query: {
id: {
value: Number(targetId),
},
},
});
}
const sqlQueryObj = sqlGenerator({ const sqlQueryObj = sqlGenerator({
tableName: quoteIdentifier(table), tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0]; const whereClause = sqlQueryObj.where;
if (!whereClause) { if (!whereClause) {
return { return {
success: false, success: false,
msg: `No WHERE clause`, msg: `No WHERE clause`,
}; };
} }
let values = [];
let sql = ``;
sql += `UPDATE ${quoteIdentifier(table)} SET`;
const sanitizedData = sanitizeHtmlFields({ const sanitizedData = sanitizeHtmlFields({
table, table,
data, data,
@@ -41,38 +42,67 @@ export default async function DbUpdate({ table, data, query, targetId, config, }
updated_at: Date.now(), updated_at: Date.now(),
...sanitizedData, ...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 setParts = [];
const key = keys[i]; const setValues = [];
if (!key) for (const key of Object.keys(finalData)) {
continue; const { placeholder, value } = sqlValuePlaceholder({
const isLast = i == keys.length - 1; value: finalData[key],
sql += ` ${quoteIdentifier(key)}=?`; field: fieldsMap.get(key),
values.push(finalData[key] ?? null); });
if (!isLast) { setParts.push(`${quoteIdentifier(key)}=${placeholder}`);
sql += `,`; setValues.push(value);
} }
const CLIENT = resolveClient(config);
if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`);
} }
sql += ` ${whereClause}`; /**
values = [...values, ...sqlQueryObj.values]; * 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 },
});
}
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({ const res = await dbHandler({
query: sql, query: sql,
values: values, values: values,
config, client: tx,
}); });
if (res.error) { if (!res.success) {
return res; throw new UpdateAbort({ ...res, debug: { sqlObj } });
} }
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({ const updated_res = await dbHandler({
query: updated_sql, query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`,
values: updated_sql_values, values: matched_ids,
config, client: tx,
}); });
return { return {
...updated_res, ...updated_res,
@@ -81,8 +111,12 @@ export default async function DbUpdate({ table, data, query, targetId, config, }
}, },
db_res: res.db_res, db_res: res.db_res,
}; };
});
} }
catch (error) { catch (error) {
if (error instanceof UpdateAbort) {
return error.response;
}
return { return {
success: false, success: false,
error: error.message, error: error.message,
-13
View File
@@ -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<ColumnInfoRow[]>;
-45
View File
@@ -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 || "",
};
});
}
+3
View File
@@ -7,6 +7,9 @@ export type ColumnInfoRow = {
columnDefault: string | null; columnDefault: string | null;
extra: string; extra: string;
}; };
/**
* Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`.
*/
export default function getTableColumns({ tableName, config, }: { export default function getTableColumns({ tableName, config, }: {
tableName: string; tableName: string;
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
+34 -4
View File
@@ -1,18 +1,48 @@
import { querySchemaRows } from "./run-schema-query"; import { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; 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, }) { export default async function getTableColumns({ tableName, config, }) {
const schemaCond = schemaCondition(config); const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({ 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], values: [...schemaCond.values, tableName],
config, config,
}); });
return rows.map((row) => ({ 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, name: row.COLUMN_NAME,
type: row.COLUMN_TYPE, type: resolvedType,
comment: row.COLUMN_COMMENT, comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT, columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "", extra: row.EXTRA || "",
})); };
});
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { quoteIdentifier } from "../../utils/sql-identifier";
export default function MariaDBQuoteGen(str) { export default function MariaDBQuoteGen(str) {
return `\`${str.replace(/`/g, "``")}\``; return quoteIdentifier(str);
} }
+14
View File
@@ -1,4 +1,18 @@
import type { BunMariaDBConfig } from "../../types"; 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<unknown>): Promise<string[]>;
/**
* Run `fn`, refusing any destructive statement that was not approved.
*/
export declare function runSchemaQueriesGuarded(fn: () => Promise<unknown>, allowDestructive: true | Set<string>): Promise<void>;
export default function runSchemaQuery({ query, values, config, }: { export default function runSchemaQuery({ query, values, config, }: {
query: string; query: string;
values?: any[]; values?: any[];
+48
View File
@@ -1,5 +1,53 @@
import dbHandler from "../db-handler"; 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, }) { 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({ const res = await dbHandler({
query, query,
values, values,
+1 -2
View File
@@ -1,7 +1,6 @@
import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition"; import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition";
import createTable from "./create-table"; import createTable from "./create-table";
import getTableColumns, {} from "./get-table-columns"; import getTableColumns, {} from "./get-table-columns";
import getTableColumnsGemini from "./get-table-columns-gemnini";
import isVectorField from "./is-vector-field"; import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
@@ -188,7 +187,7 @@ async function recreateColumn({ tableName, field, config, }) {
await addColumn({ tableName, field, config }); await addColumn({ tableName, field, config });
} }
export default async function updateTable({ table, config, }) { export default async function updateTable({ table, config, }) {
const existingColumns = await getTableColumnsGemini({ const existingColumns = await getTableColumns({
tableName: table.tableName, tableName: table.tableName,
config, config,
}); });
+6
View File
@@ -1392,6 +1392,12 @@ export type BunMariaDBConfig = {
* File path to the SSL certificate * File path to the SSL certificate
*/ */
ssl_ca?: string; 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 * Extra HTML sanitization allowlists appended to the defaults
* when inserting/updating fields marked `html: true`. * when inserting/updating fields marked `html: true`.
+3 -2
View File
@@ -165,10 +165,11 @@ function findFileContents(dir, predicate) {
const current = stack.pop(); const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name); const full = path.join(current, entry.name);
// Never follow symlinks from an extracted (untrusted) archive
if (entry.isDirectory()) { if (entry.isDirectory()) {
stack.push(full); stack.push(full);
} }
else if (predicate(entry.name)) { else if (entry.isFile() && predicate(entry.name)) {
return fs.readFileSync(full, "utf-8"); return fs.readFileSync(full, "utf-8");
} }
} }
@@ -184,7 +185,7 @@ function findSchemaFile(dir) {
if (entry.isDirectory()) { if (entry.isDirectory()) {
stack.push(full); stack.push(full);
} }
else if (isSchemaFileName(entry.name)) { else if (entry.isFile() && isSchemaFileName(entry.name)) {
return { return {
content: fs.readFileSync(full, "utf-8"), content: fs.readFileSync(full, "utf-8"),
fileName: entry.name === AppData.DbSchemaFileName fileName: entry.name === AppData.DbSchemaFileName
+5
View File
@@ -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[];
+9
View File
@@ -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 || [];
}
+1 -6
View File
@@ -1,11 +1,6 @@
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { readLiveSchema } from "../functions/live-schema";
import getSanitizeHtmlOptions from "./sanitize-html-options"; import getSanitizeHtmlOptions from "./sanitize-html-options";
function grabTableFields(tableName) { import grabTableFields from "./grab-table-fields";
const dbSchema = global.DB_SCHEMA || readLiveSchema();
const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName);
return tableSchema?.fields || [];
}
/** /**
* Only fields with an explicit `html: true` flag are sanitized. * Only fields with an explicit `html: true` flag are sanitized.
* Missing / falsy / non-true values are never sanitized. * Missing / falsy / non-true values are never sanitized.
+5 -4
View File
@@ -18,7 +18,6 @@ export const defaultSanitizeHtmlOptions = {
"h6", "h6",
"img", "img",
"div", "div",
"button",
"pre", "pre",
"code", "code",
"br", "br",
@@ -35,10 +34,12 @@ export const defaultSanitizeHtmlOptions = {
"colgroup", "colgroup",
"col", "col",
], ],
// `style` / `id` are intentionally not allowed by default (CSS overlays,
// DOM clobbering). Add them via `html_sanitize.allowed_attributes`.
allowedAttributes: { allowedAttributes: {
a: ["href", "title", "class", "style", "target", "rel"], a: ["href", "title", "class", "target", "rel"],
img: ["src", "alt", "width", "height", "class", "style"], img: ["src", "alt", "width", "height", "class"],
"*": ["style", "class", "title", "id"], "*": ["class", "title"],
}, },
}; };
function uniqueStrings(values) { function uniqueStrings(values) {
+2
View File
@@ -1,11 +1,13 @@
import type { ServerQueryEqualities, ServerQueryObject, SQLInsertGenValueType } from "../types"; import type { ServerQueryEqualities, ServerQueryObject, SQLInsertGenValueType } from "../types";
type Params = { type Params = {
/** Already-quoted column reference */
fieldName: string; fieldName: string;
value?: SQLInsertGenValueType; value?: SQLInsertGenValueType;
equality?: (typeof ServerQueryEqualities)[number]; equality?: (typeof ServerQueryEqualities)[number];
queryObj: ServerQueryObject<{ queryObj: ServerQueryObject<{
[key: string]: any; [key: string]: any;
}, string>; }, string>;
/** `value` is an already-quoted column reference, not a parameter */
isValueFieldValue?: boolean; isValueFieldValue?: boolean;
}; };
type Return = { type Return = {
+16 -7
View File
@@ -10,9 +10,12 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
if (queryObj.notNullValue) { if (queryObj.notNullValue) {
return { str: `${fieldName} IS NOT NULL` }; 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 finalValue = isValueFieldValue ? value : "?";
const finalParams = isValueFieldValue ? undefined : value; const finalParams = isValueFieldValue ? undefined : value;
const likeParams = isValueFieldValue ? undefined : `%${value}%`;
if (equality == "MATCH") { if (equality == "MATCH") {
return { return {
str: `MATCH(${fieldName}) AGAINST(${finalValue} IN NATURAL LANGUAGE MODE)`, 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") { else if (equality == "LIKE_LOWER") {
return { return {
str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`, str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`,
param: `%${finalParams}%`, param: likeParams,
}; };
} }
else if (equality == "LIKE_LOWER_RAW") { else if (equality == "LIKE_LOWER_RAW") {
@@ -40,7 +43,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "LIKE") { else if (equality == "LIKE") {
return { return {
str: `${fieldName} LIKE ${finalValue}`, str: `${fieldName} LIKE ${finalValue}`,
param: `%${finalParams}%`, param: likeParams,
}; };
} }
else if (equality == "LIKE_RAW") { else if (equality == "LIKE_RAW") {
@@ -52,7 +55,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "NOT_LIKE_LOWER") { else if (equality == "NOT_LIKE_LOWER") {
return { return {
str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`, str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`,
param: `%${finalParams}%`, param: likeParams,
}; };
} }
else if (equality == "NOT_LIKE_LOWER_RAW") { else if (equality == "NOT_LIKE_LOWER_RAW") {
@@ -64,7 +67,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "NOT LIKE") { else if (equality == "NOT LIKE") {
return { return {
str: `${fieldName} NOT LIKE ${finalValue}`, str: `${fieldName} NOT LIKE ${finalValue}`,
param: finalParams, param: likeParams,
}; };
} }
else if (equality == "NOT LIKE_RAW") { else if (equality == "NOT LIKE_RAW") {
@@ -97,6 +100,12 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
param: finalParams, param: finalParams,
}; };
} }
else if (equality == "IN" || equality == "NOT IN") {
return {
str: `${fieldName} ${equality} (${finalValue})`,
param: finalParams,
};
}
else if (equality) { else if (equality) {
return { return {
str: `${fieldName} ${sqlEqualityParser(equality)} ${finalValue}`, str: `${fieldName} ${sqlEqualityParser(equality)} ${finalValue}`,
@@ -120,13 +129,13 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality) { else if (equality) {
return { return {
str: `${fieldName} ${sqlEqualityParser(equality)} ?`, str: `${fieldName} ${sqlEqualityParser(equality)} ?`,
param: value, param: value ?? "",
}; };
} }
else { else {
return { return {
str: `${fieldName} = ?`, str: `${fieldName} = ?`,
param: value, param: value ?? "",
}; };
} }
} }
+1
View File
@@ -2,6 +2,7 @@ import type { ServerQueryParamsJoin, ServerQueryParamsJoinMatchObject, SQLInsert
type Param = { type Param = {
mtch: ServerQueryParamsJoinMatchObject; mtch: ServerQueryParamsJoinMatchObject;
join: ServerQueryParamsJoin; join: ServerQueryParamsJoin;
/** Already-quoted root table name */
table_name: string; table_name: string;
}; };
export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param): { export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param): {
+18 -23
View File
@@ -1,3 +1,4 @@
import { columnRef, sqlLogicalOperator, sqlRawComparison, } from "./sql-identifier";
export default function sqlGenGenJoinStr({ join, mtch, table_name }) { export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
let values = []; let values = [];
if (mtch.__batch) { if (mtch.__batch) {
@@ -13,7 +14,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
btch_mtch += str; btch_mtch += str;
values.push(...batch_values); values.push(...batch_values);
if (i < mtch.__batch.matches.length - 1) { if (i < mtch.__batch.matches.length - 1) {
btch_mtch += ` ${mtch.__batch.operator || "OR"} `; btch_mtch += ` ${sqlLogicalOperator(mtch.__batch.operator || "OR")} `;
} }
} }
btch_mtch += `)`; btch_mtch += `)`;
@@ -22,28 +23,12 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
values, values,
}; };
} }
const equality = mtch.raw_equality || "="; 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}`; ? sqlRawComparison(mtch.raw_equality)
const rhs = `${(() => { : "=";
if (mtch.targetLiteral) { const lhs = typeof mtch.source == "object"
values.push(mtch.targetLiteral); ? columnRef(mtch.source.fieldName, mtch.source.tableName)
// if (typeof mtch.targetLiteral == "number") { : columnRef(String(mtch.source), table_name);
// return `${mtch.targetLiteral}`;
// }
// return `'${mtch.targetLiteral}'`;
return `?`;
}
if (join.alias) {
return `${typeof mtch.target == "object"
? mtch.target.tableName
: join.alias}.${typeof mtch.target == "object"
? mtch.target.fieldName
: mtch.target}`;
}
return `${typeof mtch.target == "object"
? mtch.target.tableName
: join.tableName}.${typeof mtch.target == "object" ? mtch.target.fieldName : mtch.target}`;
})()}`;
if (mtch.between) { if (mtch.between) {
values.push(mtch.between.min, mtch.between.max); values.push(mtch.between.min, mtch.between.max);
return { return {
@@ -58,6 +43,16 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
values, 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 { return {
str: `${lhs} ${equality} ${rhs}`, str: `${lhs} ${equality} ${rhs}`,
values, values,
+1
View File
@@ -7,6 +7,7 @@ type Param<T extends {
genObject?: ServerQueryParam<T>; genObject?: ServerQueryParam<T>;
selectFields?: (keyof T | TableSelectFieldsObject<T>)[]; selectFields?: (keyof T | TableSelectFieldsObject<T>)[];
append_table_names?: boolean; append_table_names?: boolean;
/** Already-quoted table name */
table_name: string; table_name: string;
full_text_match_str?: string; full_text_match_str?: string;
full_text_search_str?: string; full_text_search_str?: string;
+35 -34
View File
@@ -2,6 +2,7 @@ import _, { isUndefined } from "lodash";
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import sqlGenGenJoinStr from "./sql-generator-gen-join-str"; import sqlGenGenJoinStr from "./sql-generator-gen-join-str";
import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql"; import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql";
import { columnRef, quoteIdentifier, quoteQualified, sqlJoinType, sqlLogicalOperator, } from "./sql-identifier";
export default function sqlGenGenQueryStr(params) { export default function sqlGenGenQueryStr(params) {
let str = "SELECT"; let str = "SELECT";
const genObject = params.genObject; const genObject = params.genObject;
@@ -42,47 +43,47 @@ export default function sqlGenGenQueryStr(params) {
if (!countSubQuery) if (!countSubQuery)
continue; continue;
const tableAlias = countSubQuery.table_alias; const tableAlias = countSubQuery.table_alias;
const countTableRef = quoteQualified(tableAlias || countSubQuery.table);
let subQStr = `(SELECT COUNT(*)`; let subQStr = `(SELECT COUNT(*)`;
subQStr += ` FROM ${countSubQuery.table}${tableAlias ? ` ${tableAlias}` : ""}`; subQStr += ` FROM ${quoteQualified(countSubQuery.table)}${tableAlias ? ` ${quoteIdentifier(tableAlias)}` : ""}`;
subQStr += ` WHERE (`; subQStr += ` WHERE (`;
for (let j = 0; j < countSubQuery.srcTrgMap.length; j++) { for (let j = 0; j < countSubQuery.srcTrgMap.length; j++) {
const csqSrc = countSubQuery.srcTrgMap[j]; const csqSrc = countSubQuery.srcTrgMap[j];
if (!csqSrc) if (!csqSrc)
continue; continue;
subQStr += ` ${tableAlias || countSubQuery.table}.${csqSrc.src}`; subQStr += ` ${countTableRef}.${quoteIdentifier(csqSrc.src)}`;
if (typeof csqSrc.trg == "string") { if (typeof csqSrc.trg == "string") {
subQStr += ` = ?`; subQStr += ` = ?`;
sqlSearhValues.push(csqSrc.trg); sqlSearhValues.push(csqSrc.trg);
} }
else if (typeof csqSrc.trg == "object") { else if (typeof csqSrc.trg == "object") {
subQStr += ` = ${csqSrc.trg.table}.${csqSrc.trg.field}`; subQStr += ` = ${columnRef(csqSrc.trg.field, csqSrc.trg.table)}`;
} }
if (j < countSubQuery.srcTrgMap.length - 1) { if (j < countSubQuery.srcTrgMap.length - 1) {
subQStr += ` AND `; subQStr += ` AND `;
} }
} }
subQStr += ` )) AS ${countSubQuery.alias}`; subQStr += ` )) AS ${quoteIdentifier(countSubQuery.alias)}`;
countSqls.push(subQStr); countSqls.push(subQStr);
} }
str += `, ${countSqls.join(",")}`; str += `, ${countSqls.join(",")}`;
} }
if (genObject?.join) { if (genObject?.join) {
const existingJoinTableNames = [table_name]; const existingJoinTableNames = [table_name];
str += const joinSelects = genObject.join
"," +
genObject.join
.flat() .flat()
.filter((j) => !isUndefined(j)) .filter((j) => !isUndefined(j))
.map((joinObj) => { .map((joinObj) => {
const joinTableName = joinObj.alias const joinTableName = joinObj.alias
? joinObj.alias ? joinObj.alias
: joinObj.tableName; : joinObj.tableName;
if (existingJoinTableNames.includes(joinTableName)) const joinTableRef = quoteQualified(joinTableName);
if (existingJoinTableNames.includes(joinTableRef))
return null; return null;
existingJoinTableNames.push(joinTableName); existingJoinTableNames.push(joinTableRef);
if (joinObj.group_concat) { if (joinObj.group_concat) {
return sqlGenGrabConcatStr({ return sqlGenGrabConcatStr({
field: `${joinTableName}.${joinObj.group_concat.field}`, field: columnRef(joinObj.group_concat.field, joinTableName),
alias: joinObj.group_concat.alias, alias: joinObj.group_concat.alias,
separator: joinObj.group_concat.separator, separator: joinObj.group_concat.separator,
}); });
@@ -91,57 +92,59 @@ export default function sqlGenGenQueryStr(params) {
return joinObj.selectFields return joinObj.selectFields
.map((selectField) => { .map((selectField) => {
if (typeof selectField == "string") { if (typeof selectField == "string") {
return `${joinTableName}.${selectField}`; return columnRef(selectField, joinTableName);
} }
else if (typeof selectField == "object") { else if (typeof selectField == "object") {
let aliasSelectField = `${joinTableName}.${selectField.field}`; const fieldRef = columnRef(String(selectField.field), joinTableName);
let aliasSelectField = fieldRef;
if (selectField.count) { if (selectField.count) {
aliasSelectField = `COUNT(${joinTableName}.${selectField.field})`; aliasSelectField = `COUNT(${fieldRef})`;
} }
else if (selectField.sum) { else if (selectField.sum) {
aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${joinTableName}.${selectField.field})`; aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${fieldRef})`;
} }
else if (selectField.average) { else if (selectField.average) {
aliasSelectField = `AVERAGE(${joinTableName}.${selectField.field})`; aliasSelectField = `AVG(${fieldRef})`;
} }
else if (selectField.max) { else if (selectField.max) {
aliasSelectField = `MAX(${joinTableName}.${selectField.field})`; aliasSelectField = `MAX(${fieldRef})`;
} }
else if (selectField.min) { else if (selectField.min) {
aliasSelectField = `MIN(${joinTableName}.${selectField.field})`; aliasSelectField = `MIN(${fieldRef})`;
} }
else if (selectField.group_concat && else if (selectField.group_concat &&
selectField.alias) { selectField.alias) {
return sqlGenGrabConcatStr({ return sqlGenGrabConcatStr({
field: `${joinTableName}.${selectField.field}`, field: fieldRef,
alias: selectField.alias, alias: selectField.alias,
separator: selectField.group_concat separator: selectField.group_concat.separator,
.separator, distinct: selectField.group_concat.distinct,
distinct: selectField.group_concat
.distinct,
}); });
} }
else if (selectField.distinct) { else if (selectField.distinct) {
aliasSelectField = `DISTINCT ${joinTableName}.${selectField.field}`; aliasSelectField = `DISTINCT ${fieldRef}`;
} }
if (selectField.alias) if (selectField.alias)
aliasSelectField += ` AS ${selectField.alias}`; aliasSelectField += ` AS ${quoteIdentifier(selectField.alias)}`;
return aliasSelectField; return aliasSelectField;
} }
}) })
.join(","); .join(",");
} }
else { else {
return `${joinTableName}.*`; return `${joinTableRef}.*`;
} }
}) })
.filter((_) => Boolean(_)) .filter((_) => Boolean(_));
.join(","); if (joinSelects.length) {
str += "," + joinSelects.join(",");
}
} }
if (genObject?.fullTextSearch && if (genObject?.fullTextSearch &&
genObject.fullTextSearch.scoreAlias &&
full_text_match_str && full_text_match_str &&
full_text_search_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); sqlSearhValues.push(full_text_search_str);
} }
str += ` FROM ${table_name}`; str += ` FROM ${table_name}`;
@@ -152,11 +155,11 @@ export default function sqlGenGenQueryStr(params) {
.flat() .flat()
.filter((j) => !isUndefined(j)) .filter((j) => !isUndefined(j))
.map((join) => { .map((join) => {
return (join.joinType + return (sqlJoinType(join.joinType) +
" " + " " +
(join.alias (join.alias
? `${join.tableName}` + " " + join.alias ? `${quoteQualified(join.tableName)} ${quoteIdentifier(join.alias)}`
: `${join.tableName}`) + : `${quoteQualified(join.tableName)}`) +
" ON " + " ON " +
(() => { (() => {
if (Array.isArray(join.match)) { if (Array.isArray(join.match)) {
@@ -172,9 +175,7 @@ export default function sqlGenGenQueryStr(params) {
sqlSearhValues.push(...values); sqlSearhValues.push(...values);
return str; return str;
}) })
.join(join.operator .join(` ${sqlLogicalOperator(join.operator)} `) +
? ` ${join.operator} `
: " AND ") +
")"); ")");
} }
else if (typeof join.match == "object") { else if (typeof join.match == "object") {
+1
View File
@@ -3,6 +3,7 @@ type Param = {
queryObj: ServerQueryQueryObject[string]; queryObj: ServerQueryQueryObject[string];
join?: (ServerQueryParamsJoin | ServerQueryParamsJoin[] | undefined)[]; join?: (ServerQueryParamsJoin | ServerQueryParamsJoin[] | undefined)[];
field?: string; field?: string;
/** Already-quoted root table name */
table_name: string; table_name: string;
}; };
export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }: Param): { export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }: Param): {
+62 -42
View File
@@ -1,92 +1,112 @@
import sqlGenOperatorGen from "./sql-gen-operator-gen"; import sqlGenOperatorGen from "./sql-gen-operator-gen";
import { columnRef, sqlLogicalOperator, sqlRawComparison, } from "./sql-identifier";
export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }) { export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }) {
let sqlSearhValues = []; let sqlSearhValues = [];
const finalFieldName = (() => { const finalFieldName = (() => {
if (queryObj?.tableName) { if (queryObj?.tableName) {
return `${queryObj.tableName}.${field}`; return columnRef(String(field), queryObj.tableName);
} }
if (join) { if (join) {
return `${table_name}.${field}`; return columnRef(String(field), table_name);
} }
return field; return columnRef(String(field));
})(); })();
let str = `${finalFieldName}=?`; let str = `${finalFieldName}=?`;
function grabValue(val) { function grabValue(val) {
const valueParsed = val; if (val === undefined || val === null)
if (!valueParsed)
return; return;
const valueString = typeof valueParsed == "string" || typeof valueParsed == "number" const isFieldRef = Boolean(typeof val == "object" && val.fieldName && val.tableName);
? valueParsed const valueString = typeof val == "string" || typeof val == "number"
: valueParsed ? val
? valueParsed.fieldName && valueParsed.tableName : isFieldRef
? `${valueParsed.tableName}.${valueParsed.fieldName}` ? columnRef(val.fieldName, val.tableName)
: valueParsed.value : val.value;
: undefined; const valueEquality = typeof val == "object"
const valueEquality = typeof valueParsed == "object" ? val.equality || queryObj.equality
? valueParsed.equality || queryObj.equality
: queryObj.equality; : queryObj.equality;
const operatorStrParam = sqlGenOperatorGen({ return sqlGenOperatorGen({
queryObj, queryObj,
equality: valueEquality, equality: valueEquality,
fieldName: finalFieldName || "", fieldName: finalFieldName,
value: valueString || "", value: valueString ?? "",
isValueFieldValue: Boolean(typeof valueParsed == "object" && isValueFieldValue: isFieldRef,
valueParsed.fieldName &&
valueParsed.tableName),
}); });
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 = []; const strArray = [];
queryObj.value.forEach((val) => { queryObj.value.forEach((val) => {
const operatorStrParam = grabValue(val); const operatorStrParam = grabValue(val);
if (!operatorStrParam) if (!operatorStrParam?.str)
return; return;
if (operatorStrParam.str && operatorStrParam.param) {
strArray.push(operatorStrParam.str); strArray.push(operatorStrParam.str);
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param); 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); const operatorStrParam = grabValue(queryObj.value);
if (operatorStrParam?.str) { if (operatorStrParam?.str) {
str = operatorStrParam.str; str = operatorStrParam.str;
if (operatorStrParam.param) { if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param); sqlSearhValues.push(operatorStrParam.param);
} }
} }
} }
else if (queryObj.raw_equality && queryObj.value) { else if (queryObj.raw_equality &&
str = `${finalFieldName} ${queryObj.raw_equality} ?`; queryObj.value !== undefined &&
queryObj.value !== null) {
str = `${finalFieldName} ${sqlRawComparison(queryObj.raw_equality)} ?`;
sqlSearhValues.push(queryObj.value); sqlSearhValues.push(queryObj.value);
} }
else if (queryObj.between) { else if (queryObj.between) {
str = `${finalFieldName} BETWEEN ? AND ?`; str = `${finalFieldName} BETWEEN ? AND ?`;
sqlSearhValues.push(queryObj.between.min, queryObj.between.max); sqlSearhValues.push(queryObj.between.min, queryObj.between.max);
} }
else if (queryObj.not_between) {
str = `${finalFieldName} NOT BETWEEN ? AND ?`;
sqlSearhValues.push(queryObj.not_between.min, queryObj.not_between.max);
}
else { else {
const valueParsed = queryObj.value ? queryObj.value : undefined;
const operatorStrParam = sqlGenOperatorGen({ const operatorStrParam = sqlGenOperatorGen({
equality: queryObj.equality, equality: queryObj.equality,
fieldName: finalFieldName || "", fieldName: finalFieldName,
value: valueParsed, value: queryObj.value ?? undefined,
queryObj, queryObj,
}); });
if (operatorStrParam.str && operatorStrParam.param) { if (operatorStrParam.str) {
str = operatorStrParam.str; str = operatorStrParam.str;
}
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param); sqlSearhValues.push(operatorStrParam.param);
} }
else if (operatorStrParam.str && !operatorStrParam.str.match(/\?/)) {
str = operatorStrParam.str;
}
else {
sqlSearhValues.push(valueParsed || "");
}
} }
return { str, values: sqlSearhValues }; return { str, values: sqlSearhValues };
} }
+1
View File
@@ -1,4 +1,5 @@
type Param = { type Param = {
/** Already-quoted column reference */
field: string; field: string;
alias: string; alias: string;
separator?: string; separator?: string;
+3 -2
View File
@@ -1,3 +1,4 @@
import { escapeStringLiteral, quoteIdentifier } from "./sql-identifier";
export default function sqlGenGrabConcatStr({ alias, field, separator = ",", distinct, }) { export default function sqlGenGrabConcatStr({ alias, field, separator = ",", distinct, }) {
let gc = `GROUP_CONCAT(`; let gc = `GROUP_CONCAT(`;
if (distinct) { if (distinct) {
@@ -5,9 +6,9 @@ export default function sqlGenGrabConcatStr({ alias, field, separator = ",", dis
} }
gc += `${field}`; gc += `${field}`;
if (!distinct) { if (!distinct) {
gc += `, '${separator}'`; gc += `, '${escapeStringLiteral(separator)}'`;
} }
gc += `)`; gc += `)`;
gc += ` AS ${alias}`; gc += ` AS ${quoteIdentifier(alias)}`;
return gc; return gc;
} }
+18 -25
View File
@@ -1,36 +1,35 @@
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import { columnRef, quoteIdentifier } from "./sql-identifier";
export default function sqlGenGrabSelectFieldSQL({ selectFields, append_table_names, table_name }) { export default function sqlGenGrabSelectFieldSQL({ selectFields, append_table_names, table_name }) {
let str = ""; const table = append_table_names ? table_name : undefined;
str += ` ${selectFields return ` ${selectFields
?.map((fld) => { .map((fld) => {
if (typeof fld != "object") {
return columnRef(String(fld), table);
}
const fld_name = columnRef(String(fld.fieldName), table);
let fld_str = ``; 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) { if (fld.count) {
fld_str += `COUNT(${fld_name})`; fld_str = `COUNT(${fld_name})`;
} }
else if (fld.sum) { else if (fld.sum) {
fld_str += `SUM(${fld_name})`; fld_str = `SUM(${fld_name})`;
} }
else if (fld.average) { else if (fld.average) {
fld_str += `AVERAGE(${fld_name})`; fld_str = `AVG(${fld_name})`;
} }
else if (fld.max) { else if (fld.max) {
fld_str += `MAX(${fld_name})`; fld_str = `MAX(${fld_name})`;
} }
else if (fld.min) { else if (fld.min) {
fld_str += `MIN(${fld_name})`; fld_str = `MIN(${fld_name})`;
} }
else if (fld.distinct) { else if (fld.distinct) {
fld_str += `DISTINCT ${fld_name}`; fld_str = `DISTINCT ${fld_name}`;
} }
else if (fld.group_concat) { else if (fld.group_concat) {
fld_str += sqlGenGrabConcatStr({ // Carries its own alias
return sqlGenGrabConcatStr({
field: fld_name, field: fld_name,
alias: fld.group_concat.alias, alias: fld.group_concat.alias,
separator: fld.group_concat.separator, separator: fld.group_concat.separator,
@@ -38,18 +37,12 @@ export default function sqlGenGrabSelectFieldSQL({ selectFields, append_table_na
}); });
} }
else { else {
fld_str += fld_str = fld_name;
final_fld_name + (fld.alias ? ` as ${fld.alias}` : ``);
} }
if (fld.alias) { if (fld.alias) {
fld_str += ` AS ${fld.alias}`; fld_str += ` AS ${quoteIdentifier(fld.alias)}`;
}
}
else {
fld_str += final_fld_name;
} }
return fld_str; return fld_str;
}) })
.join(",")}`; .join(",")}`;
return str;
} }
+11 -1
View File
@@ -5,13 +5,23 @@ type Param<T extends {
[key: string]: any; [key: string]: any;
}> = { }> = {
genObject?: ServerQueryParam<T>; genObject?: ServerQueryParam<T>;
/** Raw (unquoted) table name */
tableName: string; tableName: string;
dbFullName?: string; dbFullName?: string;
count?: boolean; count?: boolean;
/**
* Restrict to a single row id. Always combined with AND, regardless of
* `searchOperator`.
*/
targetId?: number | string;
}; };
type Return = { type Return = {
string: string; string: string;
values: SQLInsertGenValueType[]; values: SQLInsertGenValueType[];
/** `WHERE ...` clause alone (empty string if none) */
where: string;
/** Values for the `where` clause placeholders only */
whereValues: SQLInsertGenValueType[];
}; };
/** /**
* # SQL Query Generator * # SQL Query Generator
@@ -21,5 +31,5 @@ export default function sqlGenerator<T extends {
[key: string]: any; [key: string]: any;
} = { } = {
[key: string]: any; [key: string]: any;
}>({ tableName, genObject, dbFullName, count }: Param<T>): Return; }>({ tableName: rawTableName, genObject, dbFullName, count, targetId, }: Param<T>): Return;
export {}; export {};
+71 -221
View File
@@ -1,16 +1,20 @@
import sqlGenGenSearchStr from "./sql-generator-gen-search-str"; import sqlGenGenSearchStr from "./sql-generator-gen-search-str";
import sqlGenGenQueryStr from "./sql-generator-gen-query-str"; import sqlGenGenQueryStr from "./sql-generator-gen-query-str";
import { 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 * # SQL Query Generator
* @description Builds parameterized SELECT SQL for MariaDB * @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 finalQuery = genObject?.query ? genObject.query : undefined;
const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined; const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined;
const sqlSearhValues = []; const whereValues = [];
let fullTextMatchStr = genObject?.fullTextSearch let fullTextMatchStr = genObject?.fullTextSearch
? ` MATCH(${genObject.fullTextSearch.fields ? ` 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)` .join(",")}) AGAINST (? IN BOOLEAN MODE)`
: undefined; : undefined;
const fullTextSearchStr = genObject?.fullTextSearch const fullTextSearchStr = genObject?.fullTextSearch
@@ -19,14 +23,13 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
.map((t) => `${t}`) .map((t) => `${t}`)
.join(" ") .join(" ")
: undefined; : undefined;
let { str: queryString, values } = sqlGenGenQueryStr({ let { str: queryString, values: selectValues } = sqlGenGenQueryStr({
table_name: tableName, table_name: tableName,
append_table_names: true, append_table_names: true,
full_text_match_str: fullTextMatchStr, full_text_match_str: fullTextMatchStr,
full_text_search_str: fullTextSearchStr, full_text_search_str: fullTextSearchStr,
genObject, genObject,
}); });
sqlSearhValues.push(...values);
const sqlSearhString = queryKeys?.map((field) => { const sqlSearhString = queryKeys?.map((field) => {
const queryObj = finalQuery?.[field]; const queryObj = finalQuery?.[field];
if (!queryObj) if (!queryObj)
@@ -34,7 +37,8 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
if (queryObj.__query) { if (queryObj.__query) {
const subQueryGroup = queryObj.__query; const subQueryGroup = queryObj.__query;
const subSearchKeys = Object.keys(subQueryGroup); const subSearchKeys = Object.keys(subQueryGroup);
const subSearchString = subSearchKeys.map((_field) => { const subSearchString = subSearchKeys
.map((_field) => {
const newSubQueryObj = subQueryGroup?.[_field]; const newSubQueryObj = subQueryGroup?.[_field];
if (newSubQueryObj) { if (newSubQueryObj) {
const { str, values } = sqlGenGenSearchStr({ const { str, values } = sqlGenGenSearchStr({
@@ -43,12 +47,13 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
join: genObject?.join, join: genObject?.join,
table_name: tableName, table_name: tableName,
}); });
sqlSearhValues.push(...values); whereValues.push(...values);
return str; return str;
} }
}); })
.filter((str) => typeof str == "string");
return ("(" + return ("(" +
subSearchString.join(` ${queryObj.operator || "AND"} `) + subSearchString.join(` ${sqlLogicalOperator(queryObj.operator)} `) +
")"); ")");
} }
const { str, values } = sqlGenGenSearchStr({ const { str, values } = sqlGenGenSearchStr({
@@ -57,247 +62,92 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
join: genObject?.join, join: genObject?.join,
table_name: tableName, table_name: tableName,
}); });
sqlSearhValues.push(...values); whereValues.push(...values);
return str; return str;
}); });
const cleanedUpSearchStr = sqlSearhString?.filter((str) => typeof str == "string"); const cleanedUpSearchStr = sqlSearhString?.filter((str) => typeof str == "string");
const isSearchStr = cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str); const isSearchStr = cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str);
const whereConditions = [];
if (isSearchStr) { if (isSearchStr) {
const stringOperator = genObject?.searchOperator || "AND"; const stringOperator = sqlLogicalOperator(genObject?.searchOperator);
queryString += ` WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`; whereConditions.push(`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`);
} }
if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) { if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) {
queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`; whereConditions.push(fullTextMatchStr.trim());
sqlSearhValues.push(fullTextSearchStr); 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) { if (genObject?.group) {
const groupRef = (group) => typeof group == "object"
? columnRef(String(group.field), group.table)
: columnRef(String(group));
let group_by_txt = ``; let group_by_txt = ``;
if (typeof genObject.group == "string") { if (Array.isArray(genObject.group)) {
group_by_txt = genObject.group; group_by_txt = genObject.group
} .map((group) => groupRef(group))
else if (Array.isArray(genObject.group)) { .join(",");
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 { else {
group_by_txt = `${String(genObject.group.field)}`; group_by_txt = groupRef(genObject.group);
}
} }
queryString += ` GROUP BY ${group_by_txt}`; queryString += ` GROUP BY ${group_by_txt}`;
} }
function grabOrderString(order) { function grabOrderString(order) {
let orderFields = []; const scoreAlias = genObject?.fullTextSearch?.scoreAlias;
let orderSrt = ``; let orderField = ``;
if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) { if (scoreAlias && String(order.field) === scoreAlias) {
orderFields.push(genObject.fullTextSearch.scoreAlias); orderField = quoteIdentifier(scoreAlias);
}
else if (genObject?.join) {
orderFields.push(`${tableName}.${String(order.field)}`);
} }
else { else {
orderFields.push(order.field); orderField = columnRef(String(order.field), genObject?.join ? tableName : undefined);
} }
orderSrt += ` ${orderFields.join(", ")} ${order.strategy}`; return ` ${orderField} ${sqlOrderStrategy(order.strategy)}`;
return orderSrt;
} }
if (genObject?.order) { if (genObject?.order) {
let orderSrt = ` ORDER BY`; const orders = (Array.isArray(genObject.order) ? genObject.order : [genObject.order]).filter(Boolean);
if (Array.isArray(genObject.order)) { if (orders.length) {
for (let i = 0; i < genObject.order.length; i++) { queryString += ` ORDER BY${orders.map(grabOrderString).join(",")}`;
const order = genObject.order[i];
if (order) {
orderSrt +=
grabOrderString(order) +
(i < genObject.order.length - 1 ? `,` : "");
} }
} }
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 { else if (genObject?.page && limit) {
orderSrt += grabOrderString(genObject.order); const page = sqlNonNegativeInt(genObject.page, "page");
offset = Math.max(page - 1, 0) * limit;
} }
queryString += ` ${orderSrt}`; if (limit) {
queryString += ` LIMIT ${limit}`;
} }
if (genObject?.limit && !count) else if (offset) {
queryString += ` LIMIT ${genObject.limit}`; queryString += ` LIMIT ${MAX_LIMIT}`;
if (genObject?.offset) { }
queryString += ` OFFSET ${genObject.offset}`; if (offset) {
queryString += ` OFFSET ${offset}`;
} }
else if (genObject?.page && genObject.limit && !count) {
queryString += ` OFFSET ${(genObject.page - 1) * genObject.limit}`;
} }
return { return {
string: queryString, 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<T>({
// 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;
// })();
+27
View File
@@ -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;
+80
View File
@@ -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;
}
+9 -27
View File
@@ -1,6 +1,6 @@
function quoteIdentifier(identifier) { import { quoteIdentifier } from "./sql-identifier";
return `\`${identifier.replace(/`/g, "``")}\``; import sqlValuePlaceholder from "./sql-value-placeholder";
} import grabTableFields from "./grab-table-fields";
/** /**
* # SQL Insert Generator * # 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 queryBatches = [];
let queryValues = []; let queryValues = [];
data.forEach((item) => { data.forEach((item) => {
queryBatches.push(`(${insertKeys queryBatches.push(`(${insertKeys
.map((ky) => { .map((ky) => {
const value = item[ky]; const { placeholder, value } = sqlValuePlaceholder({
const is_embedding = Array.isArray(value) && value: item[ky],
typeof value[0] == "number"; field: fieldsMap.get(ky),
const finalValue = typeof value == "string" || });
typeof value == "number" queryValues.push(value);
? 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(?)"
: "?";
return placeholder; return placeholder;
}) })
.filter((k) => Boolean(k))
.join(",")})`); .join(",")})`);
}); });
const insertColumns = insertKeys.map(quoteIdentifier).join(","); const insertColumns = insertKeys.map(quoteIdentifier).join(",");
+19
View File
@@ -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 {};
+36
View File
@@ -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 };
}
+5 -1
View File
@@ -3,9 +3,13 @@ import fs from "fs";
import grabSortedBackups from "./grab-sorted-backups"; import grabSortedBackups from "./grab-sorted-backups";
import { AppData } from "../data/app-data"; import { AppData } from "../data/app-data";
import path from "path"; import path from "path";
import _ from "lodash";
export default function trimBackups({ config }) { export default function trimBackups({ config }) {
const { backup_dir } = grabDBDir({ 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"]; const max_backups = config.max_backups || AppData["MaxBackups"];
for (let i = 0; i < backups.length; i++) { for (let i = 0; i < backups.length; i++) {
const backup_name = backups[i]; const backup_name = backups[i];
+5 -1
View File
@@ -1,11 +1,15 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import _ from "lodash";
import { AppData } from "../data/app-data"; import { AppData } from "../data/app-data";
import grabDBDir from "./grab-db-dir"; import grabDBDir from "./grab-db-dir";
import grabSortedExports from "./grab-sorted-exports"; import grabSortedExports from "./grab-sorted-exports";
export default function trimExports({ config }) { export default function trimExports({ config }) {
const { export_dir } = grabDBDir({ 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"]; const max_exports = config.max_exports || AppData["MaxExports"];
for (let i = 0; i < exports.length; i++) { for (let i = 0; i < exports.length; i++) {
const export_name = exports[i]; const export_name = exports[i];
+3 -2
View File
@@ -27,12 +27,13 @@
}, },
"scripts": { "scripts": {
"dev": "tsc --watch", "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", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test tests",
"prepublishOnly": "bun run compile" "prepublishOnly": "bun run compile"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "1.3.14",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"@types/sanitize-html": "^2.16.1", "@types/sanitize-html": "^2.16.1",
+12 -1
View File
@@ -9,8 +9,19 @@ import exportCmd from "./export";
import importCmd from "./import"; import importCmd from "./import";
import admin from "./admin"; import admin from "./admin";
import init from "../functions/init"; import init from "../functions/init";
import { readFileSync } from "fs";
try {
init(); 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 * # Describe Program
@@ -18,7 +29,7 @@ init();
program program
.name(`bun-mariadb`) .name(`bun-mariadb`)
.description(`MariaDB manager for Bun`) .description(`MariaDB manager for Bun`)
.version(`1.0.0`); .version(version);
/** /**
* # Declare Commands * # Declare Commands
+99 -3
View File
@@ -6,11 +6,39 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-
import chalk from "chalk"; import chalk from "chalk";
import { writeLiveSchema } from "../functions/live-schema"; import { writeLiveSchema } from "../functions/live-schema";
import createDBSchema from "../lib/schema/create-db-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<T>(fn: () => Promise<T>): Promise<T> {
const { log, warn } = console;
console.log = () => {};
console.warn = () => {};
try {
return await fn();
} finally {
console.log = log;
console.warn = warn;
}
}
export default function () { export default function () {
return new Command("schema") return new Command("schema")
.description("Build DB From Schema") .description("Build DB From Schema")
.option("-t, --typedef", "Generate typescript type definitions") .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) => { .action(async (opts) => {
console.log(`Starting process ...`); console.log(`Starting process ...`);
@@ -26,11 +54,79 @@ export default function () {
dbSchema, dbSchema,
}); });
await createDBSchema({ const params = { db_schema: finaldbSchema, config };
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) { if (isTypeDef && config.typedef_file_path) {
const out_file = path.resolve( const out_file = path.resolve(
ROOT_DIR, ROOT_DIR,
+18 -4
View File
@@ -7,23 +7,37 @@ type Params = {
config: BunMariaDBConfig; 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<string, Bun.SQL>(); const clientCache = new Map<string, Bun.SQL>();
/**
* 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({ export default function grabMariaDBClient({
config, config,
}: Params): Bun.SQL | undefined { }: Params): Bun.SQL | undefined {
const cacheKey = config.db_name || "__default__"; const cacheKey = mariadbClientCacheKey(config);
const cached = clientCache.get(cacheKey); const cached = clientCache.get(cacheKey);
if (cached) { if (cached) {
return 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 ( if (
global.MARIADB_CLIENT && 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); clientCache.set(cacheKey, global.MARIADB_CLIENT);
return global.MARIADB_CLIENT; return global.MARIADB_CLIENT;
+23 -32
View File
@@ -24,8 +24,11 @@ declare global {
var SCHEMA_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 { export default function init(): void {
try {
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData; const { ConfigFileName } = AppData;
@@ -35,24 +38,23 @@ export default function init(): void {
); );
if (!loadedConfig) { if (!loadedConfig) {
console.error( throw new Error(
`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`, `Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`,
); );
process.exit(1);
} }
const Config = loadedConfig.data; const Config = loadedConfig.data;
if (!Config || typeof Config !== "object") { if (!Config || typeof Config !== "object") {
console.error( throw new Error(
`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`, `Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`,
); );
process.exit(1);
} }
if (!Config.db_name) { if (!Config.db_name) {
console.error(`\`db_name\` is required in your config`); throw new Error(
process.exit(1); `\`db_name\` is required in your config`,
);
} }
RequiredENVs.forEach((env_key) => { RequiredENVs.forEach((env_key) => {
@@ -64,16 +66,16 @@ export default function init(): void {
} }
if (!process.env[env_key]) { if (!process.env[env_key]) {
console.error(`\`${env_key}\` env variable is required.`); throw new Error(
process.exit(1); `\`${env_key}\` env variable is required.`,
);
} }
}); });
if (!Config.db_dir) { if (!Config.db_dir) {
console.error( 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`, `\`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); const db_dir = path.resolve(ROOT_DIR, Config.db_dir);
@@ -88,19 +90,17 @@ export default function init(): void {
); );
if (!loadedSchema) { if (!loadedSchema) {
console.error( 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." : "."}`, `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; const DbSchema = loadedSchema.data;
if (!DbSchema || typeof DbSchema !== "object") { if (!DbSchema || typeof DbSchema !== "object") {
console.error( throw new Error(
`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`, `Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`,
); );
process.exit(1);
} }
const backup_dir = const backup_dir =
@@ -121,24 +121,15 @@ export default function init(): void {
global.CONFIG_FILE_PATH = loadedConfig.path; global.CONFIG_FILE_PATH = loadedConfig.path;
global.SCHEMA_FILE_PATH = loadedSchema.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 }); setMariaDBClient({ config: Config });
}
if (!global.MARIADB_CLIENT) { /**
console.error(`Couldn't set MariaDB Client.`); * Initialize once, on first use.
process.exit(1); */
} export function ensureInit(): void {
} catch (error: any) { if (global.CONFIG && global.DB_SCHEMA && global.MARIADB_CLIENT !== undefined) {
console.error(`Initialization ERROR => ` + error.message); return;
process.exit(1);
} }
init();
} }
-13
View File
@@ -6,7 +6,6 @@ type Params = {
}; };
export default function setMariaDBClient({ config }: Params): void { export default function setMariaDBClient({ config }: Params): void {
try {
const MariaDBClient = grabMariaDBClient({ config }); const MariaDBClient = grabMariaDBClient({ config });
if (!MariaDBClient) { if (!MariaDBClient) {
@@ -14,16 +13,4 @@ export default function setMariaDBClient({ config }: Params): void {
} }
global.MARIADB_CLIENT = MariaDBClient; 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);
}
} }
+17 -8
View File
@@ -1,4 +1,4 @@
import init from "./functions/init"; import { ensureInit } from "./functions/init";
import DbDelete from "./lib/mariadb/db-delete"; import DbDelete from "./lib/mariadb/db-delete";
import DbInsert from "./lib/mariadb/db-insert"; import DbInsert from "./lib/mariadb/db-insert";
import DbSelect from "./lib/mariadb/db-select"; 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 grabDbSchema from "./utils/grab-db-schema";
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object"; 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<F extends (...args: any[]) => Promise<any>>(fn: F): F {
return (async (...args: Parameters<F>) => {
ensureInit();
return fn(...args);
}) as F;
}
const BunMariaDB = { const BunMariaDB = {
select: DbSelect, select: withInit(DbSelect),
insert: DbInsert, insert: withInit(DbInsert),
update: DbUpdate, update: withInit(DbUpdate),
delete: DbDelete, delete: withInit(DbDelete),
sql: DbSQL, sql: withInit(DbSQL),
utils: { utils: {
grab_db_schema: grabDbSchema, grab_db_schema: withInit(grabDbSchema),
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject, grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
}, },
} as const; } as const;
+53 -27
View File
@@ -1,38 +1,78 @@
import grabMariaDBClient from "../functions/grab-mariadb-client"; import grabMariaDBClient, {
mariadbClientCacheKey,
} from "../functions/grab-mariadb-client";
import type { import type {
BunMariaDBConfig, BunMariaDBConfig,
DBInsertReturn, DBInsertReturn,
DBResponseObject, DBResponseObject,
} from "../types"; } from "../types";
/** Anything that can run a raw query: a client or a transaction handle */
export type DBQueryRunner = {
unsafe: (query: string, values?: any[]) => Promise<any>;
};
type Param = { type Param = {
query: string; query: string;
values?: any[]; values?: any[];
config?: BunMariaDBConfig; 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 * Resolve a shared MariaDB client. Prefer the global client so schema sync
* and CRUD do not open a new connection per query. * and CRUD do not open a new connection per query.
*/ */
function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined { export function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined {
const resolvedConfig = config || global.CONFIG; if (!config) {
return global.MARIADB_CLIENT;
}
// Same database as the global client → reuse it // Same connection config as the global client → reuse it
if ( if (
global.MARIADB_CLIENT && global.MARIADB_CLIENT &&
(!resolvedConfig?.db_name || (!global.CONFIG ||
!global.CONFIG?.db_name || mariadbClientCacheKey(config) ===
resolvedConfig.db_name === global.CONFIG.db_name) mariadbClientCacheKey(global.CONFIG))
) { ) {
return global.MARIADB_CLIENT; return global.MARIADB_CLIENT;
} }
if (resolvedConfig) { return grabMariaDBClient({ config });
return grabMariaDBClient({ config: resolvedConfig });
} }
return global.MARIADB_CLIENT; /**
* 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();
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
return value;
}
function normalizeRows<T>(res: unknown): T[] | undefined {
if (!Array.isArray(res)) return undefined;
return res.map((row) => {
if (!row || typeof row != "object") return row;
const normalized: Record<string, unknown> = {};
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< export default async function dbHandler<
T extends { [k: string]: any } = { [k: string]: any }, T extends { [k: string]: any } = { [k: string]: any },
>({ query, values, config }: Param): Promise<DBResponseObject<T>> { >({ query, values, config, client }: Param): Promise<DBResponseObject<T>> {
try { try {
const CLIENT = resolveClient(config); const CLIENT = client || resolveClient(config);
if (!CLIENT) { if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`); throw new Error(`Couldn't grab MariaDB Client.`);
@@ -52,21 +92,7 @@ export default async function dbHandler<
const count = res.count; const count = res.count;
const res_array = (() => { const res_array = normalizeRows<T>(res);
try {
return JSON.parse(
JSON.stringify(res, (_key, value) =>
typeof value == "bigint"
? Number.isSafeInteger(Number(value))
? Number(value)
: value.toString()
: value,
),
) as T[];
} catch (error) {
return undefined;
}
})();
const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined; const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined;
const affected_rows = res_array?.[0] ? res_array.length : undefined; const affected_rows = res_array?.[0] ? res_array.length : undefined;
+1 -3
View File
@@ -4,9 +4,7 @@ type Params = {
data: any | any[]; data: any | any[];
}; };
function quoteIdentifier(identifier: string): string { import { quoteIdentifier } from "../utils/sql-identifier";
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function ({ sql: passedSql, table, data }: Params) { export default async function ({ sql: passedSql, table, data }: Params) {
const config = global.CONFIG; const config = global.CONFIG;
+2 -14
View File
@@ -30,24 +30,12 @@ export default async function DbDelete<
let sqlObj: ReturnType<typeof sqlGenerator> | null = null; let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId) {
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
finalQuery,
{
query: {
id: {
value: Number(targetId),
},
},
},
);
}
sqlObj = sqlGenerator({ sqlObj = sqlGenerator({
tableName: table, tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const whereClause = sqlObj.where; const whereClause = sqlObj.where;
+3 -15
View File
@@ -1,5 +1,4 @@
import dbHandler from "../db-handler"; import dbHandler from "../db-handler";
import _ from "lodash";
import type { import type {
BunMariaDBConfig, BunMariaDBConfig,
DBResponseObject, DBResponseObject,
@@ -31,24 +30,12 @@ export default async function DbSelect<
let sqlObj: ReturnType<typeof sqlGenerator> | null = null; let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId) {
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
finalQuery,
{
query: {
id: {
value: Number(targetId),
},
},
},
);
}
sqlObj = sqlGenerator({ sqlObj = sqlGenerator({
tableName: table, tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const res = await dbHandler<Schema>({ const res = await dbHandler<Schema>({
@@ -85,6 +72,7 @@ export default async function DbSelect<
const countSqlObject = sqlGenerator({ const countSqlObject = sqlGenerator({
tableName: table, tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
count, count,
}); });
+1 -4
View File
@@ -41,13 +41,10 @@ export default async function DbSQL<
const payload = isSelect ? ((res.payload || []) as T[]) : undefined; const payload = isSelect ? ((res.payload || []) as T[]) : undefined;
const single_res = isSelect ? payload?.[0] : (res.single_res as T); const single_res = isSelect ? payload?.[0] : (res.single_res as T);
const singleRaw = res.single_res as any;
return { return {
...res, ...res,
success: isSelect success: true,
? Boolean(single_res) || Boolean(payload?.[0])
: true,
payload, payload,
single_res, single_res,
debug: { debug: {
+73 -68
View File
@@ -1,5 +1,4 @@
import dbHandler from "../db-handler"; import dbHandler, { resolveClient } from "../db-handler";
import _ from "lodash";
import type { import type {
BunMariaDBConfig, BunMariaDBConfig,
DBResponseObject, DBResponseObject,
@@ -9,6 +8,8 @@ import type {
import sqlGenerator from "../../utils/sql-generator"; import sqlGenerator from "../../utils/sql-generator";
import sanitizeHtmlFields from "../../utils/sanitize-html-fields"; import sanitizeHtmlFields from "../../utils/sanitize-html-fields";
import { quoteIdentifier } from "../../utils/sql-identifier"; import { quoteIdentifier } from "../../utils/sql-identifier";
import sqlValuePlaceholder from "../../utils/sql-value-placeholder";
import grabTableFields from "../../utils/grab-table-fields";
type Params< type Params<
Schema extends { [k: string]: any } = { [k: string]: any }, Schema extends { [k: string]: any } = { [k: string]: any },
@@ -21,6 +22,13 @@ type Params<
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
}; };
/** 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< export default async function DbUpdate<
Schema extends { [k: string]: any } = { [k: string]: any }, Schema extends { [k: string]: any } = { [k: string]: any },
Table extends string = string, Table extends string = string,
@@ -39,24 +47,12 @@ export default async function DbUpdate<
}; };
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId) {
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
finalQuery,
{
query: {
id: {
value: Number(targetId),
},
},
},
);
}
const sqlQueryObj = sqlGenerator({ const sqlQueryObj = sqlGenerator({
tableName: table, tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const whereClause = sqlQueryObj.where; const whereClause = sqlQueryObj.where;
@@ -68,23 +64,58 @@ export default async function DbUpdate<
}; };
} }
/** const sanitizedData = sanitizeHtmlFields({
* Capture the matching row ids first, so the updated rows can be table,
* re-fetched even if the update changes a column used in the WHERE data,
* clause.
*/
const matched_res = await dbHandler<{ id: number }>({
query: `SELECT id FROM ${quoteIdentifier(table)} ${whereClause}`,
values: sqlQueryObj.whereValues as any,
config, config,
}); });
const finalData: { [k: string]: any } = {
updated_at: Date.now(),
...sanitizedData,
};
const fieldsMap = new Map(
grabTableFields(table).map((f) => [f.fieldName, f]),
);
const setParts: string[] = [];
const setValues: SQLInsertGenValueType[] = [];
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<{ id: number }>({
query: `SELECT id FROM ${quoteIdentifier(table)} ${whereClause} FOR UPDATE`,
values: sqlQueryObj.whereValues as any,
client: tx,
});
if (!matched_res.success) { if (!matched_res.success) {
return { throw new UpdateAbort({
...matched_res, ...matched_res,
success: false, success: false,
debug: { sqlObj: sqlQueryObj }, debug: { sqlObj: sqlQueryObj },
}; });
} }
const matched_ids = (matched_res.payload || []) const matched_ids = (matched_res.payload || [])
@@ -92,67 +123,36 @@ export default async function DbUpdate<
.filter((id) => id !== undefined && id !== null); .filter((id) => id !== undefined && id !== null);
if (matched_ids.length === 0) { if (matched_ids.length === 0) {
return { throw new UpdateAbort({
success: false, success: false,
msg: "No rows matched the update query", msg: "No rows matched the update query",
payload: [], payload: [],
debug: { sqlObj: sqlQueryObj }, debug: { sqlObj: sqlQueryObj },
};
}
let values: SQLInsertGenValueType[] = [];
let sql = ``;
sql += `UPDATE ${quoteIdentifier(table)} SET`;
const sanitizedData = sanitizeHtmlFields({
table,
data,
config,
}); });
const finalData: { [k: string]: SQLInsertGenValueType } = {
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 idPlaceholders = matched_ids.map(() => "?").join(","); const idPlaceholders = matched_ids.map(() => "?").join(",");
sql += ` WHERE id IN (${idPlaceholders})`; const sql = `UPDATE ${quoteIdentifier(table)} SET ${setParts.join(", ")} WHERE id IN (${idPlaceholders})`;
values = [...values, ...matched_ids]; const values = [...setValues, ...matched_ids];
sqlObj.string = sql;
sqlObj.values = values;
const res = await dbHandler({ const res = await dbHandler({
query: sql, query: sql,
values: values as any, values: values as any,
config, client: tx,
}); });
sqlObj.string = sql; if (!res.success) {
sqlObj.values = values as any[]; throw new UpdateAbort({ ...res, debug: { sqlObj } });
if (res.error) {
return { ...res, debug: { sqlObj } };
} }
const updated_res = await dbHandler({ const updated_res = await dbHandler({
query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`, query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`,
values: matched_ids, values: matched_ids,
config, client: tx,
}); });
return { return {
@@ -161,8 +161,13 @@ export default async function DbUpdate<
sqlObj, sqlObj,
}, },
db_res: res.db_res, db_res: res.db_res,
}; } as DBResponseObject;
});
} catch (error: any) { } catch (error: any) {
if (error instanceof UpdateAbort) {
return error.response;
}
return { return {
success: false, success: false,
error: error.message, error: error.message,
@@ -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<ColumnInfoRow[]> {
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 || "",
};
});
}
+37 -4
View File
@@ -11,6 +11,9 @@ export type ColumnInfoRow = {
extra: string; extra: string;
}; };
/**
* Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`.
*/
export default async function getTableColumns({ export default async function getTableColumns({
tableName, tableName,
config, config,
@@ -26,18 +29,48 @@ export default async function getTableColumns({
IS_NULLABLE: string; IS_NULLABLE: string;
COLUMN_DEFAULT: string | null; COLUMN_DEFAULT: string | null;
EXTRA: string; 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], values: [...schemaCond.values, tableName],
config, config,
}); });
return rows.map((row) => ({ 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, name: row.COLUMN_NAME,
type: row.COLUMN_TYPE, type: resolvedType,
comment: row.COLUMN_COMMENT, comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES", isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT, columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "", extra: row.EXTRA || "",
})); };
});
} }
+3 -1
View File
@@ -1,3 +1,5 @@
import { quoteIdentifier } from "../../utils/sql-identifier";
export default function MariaDBQuoteGen(str: string) { export default function MariaDBQuoteGen(str: string) {
return `\`${str.replace(/`/g, "``")}\``; return quoteIdentifier(str);
} }
+75
View File
@@ -1,6 +1,62 @@
import type { BunMariaDBConfig } from "../../types"; import type { BunMariaDBConfig } from "../../types";
import dbHandler from "../db-handler"; 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<string>;
};
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<unknown>,
): Promise<string[]> {
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<unknown>,
allowDestructive: true | Set<string>,
): Promise<void> {
const previous = schemaRunState;
schemaRunState = { dryRun: false, statements: [], allowDestructive };
try {
await fn();
} finally {
schemaRunState = previous;
}
}
export default async function runSchemaQuery({ export default async function runSchemaQuery({
query, query,
values, values,
@@ -10,6 +66,25 @@ export default async function runSchemaQuery({
values?: any[]; values?: any[];
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
}): Promise<void> { }): Promise<void> {
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({ const res = await dbHandler({
query, query,
values, values,
+1 -2
View File
@@ -8,7 +8,6 @@ import buildColumnDefinition, {
} from "./build-column-definition"; } from "./build-column-definition";
import createTable from "./create-table"; import createTable from "./create-table";
import getTableColumns, { type ColumnInfoRow } from "./get-table-columns"; import getTableColumns, { type ColumnInfoRow } from "./get-table-columns";
import getTableColumnsGemini from "./get-table-columns-gemnini";
import isVectorField from "./is-vector-field"; import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
@@ -278,7 +277,7 @@ export default async function updateTable({
table: BUN_MARIADB_TableSchemaType; table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
}): Promise<void> { }): Promise<void> {
const existingColumns = await getTableColumnsGemini({ const existingColumns = await getTableColumns({
tableName: table.tableName, tableName: table.tableName,
config, config,
}); });
+19
View File
@@ -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 || [];
}
+2 -15
View File
@@ -1,20 +1,7 @@
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import type { import type { BUN_MARIADB_FieldSchemaType, BunMariaDBConfig } from "../types";
BUN_MARIADB_DatabaseSchemaType,
BUN_MARIADB_FieldSchemaType,
BunMariaDBConfig,
} from "../types";
import { readLiveSchema } from "../functions/live-schema";
import getSanitizeHtmlOptions from "./sanitize-html-options"; import getSanitizeHtmlOptions from "./sanitize-html-options";
import grabTableFields from "./grab-table-fields";
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 || [];
}
/** /**
* Only fields with an explicit `html: true` flag are sanitized. * Only fields with an explicit `html: true` flag are sanitized.
+5
View File
@@ -122,6 +122,11 @@ export default function sqlGenOperatorGen({
str: `${fieldName} IS NOT ${finalValue}`, str: `${fieldName} IS NOT ${finalValue}`,
param: finalParams, param: finalParams,
}; };
} else if (equality == "IN" || equality == "NOT IN") {
return {
str: `${fieldName} ${equality} (${finalValue})`,
param: finalParams,
};
} else if (equality) { } else if (equality) {
return { return {
str: `${fieldName} ${sqlEqualityParser( str: `${fieldName} ${sqlEqualityParser(
+33 -1
View File
@@ -67,7 +67,39 @@ export default function sqlGenGenSearchStr({
}); });
} }
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[] = []; const strArray: string[] = [];
queryObj.value.forEach((val) => { queryObj.value.forEach((val) => {
+32 -180
View File
@@ -20,6 +20,11 @@ type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
tableName: string; tableName: string;
dbFullName?: string; dbFullName?: string;
count?: boolean; count?: boolean;
/**
* Restrict to a single row id. Always combined with AND, regardless of
* `searchOperator`.
*/
targetId?: number | string;
}; };
type Return = { type Return = {
@@ -40,7 +45,13 @@ const MAX_LIMIT = "18446744073709551615";
*/ */
export default function sqlGenerator< export default function sqlGenerator<
T extends { [key: string]: any } = { [key: string]: any }, T extends { [key: string]: any } = { [key: string]: any },
>({ tableName: rawTableName, genObject, dbFullName, count }: Param<T>): Return { >({
tableName: rawTableName,
genObject,
dbFullName,
count,
targetId,
}: Param<T>): Return {
const tableName = quoteQualified(rawTableName); const tableName = quoteQualified(rawTableName);
const finalQuery = genObject?.query ? genObject.query : undefined; const finalQuery = genObject?.query ? genObject.query : undefined;
@@ -130,18 +141,35 @@ export default function sqlGenerator<
const isSearchStr = const isSearchStr =
cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str); cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str);
let where = ``; const whereConditions: string[] = [];
if (isSearchStr) { if (isSearchStr) {
const stringOperator = sqlLogicalOperator(genObject?.searchOperator); const stringOperator = sqlLogicalOperator(genObject?.searchOperator);
where += `WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`; whereConditions.push(
`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`,
);
} }
if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) { if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) {
where += `${isSearchStr ? " AND" : "WHERE"} ${fullTextMatchStr}`; whereConditions.push(fullTextMatchStr.trim());
whereValues.push(fullTextSearchStr); 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) { if (where) {
queryString += ` ${where}`; queryString += ` ${where}`;
} }
@@ -226,179 +254,3 @@ export default function sqlGenerator<
whereValues, whereValues,
}; };
} }
// let queryString = (() => {
// let str = "SELECT";
// if (genObject?.select_sql) {
// str += ` ${genObject.select_sql}`;
// } else if (genObject?.selectFields?.[0]) {
// if (genObject.join) {
// str += sqlGenGrabSelectFieldSQL<T>({
// 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;
// })();
+11 -34
View File
@@ -4,6 +4,8 @@ import type {
SQLInsertGenValueType, SQLInsertGenValueType,
} from "../types"; } from "../types";
import { quoteIdentifier } from "./sql-identifier"; import { quoteIdentifier } from "./sql-identifier";
import sqlValuePlaceholder from "./sql-value-placeholder";
import grabTableFields from "./grab-table-fields";
/** /**
* # SQL Insert Generator * # SQL Insert Generator
@@ -28,6 +30,10 @@ export default function sqlInsertGenerator({
}); });
}); });
const fieldsMap = new Map(
grabTableFields(tableName).map((f) => [f.fieldName, f]),
);
let queryBatches: string[] = []; let queryBatches: string[] = [];
let queryValues: SQLInsertGenValueType[] = []; let queryValues: SQLInsertGenValueType[] = [];
@@ -35,41 +41,12 @@ export default function sqlInsertGenerator({
queryBatches.push( queryBatches.push(
`(${insertKeys `(${insertKeys
.map((ky) => { .map((ky) => {
const value = item[ky]; const { placeholder, value } = sqlValuePlaceholder({
value: item[ky],
if (typeof value == "function") { field: fieldsMap.get(ky),
const fnValue = value(); });
queryValues.push(fnValue.value ?? null);
return fnValue.placeholder || "?";
}
if (typeof value == "boolean") {
queryValues.push(value ? 1 : 0);
return "?";
}
if (typeof value == "number") {
queryValues.push(value); queryValues.push(value);
return "?"; return placeholder;
}
// Empty strings / null / undefined are stored as NULL
if (value === undefined || value === null || value === "") {
queryValues.push(null);
return "?";
}
const is_embedding =
Array.isArray(value) &&
typeof value[0] == "number";
if (is_embedding) {
queryValues.push(JSON.stringify(value));
return "VEC_FromText(?)";
}
queryValues.push(value);
return "?";
}) })
.join(",")})`, .join(",")})`,
); );
+57
View File
@@ -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 };
}
+149
View File
@@ -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: '<p style="position:fixed" id="x">hi</p><button>b</button>' },
{ 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<any>({ table: "bmt_items", query: { order: { field: "id", strategy: "ASC" } } });
expect(payload?.map((r) => r.active)).toEqual([0, 1, 1]);
expect(payload?.[0].body).toBe("<p>hi</p>b");
expect(payload?.[1].body).toBe("");
expect(typeof payload?.[0].id).toBe("number");
});
test("select matches 0", async () => {
const res = await DB.select<any>({ 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<any>({
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<any>({ 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<any>({ 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<any>({ table: "bmt_items", query: { query: { name: { value: "b" } } } });
const idB = payload![0].id;
const res = await DB.delete<any>({
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<any>({ 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<any>({ table: "bmt_items", query: { limit: 1 } });
expect(payload?.[0]).toHaveProperty("body");
});
});
+204
View File
@@ -0,0 +1,204 @@
import { describe, expect, test } from "bun:test";
import sqlGenerator from "../src/utils/sql-generator";
const gen = (genObject: any, extra: Record<string, any> = {}) =>
sqlGenerator({ tableName: "users", genObject, ...extra });
describe("identifier quoting", () => {
test("quotes table and field names", () => {
const { string, values } = gen({ query: { email: { value: "[email protected]" } } });
expect(string).toBe("SELECT * FROM `users` WHERE (`email` = ?)");
expect(values).toEqual(["[email protected]"]);
});
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",
);
});
});
+55
View File
@@ -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]"]);
});
});