312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { SQL } from "bun";
|
||||
|
|
import { describe, expect, test } from "bun:test";
|
|||
|
|
|
|||
|
|
describe("SQLite URL Parsing Matrix", () => {
|
|||
|
|
const protocols = [
|
|||
|
|
{ prefix: "sqlite://", name: "sqlite://" },
|
|||
|
|
{ prefix: "sqlite:", name: "sqlite:" },
|
|||
|
|
{ prefix: "file://", name: "file://" },
|
|||
|
|
{ prefix: "file:", name: "file:" },
|
|||
|
|
{ prefix: "", name: "no protocol" }, // adapter specified in these ones
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const paths = [
|
|||
|
|
{ input: ":memory:", expected: ":memory:", name: "memory database" },
|
|||
|
|
{ input: "test.db", expected: "test.db", name: "simple filename" },
|
|||
|
|
{ input: "./test.db", expected: "./test.db", name: "relative path" },
|
|||
|
|
{ input: "../test.db", expected: "../test.db", name: "parent path" },
|
|||
|
|
{ input: "path/to/test.db", expected: "path/to/test.db", name: "nested path" },
|
|||
|
|
{ input: "/tmp/test.db", expected: "/tmp/test.db", name: "absolute Unix path" },
|
|||
|
|
{ input: "test with spaces.db", expected: "test with spaces.db", name: "spaces in filename" },
|
|||
|
|
{ input: "test#hash.db", expected: "test#hash.db", name: "hash in filename" },
|
|||
|
|
{ input: "[email protected]", expected: "[email protected]", name: "@ in filename" },
|
|||
|
|
{ input: "test&.db", expected: "test&.db", name: "ampersand in filename" },
|
|||
|
|
{ input: "test%20encoded.db", expected: "test%20encoded.db", name: "percent encoding" },
|
|||
|
|
{ input: "", expected: ":memory:", name: "empty path" },
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const testMatrix = protocols
|
|||
|
|
.flatMap(protocol =>
|
|||
|
|
paths.map(path => ({
|
|||
|
|
url: protocol.prefix + path.input,
|
|||
|
|
input: path.input,
|
|||
|
|
expected: path.expected,
|
|||
|
|
protocolName: protocol.name,
|
|||
|
|
pathName: path.name,
|
|||
|
|
needsAdapter: protocol.prefix === "",
|
|||
|
|
})),
|
|||
|
|
)
|
|||
|
|
.filter(test => {
|
|||
|
|
if (test.protocolName === "no protocol" && test.pathName === "memory database") {
|
|||
|
|
return false; // :memory: without protocol is valid
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Protocol × Path matrix", () => {
|
|||
|
|
test.each(testMatrix)("$protocolName with $pathName: $url", async testCase => {
|
|||
|
|
if (testCase.needsAdapter) {
|
|||
|
|
// Test with explicit adapter for no-protocol cases
|
|||
|
|
await using sql = new SQL(testCase.url, { adapter: "sqlite" });
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
expect(sql.options.filename).toBe(testCase.expected || ":memory:");
|
|||
|
|
} else {
|
|||
|
|
// Test without adapter (should auto-detect SQLite)
|
|||
|
|
await using sql = new SQL(testCase.url);
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
|
|||
|
|
if (testCase.protocolName === "file://") {
|
|||
|
|
const filename = sql.options.filename;
|
|||
|
|
// The implementation uses Bun.fileURLToPath if valid, else strips "file://"
|
|||
|
|
let expected: string;
|
|||
|
|
try {
|
|||
|
|
expected = Bun.fileURLToPath(testCase.url);
|
|||
|
|
} catch {
|
|||
|
|
// Not a valid file:// URL, so implementation just strips the prefix
|
|||
|
|
expected = testCase.url.slice(7); // "file://".length
|
|||
|
|
}
|
|||
|
|
// Empty filename should default to :memory:
|
|||
|
|
if (expected === "") {
|
|||
|
|
expected = ":memory:";
|
|||
|
|
}
|
|||
|
|
expect(filename).toBe(expected);
|
|||
|
|
} else {
|
|||
|
|
expect(sql.options.filename).toBe(testCase.expected);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Query parameters matrix", () => {
|
|||
|
|
const protocolsWithQuery = ["sqlite://test.db", "sqlite:test.db", "file://test.db", "file:test.db"];
|
|||
|
|
|
|||
|
|
const queryParams = [
|
|||
|
|
{ query: "", readonly: undefined, create: undefined, name: "no params" },
|
|||
|
|
{ query: "?mode=ro", readonly: true, create: undefined, name: "readonly" },
|
|||
|
|
{ query: "?mode=rw", readonly: false, create: undefined, name: "read-write" },
|
|||
|
|
{ query: "?mode=rwc", readonly: false, create: true, name: "read-write-create" },
|
|||
|
|
{ query: "?mode=invalid", readonly: undefined, create: undefined, name: "invalid mode" },
|
|||
|
|
{ query: "?other=param", readonly: undefined, create: undefined, name: "other param" },
|
|||
|
|
{ query: "?mode=ro&cache=shared", readonly: true, create: undefined, name: "multiple params" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const queryMatrix = protocolsWithQuery.flatMap(base =>
|
|||
|
|
queryParams.map(param => ({
|
|||
|
|
url: base + param.query,
|
|||
|
|
base: base,
|
|||
|
|
...param,
|
|||
|
|
})),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
test.each(queryMatrix)("$base with $name", async testCase => {
|
|||
|
|
await using sql = new SQL(testCase.url);
|
|||
|
|
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
expect(sql.options.readonly).toBe(testCase.readonly!);
|
|||
|
|
expect(sql.options.create).toBe(testCase.create!);
|
|||
|
|
|
|||
|
|
if (!testCase.base.startsWith("file://")) {
|
|||
|
|
expect(sql.options.filename).toBe("test.db");
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Windows-style paths matrix", () => {
|
|||
|
|
const windowsPaths = [
|
|||
|
|
{ input: "C:/test.db", expected: "C:/test.db", name: "forward slash drive" },
|
|||
|
|
{ input: "C:\\test.db", expected: "C:\\test.db", name: "backslash drive" },
|
|||
|
|
{ input: "D:/path/to/test.db", expected: "D:/path/to/test.db", name: "nested forward slash" },
|
|||
|
|
{ input: "D:\\path\\to\\test.db", expected: "D:\\path\\to\\test.db", name: "nested backslash" },
|
|||
|
|
{ input: "\\\\server\\share\\test.db", expected: "\\\\server\\share\\test.db", name: "UNC path" },
|
|||
|
|
{ input: "C:/path\\mixed/test.db", expected: "C:/path\\mixed/test.db", name: "mixed slashes" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const windowsProtocols = [
|
|||
|
|
"sqlite://",
|
|||
|
|
"sqlite:",
|
|||
|
|
"file:///", // Three slashes for file://
|
|||
|
|
"file:",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const windowsMatrix = windowsProtocols.flatMap(protocol =>
|
|||
|
|
windowsPaths.map(path => ({
|
|||
|
|
url: protocol + path.input,
|
|||
|
|
input: path.input,
|
|||
|
|
expected: path.expected,
|
|||
|
|
protocol: protocol,
|
|||
|
|
pathName: path.name,
|
|||
|
|
})),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
test.each(windowsMatrix)("Windows: $protocol with $pathName", async testCase => {
|
|||
|
|
await using sql = new SQL(testCase.url);
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
|
|||
|
|
if (testCase.protocol.startsWith("file://")) {
|
|||
|
|
const filename = sql.options.filename;
|
|||
|
|
let expected: string;
|
|||
|
|
try {
|
|||
|
|
expected = Bun.fileURLToPath(testCase.url);
|
|||
|
|
} catch {
|
|||
|
|
expected = testCase.url.slice(testCase.protocol.length);
|
|||
|
|
}
|
|||
|
|
expect(filename).toBe(expected);
|
|||
|
|
} else {
|
|||
|
|
expect(sql.options.filename).toBe(testCase.expected);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Unix-style paths matrix", () => {
|
|||
|
|
const unixPaths = [
|
|||
|
|
{ input: "/home/user/test.db", expected: "/home/user/test.db", name: "home directory" },
|
|||
|
|
{ input: "/var/lib/test.db", expected: "/var/lib/test.db", name: "system directory" },
|
|||
|
|
{ input: ".hidden.db", expected: ".hidden.db", name: "hidden file" },
|
|||
|
|
{ input: "~/.config/test.db", expected: "~/.config/test.db", name: "tilde path" },
|
|||
|
|
{ input: "test:colon.db", expected: "test:colon.db", name: "colon in name" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const unixProtocols = ["sqlite://", "sqlite:", "file://", "file:"];
|
|||
|
|
|
|||
|
|
const unixMatrix = unixProtocols.flatMap(protocol =>
|
|||
|
|
unixPaths.map(path => ({
|
|||
|
|
url: protocol + path.input,
|
|||
|
|
input: path.input,
|
|||
|
|
expected: path.expected,
|
|||
|
|
protocol: protocol,
|
|||
|
|
pathName: path.name,
|
|||
|
|
})),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
test.each(unixMatrix)("Unix: $protocol with $pathName", async testCase => {
|
|||
|
|
await using sql = new SQL(testCase.url);
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
|
|||
|
|
if (testCase.protocol === "file://") {
|
|||
|
|
const filename = sql.options.filename;
|
|||
|
|
// Same logic as above - try Bun.fileURLToPath, fallback to stripping prefix
|
|||
|
|
let expected: string;
|
|||
|
|
try {
|
|||
|
|
expected = Bun.fileURLToPath(testCase.url);
|
|||
|
|
} catch {
|
|||
|
|
expected = testCase.url.slice(7); // "file://".length
|
|||
|
|
}
|
|||
|
|
expect(filename).toBe(expected);
|
|||
|
|
} else {
|
|||
|
|
expect(sql.options.filename).toBe(testCase.expected);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Special characters matrix", () => {
|
|||
|
|
const specialChars = [
|
|||
|
|
{ char: " ", name: "space", encoded: "%20" },
|
|||
|
|
{ char: "#", name: "hash", encoded: "%23" },
|
|||
|
|
{ char: "%", name: "percent", encoded: "%25" },
|
|||
|
|
{ char: "&", name: "ampersand", encoded: "%26" },
|
|||
|
|
{ char: "(", name: "paren open", encoded: "%28" },
|
|||
|
|
{ char: ")", name: "paren close", encoded: "%29" },
|
|||
|
|
{ char: "[", name: "bracket open", encoded: "%5B" },
|
|||
|
|
{ char: "]", name: "bracket close", encoded: "%5D" },
|
|||
|
|
{ char: "{", name: "brace open", encoded: "%7B" },
|
|||
|
|
{ char: "}", name: "brace close", encoded: "%7D" },
|
|||
|
|
{ char: "'", name: "single quote", encoded: "%27" },
|
|||
|
|
{ char: '"', name: "double quote", encoded: "%22" },
|
|||
|
|
{ char: "🎉", name: "emoji", encoded: "%F0%9F%8E%89" },
|
|||
|
|
{ char: "测", name: "chinese", encoded: "%E6%B5%8B" },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const charMatrix = specialChars.flatMap(charInfo => [
|
|||
|
|
{
|
|||
|
|
url: `sqlite://test${charInfo.char}file.db`,
|
|||
|
|
expected: `test${charInfo.char}file.db`,
|
|||
|
|
description: `sqlite:// with ${charInfo.name} (raw)`,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
url: `sqlite://test${charInfo.encoded}file.db`,
|
|||
|
|
expected: `test${charInfo.encoded}file.db`,
|
|||
|
|
description: `sqlite:// with ${charInfo.name} (encoded)`,
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
test.each(charMatrix)("$description", async testCase => {
|
|||
|
|
await using sql = new SQL(testCase.url);
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
expect(sql.options.filename).toBe(testCase.expected);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("import.meta.resolve() compatibility", () => {
|
|||
|
|
test("handles URLs from import.meta.resolve()", async () => {
|
|||
|
|
// Use import.meta.resolve() to get the actual format for the current platform
|
|||
|
|
const resolvedUrl = import.meta.resolve("./test.db");
|
|||
|
|
|
|||
|
|
await using sql = new SQL(resolvedUrl);
|
|||
|
|
expect(sql.options.adapter).toBe("sqlite");
|
|||
|
|
|
|||
|
|
const filename = sql.options.filename;
|
|||
|
|
const expected = Bun.fileURLToPath(resolvedUrl);
|
|||
|
|
expect(filename).toBe(expected);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Edge cases", () => {
|
|||
|
|
test("handles very long paths", async () => {
|
|||
|
|
const longFilename = "a".repeat(255) + ".db";
|
|||
|
|
const longPath = `/tmp/${longFilename}`;
|
|||
|
|
await using sql = new SQL(`sqlite://${longPath}`);
|
|||
|
|
expect(sql.options.filename).toBe(longPath);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("handles database with .db in middle of name", async () => {
|
|||
|
|
// Use a path that won't create a file in the project root
|
|||
|
|
const path = "/tmp/test.db.backup";
|
|||
|
|
await using sql = new SQL(`sqlite://${path}`);
|
|||
|
|
expect(sql.options.filename).toBe(path);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("handles path with multiple dots", async () => {
|
|||
|
|
// Use a path that won't create a file in the project root
|
|||
|
|
const path = "/tmp/test...db";
|
|||
|
|
await using sql = new SQL(`sqlite://${path}`);
|
|||
|
|
expect(sql.options.filename).toBe(path);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("empty string with adapter defaults to :memory:", async () => {
|
|||
|
|
await using sql = new SQL("", { adapter: "sqlite" });
|
|||
|
|
expect(sql.options.filename).toBe(":memory:");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("null with adapter defaults to :memory:", async () => {
|
|||
|
|
await using sql = new SQL(null as never, { adapter: "sqlite" });
|
|||
|
|
expect(sql.options.filename).toBe(":memory:");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test("undefined with adapter defaults to :memory:", async () => {
|
|||
|
|
await using sql = new SQL(undefined as never, { adapter: "sqlite" });
|
|||
|
|
expect(sql.options.filename).toBe(":memory:");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("Non-SQLite protocols should use postgres", () => {
|
|||
|
|
const nonSqliteUrls = [
|
|||
|
|
"http://example.com/test.db",
|
|||
|
|
"https://example.com/test.db",
|
|||
|
|
"ftp://example.com/test.db",
|
|||
|
|
"localhost/test.db",
|
|||
|
|
"localhost:5432/test.db",
|
|||
|
|
"example.com:3306/db",
|
|||
|
|
"example.com/test",
|
|||
|
|
"localhost",
|
|||
|
|
"postgres://user:pass@localhost/db",
|
|||
|
|
"postgresql://user:pass@localhost/db",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
test.each(nonSqliteUrls)("treats %s as postgres", async url => {
|
|||
|
|
await using sql = new SQL(url);
|
|||
|
|
expect(sql.options.adapter).toBe("postgres");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|