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