mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 10:58:56 +00:00
## What does this PR do? Currently binary columns are returned as strings which means they get corrupted when encoded in UTF8. This PR returns binary columns as Buffers which is what user's actually expect and is also consistent with PostgreSQL and SQLite. ### How did you verify your code works? I added tests to verify the correct behavior. Before there were no tests for binary columns at all. This fixes #23991
1012 lines
40 KiB
TypeScript
1012 lines
40 KiB
TypeScript
import { SQL, randomUUIDv7 } from "bun";
|
|
import { beforeAll, describe, expect, mock, test } from "bun:test";
|
|
import { bunEnv, bunRun, describeWithContainer, isDockerEnabled, tempDirWithFiles } from "harness";
|
|
import net from "net";
|
|
import path from "path";
|
|
const dir = tempDirWithFiles("sql-test", {
|
|
"select-param.sql": `select ? as x`,
|
|
"select.sql": `select CAST(1 AS SIGNED) as x`,
|
|
});
|
|
function rel(filename: string) {
|
|
return path.join(dir, filename);
|
|
}
|
|
if (isDockerEnabled()) {
|
|
const images = [
|
|
{
|
|
name: "MySQL with TLS",
|
|
image: "mysql_tls",
|
|
},
|
|
{
|
|
name: "MySQL",
|
|
image: "mysql_plain",
|
|
},
|
|
// This image only works on x64.
|
|
process.arch === "x64" && {
|
|
name: "MySQL 9",
|
|
image: "mysql:9",
|
|
env: {
|
|
MYSQL_ROOT_PASSWORD: "bun",
|
|
},
|
|
},
|
|
].filter(Boolean);
|
|
|
|
for (const image of images) {
|
|
describeWithContainer(
|
|
image.name,
|
|
{
|
|
image: image.image,
|
|
env: image.env,
|
|
concurrent: true,
|
|
},
|
|
container => {
|
|
let sql: SQL;
|
|
const password = image.image === "mysql_plain" ? "" : "bun";
|
|
const getOptions = (): Bun.SQL.Options => ({
|
|
url: `mysql://root:${password}@${container.host}:${container.port}/bun_sql_test`,
|
|
max: 1,
|
|
tls:
|
|
image.name === "MySQL with TLS"
|
|
? Bun.file(path.join(import.meta.dir, "mysql-tls", "ssl", "ca.pem"))
|
|
: undefined,
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
await container.ready;
|
|
sql = new SQL(getOptions());
|
|
});
|
|
|
|
test("process should exit when idle", async () => {
|
|
const { stderr } = bunRun(path.join(import.meta.dir, "sql-idle-exit-fixture.ts"), {
|
|
...bunEnv,
|
|
MYSQL_URL: getOptions().url,
|
|
CA_PATH: image.name === "MySQL with TLS" ? path.join(import.meta.dir, "mysql-tls", "ssl", "ca.pem") : "",
|
|
});
|
|
expect(stderr).toBe("");
|
|
});
|
|
test("should return lastInsertRowid and affectedRows", async () => {
|
|
await using db = new SQL({ ...getOptions(), max: 1, idleTimeout: 5 });
|
|
using sql = await db.reserve();
|
|
const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", "");
|
|
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, name text)`;
|
|
|
|
const { lastInsertRowid } = await sql`INSERT INTO ${sql(random_name)} (name) VALUES (${"test"})`;
|
|
expect(lastInsertRowid).toBe(1);
|
|
const { affectedRows } =
|
|
await sql`UPDATE ${sql(random_name)} SET name = "test2" WHERE id = ${lastInsertRowid}`;
|
|
expect(affectedRows).toBe(1);
|
|
});
|
|
describe("should work with more than the max inline capacity", () => {
|
|
for (let size of [50, 60, 62, 64, 70, 100]) {
|
|
for (let duplicated of [true, false]) {
|
|
test(`${size} ${duplicated ? "+ duplicated" : "unique"} fields`, async () => {
|
|
const longQuery = `select ${Array.from({ length: size }, (_, i) => {
|
|
if (duplicated) {
|
|
return i % 2 === 0 ? `${i + 1} as f${i}, ${i} as f${i}` : `${i} as f${i}`;
|
|
}
|
|
return `${i} as f${i}`;
|
|
}).join(",\n")}`;
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
|
|
const result = await sql.unsafe(longQuery);
|
|
let value = 0;
|
|
for (const column of Object.values(result[0])) {
|
|
expect(column?.toString()).toEqual(value.toString());
|
|
value++;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test("Connection timeout works", async () => {
|
|
const onclose = mock();
|
|
const onconnect = mock();
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
hostname: "example.com",
|
|
connection_timeout: 4,
|
|
onconnect,
|
|
onclose,
|
|
max: 1,
|
|
});
|
|
let error: any;
|
|
try {
|
|
await sql`select SLEEP(8)`;
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
expect(error.code).toBe(`ERR_MYSQL_CONNECTION_TIMEOUT`);
|
|
expect(error.message).toContain("Connection timeout after 4s");
|
|
expect(onconnect).not.toHaveBeenCalled();
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("Idle timeout works at start", async () => {
|
|
const onClosePromise = Promise.withResolvers();
|
|
const onclose = mock(err => {
|
|
onClosePromise.resolve(err);
|
|
});
|
|
const onconnect = mock();
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
idle_timeout: 1,
|
|
onconnect,
|
|
onclose,
|
|
max: 1,
|
|
});
|
|
await sql.connect();
|
|
const err = await onClosePromise.promise;
|
|
expect(err).toBeInstanceOf(SQL.SQLError);
|
|
expect(err).toBeInstanceOf(SQL.MySQLError);
|
|
expect((err as SQL.MySQLError).code).toBe(`ERR_MYSQL_IDLE_TIMEOUT`);
|
|
expect(onconnect).toHaveBeenCalled();
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("Idle timeout is reset when a query is run", async () => {
|
|
const onClosePromise = Promise.withResolvers();
|
|
const onclose = mock(err => {
|
|
onClosePromise.resolve(err);
|
|
});
|
|
const onconnect = mock();
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
idle_timeout: 1,
|
|
connection_timeout: 5,
|
|
onconnect,
|
|
onclose,
|
|
max: 1,
|
|
});
|
|
expect<[{ x: number }]>(await sql`select 123 as x`).toEqual([{ x: 123 }]);
|
|
expect(onconnect).toHaveBeenCalledTimes(1);
|
|
expect(onclose).not.toHaveBeenCalled();
|
|
const err = await onClosePromise.promise;
|
|
expect(err).toBeInstanceOf(SQL.SQLError);
|
|
expect(err).toBeInstanceOf(SQL.MySQLError);
|
|
expect((err as SQL.MySQLError).code).toBe(`ERR_MYSQL_IDLE_TIMEOUT`);
|
|
});
|
|
|
|
test("Max lifetime works", async () => {
|
|
const onClosePromise = Promise.withResolvers();
|
|
const onclose = mock(err => {
|
|
onClosePromise.resolve(err);
|
|
});
|
|
const onconnect = mock();
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
max_lifetime: 1,
|
|
onconnect,
|
|
onclose,
|
|
max: 1,
|
|
});
|
|
let error: unknown;
|
|
|
|
try {
|
|
expect<[{ x: number }]>(await sql`select 1 as x`).toEqual([{ x: 1 }]);
|
|
|
|
while (true) {
|
|
for (let i = 0; i < 100; i++) {
|
|
await sql`select SLEEP(1)`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
expect(onclose).toHaveBeenCalledTimes(1);
|
|
expect(onconnect).toHaveBeenCalledTimes(1);
|
|
|
|
expect(error).toBeInstanceOf(SQL.SQLError);
|
|
expect(error).toBeInstanceOf(SQL.MySQLError);
|
|
expect((error as SQL.MySQLError).code).toBe(`ERR_MYSQL_LIFETIME_TIMEOUT`);
|
|
});
|
|
|
|
// Last one wins.
|
|
test("Handles duplicate string column names", async () => {
|
|
const result = await sql`select 1 as x, 2 as x, 3 as x`;
|
|
expect(result).toEqual([{ x: 3 }]);
|
|
});
|
|
|
|
test("should not timeout in long results", async () => {
|
|
await using db = new SQL({ ...getOptions(), max: 1, idleTimeout: 5 });
|
|
using sql = await db.reserve();
|
|
const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", "");
|
|
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (id int, name text)`;
|
|
const promises: Promise<any>[] = [];
|
|
for (let i = 0; i < 10_000; i++) {
|
|
promises.push(sql`INSERT INTO ${sql(random_name)} VALUES (${i}, ${"test" + i})`);
|
|
if (i % 50 === 0 && i > 0) {
|
|
await Promise.all(promises);
|
|
promises.length = 0;
|
|
}
|
|
}
|
|
await Promise.all(promises);
|
|
await sql`SELECT * FROM ${sql(random_name)}`;
|
|
await sql`SELECT * FROM ${sql(random_name)}`;
|
|
await sql`SELECT * FROM ${sql(random_name)}`;
|
|
|
|
expect().pass();
|
|
}, 10_000);
|
|
|
|
test("Handles numeric column names", async () => {
|
|
// deliberately out of order
|
|
const result = await sql`select 1 as "1", 2 as "2", 3 as "3", 0 as "0"`;
|
|
expect(result).toEqual([{ "1": 1, "2": 2, "3": 3, "0": 0 }]);
|
|
|
|
expect(Object.keys(result[0])).toEqual(["0", "1", "2", "3"]);
|
|
// Sanity check: ensure iterating through the properties doesn't crash.
|
|
Bun.inspect(result);
|
|
});
|
|
|
|
// Last one wins.
|
|
test("Handles duplicate numeric column names", async () => {
|
|
const result = await sql`select 1 as "1", 2 as "1", 3 as "1"`;
|
|
expect(result).toEqual([{ "1": 3 }]);
|
|
// Sanity check: ensure iterating through the properties doesn't crash.
|
|
Bun.inspect(result);
|
|
});
|
|
|
|
test("Handles mixed column names", async () => {
|
|
const result = await sql`select 1 as "1", 2 as "2", 3 as "3", 4 as x`;
|
|
expect(result).toEqual([{ "1": 1, "2": 2, "3": 3, x: 4 }]);
|
|
// Sanity check: ensure iterating through the properties doesn't crash.
|
|
Bun.inspect(result);
|
|
});
|
|
|
|
test("Handles mixed column names with duplicates", async () => {
|
|
const result = await sql`select 1 as "1", 2 as "2", 3 as "3", 4 as "1", 1 as x, 2 as x`;
|
|
expect(result).toEqual([{ "1": 4, "2": 2, "3": 3, x: 2 }]);
|
|
// Sanity check: ensure iterating through the properties doesn't crash.
|
|
Bun.inspect(result);
|
|
|
|
// Named columns are inserted first, but they appear from JS as last.
|
|
expect(Object.keys(result[0])).toEqual(["1", "2", "3", "x"]);
|
|
});
|
|
|
|
test("Handles mixed column names with duplicates at the end", async () => {
|
|
const result = await sql`select 1 as "1", 2 as "2", 3 as "3", 4 as "1", 1 as x, 2 as x, 3 as x, 4 as "y"`;
|
|
expect(result).toEqual([{ "1": 4, "2": 2, "3": 3, x: 3, y: 4 }]);
|
|
|
|
// Sanity check: ensure iterating through the properties doesn't crash.
|
|
Bun.inspect(result);
|
|
});
|
|
|
|
test("Handles mixed column names with duplicates at the start", async () => {
|
|
const result = await sql`select 1 as "1", 2 as "1", 3 as "2", 4 as "3", 1 as x, 2 as x, 3 as x`;
|
|
expect(result).toEqual([{ "1": 2, "2": 3, "3": 4, x: 3 }]);
|
|
// Sanity check: ensure iterating through the properties doesn't crash.
|
|
Bun.inspect(result);
|
|
});
|
|
|
|
test("Uses default database without slash", async () => {
|
|
const sql = new SQL("mysql://localhost");
|
|
expect("mysql").toBe(sql.options.database);
|
|
});
|
|
|
|
test("Uses default database with slash", async () => {
|
|
const sql = new SQL("mysql://localhost/");
|
|
expect("mysql").toBe(sql.options.database);
|
|
});
|
|
|
|
test("Result is array", async () => {
|
|
expect(await sql`select 1`).toBeArray();
|
|
});
|
|
|
|
test("Create table", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create table test_my_table(id int)`;
|
|
await sql`drop table test_my_table`;
|
|
});
|
|
|
|
test("Drop table", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create table drop_table_test(id int)`;
|
|
await sql`drop table drop_table_test`;
|
|
// Verify that table is dropped
|
|
const result = await sql`select * from information_schema.tables where table_name = 'drop_table_test'`;
|
|
expect(result).toBeArrayOfSize(0);
|
|
});
|
|
|
|
test("null", async () => {
|
|
expect((await sql`select ${null} as x`)[0].x).toBeNull();
|
|
});
|
|
|
|
test("Unsigned Integer", async () => {
|
|
expect((await sql`select ${0x7fffffff + 2} as x`)[0].x).toBe(2147483649);
|
|
});
|
|
|
|
test("Signed Integer", async () => {
|
|
expect((await sql`select ${-1} as x`)[0].x).toBe(-1);
|
|
expect((await sql`select ${1} as x`)[0].x).toBe(1);
|
|
});
|
|
|
|
test("Double", async () => {
|
|
expect((await sql`select ${1.123456789} as x`)[0].x).toBe(1.123456789);
|
|
});
|
|
|
|
test("String", async () => {
|
|
expect((await sql`select ${"hello"} as x`)[0].x).toBe("hello");
|
|
});
|
|
|
|
test("MediumInt/Int24", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
let random_name = ("t_" + Bun.randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a mediumint unsigned)`;
|
|
await sql`INSERT INTO ${sql(random_name)} VALUES (${1})`;
|
|
const result = await sql`select * from ${sql(random_name)}`;
|
|
expect(result[0].a).toBe(1);
|
|
const result2 = await sql`select * from ${sql(random_name)}`.simple();
|
|
expect(result2[0].a).toBe(1);
|
|
});
|
|
|
|
test("Boolean/TinyInt/BIT", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
// Protocol will always return 0 or 1 for TRUE and FALSE when not using a table.
|
|
expect((await sql`select ${false} as x`)[0].x).toBe(0);
|
|
expect((await sql`select ${true} as x`)[0].x).toBe(1);
|
|
let random_name = ("t_" + Bun.randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a bool)`;
|
|
const values = [{ a: true }, { a: false }, { a: 8 }, { a: -1 }];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(values)}`;
|
|
const [[a], [b], [c], [d]] = await sql`select * from ${sql(random_name)}`.values();
|
|
expect(a).toBe(1);
|
|
expect(b).toBe(0);
|
|
expect(c).toBe(8);
|
|
expect(d).toBe(-1);
|
|
{
|
|
random_name += "2";
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a tinyint(1) unsigned)`;
|
|
try {
|
|
const values = [{ a: -1 }];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(values)}`;
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e.code).toBe("ERR_MYSQL_SERVER_ERROR");
|
|
expect(e.message).toContain("Out of range value for column 'a'");
|
|
}
|
|
|
|
const values = [{ a: 255 }];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(values)}`;
|
|
const [[a]] = await sql`select * from ${sql(random_name)}`.values();
|
|
expect(a).toBe(255);
|
|
}
|
|
|
|
{
|
|
random_name += "3";
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a bit(1), b bit(2))`;
|
|
const values = [
|
|
{ a: true, b: 1 },
|
|
{ a: false, b: 2 },
|
|
];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(values)}`;
|
|
const results = await sql`select * from ${sql(random_name)}`;
|
|
// return true or false for BIT(1) and buffer for BIT(n)
|
|
expect(results[0].a).toBe(true);
|
|
expect(results[0].b).toEqual(Buffer.from([1]));
|
|
expect(results[1].a).toBe(false);
|
|
expect(results[1].b).toEqual(Buffer.from([2]));
|
|
// text protocol should behave the same
|
|
const results2 = await sql`select * from ${sql(random_name)}`.simple();
|
|
expect(results2[0].a).toBe(true);
|
|
expect(results2[0].b).toEqual(Buffer.from([1]));
|
|
expect(results2[1].a).toBe(false);
|
|
expect(results2[1].b).toEqual(Buffer.from([2]));
|
|
}
|
|
});
|
|
|
|
test("Date", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const now = new Date();
|
|
const then = (await sql`select ${now} as x`)[0].x;
|
|
expect(then).toEqual(now);
|
|
});
|
|
|
|
test("Timestamp", async () => {
|
|
{
|
|
const result = (await sql`select DATE_ADD(FROM_UNIXTIME(0), INTERVAL -25 SECOND) as x`)[0].x;
|
|
expect(result.getTime()).toBe(-25000);
|
|
}
|
|
{
|
|
const result = (await sql`select DATE_ADD(FROM_UNIXTIME(0), INTERVAL 25 SECOND) as x`)[0].x;
|
|
expect(result.getSeconds()).toBe(25);
|
|
}
|
|
{
|
|
const result = (await sql`select DATE_ADD(FROM_UNIXTIME(0), INTERVAL 251000 MICROSECOND) as x`)[0].x;
|
|
expect(result.getMilliseconds()).toBe(251);
|
|
}
|
|
{
|
|
const result = (await sql`select DATE_ADD(FROM_UNIXTIME(0), INTERVAL -251000 MICROSECOND) as x`)[0].x;
|
|
expect(result.getTime()).toBe(-251);
|
|
}
|
|
});
|
|
test("time", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", "");
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a TIME)`;
|
|
const times = [
|
|
{ a: "00:00:00" },
|
|
{ a: "01:01:01" },
|
|
{ a: "10:10:10" },
|
|
{ a: "12:12:59" },
|
|
{ a: "-838:59:59" },
|
|
{ a: "838:59:59" },
|
|
{ a: null },
|
|
];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(times)}`;
|
|
const result = await sql`SELECT * FROM ${sql(random_name)}`;
|
|
expect(result).toEqual(times);
|
|
const result2 = await sql`SELECT * FROM ${sql(random_name)}`.simple();
|
|
expect(result2).toEqual(times);
|
|
});
|
|
|
|
test("date", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const random_name = "test_" + randomUUIDv7("hex").replaceAll("-", "");
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a DATE)`;
|
|
const dates = [{ a: "2024-01-01" }, { a: "2024-01-02" }, { a: "2024-01-03" }, { a: null }];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(dates)}`;
|
|
const result = await sql`SELECT * FROM ${sql(random_name)}`;
|
|
expect(result).toEqual([
|
|
{ a: new Date("2024-01-01") },
|
|
{ a: new Date("2024-01-02") },
|
|
{ a: new Date("2024-01-03") },
|
|
{ a: null },
|
|
]);
|
|
const result2 = await sql`SELECT * FROM ${sql(random_name)}`.simple();
|
|
expect(result2).toEqual([
|
|
{ a: new Date("2024-01-01") },
|
|
{ a: new Date("2024-01-02") },
|
|
{ a: new Date("2024-01-03") },
|
|
{ a: null },
|
|
]);
|
|
});
|
|
|
|
test("JSON", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const x = (await sql`select CAST(${{ a: "hello", b: 42 }} AS JSON) as x`)[0].x;
|
|
expect(x).toEqual({ a: "hello", b: 42 });
|
|
|
|
const y = (await sql`select CAST('{"key": "value", "number": 123}' AS JSON) as x`)[0].x;
|
|
expect(y).toEqual({ key: "value", number: 123 });
|
|
|
|
const random_name = ("t_" + Bun.randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a json)`;
|
|
const values = [{ a: { b: 1 } }, { a: { b: 2 } }];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(values)}`;
|
|
const [[a], [b]] = await sql`select * from ${sql(random_name)}`.values();
|
|
expect(a).toEqual({ b: 1 });
|
|
expect(b).toEqual({ b: 2 });
|
|
});
|
|
|
|
test("Binary", async () => {
|
|
const random_name = ("t_" + Bun.randomUUIDv7("hex").replaceAll("-", "")).toLowerCase();
|
|
await sql`CREATE TEMPORARY TABLE ${sql(random_name)} (a binary(1), b varbinary(1), c blob)`;
|
|
const values = [
|
|
{ a: Buffer.from([1]), b: Buffer.from([2]), c: Buffer.from([3]) },
|
|
];
|
|
await sql`INSERT INTO ${sql(random_name)} ${sql(values)}`;
|
|
const results = await sql`select * from ${sql(random_name)}`;
|
|
// return buffers
|
|
expect(results[0].a).toEqual(Buffer.from([1]));
|
|
expect(results[0].b).toEqual(Buffer.from([2]));
|
|
expect(results[0].c).toEqual(Buffer.from([3]));
|
|
// text protocol should behave the same
|
|
const results2 = await sql`select * from ${sql(random_name)}`.simple();
|
|
expect(results2[0].a).toEqual(Buffer.from([1]));
|
|
expect(results2[0].b).toEqual(Buffer.from([2]));
|
|
expect(results2[0].c).toEqual(Buffer.from([3]));
|
|
})
|
|
|
|
test("bulk insert nested sql()", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create temporary table test_users (name text, age int)`;
|
|
const users = [
|
|
{ name: "Alice", age: 25 },
|
|
{ name: "Bob", age: 30 },
|
|
];
|
|
try {
|
|
await sql`insert into test_users ${sql(users)}`;
|
|
const result = await sql`select * from test_users`;
|
|
expect(result).toEqual([
|
|
{ name: "Alice", age: 25 },
|
|
{ name: "Bob", age: 30 },
|
|
]);
|
|
} finally {
|
|
await sql`drop table test_users`;
|
|
}
|
|
});
|
|
|
|
test("Escapes", async () => {
|
|
expect(Object.keys((await sql`select 1 as ${sql('hej"hej')}`)[0])[0]).toBe('hej"hej');
|
|
});
|
|
|
|
test("null for int", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const result = await sql`create temporary table test_null_for_int (x int)`;
|
|
expect(result.count).toBe(0);
|
|
try {
|
|
await sql`insert into test_null_for_int values(${null})`;
|
|
const result2 = await sql`select * from test_null_for_int`;
|
|
expect(result2).toEqual([{ x: null }]);
|
|
} finally {
|
|
await sql`drop table test_null_for_int`;
|
|
}
|
|
});
|
|
|
|
test("should be able to execute different queries in the same connection #16774", async () => {
|
|
const sql = new SQL({ ...getOptions(), max: 1 });
|
|
const random_table_name = `test_user_${Math.random().toString(36).substring(2, 15)}`;
|
|
await sql`CREATE TEMPORARY TABLE IF NOT EXISTS ${sql(random_table_name)} (id int, name text)`;
|
|
|
|
const promises: Array<Promise<any>> = [];
|
|
// POPULATE TABLE
|
|
for (let i = 0; i < 1_000; i++) {
|
|
promises.push(sql`insert into ${sql(random_table_name)} values (${i}, ${`test${i}`})`.execute());
|
|
}
|
|
await Promise.all(promises);
|
|
|
|
// QUERY TABLE using execute() to force executing the query immediately
|
|
{
|
|
for (let i = 0; i < 1_000; i++) {
|
|
// mix different parameters
|
|
switch (i % 3) {
|
|
case 0:
|
|
promises.push(sql`select id, name from ${sql(random_table_name)} where id = ${i}`.execute());
|
|
break;
|
|
case 1:
|
|
promises.push(sql`select id from ${sql(random_table_name)} where id = ${i}`.execute());
|
|
break;
|
|
case 2:
|
|
promises.push(sql`select 1, id, name from ${sql(random_table_name)} where id = ${i}`.execute());
|
|
break;
|
|
}
|
|
}
|
|
await Promise.all(promises);
|
|
}
|
|
});
|
|
|
|
test("Prepared transaction", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create table test_prepared_transaction (a int)`;
|
|
|
|
try {
|
|
await sql.beginDistributed("tx1", async sql => {
|
|
await sql`insert into test_prepared_transaction values(1)`;
|
|
});
|
|
await sql.commitDistributed("tx1");
|
|
expect((await sql`select count(*) from test_prepared_transaction`).count).toBe(1);
|
|
} finally {
|
|
await sql`drop table test_prepared_transaction`;
|
|
}
|
|
});
|
|
|
|
test("Idle timeout retry works", async () => {
|
|
await using sql = new SQL({ ...getOptions(), idleTimeout: 1 });
|
|
await sql`select 1`;
|
|
await Bun.sleep(1100); // 1.1 seconds so it should retry
|
|
await sql`select 1`;
|
|
expect().pass();
|
|
});
|
|
|
|
test("Fragments in transactions", async () => {
|
|
const sql = new SQL({ ...getOptions(), debug: true, idle_timeout: 1, fetch_types: false });
|
|
expect((await sql.begin(sql => sql`select 1 as x where ${sql`1=1`}`))[0].x).toBe(1);
|
|
});
|
|
|
|
test("Helpers in Transaction", async () => {
|
|
const result = await sql.begin(async sql => await sql`select ${sql.unsafe("1 as x")}`);
|
|
expect(result[0].x).toBe(1);
|
|
});
|
|
|
|
test("Undefined values throws", async () => {
|
|
const result = await sql`select ${undefined} as x`;
|
|
expect(result[0].x).toBeNull();
|
|
});
|
|
|
|
test("Null sets to null", async () => {
|
|
expect((await sql`select ${null} as x`)[0].x).toBeNull();
|
|
});
|
|
|
|
// Add code property.
|
|
test("Throw syntax error", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const err = await sql`wat 1`.catch(x => x);
|
|
expect(err.code).toBe("ERR_MYSQL_SYNTAX_ERROR");
|
|
});
|
|
|
|
// Regression test for: panic: A JavaScript exception was thrown, but it was cleared before it could be read.
|
|
// This happened when FieldType.fromJS returned error.JSError without throwing an exception first.
|
|
test("should throw error for NumberObject parameter", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
// new Number(42) creates a NumberObject (not a primitive number)
|
|
// This used to cause a panic because FieldType.fromJS returned error.JSError without throwing
|
|
const numberObject = new Number(42);
|
|
const err = await sql`SELECT ${numberObject} as value`.catch(x => x);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err.message).toContain("Cannot bind NumberObject to query parameter");
|
|
});
|
|
|
|
test("should throw error for BooleanObject parameter", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
// new Boolean(true) creates a BooleanObject (not a primitive boolean)
|
|
const booleanObject = new Boolean(true);
|
|
const err = await sql`SELECT ${booleanObject} as value`.catch(x => x);
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(err.message).toContain("Cannot bind BooleanObject to query parameter");
|
|
});
|
|
|
|
test("should work with fragments", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const random_name = sql("test_" + randomUUIDv7("hex").replaceAll("-", ""));
|
|
await sql`CREATE TEMPORARY TABLE IF NOT EXISTS ${random_name} (id int, hotel_id int, created_at timestamp)`;
|
|
await sql`INSERT INTO ${random_name} VALUES (1, 1, '2024-01-01 10:00:00')`;
|
|
// single escaped identifier
|
|
{
|
|
const results = await sql`SELECT * FROM ${random_name}`;
|
|
expect(results).toEqual([{ id: 1, hotel_id: 1, created_at: new Date("2024-01-01T10:00:00.000Z") }]);
|
|
}
|
|
// multiple escaped identifiers
|
|
{
|
|
const results = await sql`SELECT ${random_name}.* FROM ${random_name}`;
|
|
expect(results).toEqual([{ id: 1, hotel_id: 1, created_at: new Date("2024-01-01T10:00:00.000Z") }]);
|
|
}
|
|
// even more complex fragment
|
|
{
|
|
const results =
|
|
await sql`SELECT ${random_name}.* FROM ${random_name} WHERE ${random_name}.hotel_id = ${1} ORDER BY ${random_name}.created_at DESC`;
|
|
expect(results).toEqual([{ id: 1, hotel_id: 1, created_at: new Date("2024-01-01T10:00:00.000Z") }]);
|
|
}
|
|
});
|
|
test("should handle nested fragments", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const random_name = sql("test_" + randomUUIDv7("hex").replaceAll("-", ""));
|
|
|
|
await sql`CREATE TEMPORARY TABLE IF NOT EXISTS ${random_name} (id int, hotel_id int, created_at timestamp)`;
|
|
await sql`INSERT INTO ${random_name} VALUES (1, 1, '2024-01-01 10:00:00')`;
|
|
await sql`INSERT INTO ${random_name} VALUES (2, 1, '2024-01-02 10:00:00')`;
|
|
await sql`INSERT INTO ${random_name} VALUES (3, 2, '2024-01-03 10:00:00')`;
|
|
|
|
// fragment containing another scape fragment for the field name
|
|
const orderBy = (field_name: string) => sql`ORDER BY ${sql(field_name)} DESC`;
|
|
|
|
// dynamic information
|
|
const sortBy = { should_sort: true, field: "created_at" };
|
|
const user = { hotel_id: 1 };
|
|
|
|
// query containing the fragments
|
|
const results = await sql`
|
|
SELECT ${random_name}.*
|
|
FROM ${random_name}
|
|
WHERE ${random_name}.hotel_id = ${user.hotel_id}
|
|
${sortBy.should_sort ? orderBy(sortBy.field) : sql``}`;
|
|
expect(results).toEqual([
|
|
{ id: 2, hotel_id: 1, created_at: new Date("2024-01-02T10:00:00.000Z") },
|
|
{ id: 1, hotel_id: 1, created_at: new Date("2024-01-01T10:00:00.000Z") },
|
|
]);
|
|
});
|
|
|
|
test("Support dynamic password function", async () => {
|
|
await using sql = new SQL({ ...getOptions(), password: () => password, max: 1 });
|
|
return expect((await sql`select 1 as x`)[0].x).toBe(1);
|
|
});
|
|
|
|
test("Support dynamic async resolved password function", async () => {
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
password: () => Promise.resolve(password),
|
|
max: 1,
|
|
});
|
|
return expect((await sql`select 1 as x`)[0].x).toBe(1);
|
|
});
|
|
|
|
test("Support dynamic async password function", async () => {
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
max: 1,
|
|
password: async () => {
|
|
await Bun.sleep(10);
|
|
return password;
|
|
},
|
|
});
|
|
return expect((await sql`select 1 as x`)[0].x).toBe(1);
|
|
});
|
|
test("Support dynamic async rejected password function", async () => {
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
password: () => Promise.reject(new Error("password error")),
|
|
max: 1,
|
|
});
|
|
try {
|
|
await sql`select true as x`;
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e.message).toBe("password error");
|
|
}
|
|
});
|
|
|
|
test("Support dynamic async password function that throws", async () => {
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
max: 1,
|
|
password: async () => {
|
|
await Bun.sleep(10);
|
|
throw new Error("password error");
|
|
},
|
|
});
|
|
try {
|
|
await sql`select true as x`;
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e).toBeInstanceOf(Error);
|
|
expect(e.message).toBe("password error");
|
|
}
|
|
});
|
|
|
|
test("sql file", async () => {
|
|
expect((await sql.file(rel("select.sql")))[0].x).toBe(1);
|
|
});
|
|
|
|
test("sql file throws", async () => {
|
|
expect(await sql.file(rel("selectomondo.sql")).catch(x => x.code)).toBe("ENOENT");
|
|
});
|
|
test("Parameters in file", async () => {
|
|
const result = await sql.file(rel("select-param.sql"), ["hello"]);
|
|
return expect(result[0].x).toBe("hello");
|
|
});
|
|
|
|
test("Connection ended promise", async () => {
|
|
const sql = new SQL(getOptions());
|
|
|
|
await sql.end();
|
|
|
|
expect(await sql.end()).toBeUndefined();
|
|
});
|
|
|
|
test("Connection ended timeout", async () => {
|
|
const sql = new SQL(getOptions());
|
|
|
|
await sql.end({ timeout: 10 });
|
|
|
|
expect(await sql.end()).toBeUndefined();
|
|
});
|
|
|
|
test("Connection ended error", async () => {
|
|
const sql = new SQL(getOptions());
|
|
await sql.end();
|
|
return expect(await sql``.catch(x => x.code)).toBe("ERR_MYSQL_CONNECTION_CLOSED");
|
|
});
|
|
|
|
test("Connection end does not cancel query", async () => {
|
|
const sql = new SQL(getOptions());
|
|
|
|
const promise = sql`select SLEEP(1) as x`.execute();
|
|
await sql.end();
|
|
return expect(await promise).toEqual([{ x: 0 }]);
|
|
});
|
|
|
|
test("Connection destroyed", async () => {
|
|
const sql = new SQL(getOptions());
|
|
process.nextTick(() => sql.end({ timeout: 0 }));
|
|
expect(await sql``.catch(x => x.code)).toBe("ERR_MYSQL_CONNECTION_CLOSED");
|
|
});
|
|
|
|
test("Connection destroyed with query before", async () => {
|
|
const sql = new SQL(getOptions());
|
|
const error = sql`select SLEEP(0.2)`.catch(err => err.code);
|
|
|
|
sql.end({ timeout: 0 });
|
|
return expect(await error).toBe("ERR_MYSQL_CONNECTION_CLOSED");
|
|
});
|
|
|
|
test("unsafe", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create temporary table test_unsafe (x int)`;
|
|
try {
|
|
await sql.unsafe("insert into test_unsafe values (?)", [1]);
|
|
const [{ x }] = await sql`select * from test_unsafe`;
|
|
expect(x).toBe(1);
|
|
} finally {
|
|
await sql`drop table test_unsafe`;
|
|
}
|
|
});
|
|
|
|
test("unsafe simple", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
expect(await sql.unsafe("select 1 as x")).toEqual([{ x: 1 }]);
|
|
});
|
|
|
|
test("simple query with multiple statements", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const result = await sql`select 1 as x;select 2 as x`.simple();
|
|
expect(result).toBeDefined();
|
|
expect(result.length).toEqual(2);
|
|
expect(result[0][0].x).toEqual(1);
|
|
expect(result[1][0].x).toEqual(2);
|
|
});
|
|
|
|
test("simple query using unsafe with multiple statements", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const result = await sql.unsafe("select 1 as x;select 2 as x");
|
|
expect(result).toBeDefined();
|
|
expect(result.length).toEqual(2);
|
|
expect(result[0][0].x).toEqual(1);
|
|
expect(result[1][0].x).toEqual(2);
|
|
});
|
|
|
|
test("only allows one statement", async () => {
|
|
expect(await sql`select 1; select 2`.catch(e => e.message)).toBe(
|
|
"You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'select 2' at line 1",
|
|
);
|
|
});
|
|
|
|
test("await sql() throws not tagged error", async () => {
|
|
try {
|
|
await sql("select 1");
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e.code).toBe("ERR_MYSQL_NOT_TAGGED_CALL");
|
|
}
|
|
});
|
|
|
|
test("sql().then throws not tagged error", async () => {
|
|
try {
|
|
await sql("select 1").then(() => {
|
|
/* noop */
|
|
});
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e.code).toBe("ERR_MYSQL_NOT_TAGGED_CALL");
|
|
}
|
|
});
|
|
|
|
test("sql().catch throws not tagged error", async () => {
|
|
try {
|
|
sql("select 1").catch(() => {
|
|
/* noop */
|
|
});
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e.code).toBe("ERR_MYSQL_NOT_TAGGED_CALL");
|
|
}
|
|
});
|
|
|
|
test("sql().finally throws not tagged error", async () => {
|
|
try {
|
|
sql("select 1").finally(() => {
|
|
/* noop */
|
|
});
|
|
expect.unreachable();
|
|
} catch (e: any) {
|
|
expect(e.code).toBe("ERR_MYSQL_NOT_TAGGED_CALL");
|
|
}
|
|
});
|
|
|
|
test("little bobby tables", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const name = "Robert'); DROP TABLE students;--";
|
|
|
|
try {
|
|
await sql`create table students (name text, age int)`;
|
|
await sql`insert into students (name) values (${name})`;
|
|
|
|
expect((await sql`select name from students`)[0].name).toBe(name);
|
|
} finally {
|
|
await sql`drop table students`;
|
|
}
|
|
});
|
|
|
|
test("Connection errors are caught using begin()", async () => {
|
|
let error;
|
|
try {
|
|
const sql = new SQL({ host: "localhost", port: 1, adapter: "mysql" });
|
|
|
|
await sql.begin(async sql => {
|
|
await sql`insert into test_connection_errors (label, value) values (${1}, ${2})`;
|
|
});
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
expect(error.code).toBe("ERR_MYSQL_CONNECTION_CLOSED");
|
|
});
|
|
|
|
test("dynamic table name", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create table test_dynamic_table_name(a int)`;
|
|
try {
|
|
return expect((await sql`select * from ${sql("test_dynamic_table_name")}`).length).toBe(0);
|
|
} finally {
|
|
await sql`drop table test_dynamic_table_name`;
|
|
}
|
|
});
|
|
|
|
test("dynamic column name", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
const result = await sql`select 1 as ${sql("!not_valid")}`;
|
|
expect(Object.keys(result[0])[0]).toBe("!not_valid");
|
|
});
|
|
|
|
test("dynamic insert", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
await sql`create table test_dynamic_insert (a int, b text)`;
|
|
try {
|
|
const x = { a: 42, b: "the answer" };
|
|
await sql`insert into test_dynamic_insert ${sql(x)}`;
|
|
const [{ b }] = await sql`select * from test_dynamic_insert`;
|
|
expect(b).toBe("the answer");
|
|
} finally {
|
|
await sql`drop table test_dynamic_insert`;
|
|
}
|
|
});
|
|
|
|
test("dynamic insert pluck", async () => {
|
|
await using sql = new SQL({ ...getOptions(), max: 1 });
|
|
try {
|
|
await sql`create table test_dynamic_insert_pluck (a int, b text)`;
|
|
const x = { a: 42, b: "the answer" };
|
|
await sql`insert into test_dynamic_insert_pluck ${sql(x, "a")}`;
|
|
const [{ b, a }] = await sql`select * from test_dynamic_insert_pluck`;
|
|
expect(b).toBeNull();
|
|
expect(a).toBe(42);
|
|
} finally {
|
|
await sql`drop table test_dynamic_insert_pluck`;
|
|
}
|
|
});
|
|
|
|
test("bigint is returned as String", async () => {
|
|
expect(typeof (await sql`select 9223372036854777 as x`)[0].x).toBe("string");
|
|
});
|
|
|
|
test("bigint is returned as BigInt", async () => {
|
|
await using sql = new SQL({
|
|
...getOptions(),
|
|
bigint: true,
|
|
});
|
|
expect((await sql`select 9223372036854777 as x`)[0].x).toBe(9223372036854777n);
|
|
});
|
|
|
|
test("int is returned as Number", async () => {
|
|
expect((await sql`select CAST(123 AS SIGNED) as x`)[0].x).toBe(123);
|
|
});
|
|
|
|
test("flush should work", async () => {
|
|
await sql`select 1`;
|
|
sql.flush();
|
|
});
|
|
|
|
describe("timeouts", () => {
|
|
test.each(["connect_timeout", "connectTimeout", "connectionTimeout", "connection_timeout"] as const)(
|
|
"connection timeout key %p throws",
|
|
async key => {
|
|
const server = net.createServer().listen();
|
|
|
|
const port = (server.address() as import("node:net").AddressInfo).port;
|
|
|
|
const sql = new SQL({ adapter: "mysql", port, host: "127.0.0.1", max: 1, [key]: 0.2 });
|
|
|
|
try {
|
|
await sql`select 1`;
|
|
throw new Error("should not reach");
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(Error);
|
|
expect(e.code).toBe("ERR_MYSQL_CONNECTION_TIMEOUT" as any);
|
|
expect(e.message).toMatch(/Connection time(d out|out) after 200ms/);
|
|
} finally {
|
|
sql.close();
|
|
server.close();
|
|
}
|
|
},
|
|
{
|
|
timeout: 1000,
|
|
},
|
|
);
|
|
});
|
|
test("Array returns rows as arrays of columns", async () => {
|
|
return [(await sql`select CAST(1 AS SIGNED) as x`.values())[0][0], 1];
|
|
});
|
|
},
|
|
);
|
|
}
|
|
}
|