Merge branch 'fix/security-and-query-bugs'

This commit is contained in:
2026-10-15 18:10:16 +01:00
95 changed files with 2825 additions and 1706 deletions
+35 -5
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)
- [Default Fields](#default-fields)
- [Project Structure](#project-structure)
- [Testing](#testing)
---
@@ -212,6 +213,7 @@ The config file must be named `bun-mariadb.config.ts` and placed at the project
| `charset` | `string` | No | Database charset (default: `utf8mb4`) |
| `connection_timeout` | `number` | No | Connection timeout in ms (default: `10000`) |
| `ssl_ca` | `string` | No | Path to SSL CA certificate (relative to project root) |
| `ssl_reject_unauthorized` | `boolean` | No | Verify the server TLS certificate (default: `true`). Set `false` only for trusted self-signed setups |
| `html_sanitize` | `object` | No | Extra HTML sanitizer allowlists (see [HTML Sanitization](#html-sanitization)) |
Schema file path is always: `{db_dir}/schema.ts`.
@@ -376,8 +378,10 @@ bunx bun-mariadb schema [options]
```
| Option | Description |
| ----------------- | ------------------------------------------------------- |
| ----------------- | ------------------------------------------------------------------------ |
| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing |
| `--dry-run` | Print the planned schema changes without applying them |
| `-y`, `--yes` | Apply destructive changes (dropped tables / columns) without confirmation |
**Examples:**
@@ -387,8 +391,13 @@ bunx bun-mariadb schema
# Sync schema and regenerate types
bunx bun-mariadb schema --typedef
# Review planned changes first
bunx bun-mariadb schema --dry-run
```
Changes that discard data (dropping a table or column, or recreating a `VECTOR` column whose size changed) are listed and need confirmation before they run. Non-interactive runs (CI, scripts) refuse them unless `--yes` is passed.
What it does:
1. Ensures the internal `__db_schema_manager__` tracking table exists
@@ -469,11 +478,12 @@ bunx bun-mariadb import [file] [options]
| `[file]` | Path to a `.sql` dump or export archive (`.tar.gz` / `.tar` / `.zip`) |
| `--sql-only` | Archive only: restore SQL, do not overwrite `schema.ts` |
| `--schema-only` | Archive only: write `schema.ts`, do not restore SQL |
| `-y, --yes` | Archive only: skip the confirmation before writing a `.ts`/`.js` schema |
Behavior:
- **`.sql` file** — restores SQL into the configured database (same as restore, but non-interactive)
- **Export archive** — restores `dump.sql` and writes `schema.ts` into `db_dir`
- **Export archive** — restores `dump.sql` and writes `schema.ts` into `db_dir`. A `.ts`/`.js` schema is executed when loaded, so you are asked to confirm first (non-interactive runs require `--yes`). Only import archives from sources you trust.
- **No file argument** — interactive picker of archives in `{db_dir}/.exports`
**Examples:**
@@ -663,9 +673,11 @@ await BunMariaDB.delete({
### Raw SQL
```ts
BunMariaDB.sql<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
const res = await BunMariaDB.sql({
sql: "SELECT * FROM users WHERE id = ?",
@@ -837,6 +849,8 @@ const config: BunMariaDBConfig = {
Config values are **appended** to the built-in defaults (safe rich-text tags/attributes).
`style` and `id` attributes (and the `button` tag) are **not** allowed by default, since they enable CSS overlays and DOM clobbering. Add them through `allowed_attributes` / `allowed_tags` if you need them.
---
## TypeScript Type Generation
@@ -937,12 +951,28 @@ bun-mariadb/
│ ├── sql-insert-generator.ts
│ ├── sanitize-html-fields.ts
│ └── ...
└── test/
└── coderank/ # Example project
└── tests/ # bun test suite
```
---
## Testing
```bash
bun run test
```
Starts a throwaway MariaDB container (Docker required, image `mariadb:11.8` by default), runs every test file against it, then removes the container. Extra arguments are passed to `bun test`, e.g. `bun run test tests/db.test.ts`.
| Variable | Description |
| ------------------------ | --------------------------------------------------------------------------- |
| `BUN_MARIADB_TEST_IMAGE` | Docker image to use (MariaDB 11.7+ for `VECTOR`) |
| `BUN_MARIADB_TEST_DB` | Skip Docker and use this existing database (with `BUN_MARIADB_SERVER_*`). The tests create and drop tables, so use a throwaway database. |
`bun run test:unit` (or plain `bun test`) runs without a database; the integration tests are skipped.
---
## License
MIT
+1 -1
View File
@@ -12,7 +12,7 @@
"sanitize-html": "^2.17.6",
},
"devDependencies": {
"@types/bun": "latest",
"@types/bun": "1.3.14",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.3",
"@types/sanitize-html": "^2.16.1",
+22 -1
View File
@@ -2,7 +2,7 @@ import { Command } from "commander";
import path from "path";
import fs from "fs";
import chalk from "chalk";
import { select } from "@inquirer/prompts";
import { confirm, select } from "@inquirer/prompts";
import grabDBDir from "../utils/grab-db-dir";
import grabSortedExports from "../utils/grab-sorted-exports";
import grabBackupData from "../utils/grab-backup-data";
@@ -23,6 +23,7 @@ export default function () {
.argument("[file]", "Path to .sql file or export archive (.tar.gz / .tar / .zip)")
.option("--sql-only", "When importing an archive, restore SQL only (skip writing schema)")
.option("--schema-only", "When importing an archive, write schema only (skip SQL restore)")
.option("-y, --yes", "Do not ask for confirmation before writing an executable (.ts/.js) schema from the archive")
.action(async (fileArg, opts) => {
console.log(`Importing database ...`);
const config = global.CONFIG;
@@ -67,6 +68,26 @@ export default function () {
process.exit(1);
}
const { sql, schema, schemaFileName } = await readExportArchive(filePath);
/**
* A .ts/.js schema is executed (require) on the next run.
* Confirm before writing code from an archive into the project.
*/
const isExecutableSchema = /\.(ts|js)$/i.test(schemaFileName);
if (!opts.sqlOnly && isExecutableSchema && !opts.yes) {
if (!process.stdin.isTTY) {
console.error(chalk.red(`Archive contains an executable schema (${schemaFileName}). Re-run with --yes to accept it, or --sql-only to skip it.`));
process.exit(1);
}
console.log(chalk.yellow(`\nThis archive contains an executable schema file (${schemaFileName}).\nIt will run as code the next time bun-mariadb or your app loads it.\nOnly continue if you trust the source of this archive.\n`));
const proceed = await confirm({
message: `Write ${schemaFileName} into ${db_dir}?`,
default: false,
});
if (!proceed) {
console.log(chalk.dim(`Import cancelled.`));
process.exit(1);
}
}
if (!opts.schemaOnly) {
await restoreDatabase(config, sql);
console.log(chalk.green(`SQL restored from archive`));
+10 -1
View File
@@ -8,14 +8,23 @@ import exportCmd from "./export";
import importCmd from "./import";
import admin from "./admin";
import init from "../functions/init";
import { readFileSync } from "fs";
try {
init();
}
catch (error) {
console.error(`Initialization ERROR => ${error.message}`);
process.exit(1);
}
/** Resolves to the package root from both src/commands and dist/commands */
const { version } = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
/**
* # Describe Program
*/
program
.name(`bun-mariadb`)
.description(`MariaDB manager for Bun`)
.version(`1.0.0`);
.version(version);
/**
* # Declare Commands
*/
+59 -3
View File
@@ -6,10 +6,28 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-
import chalk from "chalk";
import { writeLiveSchema } from "../functions/live-schema";
import createDBSchema from "../lib/schema/create-db-schema";
import { isDestructiveSchemaStatement, planSchemaQueries, runSchemaQueriesGuarded, } from "../lib/schema/run-schema-query";
import { confirm } from "@inquirer/prompts";
import { AppData } from "../data/app-data";
/** Silence progress logs while computing a plan */
async function quietly(fn) {
const { log, warn } = console;
console.log = () => { };
console.warn = () => { };
try {
return await fn();
}
finally {
console.log = log;
console.warn = warn;
}
}
export default function () {
return new Command("schema")
.description("Build DB From Schema")
.option("-t, --typedef", "Generate typescript type definitions")
.option("--dry-run", "Print the schema changes that would be applied, without applying them")
.option("-y, --yes", "Apply destructive changes (dropped tables / columns) without confirmation")
.action(async (opts) => {
console.log(`Starting process ...`);
const config = global.CONFIG;
@@ -20,10 +38,48 @@ export default function () {
const finaldbSchema = appendDefaultFieldsToDbSchema({
dbSchema,
});
await createDBSchema({
db_schema: finaldbSchema,
config,
const params = { db_schema: finaldbSchema, config };
// Hide internal bookkeeping (schema manager rows, FK check toggles)
const plan = (await quietly(() => planSchemaQueries(() => createDBSchema(params)))).filter((statement) => !statement.includes(AppData.DbSchemaManagerTableName) &&
!/^SET FOREIGN_KEY_CHECKS/i.test(statement));
const destructive = plan.filter(isDestructiveSchemaStatement);
if (opts.dryRun) {
if (!plan.length) {
console.log(chalk.green(`Schema is up to date.`));
}
else {
console.log(chalk.bold(`Planned schema changes:\n`));
for (const statement of plan) {
console.log(isDestructiveSchemaStatement(statement)
? chalk.red(` ${statement}`)
: ` ${statement}`);
}
if (destructive.length) {
console.log(chalk.red(`\n${destructive.length} destructive change(s) (in red) would discard data.`));
}
}
process.exit(0);
}
if (destructive.length && !opts.yes) {
console.log(chalk.yellow(`\nThese changes will permanently discard data:\n`));
for (const statement of destructive) {
console.log(chalk.red(` ${statement}`));
}
console.log();
if (!process.stdin.isTTY) {
console.error(chalk.red(`Refusing to apply destructive changes non-interactively. Re-run with --yes to apply them.`));
process.exit(1);
}
const proceed = await confirm({
message: `Apply ${destructive.length} destructive change(s)?`,
default: false,
});
if (!proceed) {
console.log(chalk.dim(`Schema sync cancelled.`));
process.exit(1);
}
}
await runSchemaQueriesGuarded(() => createDBSchema(params), opts.yes ? true : new Set(destructive));
if (isTypeDef && config.typedef_file_path) {
const out_file = path.resolve(ROOT_DIR, config.typedef_file_path);
dbSchemaToTypeDef({
+5
View File
@@ -2,5 +2,10 @@ import { type BunMariaDBConfig } from "../types";
type Params = {
config: BunMariaDBConfig;
};
/**
* Cache key covering everything that affects the connection, not just the
* database name.
*/
export declare function mariadbClientCacheKey(config?: BunMariaDBConfig): string;
export default function grabMariaDBClient({ config, }: Params): Bun.SQL | undefined;
export {};
+22 -7
View File
@@ -2,29 +2,44 @@ import path from "path";
import grabDirNames from "../data/grab-dir-names";
import {} from "../types";
import { SQL } from "bun";
/** Reuse one client per database name to avoid exhausting MariaDB connections. */
/** Reuse one client per distinct connection config to avoid exhausting MariaDB connections. */
const clientCache = new Map();
/**
* Cache key covering everything that affects the connection, not just the
* database name.
*/
export function mariadbClientCacheKey(config) {
return JSON.stringify({
db_name: config?.db_name || "__default__",
db_config: config?.db_config || null,
ssl_ca: config?.ssl_ca || null,
ssl_reject_unauthorized: config?.ssl_reject_unauthorized ?? null,
});
}
export default function grabMariaDBClient({ config, }) {
const cacheKey = config.db_name || "__default__";
const cacheKey = mariadbClientCacheKey(config);
const cached = clientCache.get(cacheKey);
if (cached) {
return cached;
}
// Prefer the process-wide client when it matches the requested database
// Prefer the process-wide client when it was created from the same config
if (global.MARIADB_CLIENT &&
(!global.CONFIG?.db_name || global.CONFIG.db_name === config.db_name)) {
global.CONFIG &&
mariadbClientCacheKey(global.CONFIG) === cacheKey) {
clientCache.set(cacheKey, global.MARIADB_CLIENT);
return global.MARIADB_CLIENT;
}
const { ROOT_DIR } = grabDirNames();
try {
// Verify the server certificate unless explicitly disabled
const rejectUnauthorized = config.ssl_reject_unauthorized !== false;
const tls = config.ssl_ca
? {
ca: Bun.file(path.resolve(ROOT_DIR, config.ssl_ca)),
rejectUnauthorized: false,
rejectUnauthorized,
}
: {
rejectUnauthorized: false,
rejectUnauthorized,
};
const MariaDBClient = new SQL({
hostname: process.env.BUN_MARIADB_SERVER_HOST,
@@ -34,8 +49,8 @@ export default function grabMariaDBClient({ config, }) {
port: process.env.BUN_MARIADB_SERVER_PORT
? Number(process.env.BUN_MARIADB_SERVER_PORT)
: undefined,
...config.db_config,
tls,
...config.db_config,
adapter: "mariadb",
});
clientCache.set(cacheKey, MariaDBClient);
+8
View File
@@ -9,4 +9,12 @@ declare global {
var CONFIG_FILE_PATH: string;
var SCHEMA_FILE_PATH: string;
}
/**
* Load config + schema and set up the shared MariaDB client.
* Throws on any problem (never exits the process).
*/
export default function init(): void;
/**
* Initialize once, on first use.
*/
export declare function ensureInit(): void;
+18 -30
View File
@@ -5,23 +5,23 @@ import grabDirNames from "../data/grab-dir-names";
import { RequiredENVs, } from "../types";
import setMariaDBClient from "./set-mariadb-client";
import { resolveAndLoadDataFile, supportedDataFileNames, } from "../utils/resolve-and-load-data-file";
/**
* Load config + schema and set up the shared MariaDB client.
* Throws on any problem (never exits the process).
*/
export default function init() {
try {
const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData;
const loadedConfig = resolveAndLoadDataFile(ROOT_DIR, ConfigFileName);
if (!loadedConfig) {
console.error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`);
process.exit(1);
throw new Error(`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`);
}
const Config = loadedConfig.data;
if (!Config || typeof Config !== "object") {
console.error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`);
process.exit(1);
throw new Error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`);
}
if (!Config.db_name) {
console.error(`\`db_name\` is required in your config`);
process.exit(1);
throw new Error(`\`db_name\` is required in your config`);
}
RequiredENVs.forEach((env_key) => {
if (env_key == "BUN_MARIADB_SERVER_PORT" ||
@@ -29,13 +29,11 @@ export default function init() {
return;
}
if (!process.env[env_key]) {
console.error(`\`${env_key}\` env variable is required.`);
process.exit(1);
throw new Error(`\`${env_key}\` env variable is required.`);
}
});
if (!Config.db_dir) {
console.error(`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`);
process.exit(1);
throw new Error(`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`);
}
const db_dir = path.resolve(ROOT_DIR, Config.db_dir);
if (!fs.existsSync(db_dir)) {
@@ -43,13 +41,11 @@ export default function init() {
}
const loadedSchema = resolveAndLoadDataFile(db_dir, AppData.DbSchemaFileName);
if (!loadedSchema) {
console.error(`Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`);
process.exit(1);
throw new Error(`Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`);
}
const DbSchema = loadedSchema.data;
if (!DbSchema || typeof DbSchema !== "object") {
console.error(`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`);
process.exit(1);
throw new Error(`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`);
}
const backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"];
const BackupDir = path.resolve(db_dir, backup_dir);
@@ -64,22 +60,14 @@ export default function init() {
global.DB_SCHEMA = DbSchema;
global.CONFIG_FILE_PATH = loadedConfig.path;
global.SCHEMA_FILE_PATH = loadedSchema.path;
if (!global.CONFIG) {
console.error(`Couldn't grab global Config.`);
process.exit(1);
}
if (!global.DB_SCHEMA) {
console.error(`Couldn't grab Database Schema.`);
process.exit(1);
}
setMariaDBClient({ config: Config });
if (!global.MARIADB_CLIENT) {
console.error(`Couldn't set MariaDB Client.`);
process.exit(1);
}
/**
* Initialize once, on first use.
*/
export function ensureInit() {
if (global.CONFIG && global.DB_SCHEMA && global.MARIADB_CLIENT !== undefined) {
return;
}
catch (error) {
console.error(`Initialization ERROR => ` + error.message);
process.exit(1);
}
init();
}
-12
View File
@@ -1,21 +1,9 @@
import {} from "../types";
import grabMariaDBClient from "./grab-mariadb-client";
export default function setMariaDBClient({ config }) {
try {
const MariaDBClient = grabMariaDBClient({ config });
if (!MariaDBClient) {
throw new Error(`Couldn't grab MariaDB Client`);
}
global.MARIADB_CLIENT = MariaDBClient;
// const test = await MariaDBClient.unsafe(`SHOW DATABASES`);
// if (!test.count) {
// console.error(`MariaDBClient Error: Database not ready.`);
// console.log(test);
// process.exit(1);
// }
}
catch (error) {
console.error(`ERROR setting MariaDB Client => ` + error.message);
process.exit(1);
}
}
+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 DbInsert from "./lib/mariadb/db-insert";
import DbSelect from "./lib/mariadb/db-select";
@@ -6,15 +6,24 @@ import DbSQL from "./lib/mariadb/db-sql";
import DbUpdate from "./lib/mariadb/db-update";
import grabDbSchema from "./utils/grab-db-schema";
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object";
init();
/**
* Initialize lazily on first call, so importing the library never exits or
* throws. Setup errors (missing config / env) reject the call instead.
*/
function withInit(fn) {
return (async (...args) => {
ensureInit();
return fn(...args);
});
}
const BunMariaDB = {
select: DbSelect,
insert: DbInsert,
update: DbUpdate,
delete: DbDelete,
sql: DbSQL,
select: withInit(DbSelect),
insert: withInit(DbInsert),
update: withInit(DbUpdate),
delete: withInit(DbDelete),
sql: withInit(DbSQL),
utils: {
grab_db_schema: grabDbSchema,
grab_db_schema: withInit(grabDbSchema),
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
},
};
+12 -1
View File
@@ -1,9 +1,20 @@
import type { BunMariaDBConfig, DBResponseObject } from "../types";
/** Anything that can run a raw query: a client or a transaction handle */
export type DBQueryRunner = {
unsafe: (query: string, values?: any[]) => Promise<any>;
};
type Param = {
query: string;
values?: any[];
config?: BunMariaDBConfig;
/** Run on this client / transaction instead of the shared client */
client?: DBQueryRunner;
};
/**
* Resolve a shared MariaDB client. Prefer the global client so schema sync
* and CRUD do not open a new connection per query.
*/
export declare function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined;
/**
* # Main DB Handler Function
*/
@@ -11,5 +22,5 @@ export default function dbHandler<T extends {
[k: string]: any;
} = {
[k: string]: any;
}>({ query, values, config }: Param): Promise<DBResponseObject<T>>;
}>({ query, values, config, client }: Param): Promise<DBResponseObject<T>>;
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
* and CRUD do not open a new connection per query.
*/
function resolveClient(config) {
const resolvedConfig = config || global.CONFIG;
// Same database as the global client → reuse it
export function resolveClient(config) {
if (!config) {
return global.MARIADB_CLIENT;
}
// Same connection config as the global client → reuse it
if (global.MARIADB_CLIENT &&
(!resolvedConfig?.db_name ||
!global.CONFIG?.db_name ||
resolvedConfig.db_name === global.CONFIG.db_name)) {
(!global.CONFIG ||
mariadbClientCacheKey(config) ===
mariadbClientCacheKey(global.CONFIG))) {
return global.MARIADB_CLIENT;
}
if (resolvedConfig) {
return grabMariaDBClient({ config: resolvedConfig });
return grabMariaDBClient({ config });
}
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
*/
export default async function dbHandler({ query, values, config }) {
export default async function dbHandler({ query, values, config, client }) {
try {
const CLIENT = resolveClient(config);
const CLIENT = client || resolveClient(config);
if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`);
}
const res = await CLIENT.unsafe(query, values);
const count = res.count;
const res_array = (() => {
try {
return JSON.parse(JSON.stringify(res));
}
catch (error) {
return undefined;
}
})();
const res_array = normalizeRows(res);
const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined;
const affected_rows = res_array?.[0] ? res_array.length : undefined;
const insert_return = {
+1 -3
View File
@@ -1,6 +1,4 @@
function quoteIdentifier(identifier) {
return `\`${identifier.replace(/`/g, "``")}\``;
}
import { quoteIdentifier } from "../utils/sql-identifier";
export default async function ({ sql: passedSql, table, data }) {
const config = global.CONFIG;
const dbSchema = global.DB_SCHEMA;
+6 -15
View File
@@ -1,27 +1,17 @@
import dbHandler from "../db-handler";
import _ from "lodash";
import sqlGenerator from "../../utils/sql-generator";
function quoteIdentifier(identifier) {
return `\`${identifier.replace(/`/g, "``")}\``;
}
import { quoteIdentifier } from "../../utils/sql-identifier";
export default async function DbDelete({ table, query, targetId, config, }) {
let sqlObj = null;
try {
let finalQuery = query || {};
if (targetId) {
finalQuery = _.merge(finalQuery, {
query: {
id: {
value: Number(targetId),
},
},
});
}
const finalQuery = query || {};
sqlObj = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
});
const whereClause = sqlObj.string.match(/WHERE .*/)?.[0];
const whereClause = sqlObj.where;
if (!whereClause) {
return {
success: false,
@@ -34,6 +24,7 @@ export default async function DbDelete({ table, query, targetId, config, }) {
let sql = `DELETE FROM ${quoteIdentifier(table)} ${whereClause}`;
sql += ` RETURNING *`;
sqlObj.string = sql;
sqlObj.values = sqlObj.whereValues;
const res = await dbHandler({
query: sql,
values: sqlObj.values,
+7 -10
View File
@@ -1,5 +1,6 @@
import _ from "lodash";
import generateTypeDefinition from "./db-generate-type-defs";
import resolveTable from "../schema/resolve-table";
export default function dbSchemaToType({ config, dbSchema, }) {
let datasquirelSchema = dbSchema;
if (!datasquirelSchema)
@@ -14,16 +15,12 @@ export default function dbSchemaToType({ config, dbSchema, }) {
const defNames = [];
const schemas = dbTablesSchemas
.map((table) => {
let final_table = _.cloneDeep(table);
if (final_table.parentTableName) {
const parent_table = dbTablesSchemas.find((t) => t.tableName === final_table.parentTableName);
if (parent_table) {
final_table = _.merge(parent_table, {
tableName: final_table.tableName,
tableDescription: final_table.tableDescription,
});
}
}
const has_parent = Boolean(table.parentTableName &&
dbTablesSchemas.find((t) => t.tableName === table.parentTableName));
// Merge parent fields into a copy; never mutate the parent schema
const final_table = has_parent
? resolveTable(table, datasquirelSchema)
: _.cloneDeep(table);
const defObj = generateTypeDefinition({
paradigm: "TypeScript",
table: final_table,
+5 -16
View File
@@ -1,25 +1,13 @@
import dbHandler from "../db-handler";
import _ from "lodash";
import sqlGenerator from "../../utils/sql-generator";
function quoteIdentifier(identifier) {
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function DbSelect({ table, query, count, targetId, config, }) {
let sqlObj = null;
try {
let finalQuery = query || {};
if (targetId) {
finalQuery = _.merge(finalQuery, {
query: {
id: {
value: Number(targetId),
},
},
});
}
const finalQuery = query || {};
sqlObj = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
});
const res = await dbHandler({
query: sqlObj.string,
@@ -49,8 +37,9 @@ export default async function DbSelect({ table, query, count, targetId, config,
};
if (count) {
const countSqlObject = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
count,
});
const countSql = `SELECT COUNT(*) AS count FROM (${countSqlObject.string}) AS c`;
+3 -2
View File
@@ -1,11 +1,12 @@
import type { DBResponseObject, SQLInsertGenValueType } from "../../types";
import type { BunMariaDBConfig, DBResponseObject, SQLInsertGenValueType } from "../../types";
type Params = {
sql: string;
values?: SQLInsertGenValueType[];
config?: BunMariaDBConfig;
};
export default function DbSQL<T extends {
[k: string]: any;
} = {
[k: string]: any;
}>({ sql, values }: Params): Promise<DBResponseObject<T>>;
}>({ sql, values, config }: Params): Promise<DBResponseObject<T>>;
export {};
+3 -5
View File
@@ -1,11 +1,12 @@
import dbHandler from "../db-handler";
export default async function DbSQL({ sql, values }) {
export default async function DbSQL({ sql, values, config }) {
try {
const trimmedSql = sql.trim();
const isSelect = trimmedSql.match(/^select/i);
const res = await dbHandler({
query: trimmedSql,
values: values,
config,
});
if (!res.success) {
return {
@@ -23,12 +24,9 @@ export default async function DbSQL({ sql, values }) {
}
const payload = isSelect ? (res.payload || []) : undefined;
const single_res = isSelect ? payload?.[0] : res.single_res;
const singleRaw = res.single_res;
return {
...res,
success: isSelect
? Boolean(single_res) || Boolean(payload?.[0])
: true,
success: true,
payload,
single_res,
debug: {
+78 -44
View File
@@ -1,37 +1,38 @@
import dbHandler from "../db-handler";
import _ from "lodash";
import dbHandler, { resolveClient } from "../db-handler";
import sqlGenerator from "../../utils/sql-generator";
import sanitizeHtmlFields from "../../utils/sanitize-html-fields";
function quoteIdentifier(identifier) {
return `\`${identifier.replace(/`/g, "``")}\``;
import { quoteIdentifier } from "../../utils/sql-identifier";
import sqlValuePlaceholder from "../../utils/sql-value-placeholder";
import grabTableFields from "../../utils/grab-table-fields";
/** Thrown inside the transaction to roll back and return a response */
class UpdateAbort extends Error {
response;
constructor(response) {
super(response.msg || response.error || "Update aborted");
this.response = response;
}
}
export default async function DbUpdate({ table, data, query, targetId, config, }) {
let sqlObj = { string: "", values: [] };
let sqlObj = {
string: "",
values: [],
where: "",
whereValues: [],
};
try {
let finalQuery = query || {};
if (targetId) {
finalQuery = _.merge(finalQuery, {
query: {
id: {
value: Number(targetId),
},
},
});
}
const finalQuery = query || {};
const sqlQueryObj = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
});
const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0];
const whereClause = sqlQueryObj.where;
if (!whereClause) {
return {
success: false,
msg: `No WHERE clause`,
};
}
let values = [];
let sql = ``;
sql += `UPDATE ${quoteIdentifier(table)} SET`;
const sanitizedData = sanitizeHtmlFields({
table,
data,
@@ -41,38 +42,67 @@ export default async function DbUpdate({ table, data, query, targetId, config, }
updated_at: Date.now(),
...sanitizedData,
};
const keys = Object.keys(finalData);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!key)
continue;
const isLast = i == keys.length - 1;
sql += ` ${quoteIdentifier(key)}=?`;
values.push(finalData[key] ?? null);
if (!isLast) {
sql += `,`;
const fieldsMap = new Map(grabTableFields(table).map((f) => [f.fieldName, f]));
const setParts = [];
const setValues = [];
for (const key of Object.keys(finalData)) {
const { placeholder, value } = sqlValuePlaceholder({
value: finalData[key],
field: fieldsMap.get(key),
});
setParts.push(`${quoteIdentifier(key)}=${placeholder}`);
setValues.push(value);
}
const CLIENT = resolveClient(config);
if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`);
}
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({
query: sql,
values: values,
config,
client: tx,
});
if (res.error) {
return res;
if (!res.success) {
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({
query: updated_sql,
values: updated_sql_values,
config,
query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`,
values: matched_ids,
client: tx,
});
return {
...updated_res,
@@ -81,8 +111,12 @@ export default async function DbUpdate({ table, data, query, targetId, config, }
},
db_res: res.db_res,
};
});
}
catch (error) {
if (error instanceof UpdateAbort) {
return error.response;
}
return {
success: false,
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;
extra: string;
};
/**
* Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`.
*/
export default function getTableColumns({ tableName, config, }: {
tableName: string;
config?: BunMariaDBConfig;
+34 -4
View File
@@ -1,18 +1,48 @@
import { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
/**
* Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`.
*/
export default async function getTableColumns({ tableName, config, }) {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({
query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
query: `
SELECT
c.COLUMN_NAME,
c.COLUMN_TYPE,
c.COLUMN_COMMENT,
c.IS_NULLABLE,
c.COLUMN_DEFAULT,
c.EXTRA,
EXISTS (
SELECT 1
FROM information_schema.CHECK_CONSTRAINTS cc
WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%')
) AS IS_JSON
FROM information_schema.COLUMNS c
WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")}
AND c.TABLE_NAME = ?
ORDER BY c.ORDINAL_POSITION
`,
values: [...schemaCond.values, tableName],
config,
});
return rows.map((row) => ({
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: row.COLUMN_TYPE,
type: resolvedType,
comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "",
}));
};
});
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { quoteIdentifier } from "../../utils/sql-identifier";
export default function MariaDBQuoteGen(str) {
return `\`${str.replace(/`/g, "``")}\``;
return quoteIdentifier(str);
}
+14
View File
@@ -1,4 +1,18 @@
import type { BunMariaDBConfig } from "../../types";
/**
* Statements that discard stored data (tables or column values).
*/
export declare function isDestructiveSchemaStatement(query: string): boolean;
/**
* Run `fn` with schema statements recorded but not executed.
* Reads (information_schema) still run, so the plan is a close approximation:
* steps that depend on earlier changes (e.g. after a table rename) may differ.
*/
export declare function planSchemaQueries(fn: () => Promise<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, }: {
query: string;
values?: any[];
+48
View File
@@ -1,5 +1,53 @@
import dbHandler from "../db-handler";
let schemaRunState = null;
/**
* Statements that discard stored data (tables or column values).
*/
export function isDestructiveSchemaStatement(query) {
return /^\s*DROP\s+TABLE\b/i.test(query) || /\bDROP\s+COLUMN\b/i.test(query);
}
/**
* Run `fn` with schema statements recorded but not executed.
* Reads (information_schema) still run, so the plan is a close approximation:
* steps that depend on earlier changes (e.g. after a table rename) may differ.
*/
export async function planSchemaQueries(fn) {
const previous = schemaRunState;
schemaRunState = { dryRun: true, statements: [], allowDestructive: true };
try {
await fn();
return schemaRunState.statements;
}
finally {
schemaRunState = previous;
}
}
/**
* Run `fn`, refusing any destructive statement that was not approved.
*/
export async function runSchemaQueriesGuarded(fn, allowDestructive) {
const previous = schemaRunState;
schemaRunState = { dryRun: false, statements: [], allowDestructive };
try {
await fn();
}
finally {
schemaRunState = previous;
}
}
export default async function runSchemaQuery({ query, values, config, }) {
if (schemaRunState) {
schemaRunState.statements.push(query);
if (schemaRunState.dryRun) {
return;
}
const allowed = schemaRunState.allowDestructive;
if (allowed !== true &&
isDestructiveSchemaStatement(query) &&
!allowed.has(query)) {
throw new Error(`Refusing unapproved destructive statement: ${query}. Re-run with --dry-run to review, or --yes to allow it.`);
}
}
const res = await dbHandler({
query,
values,
+1 -2
View File
@@ -1,7 +1,6 @@
import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition";
import createTable from "./create-table";
import getTableColumns, {} from "./get-table-columns";
import getTableColumnsGemini from "./get-table-columns-gemnini";
import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen";
@@ -188,7 +187,7 @@ async function recreateColumn({ tableName, field, config, }) {
await addColumn({ tableName, field, config });
}
export default async function updateTable({ table, config, }) {
const existingColumns = await getTableColumnsGemini({
const existingColumns = await getTableColumns({
tableName: table.tableName,
config,
});
+6
View File
@@ -1392,6 +1392,12 @@ export type BunMariaDBConfig = {
* File path to the SSL certificate
*/
ssl_ca?: string;
/**
* Verify the server's TLS certificate. Defaults to `true`.
* Set to `false` only for trusted networks with self-signed certificates
* (prefer providing `ssl_ca` instead).
*/
ssl_reject_unauthorized?: boolean;
/**
* Extra HTML sanitization allowlists appended to the defaults
* when inserting/updating fields marked `html: true`.
+3 -2
View File
@@ -165,10 +165,11 @@ function findFileContents(dir, predicate) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
// Never follow symlinks from an extracted (untrusted) archive
if (entry.isDirectory()) {
stack.push(full);
}
else if (predicate(entry.name)) {
else if (entry.isFile() && predicate(entry.name)) {
return fs.readFileSync(full, "utf-8");
}
}
@@ -184,7 +185,7 @@ function findSchemaFile(dir) {
if (entry.isDirectory()) {
stack.push(full);
}
else if (isSchemaFileName(entry.name)) {
else if (entry.isFile() && isSchemaFileName(entry.name)) {
return {
content: fs.readFileSync(full, "utf-8"),
fileName: entry.name === AppData.DbSchemaFileName
+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 { readLiveSchema } from "../functions/live-schema";
import getSanitizeHtmlOptions from "./sanitize-html-options";
function grabTableFields(tableName) {
const dbSchema = global.DB_SCHEMA || readLiveSchema();
const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName);
return tableSchema?.fields || [];
}
import grabTableFields from "./grab-table-fields";
/**
* Only fields with an explicit `html: true` flag are sanitized.
* Missing / falsy / non-true values are never sanitized.
+5 -4
View File
@@ -18,7 +18,6 @@ export const defaultSanitizeHtmlOptions = {
"h6",
"img",
"div",
"button",
"pre",
"code",
"br",
@@ -35,10 +34,12 @@ export const defaultSanitizeHtmlOptions = {
"colgroup",
"col",
],
// `style` / `id` are intentionally not allowed by default (CSS overlays,
// DOM clobbering). Add them via `html_sanitize.allowed_attributes`.
allowedAttributes: {
a: ["href", "title", "class", "style", "target", "rel"],
img: ["src", "alt", "width", "height", "class", "style"],
"*": ["style", "class", "title", "id"],
a: ["href", "title", "class", "target", "rel"],
img: ["src", "alt", "width", "height", "class"],
"*": ["class", "title"],
},
};
function uniqueStrings(values) {
+2
View File
@@ -1,11 +1,13 @@
import type { ServerQueryEqualities, ServerQueryObject, SQLInsertGenValueType } from "../types";
type Params = {
/** Already-quoted column reference */
fieldName: string;
value?: SQLInsertGenValueType;
equality?: (typeof ServerQueryEqualities)[number];
queryObj: ServerQueryObject<{
[key: string]: any;
}, string>;
/** `value` is an already-quoted column reference, not a parameter */
isValueFieldValue?: boolean;
};
type Return = {
+16 -7
View File
@@ -10,9 +10,12 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
if (queryObj.notNullValue) {
return { str: `${fieldName} IS NOT NULL` };
}
if (value) {
// `0` is a real value; only null / undefined / "" count as "no value"
const hasValue = value !== undefined && value !== null && value !== "";
if (hasValue) {
const finalValue = isValueFieldValue ? value : "?";
const finalParams = isValueFieldValue ? undefined : value;
const likeParams = isValueFieldValue ? undefined : `%${value}%`;
if (equality == "MATCH") {
return {
str: `MATCH(${fieldName}) AGAINST(${finalValue} IN NATURAL LANGUAGE MODE)`,
@@ -28,7 +31,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "LIKE_LOWER") {
return {
str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`,
param: `%${finalParams}%`,
param: likeParams,
};
}
else if (equality == "LIKE_LOWER_RAW") {
@@ -40,7 +43,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "LIKE") {
return {
str: `${fieldName} LIKE ${finalValue}`,
param: `%${finalParams}%`,
param: likeParams,
};
}
else if (equality == "LIKE_RAW") {
@@ -52,7 +55,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "NOT_LIKE_LOWER") {
return {
str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`,
param: `%${finalParams}%`,
param: likeParams,
};
}
else if (equality == "NOT_LIKE_LOWER_RAW") {
@@ -64,7 +67,7 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality == "NOT LIKE") {
return {
str: `${fieldName} NOT LIKE ${finalValue}`,
param: finalParams,
param: likeParams,
};
}
else if (equality == "NOT LIKE_RAW") {
@@ -97,6 +100,12 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
param: finalParams,
};
}
else if (equality == "IN" || equality == "NOT IN") {
return {
str: `${fieldName} ${equality} (${finalValue})`,
param: finalParams,
};
}
else if (equality) {
return {
str: `${fieldName} ${sqlEqualityParser(equality)} ${finalValue}`,
@@ -120,13 +129,13 @@ export default function sqlGenOperatorGen({ fieldName, value, equality, queryObj
else if (equality) {
return {
str: `${fieldName} ${sqlEqualityParser(equality)} ?`,
param: value,
param: value ?? "",
};
}
else {
return {
str: `${fieldName} = ?`,
param: value,
param: value ?? "",
};
}
}
+1
View File
@@ -2,6 +2,7 @@ import type { ServerQueryParamsJoin, ServerQueryParamsJoinMatchObject, SQLInsert
type Param = {
mtch: ServerQueryParamsJoinMatchObject;
join: ServerQueryParamsJoin;
/** Already-quoted root table name */
table_name: string;
};
export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param): {
+18 -23
View File
@@ -1,3 +1,4 @@
import { columnRef, sqlLogicalOperator, sqlRawComparison, } from "./sql-identifier";
export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
let values = [];
if (mtch.__batch) {
@@ -13,7 +14,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
btch_mtch += str;
values.push(...batch_values);
if (i < mtch.__batch.matches.length - 1) {
btch_mtch += ` ${mtch.__batch.operator || "OR"} `;
btch_mtch += ` ${sqlLogicalOperator(mtch.__batch.operator || "OR")} `;
}
}
btch_mtch += `)`;
@@ -22,28 +23,12 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
values,
};
}
const equality = mtch.raw_equality || "=";
const lhs = `${typeof mtch.source == "object" ? mtch.source.tableName : table_name}.${typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source}`;
const rhs = `${(() => {
if (mtch.targetLiteral) {
values.push(mtch.targetLiteral);
// if (typeof mtch.targetLiteral == "number") {
// return `${mtch.targetLiteral}`;
// }
// return `'${mtch.targetLiteral}'`;
return `?`;
}
if (join.alias) {
return `${typeof mtch.target == "object"
? mtch.target.tableName
: join.alias}.${typeof mtch.target == "object"
? mtch.target.fieldName
: mtch.target}`;
}
return `${typeof mtch.target == "object"
? mtch.target.tableName
: join.tableName}.${typeof mtch.target == "object" ? mtch.target.fieldName : mtch.target}`;
})()}`;
const equality = mtch.raw_equality
? sqlRawComparison(mtch.raw_equality)
: "=";
const lhs = typeof mtch.source == "object"
? columnRef(mtch.source.fieldName, mtch.source.tableName)
: columnRef(String(mtch.source), table_name);
if (mtch.between) {
values.push(mtch.between.min, mtch.between.max);
return {
@@ -58,6 +43,16 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }) {
values,
};
}
const rhs = (() => {
if (mtch.targetLiteral !== undefined && mtch.targetLiteral !== null) {
values.push(mtch.targetLiteral);
return `?`;
}
if (typeof mtch.target == "object") {
return columnRef(mtch.target.fieldName, mtch.target.tableName);
}
return columnRef(String(mtch.target), join.alias || join.tableName);
})();
return {
str: `${lhs} ${equality} ${rhs}`,
values,
+1
View File
@@ -7,6 +7,7 @@ type Param<T extends {
genObject?: ServerQueryParam<T>;
selectFields?: (keyof T | TableSelectFieldsObject<T>)[];
append_table_names?: boolean;
/** Already-quoted table name */
table_name: string;
full_text_match_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 sqlGenGenJoinStr from "./sql-generator-gen-join-str";
import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql";
import { columnRef, quoteIdentifier, quoteQualified, sqlJoinType, sqlLogicalOperator, } from "./sql-identifier";
export default function sqlGenGenQueryStr(params) {
let str = "SELECT";
const genObject = params.genObject;
@@ -42,47 +43,47 @@ export default function sqlGenGenQueryStr(params) {
if (!countSubQuery)
continue;
const tableAlias = countSubQuery.table_alias;
const countTableRef = quoteQualified(tableAlias || countSubQuery.table);
let subQStr = `(SELECT COUNT(*)`;
subQStr += ` FROM ${countSubQuery.table}${tableAlias ? ` ${tableAlias}` : ""}`;
subQStr += ` FROM ${quoteQualified(countSubQuery.table)}${tableAlias ? ` ${quoteIdentifier(tableAlias)}` : ""}`;
subQStr += ` WHERE (`;
for (let j = 0; j < countSubQuery.srcTrgMap.length; j++) {
const csqSrc = countSubQuery.srcTrgMap[j];
if (!csqSrc)
continue;
subQStr += ` ${tableAlias || countSubQuery.table}.${csqSrc.src}`;
subQStr += ` ${countTableRef}.${quoteIdentifier(csqSrc.src)}`;
if (typeof csqSrc.trg == "string") {
subQStr += ` = ?`;
sqlSearhValues.push(csqSrc.trg);
}
else if (typeof csqSrc.trg == "object") {
subQStr += ` = ${csqSrc.trg.table}.${csqSrc.trg.field}`;
subQStr += ` = ${columnRef(csqSrc.trg.field, csqSrc.trg.table)}`;
}
if (j < countSubQuery.srcTrgMap.length - 1) {
subQStr += ` AND `;
}
}
subQStr += ` )) AS ${countSubQuery.alias}`;
subQStr += ` )) AS ${quoteIdentifier(countSubQuery.alias)}`;
countSqls.push(subQStr);
}
str += `, ${countSqls.join(",")}`;
}
if (genObject?.join) {
const existingJoinTableNames = [table_name];
str +=
"," +
genObject.join
const joinSelects = genObject.join
.flat()
.filter((j) => !isUndefined(j))
.map((joinObj) => {
const joinTableName = joinObj.alias
? joinObj.alias
: joinObj.tableName;
if (existingJoinTableNames.includes(joinTableName))
const joinTableRef = quoteQualified(joinTableName);
if (existingJoinTableNames.includes(joinTableRef))
return null;
existingJoinTableNames.push(joinTableName);
existingJoinTableNames.push(joinTableRef);
if (joinObj.group_concat) {
return sqlGenGrabConcatStr({
field: `${joinTableName}.${joinObj.group_concat.field}`,
field: columnRef(joinObj.group_concat.field, joinTableName),
alias: joinObj.group_concat.alias,
separator: joinObj.group_concat.separator,
});
@@ -91,57 +92,59 @@ export default function sqlGenGenQueryStr(params) {
return joinObj.selectFields
.map((selectField) => {
if (typeof selectField == "string") {
return `${joinTableName}.${selectField}`;
return columnRef(selectField, joinTableName);
}
else if (typeof selectField == "object") {
let aliasSelectField = `${joinTableName}.${selectField.field}`;
const fieldRef = columnRef(String(selectField.field), joinTableName);
let aliasSelectField = fieldRef;
if (selectField.count) {
aliasSelectField = `COUNT(${joinTableName}.${selectField.field})`;
aliasSelectField = `COUNT(${fieldRef})`;
}
else if (selectField.sum) {
aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${joinTableName}.${selectField.field})`;
aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${fieldRef})`;
}
else if (selectField.average) {
aliasSelectField = `AVERAGE(${joinTableName}.${selectField.field})`;
aliasSelectField = `AVG(${fieldRef})`;
}
else if (selectField.max) {
aliasSelectField = `MAX(${joinTableName}.${selectField.field})`;
aliasSelectField = `MAX(${fieldRef})`;
}
else if (selectField.min) {
aliasSelectField = `MIN(${joinTableName}.${selectField.field})`;
aliasSelectField = `MIN(${fieldRef})`;
}
else if (selectField.group_concat &&
selectField.alias) {
return sqlGenGrabConcatStr({
field: `${joinTableName}.${selectField.field}`,
field: fieldRef,
alias: selectField.alias,
separator: selectField.group_concat
.separator,
distinct: selectField.group_concat
.distinct,
separator: selectField.group_concat.separator,
distinct: selectField.group_concat.distinct,
});
}
else if (selectField.distinct) {
aliasSelectField = `DISTINCT ${joinTableName}.${selectField.field}`;
aliasSelectField = `DISTINCT ${fieldRef}`;
}
if (selectField.alias)
aliasSelectField += ` AS ${selectField.alias}`;
aliasSelectField += ` AS ${quoteIdentifier(selectField.alias)}`;
return aliasSelectField;
}
})
.join(",");
}
else {
return `${joinTableName}.*`;
return `${joinTableRef}.*`;
}
})
.filter((_) => Boolean(_))
.join(",");
.filter((_) => Boolean(_));
if (joinSelects.length) {
str += "," + joinSelects.join(",");
}
}
if (genObject?.fullTextSearch &&
genObject.fullTextSearch.scoreAlias &&
full_text_match_str &&
full_text_search_str) {
str += `, ${full_text_match_str} AS ${genObject.fullTextSearch.scoreAlias}`;
str += `, ${full_text_match_str} AS ${quoteIdentifier(genObject.fullTextSearch.scoreAlias)}`;
sqlSearhValues.push(full_text_search_str);
}
str += ` FROM ${table_name}`;
@@ -152,11 +155,11 @@ export default function sqlGenGenQueryStr(params) {
.flat()
.filter((j) => !isUndefined(j))
.map((join) => {
return (join.joinType +
return (sqlJoinType(join.joinType) +
" " +
(join.alias
? `${join.tableName}` + " " + join.alias
: `${join.tableName}`) +
? `${quoteQualified(join.tableName)} ${quoteIdentifier(join.alias)}`
: `${quoteQualified(join.tableName)}`) +
" ON " +
(() => {
if (Array.isArray(join.match)) {
@@ -172,9 +175,7 @@ export default function sqlGenGenQueryStr(params) {
sqlSearhValues.push(...values);
return str;
})
.join(join.operator
? ` ${join.operator} `
: " AND ") +
.join(` ${sqlLogicalOperator(join.operator)} `) +
")");
}
else if (typeof join.match == "object") {
+1
View File
@@ -3,6 +3,7 @@ type Param = {
queryObj: ServerQueryQueryObject[string];
join?: (ServerQueryParamsJoin | ServerQueryParamsJoin[] | undefined)[];
field?: string;
/** Already-quoted root table name */
table_name: string;
};
export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }: Param): {
+62 -42
View File
@@ -1,92 +1,112 @@
import sqlGenOperatorGen from "./sql-gen-operator-gen";
import { columnRef, sqlLogicalOperator, sqlRawComparison, } from "./sql-identifier";
export default function sqlGenGenSearchStr({ queryObj, join, field, table_name, }) {
let sqlSearhValues = [];
const finalFieldName = (() => {
if (queryObj?.tableName) {
return `${queryObj.tableName}.${field}`;
return columnRef(String(field), queryObj.tableName);
}
if (join) {
return `${table_name}.${field}`;
return columnRef(String(field), table_name);
}
return field;
return columnRef(String(field));
})();
let str = `${finalFieldName}=?`;
function grabValue(val) {
const valueParsed = val;
if (!valueParsed)
if (val === undefined || val === null)
return;
const valueString = typeof valueParsed == "string" || typeof valueParsed == "number"
? valueParsed
: valueParsed
? valueParsed.fieldName && valueParsed.tableName
? `${valueParsed.tableName}.${valueParsed.fieldName}`
: valueParsed.value
: undefined;
const valueEquality = typeof valueParsed == "object"
? valueParsed.equality || queryObj.equality
const isFieldRef = Boolean(typeof val == "object" && val.fieldName && val.tableName);
const valueString = typeof val == "string" || typeof val == "number"
? val
: isFieldRef
? columnRef(val.fieldName, val.tableName)
: val.value;
const valueEquality = typeof val == "object"
? val.equality || queryObj.equality
: queryObj.equality;
const operatorStrParam = sqlGenOperatorGen({
return sqlGenOperatorGen({
queryObj,
equality: valueEquality,
fieldName: finalFieldName || "",
value: valueString || "",
isValueFieldValue: Boolean(typeof valueParsed == "object" &&
valueParsed.fieldName &&
valueParsed.tableName),
fieldName: finalFieldName,
value: valueString ?? "",
isValueFieldValue: isFieldRef,
});
return operatorStrParam;
}
if (Array.isArray(queryObj.value)) {
const listEquality = queryObj.equality == "IN" || queryObj.equality == "NOT IN"
? queryObj.equality
: undefined;
if (listEquality &&
!queryObj.nullValue &&
!queryObj.notNullValue &&
queryObj.value !== undefined &&
queryObj.value !== null) {
const rawValues = Array.isArray(queryObj.value)
? queryObj.value
: [queryObj.value];
const listValues = rawValues
.map((val) => val !== null && typeof val == "object" ? val.value : val)
.filter((val) => val !== undefined && val !== null);
if (listValues.length === 0) {
// Empty IN () is invalid SQL: IN matches nothing, NOT IN matches everything
str = listEquality == "IN" ? "1 = 0" : "1 = 1";
}
else {
str = `${finalFieldName} ${listEquality} (${listValues.map(() => "?").join(",")})`;
sqlSearhValues.push(...listValues);
}
}
else if (Array.isArray(queryObj.value)) {
const strArray = [];
queryObj.value.forEach((val) => {
const operatorStrParam = grabValue(val);
if (!operatorStrParam)
if (!operatorStrParam?.str)
return;
if (operatorStrParam.str && operatorStrParam.param) {
strArray.push(operatorStrParam.str);
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param);
}
else if (operatorStrParam.str) {
strArray.push(operatorStrParam.str);
}
});
str = "(" + strArray.join(` ${queryObj.operator || "AND"} `) + ")";
str =
"(" +
strArray.join(` ${sqlLogicalOperator(queryObj.operator)} `) +
")";
}
else if (typeof queryObj.value == "object") {
else if (queryObj.value !== null && typeof queryObj.value == "object") {
const operatorStrParam = grabValue(queryObj.value);
if (operatorStrParam?.str) {
str = operatorStrParam.str;
if (operatorStrParam.param) {
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param);
}
}
}
else if (queryObj.raw_equality && queryObj.value) {
str = `${finalFieldName} ${queryObj.raw_equality} ?`;
else if (queryObj.raw_equality &&
queryObj.value !== undefined &&
queryObj.value !== null) {
str = `${finalFieldName} ${sqlRawComparison(queryObj.raw_equality)} ?`;
sqlSearhValues.push(queryObj.value);
}
else if (queryObj.between) {
str = `${finalFieldName} BETWEEN ? AND ?`;
sqlSearhValues.push(queryObj.between.min, queryObj.between.max);
}
else if (queryObj.not_between) {
str = `${finalFieldName} NOT BETWEEN ? AND ?`;
sqlSearhValues.push(queryObj.not_between.min, queryObj.not_between.max);
}
else {
const valueParsed = queryObj.value ? queryObj.value : undefined;
const operatorStrParam = sqlGenOperatorGen({
equality: queryObj.equality,
fieldName: finalFieldName || "",
value: valueParsed,
fieldName: finalFieldName,
value: queryObj.value ?? undefined,
queryObj,
});
if (operatorStrParam.str && operatorStrParam.param) {
if (operatorStrParam.str) {
str = operatorStrParam.str;
}
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param);
}
else if (operatorStrParam.str && !operatorStrParam.str.match(/\?/)) {
str = operatorStrParam.str;
}
else {
sqlSearhValues.push(valueParsed || "");
}
}
return { str, values: sqlSearhValues };
}
+1
View File
@@ -1,4 +1,5 @@
type Param = {
/** Already-quoted column reference */
field: string;
alias: 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, }) {
let gc = `GROUP_CONCAT(`;
if (distinct) {
@@ -5,9 +6,9 @@ export default function sqlGenGrabConcatStr({ alias, field, separator = ",", dis
}
gc += `${field}`;
if (!distinct) {
gc += `, '${separator}'`;
gc += `, '${escapeStringLiteral(separator)}'`;
}
gc += `)`;
gc += ` AS ${alias}`;
gc += ` AS ${quoteIdentifier(alias)}`;
return gc;
}
+18 -25
View File
@@ -1,36 +1,35 @@
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import { columnRef, quoteIdentifier } from "./sql-identifier";
export default function sqlGenGrabSelectFieldSQL({ selectFields, append_table_names, table_name }) {
let str = "";
str += ` ${selectFields
?.map((fld) => {
const table = append_table_names ? table_name : undefined;
return ` ${selectFields
.map((fld) => {
if (typeof fld != "object") {
return columnRef(String(fld), table);
}
const fld_name = columnRef(String(fld.fieldName), table);
let fld_str = ``;
const final_fld_name = typeof fld == "object"
? append_table_names
? `${table_name}.${String(fld)}`
: `${String(fld.fieldName)}`
: `${String(fld)}`;
if (typeof fld == "object") {
const fld_name = `${String(fld.fieldName)}`;
if (fld.count) {
fld_str += `COUNT(${fld_name})`;
fld_str = `COUNT(${fld_name})`;
}
else if (fld.sum) {
fld_str += `SUM(${fld_name})`;
fld_str = `SUM(${fld_name})`;
}
else if (fld.average) {
fld_str += `AVERAGE(${fld_name})`;
fld_str = `AVG(${fld_name})`;
}
else if (fld.max) {
fld_str += `MAX(${fld_name})`;
fld_str = `MAX(${fld_name})`;
}
else if (fld.min) {
fld_str += `MIN(${fld_name})`;
fld_str = `MIN(${fld_name})`;
}
else if (fld.distinct) {
fld_str += `DISTINCT ${fld_name}`;
fld_str = `DISTINCT ${fld_name}`;
}
else if (fld.group_concat) {
fld_str += sqlGenGrabConcatStr({
// Carries its own alias
return sqlGenGrabConcatStr({
field: fld_name,
alias: fld.group_concat.alias,
separator: fld.group_concat.separator,
@@ -38,18 +37,12 @@ export default function sqlGenGrabSelectFieldSQL({ selectFields, append_table_na
});
}
else {
fld_str +=
final_fld_name + (fld.alias ? ` as ${fld.alias}` : ``);
fld_str = fld_name;
}
if (fld.alias) {
fld_str += ` AS ${fld.alias}`;
}
}
else {
fld_str += final_fld_name;
fld_str += ` AS ${quoteIdentifier(fld.alias)}`;
}
return fld_str;
})
.join(",")}`;
return str;
}
+11 -1
View File
@@ -5,13 +5,23 @@ type Param<T extends {
[key: string]: any;
}> = {
genObject?: ServerQueryParam<T>;
/** Raw (unquoted) table name */
tableName: string;
dbFullName?: string;
count?: boolean;
/**
* Restrict to a single row id. Always combined with AND, regardless of
* `searchOperator`.
*/
targetId?: number | string;
};
type Return = {
string: string;
values: SQLInsertGenValueType[];
/** `WHERE ...` clause alone (empty string if none) */
where: string;
/** Values for the `where` clause placeholders only */
whereValues: SQLInsertGenValueType[];
};
/**
* # SQL Query Generator
@@ -21,5 +31,5 @@ export default function sqlGenerator<T extends {
[key: string]: any;
} = {
[key: string]: any;
}>({ tableName, genObject, dbFullName, count }: Param<T>): Return;
}>({ tableName: rawTableName, genObject, dbFullName, count, targetId, }: Param<T>): Return;
export {};
+71 -221
View File
@@ -1,16 +1,20 @@
import sqlGenGenSearchStr from "./sql-generator-gen-search-str";
import sqlGenGenQueryStr from "./sql-generator-gen-query-str";
import { columnRef, quoteIdentifier, quoteQualified, sqlLogicalOperator, sqlNonNegativeInt, sqlOrderStrategy, } from "./sql-identifier";
/** MariaDB requires LIMIT before OFFSET; this is the documented "no limit" value */
const MAX_LIMIT = "18446744073709551615";
/**
* # SQL Query Generator
* @description Builds parameterized SELECT SQL for MariaDB
*/
export default function sqlGenerator({ tableName, genObject, dbFullName, count }) {
export default function sqlGenerator({ tableName: rawTableName, genObject, dbFullName, count, targetId, }) {
const tableName = quoteQualified(rawTableName);
const finalQuery = genObject?.query ? genObject.query : undefined;
const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined;
const sqlSearhValues = [];
const whereValues = [];
let fullTextMatchStr = genObject?.fullTextSearch
? ` MATCH(${genObject.fullTextSearch.fields
.map((f) => genObject.join ? `${tableName}.${String(f)}` : `${String(f)}`)
.map((f) => columnRef(String(f), genObject.join ? tableName : undefined))
.join(",")}) AGAINST (? IN BOOLEAN MODE)`
: undefined;
const fullTextSearchStr = genObject?.fullTextSearch
@@ -19,14 +23,13 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
.map((t) => `${t}`)
.join(" ")
: undefined;
let { str: queryString, values } = sqlGenGenQueryStr({
let { str: queryString, values: selectValues } = sqlGenGenQueryStr({
table_name: tableName,
append_table_names: true,
full_text_match_str: fullTextMatchStr,
full_text_search_str: fullTextSearchStr,
genObject,
});
sqlSearhValues.push(...values);
const sqlSearhString = queryKeys?.map((field) => {
const queryObj = finalQuery?.[field];
if (!queryObj)
@@ -34,7 +37,8 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
if (queryObj.__query) {
const subQueryGroup = queryObj.__query;
const subSearchKeys = Object.keys(subQueryGroup);
const subSearchString = subSearchKeys.map((_field) => {
const subSearchString = subSearchKeys
.map((_field) => {
const newSubQueryObj = subQueryGroup?.[_field];
if (newSubQueryObj) {
const { str, values } = sqlGenGenSearchStr({
@@ -43,12 +47,13 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
join: genObject?.join,
table_name: tableName,
});
sqlSearhValues.push(...values);
whereValues.push(...values);
return str;
}
});
})
.filter((str) => typeof str == "string");
return ("(" +
subSearchString.join(` ${queryObj.operator || "AND"} `) +
subSearchString.join(` ${sqlLogicalOperator(queryObj.operator)} `) +
")");
}
const { str, values } = sqlGenGenSearchStr({
@@ -57,247 +62,92 @@ export default function sqlGenerator({ tableName, genObject, dbFullName, count }
join: genObject?.join,
table_name: tableName,
});
sqlSearhValues.push(...values);
whereValues.push(...values);
return str;
});
const cleanedUpSearchStr = sqlSearhString?.filter((str) => typeof str == "string");
const isSearchStr = cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str);
const whereConditions = [];
if (isSearchStr) {
const stringOperator = genObject?.searchOperator || "AND";
queryString += ` WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`;
const stringOperator = sqlLogicalOperator(genObject?.searchOperator);
whereConditions.push(`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`);
}
if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) {
queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`;
sqlSearhValues.push(fullTextSearchStr);
whereConditions.push(fullTextMatchStr.trim());
whereValues.push(fullTextSearchStr);
}
if (targetId !== undefined && targetId !== null && targetId !== "") {
const id = Number(targetId);
if (!Number.isFinite(id)) {
throw new Error(`Invalid targetId: ${String(targetId)}`);
}
whereConditions.push(`${columnRef("id", genObject?.join ? tableName : undefined)} = ?`);
whereValues.push(id);
}
const where = whereConditions.length
? `WHERE ${whereConditions.join(" AND ")}`
: ``;
if (where) {
queryString += ` ${where}`;
}
if (genObject?.group) {
const groupRef = (group) => typeof group == "object"
? columnRef(String(group.field), group.table)
: columnRef(String(group));
let group_by_txt = ``;
if (typeof genObject.group == "string") {
group_by_txt = genObject.group;
}
else if (Array.isArray(genObject.group)) {
for (let i = 0; i < genObject.group.length; i++) {
const group = genObject.group[i];
if (typeof group == "string") {
group_by_txt += `\`${group.toString()}\``;
}
else if (typeof group == "object" && group.table) {
group_by_txt += `${group.table}.${String(group.field)}`;
}
else if (typeof group == "object") {
group_by_txt += `${String(group.field)}`;
}
if (i < genObject.group.length - 1) {
group_by_txt += ",";
}
}
}
else if (typeof genObject.group == "object") {
if (genObject.group.table) {
group_by_txt = `${genObject.group.table}.${String(genObject.group.field)}`;
if (Array.isArray(genObject.group)) {
group_by_txt = genObject.group
.map((group) => groupRef(group))
.join(",");
}
else {
group_by_txt = `${String(genObject.group.field)}`;
}
group_by_txt = groupRef(genObject.group);
}
queryString += ` GROUP BY ${group_by_txt}`;
}
function grabOrderString(order) {
let orderFields = [];
let orderSrt = ``;
if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) {
orderFields.push(genObject.fullTextSearch.scoreAlias);
}
else if (genObject?.join) {
orderFields.push(`${tableName}.${String(order.field)}`);
const scoreAlias = genObject?.fullTextSearch?.scoreAlias;
let orderField = ``;
if (scoreAlias && String(order.field) === scoreAlias) {
orderField = quoteIdentifier(scoreAlias);
}
else {
orderFields.push(order.field);
orderField = columnRef(String(order.field), genObject?.join ? tableName : undefined);
}
orderSrt += ` ${orderFields.join(", ")} ${order.strategy}`;
return orderSrt;
return ` ${orderField} ${sqlOrderStrategy(order.strategy)}`;
}
if (genObject?.order) {
let orderSrt = ` ORDER BY`;
if (Array.isArray(genObject.order)) {
for (let i = 0; i < genObject.order.length; i++) {
const order = genObject.order[i];
if (order) {
orderSrt +=
grabOrderString(order) +
(i < genObject.order.length - 1 ? `,` : "");
const orders = (Array.isArray(genObject.order) ? genObject.order : [genObject.order]).filter(Boolean);
if (orders.length) {
queryString += ` ORDER BY${orders.map(grabOrderString).join(",")}`;
}
}
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 {
orderSrt += grabOrderString(genObject.order);
else if (genObject?.page && limit) {
const page = sqlNonNegativeInt(genObject.page, "page");
offset = Math.max(page - 1, 0) * limit;
}
queryString += ` ${orderSrt}`;
if (limit) {
queryString += ` LIMIT ${limit}`;
}
if (genObject?.limit && !count)
queryString += ` LIMIT ${genObject.limit}`;
if (genObject?.offset) {
queryString += ` OFFSET ${genObject.offset}`;
else if (offset) {
queryString += ` LIMIT ${MAX_LIMIT}`;
}
if (offset) {
queryString += ` OFFSET ${offset}`;
}
else if (genObject?.page && genObject.limit && !count) {
queryString += ` OFFSET ${(genObject.page - 1) * genObject.limit}`;
}
return {
string: queryString,
values: sqlSearhValues,
values: [...selectValues, ...whereValues],
where,
whereValues,
};
}
// let queryString = (() => {
// let str = "SELECT";
// if (genObject?.select_sql) {
// str += ` ${genObject.select_sql}`;
// } else if (genObject?.selectFields?.[0]) {
// if (genObject.join) {
// str += sqlGenGrabSelectFieldSQL<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) {
return `\`${identifier.replace(/`/g, "``")}\``;
}
import { quoteIdentifier } from "./sql-identifier";
import sqlValuePlaceholder from "./sql-value-placeholder";
import grabTableFields from "./grab-table-fields";
/**
* # SQL Insert Generator
*/
@@ -17,37 +17,19 @@ export default function sqlInsertGenerator({ tableName, data, dbFullName, }) {
}
});
});
const fieldsMap = new Map(grabTableFields(tableName).map((f) => [f.fieldName, f]));
let queryBatches = [];
let queryValues = [];
data.forEach((item) => {
queryBatches.push(`(${insertKeys
.map((ky) => {
const value = item[ky];
const is_embedding = Array.isArray(value) &&
typeof value[0] == "number";
const finalValue = typeof value == "string" ||
typeof value == "number"
? value
: typeof value == "function"
? value().value
: is_embedding
? JSON.stringify(value)
: value
? value
: null;
if (!finalValue && typeof value !== "number") {
queryValues.push(null);
return "?";
}
queryValues.push(finalValue);
const placeholder = typeof value == "function"
? value().placeholder
: is_embedding
? "VEC_FromText(?)"
: "?";
const { placeholder, value } = sqlValuePlaceholder({
value: item[ky],
field: fieldsMap.get(ky),
});
queryValues.push(value);
return placeholder;
})
.filter((k) => Boolean(k))
.join(",")})`);
});
const insertColumns = insertKeys.map(quoteIdentifier).join(",");
+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 { AppData } from "../data/app-data";
import path from "path";
import _ from "lodash";
export default function trimBackups({ config }) {
const { backup_dir } = grabDBDir({ config });
const backups = grabSortedBackups({ config });
// Only consider files written by `backup` (`{db_name}-{timestamp}.sql`)
const backupNamePattern = new RegExp(`^${_.escapeRegExp(config.db_name)}-\\d+\\.sql$`);
const backups = grabSortedBackups({ config }).filter((name) => backupNamePattern.test(name) &&
fs.statSync(path.join(backup_dir, name)).isFile());
const max_backups = config.max_backups || AppData["MaxBackups"];
for (let i = 0; i < backups.length; i++) {
const backup_name = backups[i];
+5 -1
View File
@@ -1,11 +1,15 @@
import fs from "fs";
import path from "path";
import _ from "lodash";
import { AppData } from "../data/app-data";
import grabDBDir from "./grab-db-dir";
import grabSortedExports from "./grab-sorted-exports";
export default function trimExports({ config }) {
const { export_dir } = grabDBDir({ config });
const exports = grabSortedExports({ config });
// Only consider archives written by `export` (`{db_name}-{timestamp}.tar.gz|zip`)
const exportNamePattern = new RegExp(`^${_.escapeRegExp(config.db_name)}-\\d+\\.(tar\\.gz|zip)$`);
const exports = grabSortedExports({ config }).filter((name) => exportNamePattern.test(name) &&
fs.statSync(path.join(export_dir, name)).isFile());
const max_exports = config.max_exports || AppData["MaxExports"];
for (let i = 0; i < exports.length; i++) {
const export_name = exports[i];
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@moduletrace/bun-mariadb",
"version": "1.0.22",
"version": "1.0.21",
"description": "Schema-driven MariaDB manager for Bun",
"author": "Benjamin Toby",
"license": "MIT",
@@ -27,12 +27,14 @@
},
"scripts": {
"dev": "tsc --watch",
"compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json",
"compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && chmod +x dist/commands/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun run scripts/test.ts",
"test:unit": "bun test",
"prepublishOnly": "bun run compile"
},
"devDependencies": {
"@types/bun": "latest",
"@types/bun": "1.3.14",
"@types/lodash": "^4.17.24",
"@types/node": "^25.3.3",
"@types/sanitize-html": "^2.16.1",
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bun
/**
* Run the full test suite against a throwaway MariaDB Docker container.
*
* bun run test # start container, run all tests, remove container
* bun run test tests/db # extra args are passed to `bun test`
*
* Env:
* BUN_MARIADB_TEST_IMAGE Docker image (default: mariadb:11.8, needs 11.7+ for VECTOR)
* BUN_MARIADB_TEST_DB If already set, skip Docker and use the configured
* BUN_MARIADB_SERVER_* connection instead
*/
import { SQL } from "bun";
const IMAGE = process.env.BUN_MARIADB_TEST_IMAGE || "mariadb:11.8";
const DB_NAME = "bun_mariadb_test";
const PASSWORD = "bun_mariadb_test_pw";
const CONTAINER = `bun-mariadb-test-${process.pid}`;
const READY_TIMEOUT_MS = 120_000;
const testArgs = process.argv.slice(2);
async function run(cmd: string[], opts: { quiet?: boolean } = {}) {
const proc = Bun.spawn(cmd, {
stdout: opts.quiet ? "pipe" : "inherit",
stderr: opts.quiet ? "pipe" : "inherit",
});
const stdout = opts.quiet ? await new Response(proc.stdout).text() : "";
const exitCode = await proc.exited;
return { exitCode, stdout: stdout.trim() };
}
async function runTests(env: Record<string, string>): Promise<number> {
const proc = Bun.spawn(["bun", "test", ...testArgs], {
stdout: "inherit",
stderr: "inherit",
env: { ...process.env, ...env },
});
return proc.exited;
}
// Already pointed at a database: just run the tests
if (process.env.BUN_MARIADB_TEST_DB) {
process.exit(await runTests({}));
}
if ((await run(["docker", "info"], { quiet: true })).exitCode !== 0) {
console.error(
"Docker is not available. Start Docker, or set BUN_MARIADB_TEST_DB and BUN_MARIADB_SERVER_* to use an existing database.",
);
process.exit(1);
}
let removed = false;
async function removeContainer() {
if (removed) return;
removed = true;
await run(["docker", "rm", "-f", CONTAINER], { quiet: true });
}
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, async () => {
await removeContainer();
process.exit(130);
});
}
let exitCode = 1;
try {
console.log(`Starting ${IMAGE} (${CONTAINER}) ...`);
const started = await run(
[
"docker",
"run",
"-d",
"--rm",
"--name",
CONTAINER,
"-e",
`MARIADB_ROOT_PASSWORD=${PASSWORD}`,
"-e",
`MARIADB_DATABASE=${DB_NAME}`,
// Random free host port, bound to localhost only
"-p",
"127.0.0.1::3306",
IMAGE,
],
{ quiet: true },
);
if (started.exitCode !== 0) {
throw new Error(`docker run failed (exit ${started.exitCode})`);
}
const portOut = await run(["docker", "port", CONTAINER, "3306/tcp"], {
quiet: true,
});
const port = portOut.stdout.split("\n")[0]?.split(":").pop();
if (!port) {
throw new Error(`Could not read the container's mapped port`);
}
/**
* Wait for a TCP connection. The image's init phase runs a temporary
* server with networking disabled, so a successful TCP query means the
* real server is up.
*/
const deadline = Date.now() + READY_TIMEOUT_MS;
let ready = false;
while (Date.now() < deadline) {
const client = new SQL({
adapter: "mariadb",
hostname: "127.0.0.1",
port: Number(port),
username: "root",
password: PASSWORD,
database: DB_NAME,
connectionTimeout: 2,
});
try {
await client.unsafe("SELECT 1");
ready = true;
} catch {
await Bun.sleep(1000);
} finally {
await client.close().catch(() => {});
}
if (ready) break;
}
if (!ready) {
throw new Error(`MariaDB did not become ready within ${READY_TIMEOUT_MS / 1000}s`);
}
console.log(`MariaDB ready on 127.0.0.1:${port}\n`);
exitCode = await runTests({
BUN_MARIADB_TEST_DB: DB_NAME,
BUN_MARIADB_SERVER_HOST: "127.0.0.1",
BUN_MARIADB_SERVER_PORT: port,
BUN_MARIADB_SERVER_USERNAME: "root",
BUN_MARIADB_SERVER_PASSWORD: PASSWORD,
});
} catch (error: any) {
console.error(`Test setup failed: ${error.message}`);
exitCode = 1;
} finally {
await removeContainer();
}
process.exit(exitCode);
+38 -1
View File
@@ -2,7 +2,7 @@ import { Command } from "commander";
import path from "path";
import fs from "fs";
import chalk from "chalk";
import { select } from "@inquirer/prompts";
import { confirm, select } from "@inquirer/prompts";
import grabDBDir from "../utils/grab-db-dir";
import grabSortedExports from "../utils/grab-sorted-exports";
import grabBackupData from "../utils/grab-backup-data";
@@ -40,6 +40,10 @@ export default function () {
"--schema-only",
"When importing an archive, write schema only (skip SQL restore)",
)
.option(
"-y, --yes",
"Do not ask for confirmation before writing an executable (.ts/.js) schema from the archive",
)
.action(async (fileArg: string | undefined, opts) => {
console.log(`Importing database ...`);
@@ -117,6 +121,39 @@ export default function () {
const { sql, schema, schemaFileName } =
await readExportArchive(filePath);
/**
* A .ts/.js schema is executed (require) on the next run.
* Confirm before writing code from an archive into the project.
*/
const isExecutableSchema = /\.(ts|js)$/i.test(schemaFileName);
if (!opts.sqlOnly && isExecutableSchema && !opts.yes) {
if (!process.stdin.isTTY) {
console.error(
chalk.red(
`Archive contains an executable schema (${schemaFileName}). Re-run with --yes to accept it, or --sql-only to skip it.`,
),
);
process.exit(1);
}
console.log(
chalk.yellow(
`\nThis archive contains an executable schema file (${schemaFileName}).\nIt will run as code the next time bun-mariadb or your app loads it.\nOnly continue if you trust the source of this archive.\n`,
),
);
const proceed = await confirm({
message: `Write ${schemaFileName} into ${db_dir}?`,
default: false,
});
if (!proceed) {
console.log(chalk.dim(`Import cancelled.`));
process.exit(1);
}
}
if (!opts.schemaOnly) {
await restoreDatabase(config, sql);
console.log(chalk.green(`SQL restored from archive`));
+12 -1
View File
@@ -9,8 +9,19 @@ import exportCmd from "./export";
import importCmd from "./import";
import admin from "./admin";
import init from "../functions/init";
import { readFileSync } from "fs";
try {
init();
} catch (error: any) {
console.error(`Initialization ERROR => ${error.message}`);
process.exit(1);
}
/** Resolves to the package root from both src/commands and dist/commands */
const { version } = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
);
/**
* # Describe Program
@@ -18,7 +29,7 @@ init();
program
.name(`bun-mariadb`)
.description(`MariaDB manager for Bun`)
.version(`1.0.0`);
.version(version);
/**
* # Declare Commands
+99 -3
View File
@@ -6,11 +6,39 @@ import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-
import chalk from "chalk";
import { writeLiveSchema } from "../functions/live-schema";
import createDBSchema from "../lib/schema/create-db-schema";
import {
isDestructiveSchemaStatement,
planSchemaQueries,
runSchemaQueriesGuarded,
} from "../lib/schema/run-schema-query";
import { confirm } from "@inquirer/prompts";
import { AppData } from "../data/app-data";
/** Silence progress logs while computing a plan */
async function quietly<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 () {
return new Command("schema")
.description("Build DB From Schema")
.option("-t, --typedef", "Generate typescript type definitions")
.option(
"--dry-run",
"Print the schema changes that would be applied, without applying them",
)
.option(
"-y, --yes",
"Apply destructive changes (dropped tables / columns) without confirmation",
)
.action(async (opts) => {
console.log(`Starting process ...`);
@@ -26,11 +54,79 @@ export default function () {
dbSchema,
});
await createDBSchema({
db_schema: finaldbSchema,
config,
const params = { db_schema: finaldbSchema, config };
// Hide internal bookkeeping (schema manager rows, FK check toggles)
const plan = (
await quietly(() =>
planSchemaQueries(() => createDBSchema(params)),
)
).filter(
(statement) =>
!statement.includes(AppData.DbSchemaManagerTableName) &&
!/^SET FOREIGN_KEY_CHECKS/i.test(statement),
);
const destructive = plan.filter(isDestructiveSchemaStatement);
if (opts.dryRun) {
if (!plan.length) {
console.log(chalk.green(`Schema is up to date.`));
} else {
console.log(chalk.bold(`Planned schema changes:\n`));
for (const statement of plan) {
console.log(
isDestructiveSchemaStatement(statement)
? chalk.red(` ${statement}`)
: ` ${statement}`,
);
}
if (destructive.length) {
console.log(
chalk.red(
`\n${destructive.length} destructive change(s) (in red) would discard data.`,
),
);
}
}
process.exit(0);
}
if (destructive.length && !opts.yes) {
console.log(
chalk.yellow(
`\nThese changes will permanently discard data:\n`,
),
);
for (const statement of destructive) {
console.log(chalk.red(` ${statement}`));
}
console.log();
if (!process.stdin.isTTY) {
console.error(
chalk.red(
`Refusing to apply destructive changes non-interactively. Re-run with --yes to apply them.`,
),
);
process.exit(1);
}
const proceed = await confirm({
message: `Apply ${destructive.length} destructive change(s)?`,
default: false,
});
if (!proceed) {
console.log(chalk.dim(`Schema sync cancelled.`));
process.exit(1);
}
}
await runSchemaQueriesGuarded(
() => createDBSchema(params),
opts.yes ? true : new Set(destructive),
);
if (isTypeDef && config.typedef_file_path) {
const out_file = path.resolve(
ROOT_DIR,
+24 -7
View File
@@ -7,23 +7,37 @@ type Params = {
config: BunMariaDBConfig;
};
/** Reuse one client per database name to avoid exhausting MariaDB connections. */
/** Reuse one client per distinct connection config to avoid exhausting MariaDB connections. */
const clientCache = new Map<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({
config,
}: Params): Bun.SQL | undefined {
const cacheKey = config.db_name || "__default__";
const cacheKey = mariadbClientCacheKey(config);
const cached = clientCache.get(cacheKey);
if (cached) {
return cached;
}
// Prefer the process-wide client when it matches the requested database
// Prefer the process-wide client when it was created from the same config
if (
global.MARIADB_CLIENT &&
(!global.CONFIG?.db_name || global.CONFIG.db_name === config.db_name)
global.CONFIG &&
mariadbClientCacheKey(global.CONFIG) === cacheKey
) {
clientCache.set(cacheKey, global.MARIADB_CLIENT);
return global.MARIADB_CLIENT;
@@ -32,13 +46,16 @@ export default function grabMariaDBClient({
const { ROOT_DIR } = grabDirNames();
try {
// Verify the server certificate unless explicitly disabled
const rejectUnauthorized = config.ssl_reject_unauthorized !== false;
const tls = config.ssl_ca
? {
ca: Bun.file(path.resolve(ROOT_DIR, config.ssl_ca)),
rejectUnauthorized: false,
rejectUnauthorized,
}
: {
rejectUnauthorized: false,
rejectUnauthorized,
};
const MariaDBClient = new SQL({
@@ -49,8 +66,8 @@ export default function grabMariaDBClient({
port: process.env.BUN_MARIADB_SERVER_PORT
? Number(process.env.BUN_MARIADB_SERVER_PORT)
: undefined,
...config.db_config,
tls,
...config.db_config,
adapter: "mariadb",
} as Bun.SQL.Options);
+23 -32
View File
@@ -24,8 +24,11 @@ declare global {
var SCHEMA_FILE_PATH: string;
}
/**
* Load config + schema and set up the shared MariaDB client.
* Throws on any problem (never exits the process).
*/
export default function init(): void {
try {
const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData;
@@ -35,24 +38,23 @@ export default function init(): void {
);
if (!loadedConfig) {
console.error(
throw new Error(
`Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`,
);
process.exit(1);
}
const Config = loadedConfig.data;
if (!Config || typeof Config !== "object") {
console.error(
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) {
console.error(`\`db_name\` is required in your config`);
process.exit(1);
throw new Error(
`\`db_name\` is required in your config`,
);
}
RequiredENVs.forEach((env_key) => {
@@ -64,16 +66,16 @@ export default function init(): void {
}
if (!process.env[env_key]) {
console.error(`\`${env_key}\` env variable is required.`);
process.exit(1);
throw new Error(
`\`${env_key}\` env variable is required.`,
);
}
});
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`,
);
process.exit(1);
}
const db_dir = path.resolve(ROOT_DIR, Config.db_dir);
@@ -88,19 +90,17 @@ export default function init(): void {
);
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." : "."}`,
);
process.exit(1);
}
const DbSchema = loadedSchema.data;
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)" : ""}.`,
);
process.exit(1);
}
const backup_dir =
@@ -121,24 +121,15 @@ export default function init(): void {
global.CONFIG_FILE_PATH = loadedConfig.path;
global.SCHEMA_FILE_PATH = loadedSchema.path;
if (!global.CONFIG) {
console.error(`Couldn't grab global Config.`);
process.exit(1);
}
if (!global.DB_SCHEMA) {
console.error(`Couldn't grab Database Schema.`);
process.exit(1);
}
setMariaDBClient({ config: Config });
}
if (!global.MARIADB_CLIENT) {
console.error(`Couldn't set MariaDB Client.`);
process.exit(1);
}
} catch (error: any) {
console.error(`Initialization ERROR => ` + error.message);
process.exit(1);
/**
* Initialize once, on first use.
*/
export function ensureInit(): void {
if (global.CONFIG && global.DB_SCHEMA && global.MARIADB_CLIENT !== undefined) {
return;
}
init();
}
-13
View File
@@ -6,7 +6,6 @@ type Params = {
};
export default function setMariaDBClient({ config }: Params): void {
try {
const MariaDBClient = grabMariaDBClient({ config });
if (!MariaDBClient) {
@@ -14,16 +13,4 @@ export default function setMariaDBClient({ config }: Params): void {
}
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 DbInsert from "./lib/mariadb/db-insert";
import DbSelect from "./lib/mariadb/db-select";
@@ -7,16 +7,25 @@ import DbUpdate from "./lib/mariadb/db-update";
import grabDbSchema from "./utils/grab-db-schema";
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object";
init();
/**
* Initialize lazily on first call, so importing the library never exits or
* throws. Setup errors (missing config / env) reject the call instead.
*/
function withInit<F extends (...args: any[]) => Promise<any>>(fn: F): F {
return (async (...args: Parameters<F>) => {
ensureInit();
return fn(...args);
}) as F;
}
const BunMariaDB = {
select: DbSelect,
insert: DbInsert,
update: DbUpdate,
delete: DbDelete,
sql: DbSQL,
select: withInit(DbSelect),
insert: withInit(DbInsert),
update: withInit(DbUpdate),
delete: withInit(DbDelete),
sql: withInit(DbSQL),
utils: {
grab_db_schema: grabDbSchema,
grab_db_schema: withInit(grabDbSchema),
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
},
} as const;
+53 -19
View File
@@ -1,38 +1,78 @@
import grabMariaDBClient from "../functions/grab-mariadb-client";
import grabMariaDBClient, {
mariadbClientCacheKey,
} from "../functions/grab-mariadb-client";
import type {
BunMariaDBConfig,
DBInsertReturn,
DBResponseObject,
} from "../types";
/** Anything that can run a raw query: a client or a transaction handle */
export type DBQueryRunner = {
unsafe: (query: string, values?: any[]) => Promise<any>;
};
type Param = {
query: string;
values?: any[];
config?: BunMariaDBConfig;
/** Run on this client / transaction instead of the shared client */
client?: DBQueryRunner;
};
/**
* Resolve a shared MariaDB client. Prefer the global client so schema sync
* and CRUD do not open a new connection per query.
*/
function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined {
const resolvedConfig = config || global.CONFIG;
export function resolveClient(config?: BunMariaDBConfig): Bun.SQL | undefined {
if (!config) {
return global.MARIADB_CLIENT;
}
// Same database as the global client → reuse it
// Same connection config as the global client → reuse it
if (
global.MARIADB_CLIENT &&
(!resolvedConfig?.db_name ||
!global.CONFIG?.db_name ||
resolvedConfig.db_name === global.CONFIG.db_name)
(!global.CONFIG ||
mariadbClientCacheKey(config) ===
mariadbClientCacheKey(global.CONFIG))
) {
return global.MARIADB_CLIENT;
}
if (resolvedConfig) {
return grabMariaDBClient({ config: resolvedConfig });
return grabMariaDBClient({ config });
}
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<
T extends { [k: string]: any } = { [k: string]: any },
>({ query, values, config }: Param): Promise<DBResponseObject<T>> {
>({ query, values, config, client }: Param): Promise<DBResponseObject<T>> {
try {
const CLIENT = resolveClient(config);
const CLIENT = client || resolveClient(config);
if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`);
@@ -52,13 +92,7 @@ export default async function dbHandler<
const count = res.count;
const res_array = (() => {
try {
return JSON.parse(JSON.stringify(res)) as T[];
} catch (error) {
return undefined;
}
})();
const res_array = normalizeRows<T>(res);
const last_insert_id = res_array?.[0] ? res_array[0]?.id : undefined;
const affected_rows = res_array?.[0] ? res_array.length : undefined;
+1 -3
View File
@@ -4,9 +4,7 @@ type Params = {
data: any | any[];
};
function quoteIdentifier(identifier: string): string {
return `\`${identifier.replace(/`/g, "``")}\``;
}
import { quoteIdentifier } from "../utils/sql-identifier";
export default async function ({ sql: passedSql, table, data }: Params) {
const config = global.CONFIG;
+6 -20
View File
@@ -6,6 +6,7 @@ import type {
ServerQueryParam,
} from "../../types";
import sqlGenerator from "../../utils/sql-generator";
import { quoteIdentifier } from "../../utils/sql-identifier";
type Params<
Schema extends { [k: string]: any } = { [k: string]: any },
@@ -17,10 +18,6 @@ type Params<
config?: BunMariaDBConfig;
};
function quoteIdentifier(identifier: string): string {
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function DbDelete<
Schema extends { [k: string]: any } = { [k: string]: any },
Table extends string = string,
@@ -33,27 +30,15 @@ export default async function DbDelete<
let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
try {
let finalQuery = query || {};
if (targetId) {
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
finalQuery,
{
query: {
id: {
value: Number(targetId),
},
},
},
);
}
const finalQuery = query || {};
sqlObj = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
});
const whereClause = sqlObj.string.match(/WHERE .*/)?.[0];
const whereClause = sqlObj.where;
if (!whereClause) {
return {
@@ -68,6 +53,7 @@ export default async function DbDelete<
let sql = `DELETE FROM ${quoteIdentifier(table)} ${whereClause}`;
sql += ` RETURNING *`;
sqlObj.string = sql;
sqlObj.values = sqlObj.whereValues;
const res = await dbHandler({
query: sql,
+10 -12
View File
@@ -4,6 +4,7 @@ import type {
BunMariaDBConfig,
} from "../../types";
import generateTypeDefinition from "./db-generate-type-defs";
import resolveTable from "../schema/resolve-table";
type Params = {
dbSchema: BUN_MARIADB_DatabaseSchemaType;
@@ -32,20 +33,17 @@ export default function dbSchemaToType({
const schemas = dbTablesSchemas
.map((table) => {
let final_table = _.cloneDeep(table);
if (final_table.parentTableName) {
const parent_table = dbTablesSchemas.find(
(t) => t.tableName === final_table.parentTableName,
const has_parent = Boolean(
table.parentTableName &&
dbTablesSchemas.find(
(t) => t.tableName === table.parentTableName,
),
);
if (parent_table) {
final_table = _.merge(parent_table, {
tableName: final_table.tableName,
tableDescription: final_table.tableDescription,
});
}
}
// Merge parent fields into a copy; never mutate the parent schema
const final_table = has_parent
? resolveTable(table, datasquirelSchema)
: _.cloneDeep(table);
const defObj = generateTypeDefinition({
paradigm: "TypeScript",
+5 -21
View File
@@ -1,5 +1,4 @@
import dbHandler from "../db-handler";
import _ from "lodash";
import type {
BunMariaDBConfig,
DBResponseObject,
@@ -18,10 +17,6 @@ type Params<
config?: BunMariaDBConfig;
};
function quoteIdentifier(identifier: string): string {
return `\`${identifier.replace(/`/g, "``")}\``;
}
export default async function DbSelect<
Schema extends { [k: string]: any } = { [k: string]: any },
Table extends string = string,
@@ -35,24 +30,12 @@ export default async function DbSelect<
let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
try {
let finalQuery = query || {};
if (targetId) {
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
finalQuery,
{
query: {
id: {
value: Number(targetId),
},
},
},
);
}
const finalQuery = query || {};
sqlObj = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
});
const res = await dbHandler<Schema>({
@@ -87,8 +70,9 @@ export default async function DbSelect<
if (count) {
const countSqlObject = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
count,
});
+9 -6
View File
@@ -1,14 +1,19 @@
import dbHandler from "../db-handler";
import type { DBResponseObject, SQLInsertGenValueType } from "../../types";
import type {
BunMariaDBConfig,
DBResponseObject,
SQLInsertGenValueType,
} from "../../types";
type Params = {
sql: string;
values?: SQLInsertGenValueType[];
config?: BunMariaDBConfig;
};
export default async function DbSQL<
T extends { [k: string]: any } = { [k: string]: any },
>({ sql, values }: Params): Promise<DBResponseObject<T>> {
>({ sql, values, config }: Params): Promise<DBResponseObject<T>> {
try {
const trimmedSql = sql.trim();
const isSelect = trimmedSql.match(/^select/i);
@@ -16,6 +21,7 @@ export default async function DbSQL<
const res = await dbHandler<T>({
query: trimmedSql,
values: values as any,
config,
});
if (!res.success) {
@@ -35,13 +41,10 @@ export default async function DbSQL<
const payload = isSelect ? ((res.payload || []) as T[]) : undefined;
const single_res = isSelect ? payload?.[0] : (res.single_res as T);
const singleRaw = res.single_res as any;
return {
...res,
success: isSelect
? Boolean(single_res) || Boolean(payload?.[0])
: true,
success: true,
payload,
single_res,
debug: {
+90 -55
View File
@@ -1,5 +1,4 @@
import dbHandler from "../db-handler";
import _ from "lodash";
import dbHandler, { resolveClient } from "../db-handler";
import type {
BunMariaDBConfig,
DBResponseObject,
@@ -8,6 +7,9 @@ import type {
} from "../../types";
import sqlGenerator from "../../utils/sql-generator";
import sanitizeHtmlFields from "../../utils/sanitize-html-fields";
import { quoteIdentifier } from "../../utils/sql-identifier";
import sqlValuePlaceholder from "../../utils/sql-value-placeholder";
import grabTableFields from "../../utils/grab-table-fields";
type Params<
Schema extends { [k: string]: any } = { [k: string]: any },
@@ -20,8 +22,11 @@ type Params<
config?: BunMariaDBConfig;
};
function quoteIdentifier(identifier: string): string {
return `\`${identifier.replace(/`/g, "``")}\``;
/** Thrown inside the transaction to roll back and return a response */
class UpdateAbort extends Error {
constructor(public response: DBResponseObject) {
super(response.msg || response.error || "Update aborted");
}
}
export default async function DbUpdate<
@@ -34,30 +39,23 @@ export default async function DbUpdate<
targetId,
config,
}: Params<Schema, Table>): Promise<DBResponseObject> {
let sqlObj: ReturnType<typeof sqlGenerator> = { string: "", values: [] };
let sqlObj: ReturnType<typeof sqlGenerator> = {
string: "",
values: [],
where: "",
whereValues: [],
};
try {
let finalQuery = query || {};
if (targetId) {
finalQuery = _.merge<ServerQueryParam<any>, ServerQueryParam<any>>(
finalQuery,
{
query: {
id: {
value: Number(targetId),
},
},
},
);
}
const finalQuery = query || {};
const sqlQueryObj = sqlGenerator({
tableName: quoteIdentifier(table),
tableName: table,
genObject: finalQuery,
targetId,
});
const whereClause = sqlQueryObj.string.match(/WHERE .*/)?.[0];
const whereClause = sqlQueryObj.where;
if (!whereClause) {
return {
@@ -66,63 +64,95 @@ export default async function DbUpdate<
};
}
let values: SQLInsertGenValueType[] = [];
let sql = ``;
sql += `UPDATE ${quoteIdentifier(table)} SET`;
const sanitizedData = sanitizeHtmlFields({
table,
data,
config,
});
const finalData: { [k: string]: SQLInsertGenValueType } = {
const finalData: { [k: string]: any } = {
updated_at: Date.now(),
...sanitizedData,
};
const keys = Object.keys(finalData);
const fieldsMap = new Map(
grabTableFields(table).map((f) => [f.fieldName, f]),
);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!key) continue;
const setParts: string[] = [];
const setValues: SQLInsertGenValueType[] = [];
const isLast = i == keys.length - 1;
for (const key of Object.keys(finalData)) {
const { placeholder, value } = sqlValuePlaceholder({
value: finalData[key],
field: fieldsMap.get(key),
});
sql += ` ${quoteIdentifier(key)}=?`;
values.push(finalData[key] ?? null);
if (!isLast) {
sql += `,`;
}
setParts.push(`${quoteIdentifier(key)}=${placeholder}`);
setValues.push(value);
}
sql += ` ${whereClause}`;
values = [...values, ...sqlQueryObj.values];
const CLIENT = resolveClient(config);
if (!CLIENT) {
throw new Error(`Couldn't grab MariaDB Client.`);
}
/**
* Lock the matching rows, update them by id, and re-fetch them by id,
* all in one transaction. Re-fetching by id still works when the update
* changes a column used in the WHERE clause.
*/
return await CLIENT.begin(async (tx) => {
const matched_res = await dbHandler<{ id: number }>({
query: `SELECT id FROM ${quoteIdentifier(table)} ${whereClause} FOR UPDATE`,
values: sqlQueryObj.whereValues as any,
client: tx,
});
if (!matched_res.success) {
throw new UpdateAbort({
...matched_res,
success: false,
debug: { sqlObj: sqlQueryObj },
});
}
const matched_ids = (matched_res.payload || [])
.map((row) => row.id)
.filter((id) => id !== undefined && id !== null);
if (matched_ids.length === 0) {
throw new UpdateAbort({
success: false,
msg: "No rows matched the update query",
payload: [],
debug: { sqlObj: sqlQueryObj },
});
}
const idPlaceholders = matched_ids.map(() => "?").join(",");
const sql = `UPDATE ${quoteIdentifier(table)} SET ${setParts.join(", ")} WHERE id IN (${idPlaceholders})`;
const values = [...setValues, ...matched_ids];
sqlObj.string = sql;
sqlObj.values = values;
const res = await dbHandler({
query: sql,
values: values as any,
config,
client: tx,
});
if (res.error) {
return res;
if (!res.success) {
throw new UpdateAbort({ ...res, debug: { sqlObj } });
}
sqlObj.string = sql;
sqlObj.values = values as any[];
let updated_sql = ``;
let updated_sql_values: any[] = [];
updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`;
updated_sql_values = [...sqlQueryObj.values];
const updated_res = await dbHandler({
query: updated_sql,
values: updated_sql_values,
config,
query: `SELECT * FROM ${quoteIdentifier(table)} WHERE id IN (${idPlaceholders})`,
values: matched_ids,
client: tx,
});
return {
@@ -131,8 +161,13 @@ export default async function DbUpdate<
sqlObj,
},
db_res: res.db_res,
};
} as DBResponseObject;
});
} catch (error: any) {
if (error instanceof UpdateAbort) {
return error.response;
}
return {
success: false,
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;
};
/**
* Live column info. LONGTEXT columns with a `json_valid` check are reported as `json`.
*/
export default async function getTableColumns({
tableName,
config,
@@ -26,18 +29,48 @@ export default async function getTableColumns({
IS_NULLABLE: string;
COLUMN_DEFAULT: string | null;
EXTRA: string;
IS_JSON: number | boolean;
}>({
query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
query: `
SELECT
c.COLUMN_NAME,
c.COLUMN_TYPE,
c.COLUMN_COMMENT,
c.IS_NULLABLE,
c.COLUMN_DEFAULT,
c.EXTRA,
EXISTS (
SELECT 1
FROM information_schema.CHECK_CONSTRAINTS cc
WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%')
) AS IS_JSON
FROM information_schema.COLUMNS c
WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")}
AND c.TABLE_NAME = ?
ORDER BY c.ORDINAL_POSITION
`,
values: [...schemaCond.values, tableName],
config,
});
return rows.map((row) => ({
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: row.COLUMN_TYPE,
type: resolvedType,
comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "",
}));
};
});
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { quoteIdentifier } from "../../utils/sql-identifier";
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 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({
query,
values,
@@ -10,6 +66,25 @@ export default async function runSchemaQuery({
values?: any[];
config?: BunMariaDBConfig;
}): 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({
query,
values,
+1 -2
View File
@@ -8,7 +8,6 @@ import buildColumnDefinition, {
} from "./build-column-definition";
import createTable from "./create-table";
import getTableColumns, { type ColumnInfoRow } from "./get-table-columns";
import getTableColumnsGemini from "./get-table-columns-gemnini";
import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen";
@@ -278,7 +277,7 @@ export default async function updateTable({
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const existingColumns = await getTableColumnsGemini({
const existingColumns = await getTableColumns({
tableName: table.tableName,
config,
});
+6
View File
@@ -1582,6 +1582,12 @@ export type BunMariaDBConfig = {
* File path to the SSL certificate
*/
ssl_ca?: string;
/**
* Verify the server's TLS certificate. Defaults to `true`.
* Set to `false` only for trusted networks with self-signed certificates
* (prefer providing `ssl_ca` instead).
*/
ssl_reject_unauthorized?: boolean;
/**
* Extra HTML sanitization allowlists appended to the defaults
* when inserting/updating fields marked `html: true`.
+3 -2
View File
@@ -253,9 +253,10 @@ function findFileContents(
const current = stack.pop()!;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
// Never follow symlinks from an extracted (untrusted) archive
if (entry.isDirectory()) {
stack.push(full);
} else if (predicate(entry.name)) {
} else if (entry.isFile() && predicate(entry.name)) {
return fs.readFileSync(full, "utf-8");
}
}
@@ -273,7 +274,7 @@ function findSchemaFile(
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (isSchemaFileName(entry.name)) {
} else if (entry.isFile() && isSchemaFileName(entry.name)) {
return {
content: fs.readFileSync(full, "utf-8"),
fileName:
+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 type {
BUN_MARIADB_DatabaseSchemaType,
BUN_MARIADB_FieldSchemaType,
BunMariaDBConfig,
} from "../types";
import { readLiveSchema } from "../functions/live-schema";
import type { BUN_MARIADB_FieldSchemaType, BunMariaDBConfig } from "../types";
import getSanitizeHtmlOptions from "./sanitize-html-options";
function grabTableFields(tableName: string): BUN_MARIADB_FieldSchemaType[] {
const dbSchema: BUN_MARIADB_DatabaseSchemaType | undefined =
global.DB_SCHEMA || readLiveSchema();
const tableSchema = dbSchema?.tables?.find((t) => t.tableName === tableName);
return tableSchema?.fields || [];
}
import grabTableFields from "./grab-table-fields";
/**
* Only fields with an explicit `html: true` flag are sanitized.
+5 -4
View File
@@ -21,7 +21,6 @@ export const defaultSanitizeHtmlOptions: IOptions = {
"h6",
"img",
"div",
"button",
"pre",
"code",
"br",
@@ -38,10 +37,12 @@ export const defaultSanitizeHtmlOptions: IOptions = {
"colgroup",
"col",
],
// `style` / `id` are intentionally not allowed by default (CSS overlays,
// DOM clobbering). Add them via `html_sanitize.allowed_attributes`.
allowedAttributes: {
a: ["href", "title", "class", "style", "target", "rel"],
img: ["src", "alt", "width", "height", "class", "style"],
"*": ["style", "class", "title", "id"],
a: ["href", "title", "class", "target", "rel"],
img: ["src", "alt", "width", "height", "class"],
"*": ["class", "title"],
},
};
+18 -7
View File
@@ -6,6 +6,7 @@ import type {
import sqlEqualityParser from "./sql-equality-parser";
type Params = {
/** Already-quoted column reference */
fieldName: string;
value?: SQLInsertGenValueType;
equality?: (typeof ServerQueryEqualities)[number];
@@ -15,6 +16,7 @@ type Params = {
},
string
>;
/** `value` is an already-quoted column reference, not a parameter */
isValueFieldValue?: boolean;
};
@@ -42,9 +44,13 @@ export default function sqlGenOperatorGen({
return { str: `${fieldName} IS NOT NULL` };
}
if (value) {
// `0` is a real value; only null / undefined / "" count as "no value"
const hasValue = value !== undefined && value !== null && value !== "";
if (hasValue) {
const finalValue = isValueFieldValue ? value : "?";
const finalParams = isValueFieldValue ? undefined : value;
const likeParams = isValueFieldValue ? undefined : `%${value}%`;
if (equality == "MATCH") {
return {
@@ -59,7 +65,7 @@ export default function sqlGenOperatorGen({
} else if (equality == "LIKE_LOWER") {
return {
str: `LOWER(${fieldName}) LIKE LOWER(${finalValue})`,
param: `%${finalParams}%`,
param: likeParams,
};
} else if (equality == "LIKE_LOWER_RAW") {
return {
@@ -69,7 +75,7 @@ export default function sqlGenOperatorGen({
} else if (equality == "LIKE") {
return {
str: `${fieldName} LIKE ${finalValue}`,
param: `%${finalParams}%`,
param: likeParams,
};
} else if (equality == "LIKE_RAW") {
return {
@@ -79,7 +85,7 @@ export default function sqlGenOperatorGen({
} else if (equality == "NOT_LIKE_LOWER") {
return {
str: `LOWER(${fieldName}) NOT LIKE LOWER(${finalValue})`,
param: `%${finalParams}%`,
param: likeParams,
};
} else if (equality == "NOT_LIKE_LOWER_RAW") {
return {
@@ -89,7 +95,7 @@ export default function sqlGenOperatorGen({
} else if (equality == "NOT LIKE") {
return {
str: `${fieldName} NOT LIKE ${finalValue}`,
param: finalParams,
param: likeParams,
};
} else if (equality == "NOT LIKE_RAW") {
return {
@@ -116,6 +122,11 @@ export default function sqlGenOperatorGen({
str: `${fieldName} IS NOT ${finalValue}`,
param: finalParams,
};
} else if (equality == "IN" || equality == "NOT IN") {
return {
str: `${fieldName} ${equality} (${finalValue})`,
param: finalParams,
};
} else if (equality) {
return {
str: `${fieldName} ${sqlEqualityParser(
@@ -137,12 +148,12 @@ export default function sqlGenOperatorGen({
} else if (equality) {
return {
str: `${fieldName} ${sqlEqualityParser(equality)} ?`,
param: value,
param: value ?? "",
};
} else {
return {
str: `${fieldName} = ?`,
param: value,
param: value ?? "",
};
}
}
+27 -38
View File
@@ -3,10 +3,16 @@ import type {
ServerQueryParamsJoinMatchObject,
SQLInsertGenValueType,
} from "../types";
import {
columnRef,
sqlLogicalOperator,
sqlRawComparison,
} from "./sql-identifier";
type Param = {
mtch: ServerQueryParamsJoinMatchObject;
join: ServerQueryParamsJoin;
/** Already-quoted root table name */
table_name: string;
};
@@ -33,7 +39,7 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) {
values.push(...batch_values);
if (i < mtch.__batch.matches.length - 1) {
btch_mtch += ` ${mtch.__batch.operator || "OR"} `;
btch_mtch += ` ${sqlLogicalOperator(mtch.__batch.operator || "OR")} `;
}
}
@@ -45,44 +51,14 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) {
};
}
const equality = mtch.raw_equality || "=";
const equality = mtch.raw_equality
? sqlRawComparison(mtch.raw_equality)
: "=";
const lhs = `${
typeof mtch.source == "object" ? mtch.source.tableName : table_name
}.${typeof mtch.source == "object" ? mtch.source.fieldName : mtch.source}`;
const rhs = `${(() => {
if (mtch.targetLiteral) {
values.push(mtch.targetLiteral);
// if (typeof mtch.targetLiteral == "number") {
// return `${mtch.targetLiteral}`;
// }
// return `'${mtch.targetLiteral}'`;
return `?`;
}
if (join.alias) {
return `${
typeof mtch.target == "object"
? mtch.target.tableName
: join.alias
}.${
typeof mtch.target == "object"
? mtch.target.fieldName
: mtch.target
}`;
}
return `${
typeof mtch.target == "object"
? mtch.target.tableName
: join.tableName
}.${
typeof mtch.target == "object" ? mtch.target.fieldName : mtch.target
}`;
})()}`;
const lhs =
typeof mtch.source == "object"
? columnRef(mtch.source.fieldName, mtch.source.tableName)
: columnRef(String(mtch.source), table_name);
if (mtch.between) {
values.push(mtch.between.min, mtch.between.max);
@@ -102,6 +78,19 @@ export default function sqlGenGenJoinStr({ join, mtch, table_name }: Param) {
};
}
const rhs = (() => {
if (mtch.targetLiteral !== undefined && mtch.targetLiteral !== null) {
values.push(mtch.targetLiteral);
return `?`;
}
if (typeof mtch.target == "object") {
return columnRef(mtch.target.fieldName, mtch.target.tableName);
}
return columnRef(String(mtch.target), join.alias || join.tableName);
})();
return {
str: `${lhs} ${equality} ${rhs}`,
values,
+52 -38
View File
@@ -3,11 +3,19 @@ import type { ServerQueryParam, TableSelectFieldsObject } from "../types";
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import sqlGenGenJoinStr from "./sql-generator-gen-join-str";
import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql";
import {
columnRef,
quoteIdentifier,
quoteQualified,
sqlJoinType,
sqlLogicalOperator,
} from "./sql-identifier";
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
genObject?: ServerQueryParam<T>;
selectFields?: (keyof T | TableSelectFieldsObject<T>)[];
append_table_names?: boolean;
/** Already-quoted table name */
table_name: string;
full_text_match_str?: string;
full_text_search_str?: string;
@@ -56,11 +64,14 @@ export default function sqlGenGenQueryStr<
if (!countSubQuery) continue;
const tableAlias = countSubQuery.table_alias;
const countTableRef = quoteQualified(
tableAlias || countSubQuery.table,
);
let subQStr = `(SELECT COUNT(*)`;
subQStr += ` FROM ${countSubQuery.table}${
tableAlias ? ` ${tableAlias}` : ""
subQStr += ` FROM ${quoteQualified(countSubQuery.table)}${
tableAlias ? ` ${quoteIdentifier(tableAlias)}` : ""
}`;
subQStr += ` WHERE (`;
@@ -69,15 +80,13 @@ export default function sqlGenGenQueryStr<
const csqSrc = countSubQuery.srcTrgMap[j];
if (!csqSrc) continue;
subQStr += ` ${tableAlias || countSubQuery.table}.${
csqSrc.src
}`;
subQStr += ` ${countTableRef}.${quoteIdentifier(csqSrc.src)}`;
if (typeof csqSrc.trg == "string") {
subQStr += ` = ?`;
sqlSearhValues.push(csqSrc.trg);
} else if (typeof csqSrc.trg == "object") {
subQStr += ` = ${csqSrc.trg.table}.${csqSrc.trg.field}`;
subQStr += ` = ${columnRef(csqSrc.trg.field, csqSrc.trg.table)}`;
}
if (j < countSubQuery.srcTrgMap.length - 1) {
@@ -85,7 +94,7 @@ export default function sqlGenGenQueryStr<
}
}
subQStr += ` )) AS ${countSubQuery.alias}`;
subQStr += ` )) AS ${quoteIdentifier(countSubQuery.alias)}`;
countSqls.push(subQStr);
}
@@ -95,23 +104,24 @@ export default function sqlGenGenQueryStr<
if (genObject?.join) {
const existingJoinTableNames: string[] = [table_name];
str +=
"," +
genObject.join
const joinSelects = genObject.join
.flat()
.filter((j) => !isUndefined(j))
.map((joinObj) => {
const joinTableName = joinObj.alias
? joinObj.alias
: joinObj.tableName;
const joinTableRef = quoteQualified(joinTableName);
if (existingJoinTableNames.includes(joinTableName))
return null;
existingJoinTableNames.push(joinTableName);
if (existingJoinTableNames.includes(joinTableRef)) return null;
existingJoinTableNames.push(joinTableRef);
if (joinObj.group_concat) {
return sqlGenGrabConcatStr({
field: `${joinTableName}.${joinObj.group_concat.field}`,
field: columnRef(
joinObj.group_concat.field,
joinTableName,
),
alias: joinObj.group_concat.alias,
separator: joinObj.group_concat.separator,
});
@@ -119,58 +129,64 @@ export default function sqlGenGenQueryStr<
return joinObj.selectFields
.map((selectField) => {
if (typeof selectField == "string") {
return `${joinTableName}.${selectField}`;
return columnRef(selectField, joinTableName);
} else if (typeof selectField == "object") {
let aliasSelectField = `${joinTableName}.${selectField.field}`;
const fieldRef = columnRef(
String(selectField.field),
joinTableName,
);
let aliasSelectField = fieldRef;
if (selectField.count) {
aliasSelectField = `COUNT(${joinTableName}.${selectField.field})`;
aliasSelectField = `COUNT(${fieldRef})`;
} else if (selectField.sum) {
aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${joinTableName}.${selectField.field})`;
aliasSelectField = `SUM(${selectField.distinct ? "DISTINCT " : ""}${fieldRef})`;
} else if (selectField.average) {
aliasSelectField = `AVERAGE(${joinTableName}.${selectField.field})`;
aliasSelectField = `AVG(${fieldRef})`;
} else if (selectField.max) {
aliasSelectField = `MAX(${joinTableName}.${selectField.field})`;
aliasSelectField = `MAX(${fieldRef})`;
} else if (selectField.min) {
aliasSelectField = `MIN(${joinTableName}.${selectField.field})`;
aliasSelectField = `MIN(${fieldRef})`;
} else if (
selectField.group_concat &&
selectField.alias
) {
return sqlGenGrabConcatStr({
field: `${joinTableName}.${selectField.field}`,
field: fieldRef,
alias: selectField.alias,
separator:
selectField.group_concat
.separator,
selectField.group_concat.separator,
distinct:
selectField.group_concat
.distinct,
selectField.group_concat.distinct,
});
} else if (selectField.distinct) {
aliasSelectField = `DISTINCT ${joinTableName}.${selectField.field}`;
aliasSelectField = `DISTINCT ${fieldRef}`;
}
if (selectField.alias)
aliasSelectField += ` AS ${selectField.alias}`;
aliasSelectField += ` AS ${quoteIdentifier(selectField.alias)}`;
return aliasSelectField;
}
})
.join(",");
} else {
return `${joinTableName}.*`;
return `${joinTableRef}.*`;
}
})
.filter((_) => Boolean(_))
.join(",");
.filter((_) => Boolean(_));
if (joinSelects.length) {
str += "," + joinSelects.join(",");
}
}
if (
genObject?.fullTextSearch &&
genObject.fullTextSearch.scoreAlias &&
full_text_match_str &&
full_text_search_str
) {
str += `, ${full_text_match_str} AS ${genObject.fullTextSearch.scoreAlias}`;
str += `, ${full_text_match_str} AS ${quoteIdentifier(genObject.fullTextSearch.scoreAlias)}`;
sqlSearhValues.push(full_text_search_str);
}
@@ -184,11 +200,11 @@ export default function sqlGenGenQueryStr<
.filter((j) => !isUndefined(j))
.map((join) => {
return (
join.joinType +
sqlJoinType(join.joinType) +
" " +
(join.alias
? `${join.tableName}` + " " + join.alias
: `${join.tableName}`) +
? `${quoteQualified(join.tableName)} ${quoteIdentifier(join.alias)}`
: `${quoteQualified(join.tableName)}`) +
" ON " +
(() => {
if (Array.isArray(join.match)) {
@@ -209,9 +225,7 @@ export default function sqlGenGenQueryStr<
return str;
})
.join(
join.operator
? ` ${join.operator} `
: " AND ",
` ${sqlLogicalOperator(join.operator)} `,
) +
")"
);
+80 -44
View File
@@ -1,16 +1,21 @@
import type {
QueryRawValueType,
ServerQueryParamsJoin,
ServerQueryQueryObject,
ServerQueryValuesObject,
SQLInsertGenValueType,
} from "../types";
import sqlGenOperatorGen from "./sql-gen-operator-gen";
import {
columnRef,
sqlLogicalOperator,
sqlRawComparison,
} from "./sql-identifier";
type Param = {
queryObj: ServerQueryQueryObject[string];
join?: (ServerQueryParamsJoin | ServerQueryParamsJoin[] | undefined)[];
field?: string;
/** Already-quoted root table name */
table_name: string;
};
@@ -24,98 +29,129 @@ export default function sqlGenGenSearchStr({
const finalFieldName = (() => {
if (queryObj?.tableName) {
return `${queryObj.tableName}.${field}`;
return columnRef(String(field), queryObj.tableName);
}
if (join) {
return `${table_name}.${field}`;
return columnRef(String(field), table_name);
}
return field;
return columnRef(String(field));
})();
let str = `${finalFieldName}=?`;
function grabValue(val?: string | number | ServerQueryValuesObject | null) {
const valueParsed = val;
if (val === undefined || val === null) return;
if (!valueParsed) return;
const isFieldRef = Boolean(
typeof val == "object" && val.fieldName && val.tableName,
);
const valueString =
typeof valueParsed == "string" || typeof valueParsed == "number"
? valueParsed
: valueParsed
? valueParsed.fieldName && valueParsed.tableName
? `${valueParsed.tableName}.${valueParsed.fieldName}`
: valueParsed.value
: undefined;
typeof val == "string" || typeof val == "number"
? val
: isFieldRef
? columnRef(val.fieldName!, val.tableName)
: val.value;
const valueEquality =
typeof valueParsed == "object"
? valueParsed.equality || queryObj.equality
typeof val == "object"
? val.equality || queryObj.equality
: queryObj.equality;
const operatorStrParam = sqlGenOperatorGen({
return sqlGenOperatorGen({
queryObj,
equality: valueEquality,
fieldName: finalFieldName || "",
value: valueString || "",
isValueFieldValue: Boolean(
typeof valueParsed == "object" &&
valueParsed.fieldName &&
valueParsed.tableName,
),
fieldName: finalFieldName,
value: valueString ?? "",
isValueFieldValue: isFieldRef,
});
return operatorStrParam;
}
if (Array.isArray(queryObj.value)) {
const listEquality =
queryObj.equality == "IN" || queryObj.equality == "NOT IN"
? queryObj.equality
: undefined;
if (
listEquality &&
!queryObj.nullValue &&
!queryObj.notNullValue &&
queryObj.value !== undefined &&
queryObj.value !== null
) {
const rawValues = Array.isArray(queryObj.value)
? queryObj.value
: [queryObj.value];
const listValues = rawValues
.map((val) =>
val !== null && typeof val == "object" ? val.value : val,
)
.filter(
(val): val is string | number =>
val !== undefined && val !== null,
);
if (listValues.length === 0) {
// Empty IN () is invalid SQL: IN matches nothing, NOT IN matches everything
str = listEquality == "IN" ? "1 = 0" : "1 = 1";
} else {
str = `${finalFieldName} ${listEquality} (${listValues.map(() => "?").join(",")})`;
sqlSearhValues.push(...listValues);
}
} else if (Array.isArray(queryObj.value)) {
const strArray: string[] = [];
queryObj.value.forEach((val) => {
const operatorStrParam = grabValue(val);
if (!operatorStrParam) return;
if (!operatorStrParam?.str) return;
if (operatorStrParam.str && operatorStrParam.param) {
strArray.push(operatorStrParam.str);
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param);
} else if (operatorStrParam.str) {
strArray.push(operatorStrParam.str);
}
});
str = "(" + strArray.join(` ${queryObj.operator || "AND"} `) + ")";
} else if (typeof queryObj.value == "object") {
str =
"(" +
strArray.join(` ${sqlLogicalOperator(queryObj.operator)} `) +
")";
} else if (queryObj.value !== null && typeof queryObj.value == "object") {
const operatorStrParam = grabValue(queryObj.value);
if (operatorStrParam?.str) {
str = operatorStrParam.str;
if (operatorStrParam.param) {
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param);
}
}
} else if (queryObj.raw_equality && queryObj.value) {
str = `${finalFieldName} ${queryObj.raw_equality} ?`;
} else if (
queryObj.raw_equality &&
queryObj.value !== undefined &&
queryObj.value !== null
) {
str = `${finalFieldName} ${sqlRawComparison(queryObj.raw_equality)} ?`;
sqlSearhValues.push(queryObj.value);
} else if (queryObj.between) {
str = `${finalFieldName} BETWEEN ? AND ?`;
sqlSearhValues.push(queryObj.between.min, queryObj.between.max);
} else if (queryObj.not_between) {
str = `${finalFieldName} NOT BETWEEN ? AND ?`;
sqlSearhValues.push(queryObj.not_between.min, queryObj.not_between.max);
} else {
const valueParsed = queryObj.value ? queryObj.value : undefined;
const operatorStrParam = sqlGenOperatorGen({
equality: queryObj.equality,
fieldName: finalFieldName || "",
value: valueParsed,
fieldName: finalFieldName,
value: queryObj.value ?? undefined,
queryObj,
});
if (operatorStrParam.str && operatorStrParam.param) {
if (operatorStrParam.str) {
str = operatorStrParam.str;
}
if (operatorStrParam.param !== undefined) {
sqlSearhValues.push(operatorStrParam.param);
} else if (operatorStrParam.str && !operatorStrParam.str.match(/\?/)) {
str = operatorStrParam.str;
} else {
sqlSearhValues.push(valueParsed || "");
}
}
+5 -2
View File
@@ -1,4 +1,7 @@
import { escapeStringLiteral, quoteIdentifier } from "./sql-identifier";
type Param = {
/** Already-quoted column reference */
field: string;
alias: string;
separator?: string;
@@ -20,11 +23,11 @@ export default function sqlGenGrabConcatStr({
gc += `${field}`;
if (!distinct) {
gc += `, '${separator}'`;
gc += `, '${escapeStringLiteral(separator)}'`;
}
gc += `)`;
gc += ` AS ${alias}`;
gc += ` AS ${quoteIdentifier(alias)}`;
return gc;
}
@@ -1,5 +1,6 @@
import type { TableSelectFieldsObject } from "../types";
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import { columnRef, quoteIdentifier } from "./sql-identifier";
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
selectFields: (keyof T | TableSelectFieldsObject<T>)[];
@@ -10,56 +11,46 @@ type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
export default function sqlGenGrabSelectFieldSQL<
T extends { [key: string]: any } = { [key: string]: any },
>({ selectFields, append_table_names, table_name }: Param<T>) {
let str = "";
const table = append_table_names ? table_name : undefined;
str += ` ${selectFields
?.map((fld) => {
return ` ${selectFields
.map((fld) => {
if (typeof fld != "object") {
return columnRef(String(fld), table);
}
const fld_name = columnRef(String(fld.fieldName), table);
let fld_str = ``;
const final_fld_name =
typeof fld == "object"
? append_table_names
? `${table_name}.${String(fld)}`
: `${String(fld.fieldName)}`
: `${String(fld)}`;
if (typeof fld == "object") {
const fld_name = `${String(fld.fieldName)}`;
if (fld.count) {
fld_str += `COUNT(${fld_name})`;
fld_str = `COUNT(${fld_name})`;
} else if (fld.sum) {
fld_str += `SUM(${fld_name})`;
fld_str = `SUM(${fld_name})`;
} else if (fld.average) {
fld_str += `AVERAGE(${fld_name})`;
fld_str = `AVG(${fld_name})`;
} else if (fld.max) {
fld_str += `MAX(${fld_name})`;
fld_str = `MAX(${fld_name})`;
} else if (fld.min) {
fld_str += `MIN(${fld_name})`;
fld_str = `MIN(${fld_name})`;
} else if (fld.distinct) {
fld_str += `DISTINCT ${fld_name}`;
fld_str = `DISTINCT ${fld_name}`;
} else if (fld.group_concat) {
fld_str += sqlGenGrabConcatStr({
// Carries its own alias
return sqlGenGrabConcatStr({
field: fld_name,
alias: fld.group_concat.alias,
separator: fld.group_concat.separator,
distinct: fld.group_concat.distinct,
});
} else {
fld_str +=
final_fld_name + (fld.alias ? ` as ${fld.alias}` : ``);
fld_str = fld_name;
}
if (fld.alias) {
fld_str += ` AS ${fld.alias}`;
}
} else {
fld_str += final_fld_name;
fld_str += ` AS ${quoteIdentifier(fld.alias)}`;
}
return fld_str;
})
.join(",")}`;
return str;
}
+122 -244
View File
@@ -5,36 +5,68 @@ import type {
} from "../types";
import sqlGenGenSearchStr from "./sql-generator-gen-search-str";
import sqlGenGenQueryStr from "./sql-generator-gen-query-str";
import {
columnRef,
quoteIdentifier,
quoteQualified,
sqlLogicalOperator,
sqlNonNegativeInt,
sqlOrderStrategy,
} from "./sql-identifier";
type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
genObject?: ServerQueryParam<T>;
/** Raw (unquoted) table name */
tableName: string;
dbFullName?: string;
count?: boolean;
/**
* Restrict to a single row id. Always combined with AND, regardless of
* `searchOperator`.
*/
targetId?: number | string;
};
type Return = {
string: string;
values: SQLInsertGenValueType[];
/** `WHERE ...` clause alone (empty string if none) */
where: string;
/** Values for the `where` clause placeholders only */
whereValues: SQLInsertGenValueType[];
};
/** MariaDB requires LIMIT before OFFSET; this is the documented "no limit" value */
const MAX_LIMIT = "18446744073709551615";
/**
* # SQL Query Generator
* @description Builds parameterized SELECT SQL for MariaDB
*/
export default function sqlGenerator<
T extends { [key: string]: any } = { [key: string]: any },
>({ tableName, genObject, dbFullName, count }: Param<T>): Return {
>({
tableName: rawTableName,
genObject,
dbFullName,
count,
targetId,
}: Param<T>): Return {
const tableName = quoteQualified(rawTableName);
const finalQuery = genObject?.query ? genObject.query : undefined;
const queryKeys = finalQuery ? Object.keys(finalQuery) : undefined;
const sqlSearhValues: SQLInsertGenValueType[] = [];
const whereValues: SQLInsertGenValueType[] = [];
let fullTextMatchStr = genObject?.fullTextSearch
? ` MATCH(${genObject.fullTextSearch.fields
.map((f) =>
genObject.join ? `${tableName}.${String(f)}` : `${String(f)}`,
columnRef(
String(f),
genObject.join ? tableName : undefined,
),
)
.join(",")}) AGAINST (? IN BOOLEAN MODE)`
: undefined;
@@ -46,7 +78,7 @@ export default function sqlGenerator<
.join(" ")
: undefined;
let { str: queryString, values } = sqlGenGenQueryStr<T>({
let { str: queryString, values: selectValues } = sqlGenGenQueryStr<T>({
table_name: tableName,
append_table_names: true,
full_text_match_str: fullTextMatchStr,
@@ -54,8 +86,6 @@ export default function sqlGenerator<
genObject,
});
sqlSearhValues.push(...values);
const sqlSearhString = queryKeys?.map((field) => {
const queryObj = finalQuery?.[field];
if (!queryObj) return;
@@ -64,7 +94,8 @@ export default function sqlGenerator<
const subQueryGroup = queryObj.__query;
const subSearchKeys = Object.keys(subQueryGroup);
const subSearchString = subSearchKeys.map((_field) => {
const subSearchString = subSearchKeys
.map((_field) => {
const newSubQueryObj = subQueryGroup?.[_field];
if (newSubQueryObj) {
@@ -75,15 +106,18 @@ export default function sqlGenerator<
table_name: tableName,
});
sqlSearhValues.push(...values);
whereValues.push(...values);
return str;
}
});
})
.filter((str) => typeof str == "string");
return (
"(" +
subSearchString.join(` ${queryObj.operator || "AND"} `) +
subSearchString.join(
` ${sqlLogicalOperator(queryObj.operator)} `,
) +
")"
);
}
@@ -95,7 +129,7 @@ export default function sqlGenerator<
table_name: tableName,
});
sqlSearhValues.push(...values);
whereValues.push(...values);
return str;
});
@@ -107,272 +141,116 @@ export default function sqlGenerator<
const isSearchStr =
cleanedUpSearchStr?.[0] && cleanedUpSearchStr.find((str) => str);
const whereConditions: string[] = [];
if (isSearchStr) {
const stringOperator = genObject?.searchOperator || "AND";
queryString += ` WHERE ${cleanedUpSearchStr.join(
` ${stringOperator} `,
)}`;
const stringOperator = sqlLogicalOperator(genObject?.searchOperator);
whereConditions.push(
`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`,
);
}
if (genObject?.fullTextSearch && fullTextSearchStr && fullTextMatchStr) {
queryString += `${isSearchStr ? " AND" : " WHERE"} ${fullTextMatchStr}`;
sqlSearhValues.push(fullTextSearchStr);
whereConditions.push(fullTextMatchStr.trim());
whereValues.push(fullTextSearchStr);
}
if (targetId !== undefined && targetId !== null && targetId !== "") {
const id = Number(targetId);
if (!Number.isFinite(id)) {
throw new Error(`Invalid targetId: ${String(targetId)}`);
}
whereConditions.push(
`${columnRef("id", genObject?.join ? tableName : undefined)} = ?`,
);
whereValues.push(id);
}
const where = whereConditions.length
? `WHERE ${whereConditions.join(" AND ")}`
: ``;
if (where) {
queryString += ` ${where}`;
}
if (genObject?.group) {
const groupRef = (
group: string | { field: unknown; table?: string },
) =>
typeof group == "object"
? columnRef(String(group.field), group.table)
: columnRef(String(group));
let group_by_txt = ``;
if (typeof genObject.group == "string") {
group_by_txt = genObject.group;
} else if (Array.isArray(genObject.group)) {
for (let i = 0; i < genObject.group.length; i++) {
const group = genObject.group[i];
if (typeof group == "string") {
group_by_txt += `\`${group.toString()}\``;
} else if (typeof group == "object" && group.table) {
group_by_txt += `${group.table}.${String(group.field)}`;
} else if (typeof group == "object") {
group_by_txt += `${String(group.field)}`;
}
if (i < genObject.group.length - 1) {
group_by_txt += ",";
}
}
} else if (typeof genObject.group == "object") {
if (genObject.group.table) {
group_by_txt = `${genObject.group.table}.${String(genObject.group.field)}`;
if (Array.isArray(genObject.group)) {
group_by_txt = genObject.group
.map((group) => groupRef(group as any))
.join(",");
} else {
group_by_txt = `${String(genObject.group.field)}`;
}
group_by_txt = groupRef(genObject.group as any);
}
queryString += ` GROUP BY ${group_by_txt}`;
}
function grabOrderString(order: ServerQueryParamOrder<T>) {
let orderFields = [];
let orderSrt = ``;
const scoreAlias = genObject?.fullTextSearch?.scoreAlias;
let orderField = ``;
if (genObject?.fullTextSearch && genObject.fullTextSearch.scoreAlias) {
orderFields.push(genObject.fullTextSearch.scoreAlias);
} else if (genObject?.join) {
orderFields.push(`${tableName}.${String(order.field)}`);
if (scoreAlias && String(order.field) === scoreAlias) {
orderField = quoteIdentifier(scoreAlias);
} else {
orderFields.push(order.field);
orderField = columnRef(
String(order.field),
genObject?.join ? tableName : undefined,
);
}
orderSrt += ` ${orderFields.join(", ")} ${order.strategy}`;
return orderSrt;
return ` ${orderField} ${sqlOrderStrategy(order.strategy)}`;
}
if (genObject?.order) {
let orderSrt = ` ORDER BY`;
const orders = (
Array.isArray(genObject.order) ? genObject.order : [genObject.order]
).filter(Boolean);
if (Array.isArray(genObject.order)) {
for (let i = 0; i < genObject.order.length; i++) {
const order = genObject.order[i];
if (order) {
orderSrt +=
grabOrderString(order) +
(i < genObject.order.length - 1 ? `,` : "");
if (orders.length) {
queryString += ` ORDER BY${orders.map(grabOrderString).join(",")}`;
}
}
} else {
orderSrt += grabOrderString(genObject.order);
if (!count) {
const limit =
genObject?.limit !== undefined && genObject.limit !== null
? sqlNonNegativeInt(genObject.limit, "limit")
: undefined;
let offset: number | undefined;
if (genObject?.offset !== undefined && genObject.offset !== null) {
offset = sqlNonNegativeInt(genObject.offset, "offset");
} else if (genObject?.page && limit) {
const page = sqlNonNegativeInt(genObject.page, "page");
offset = Math.max(page - 1, 0) * limit;
}
queryString += ` ${orderSrt}`;
if (limit) {
queryString += ` LIMIT ${limit}`;
} else if (offset) {
queryString += ` LIMIT ${MAX_LIMIT}`;
}
if (genObject?.limit && !count) queryString += ` LIMIT ${genObject.limit}`;
if (genObject?.offset) {
queryString += ` OFFSET ${genObject.offset}`;
} else if (genObject?.page && genObject.limit && !count) {
queryString += ` OFFSET ${(genObject.page - 1) * genObject.limit}`;
if (offset) {
queryString += ` OFFSET ${offset}`;
}
}
return {
string: queryString,
values: sqlSearhValues,
values: [...selectValues, ...whereValues],
where,
whereValues,
};
}
// let queryString = (() => {
// let str = "SELECT";
// if (genObject?.select_sql) {
// str += ` ${genObject.select_sql}`;
// } else if (genObject?.selectFields?.[0]) {
// if (genObject.join) {
// str += sqlGenGrabSelectFieldSQL<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;
// })();
+82
View File
@@ -0,0 +1,82 @@
import { ServerQueryOperators, SQlComparisons } from "../types";
/**
* Quote a single identifier (table, column, alias) with backticks.
* Already-quoted identifiers (e.g. "`users`") are returned unchanged.
*/
export function quoteIdentifier(identifier: string): string {
const str = String(identifier);
if (/^`(?:[^`]|``)+`$/.test(str)) return str;
return `\`${str.replace(/`/g, "``")}\``;
}
/**
* Quote a possibly qualified identifier: "table.field" -> `table`.`field`.
* A bare "*" (or "table.*") is kept as a wildcard.
*/
export function quoteQualified(identifier: string): string {
const str = String(identifier);
if (/^`(?:[^`]|``)+`$/.test(str)) return str;
return str
.split(".")
.map((part) => (part === "*" ? "*" : quoteIdentifier(part)))
.join(".");
}
/**
* Build a column reference, prefixing the table when one is given and the
* field is not already qualified.
*/
export function columnRef(field: string, table?: string): string {
const fieldStr = String(field);
if (!table || fieldStr.includes(".")) return quoteQualified(fieldStr);
return `${quoteQualified(table)}.${fieldStr === "*" ? "*" : quoteIdentifier(fieldStr)}`;
}
/**
* Escape a value for use inside a single-quoted SQL string literal.
*/
export function escapeStringLiteral(value: string): string {
return String(value).replace(/\\/g, "\\\\").replace(/'/g, "''");
}
export function sqlLogicalOperator(operator?: string): "AND" | "OR" {
if (!operator) return "AND";
const upper = String(operator).toUpperCase();
if ((ServerQueryOperators as readonly string[]).includes(upper)) {
return upper as "AND" | "OR";
}
throw new Error(`Invalid logical operator: ${operator}`);
}
export function sqlOrderStrategy(strategy?: string): "ASC" | "DESC" {
if (!strategy) return "ASC";
const upper = String(strategy).toUpperCase();
if (upper === "ASC" || upper === "DESC") return upper;
throw new Error(`Invalid order strategy: ${strategy}`);
}
export function sqlRawComparison(comparison: string): string {
const upper = String(comparison).toUpperCase();
if ((SQlComparisons as readonly string[]).includes(upper)) return upper;
throw new Error(`Invalid comparison operator: ${comparison}`);
}
const JoinTypes = ["INNER JOIN", "JOIN", "LEFT JOIN", "RIGHT JOIN"] as const;
export function sqlJoinType(joinType: string): string {
const upper = String(joinType).toUpperCase();
if ((JoinTypes as readonly string[]).includes(upper)) return upper;
throw new Error(`Invalid join type: ${joinType}`);
}
/**
* Validate a LIMIT / OFFSET / page value as a non-negative integer.
*/
export function sqlNonNegativeInt(value: unknown, name: string): number {
const num = typeof value == "string" && value.trim() !== "" ? Number(value) : value;
if (typeof num != "number" || !Number.isInteger(num) || num < 0) {
throw new Error(`Invalid ${name}: ${String(value)}`);
}
return num;
}
+12 -37
View File
@@ -3,10 +3,9 @@ import type {
SQLInsertGenReturn,
SQLInsertGenValueType,
} from "../types";
function quoteIdentifier(identifier: string): string {
return `\`${identifier.replace(/`/g, "``")}\``;
}
import { quoteIdentifier } from "./sql-identifier";
import sqlValuePlaceholder from "./sql-value-placeholder";
import grabTableFields from "./grab-table-fields";
/**
* # SQL Insert Generator
@@ -31,6 +30,10 @@ export default function sqlInsertGenerator({
});
});
const fieldsMap = new Map(
grabTableFields(tableName).map((f) => [f.fieldName, f]),
);
let queryBatches: string[] = [];
let queryValues: SQLInsertGenValueType[] = [];
@@ -38,41 +41,13 @@ export default function sqlInsertGenerator({
queryBatches.push(
`(${insertKeys
.map((ky) => {
const value = item[ky];
const is_embedding =
Array.isArray(value) &&
typeof value[0] == "number";
const finalValue =
typeof value == "string" ||
typeof value == "number"
? value
: typeof value == "function"
? value().value
: is_embedding
? JSON.stringify(value)
: value
? value
: null;
if (!finalValue && typeof value !== "number") {
queryValues.push(null);
return "?";
}
queryValues.push(finalValue);
const placeholder =
typeof value == "function"
? value().placeholder
: is_embedding
? "VEC_FromText(?)"
: "?";
const { placeholder, value } = sqlValuePlaceholder({
value: item[ky],
field: fieldsMap.get(ky),
});
queryValues.push(value);
return placeholder;
})
.filter((k) => Boolean(k))
.join(",")})`,
);
});
+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 };
}
+11 -1
View File
@@ -4,6 +4,7 @@ import type { BunMariaDBConfig } from "../types";
import grabSortedBackups from "./grab-sorted-backups";
import { AppData } from "../data/app-data";
import path from "path";
import _ from "lodash";
type Params = {
config: BunMariaDBConfig;
@@ -12,7 +13,16 @@ type Params = {
export default function trimBackups({ config }: Params) {
const { backup_dir } = grabDBDir({ config });
const backups = grabSortedBackups({ config });
// Only consider files written by `backup` (`{db_name}-{timestamp}.sql`)
const backupNamePattern = new RegExp(
`^${_.escapeRegExp(config.db_name)}-\\d+\\.sql$`,
);
const backups = grabSortedBackups({ config }).filter(
(name) =>
backupNamePattern.test(name) &&
fs.statSync(path.join(backup_dir, name)).isFile(),
);
const max_backups = config.max_backups || AppData["MaxBackups"];
+11 -1
View File
@@ -1,5 +1,6 @@
import fs from "fs";
import path from "path";
import _ from "lodash";
import type { BunMariaDBConfig } from "../types";
import { AppData } from "../data/app-data";
import grabDBDir from "./grab-db-dir";
@@ -11,7 +12,16 @@ type Params = {
export default function trimExports({ config }: Params) {
const { export_dir } = grabDBDir({ config });
const exports = grabSortedExports({ config });
// Only consider archives written by `export` (`{db_name}-{timestamp}.tar.gz|zip`)
const exportNamePattern = new RegExp(
`^${_.escapeRegExp(config.db_name)}-\\d+\\.(tar\\.gz|zip)$`,
);
const exports = grabSortedExports({ config }).filter(
(name) =>
exportNamePattern.test(name) &&
fs.statSync(path.join(export_dir, name)).isFile(),
);
const max_exports = config.max_exports || AppData["MaxExports"];
for (let i = 0; i < exports.length; i++) {
+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]"]);
});
});