Files
bun-src/test/js/sql/sql-helpers-validation.test.ts
2026-08-27 21:09:14 +00:00

179 lines
8.7 KiB
TypeScript

// Degenerate inputs to the sql() helpers (null/undefined items where objects
// are expected, update objects with no defined values) must surface clear
// validation errors from query normalization rather than raw TypeErrors or
// engine syntax errors. The validation contract is identical across the three
// adapters, so it is tested as one matrix. Normalization runs when a query is
// first awaited, before any connection is attempted, so the postgres and
// mysql rows need no live server: their URLs point at a closed port that is
// never actually dialed. The sqlite row uses an in-memory database.
// https://github.com/oven-sh/bun/issues/32155
import { SQL } from "bun";
import { describe, expect, test } from "bun:test";
const adapters: [string, () => SQL][] = [
["sqlite", () => new SQL("sqlite://:memory:")],
["postgres", () => new SQL("postgres://[email protected]:1/bun_sql_test", { max: 1 })],
["mysql", () => new SQL("mysql://[email protected]:1/bun_sql_test", { max: 1 })],
];
describe.each(adapters)("%s helper validation", (_adapter, makeSql) => {
test("null items in WHERE IN helper with a column are rejected", async () => {
await using sql = makeSql();
for (const items of [[null], [{ id: 1 }, null]]) {
const err = await sql`SELECT * FROM t WHERE id IN ${sql(items as any, "id")}`.catch(e => e);
expect(err).toBeInstanceOf(SyntaxError);
expect(err.message).toBe("Cannot use null as an item in WHERE IN helper with a column");
}
});
test("null and undefined items in INSERT helper are rejected", async () => {
await using sql = makeSql();
for (const item of [null, undefined]) {
const err = await sql`INSERT INTO t ${sql([{ id: 1 }, item as any])}`.catch(e => e);
expect(err).toBeInstanceOf(SyntaxError);
expect(err.message).toBe("Cannot use null or undefined as an item in INSERT helper");
}
});
test("null and undefined items in UPDATE helper are rejected", async () => {
await using sql = makeSql();
const err1 = await sql`UPDATE t SET ${sql(null as any, "name")} WHERE id = 1`.catch(e => e);
expect(err1).toBeInstanceOf(SyntaxError);
expect(err1.message).toBe("Cannot use null or undefined as an item in UPDATE helper");
const err2 = await sql`UPDATE t SET ${sql([undefined as any], "name")} WHERE id = 1`.catch(e => e);
expect(err2).toBeInstanceOf(SyntaxError);
expect(err2.message).toBe("Cannot use null or undefined as an item in UPDATE helper");
});
test("empty update helper throws regardless of SET casing", async () => {
await using sql = makeSql();
for (const query of [
() => sql`update t set ${sql({ name: undefined })} where id = 1`,
() => sql`UPDATE t SET ${sql({ name: undefined })} WHERE id = 1`,
// the helper emits SET itself when the query does not end with one
() => sql`update t ${sql({ name: undefined })} where id = 1`,
]) {
const err = await query().catch(e => e);
expect(err).toBeInstanceOf(SyntaxError);
expect(err.message).toBe("Update needs to have at least one column");
}
});
test("empty update helper throws even alongside a literal assignment", async () => {
// sqlite previously allowed the helper-last form of this (it stripped the
// trailing comma and executed the literal assignment) while throwing for
// the helper-first form; postgres and mysql throw for both. All three now
// throw for both orders.
await using sql = makeSql();
for (const query of [
() => sql`UPDATE t SET updated_at = CURRENT_TIMESTAMP, ${sql({ name: undefined })} WHERE id = 1`,
() => sql`UPDATE t SET ${sql({ name: undefined })}, updated_at = CURRENT_TIMESTAMP WHERE id = 1`,
]) {
const err = await query().catch(e => e);
expect(err).toBeInstanceOf(SyntaxError);
expect(err.message).toBe("Update needs to have at least one column");
}
});
});
const distributedAdapters: [string, () => SQL][] = [
["postgres", () => new SQL("postgres://[email protected]:1/bun_sql_test", { max: 1 })],
["mysql", () => new SQL("mysql://[email protected]:1/bun_sql_test", { max: 1 })],
];
describe.each(distributedAdapters)("%s distributed transaction name validation", (_adapter, makeSql) => {
const invalidNames = [["tx'name"], 42, null, undefined, { toString: () => "tx" }];
test("commitDistributed requires the transaction name to be a string", async () => {
await using sql = makeSql();
for (const name of invalidNames) {
const err = await sql.commitDistributed(name as any).catch(e => e);
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("Distributed transaction name must be a string.");
}
});
test("rollbackDistributed requires the transaction name to be a string", async () => {
await using sql = makeSql();
for (const name of invalidNames) {
const err = await sql.rollbackDistributed(name as any).catch(e => e);
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("Distributed transaction name must be a string.");
}
});
});
describe("postgres dynamic identifier validation", () => {
test("identifiers containing a NUL byte are rejected", async () => {
await using sql = new SQL("postgres://[email protected]:1/bun_sql_test", { max: 1 });
const err = await (sql("col\0umn") as unknown as Promise<any>).catch(e => e);
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("ERR_INVALID_ARG_VALUE");
expect(err.message).toStartWith("The argument 'name' must not contain null bytes. Received ");
});
test("insert helper column names containing a NUL byte are rejected", async () => {
await using sql = new SQL("postgres://[email protected]:1/bun_sql_test", { max: 1 });
const err = await sql`INSERT INTO t ${sql([{ ["col\0umn"]: 1 }])}`.catch(e => e);
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("ERR_INVALID_ARG_VALUE");
expect(err.message).toStartWith("The argument 'name' must not contain null bytes. Received ");
});
});
const identifierAdapters: [string, () => SQL][] = [
["sqlite", () => new SQL("sqlite://:memory:")],
["mysql", () => new SQL("mysql://[email protected]:1/bun_sql_test", { max: 1 })],
];
describe.each(identifierAdapters)("%s dynamic identifier validation", (_adapter, makeSql) => {
test("identifiers containing a NUL byte are rejected", async () => {
await using sql = makeSql();
const err = await (sql("col\0umn") as unknown as Promise<any>).catch(e => e);
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("ERR_INVALID_ARG_VALUE");
expect(err.message).toStartWith("The argument 'name' must not contain null bytes. Received ");
});
test("insert helper column names containing a NUL byte are rejected", async () => {
await using sql = makeSql();
const err = await sql`INSERT INTO t ${sql([{ ["col\0umn"]: 1 }])}`.catch(e => e);
expect(err).toBeInstanceOf(TypeError);
expect(err.code).toBe("ERR_INVALID_ARG_VALUE");
expect(err.message).toStartWith("The argument 'name' must not contain null bytes. Received ");
});
});
// Behaviors that must keep working; these execute real queries, so they run
// against sqlite only.
describe("sqlite helper behavior preserved", () => {
test("update helper with lowercase set and defined values still works", async () => {
await using sql = new SQL("sqlite://:memory:");
await sql`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, age INT)`;
await sql`INSERT INTO t ${sql({ id: 1, name: "John", age: 30 })}`;
await sql`update t set ${sql({ name: "Mary", age: undefined })} where id = 1`;
expect(await sql`SELECT * FROM t`).toEqual([{ id: 1, name: "Mary", age: 30 }]);
});
test("update helper alongside a literal assignment still works with defined values", async () => {
await using sql = new SQL("sqlite://:memory:");
await sql`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, flag INT)`;
await sql`INSERT INTO t (id, name, flag) VALUES (1, 'John', 0)`;
await sql`UPDATE t SET flag = 1, ${sql({ name: "Mary", age: undefined })} WHERE id = 1`;
expect(await sql`SELECT * FROM t`).toEqual([{ id: 1, name: "Mary", flag: 1 }]);
});
test("undefined items and null column values in WHERE IN helper still bind NULL", async () => {
await using sql = new SQL("sqlite://:memory:");
// an undefined item binds NULL
expect(await sql`SELECT 1 as num WHERE 1 IN ${sql([undefined as any, { id: 1 }], "id")}`).toEqual([{ num: 1 }]);
// a null value under the column key binds NULL
expect(await sql`SELECT 1 as num WHERE 1 IN ${sql([{ id: null }, { id: 1 }], "id")}`).toEqual([{ num: 1 }]);
// a null item without a column binds NULL
expect(await sql`SELECT 1 as num WHERE 1 IN ${sql([null, 1])}`).toEqual([{ num: 1 }]);
});
});