mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(kysely-adapter): detect SQLite rowid aliases correctly (#11374)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@better-auth/kysely-adapter": patch
|
||||
---
|
||||
|
||||
Correctly detect database-generated SQLite primary keys during schema validation, including `INTEGER PRIMARY KEY` columns without `AUTOINCREMENT`.
|
||||
@@ -37,3 +37,23 @@ it("runs built-in migrations repeatedly on D1", async () => {
|
||||
);
|
||||
expect(secondResponse.status).toBe(204);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://developers.cloudflare.com/d1/sql-api/sql-statements/#pragma-index_listtable_name
|
||||
*/
|
||||
it("distinguishes rowid keys from indexed primary keys on D1", async () => {
|
||||
const { DB } = await server.getWorker<CloudflareBindings>().getEnv();
|
||||
await DB.exec("CREATE TABLE generated (id INTEGER PRIMARY KEY)");
|
||||
await DB.exec("CREATE TABLE descending (id INTEGER PRIMARY KEY DESC)");
|
||||
|
||||
const results = await DB.batch<{ origin: string }>([
|
||||
DB.prepare("PRAGMA index_list('generated')"),
|
||||
DB.prepare("PRAGMA index_list('descending')"),
|
||||
]);
|
||||
|
||||
expect(
|
||||
results.map((result) =>
|
||||
result.results?.some((index) => index.origin === "pk"),
|
||||
),
|
||||
).toEqual([false, true]);
|
||||
});
|
||||
|
||||
@@ -19,19 +19,16 @@ import {
|
||||
DEFAULT_MIGRATION_LOCK_TABLE,
|
||||
DEFAULT_MIGRATION_TABLE,
|
||||
} from "./kysely-migration-tables";
|
||||
import type { PragmaTableInfo } from "./sqlite-introspector";
|
||||
import type {
|
||||
PragmaIndexListRow,
|
||||
PragmaTableInfo,
|
||||
} from "./sqlite-introspector";
|
||||
import {
|
||||
sqliteIntegerPrimaryKeyColumn,
|
||||
toSqliteTableMetadata,
|
||||
} from "./sqlite-introspector";
|
||||
import type { DatabaseIndexIntrospector } from "./types";
|
||||
|
||||
interface D1IndexListRow {
|
||||
name: string;
|
||||
partial: number;
|
||||
unique: number;
|
||||
}
|
||||
|
||||
interface D1IndexInfoRow {
|
||||
name: string | null;
|
||||
seqno: number;
|
||||
@@ -46,7 +43,7 @@ export function createD1IndexIntrospector(
|
||||
): DatabaseIndexIntrospector {
|
||||
return async (tableNames) => {
|
||||
if (tableNames.length === 0) return [];
|
||||
const indexLists = await database.batch<D1IndexListRow>(
|
||||
const indexLists = await database.batch<PragmaIndexListRow>(
|
||||
tableNames.map((tableName) =>
|
||||
database.prepare(
|
||||
`PRAGMA index_list(${quoteSqliteStringLiteral(tableName)})`,
|
||||
@@ -204,8 +201,8 @@ class D1SqliteIntrospector implements DatabaseIntrospector {
|
||||
.where("name", "not like", "sqlite_%")
|
||||
// @ts-expect-error - D1 internal tables
|
||||
.where("name", "not like", "_cf_%")
|
||||
.select(["name", "type", "sql"])
|
||||
.$castTo<{ name: string; type: string; sql: string | null }>();
|
||||
.select(["name", "type"])
|
||||
.$castTo<{ name: string; type: string }>();
|
||||
|
||||
if (!options.withInternalKyselyTables) {
|
||||
query = query
|
||||
@@ -225,20 +222,33 @@ class D1SqliteIntrospector implements DatabaseIntrospector {
|
||||
this.#d1.prepare("SELECT * FROM pragma_table_info(?)").bind(table.name),
|
||||
);
|
||||
const batchResults = await this.#d1.batch<PragmaTableInfo>(statements);
|
||||
const rowidCandidates = tables.flatMap((table, index) => {
|
||||
const column = sqliteIntegerPrimaryKeyColumn(
|
||||
batchResults[index]?.results ?? [],
|
||||
);
|
||||
return column ? [{ index, tableName: table.name, column }] : [];
|
||||
});
|
||||
const indexResults = rowidCandidates.length
|
||||
? await this.#d1.batch<PragmaIndexListRow>(
|
||||
rowidCandidates.map(({ tableName }) =>
|
||||
this.#d1.prepare(
|
||||
`PRAGMA index_list(${quoteSqliteStringLiteral(tableName)})`,
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const generatedColumns = new Map(
|
||||
rowidCandidates
|
||||
.filter(
|
||||
(_, index) =>
|
||||
!indexResults[index]?.results?.some((row) => row.origin === "pk"),
|
||||
)
|
||||
.map(({ index, column }) => [index, column]),
|
||||
);
|
||||
|
||||
return tables.map((table, index) => {
|
||||
const columns = batchResults[index]?.results ?? [];
|
||||
const declared = table.sql
|
||||
?.split(/[(),]/)
|
||||
.find((part) => part.toLowerCase().includes("autoincrement"))
|
||||
?.split(/\s+/)
|
||||
.filter(Boolean)[0]
|
||||
?.replace(/["`]/g, "");
|
||||
return toSqliteTableMetadata(
|
||||
table,
|
||||
columns,
|
||||
declared || sqliteIntegerPrimaryKeyColumn(columns),
|
||||
);
|
||||
return toSqliteTableMetadata(table, columns, generatedColumns.get(index));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,74 @@ describe("D1 table introspection", () => {
|
||||
"session",
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/10551
|
||||
* @see https://www.sqlite.org/pragma.html#pragma_index_list
|
||||
*/
|
||||
it("checks primary-key indexes only for INTEGER PRIMARY KEY candidates", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const all = vi.fn(async () => ({
|
||||
results: ["plain's", "descending", "text"].map((name) => ({
|
||||
name,
|
||||
type: "table",
|
||||
})),
|
||||
meta: { changes: 0, last_row_id: 0 },
|
||||
}));
|
||||
const prepare = vi.fn((query: string) => ({
|
||||
query,
|
||||
bind: (tableName?: string) => ({ query, tableName, all }),
|
||||
}));
|
||||
const batch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
["plain's", "descending", "text"].map((name) => ({
|
||||
results: [
|
||||
{
|
||||
cid: 0,
|
||||
name: "id",
|
||||
type: name === "text" ? "TEXT" : "INTEGER",
|
||||
notnull: 0,
|
||||
dflt_value: null,
|
||||
pk: 1,
|
||||
},
|
||||
],
|
||||
})),
|
||||
)
|
||||
.mockResolvedValueOnce([
|
||||
{ results: [] },
|
||||
{ results: [{ origin: "pk" }] },
|
||||
]);
|
||||
const database = {
|
||||
batch,
|
||||
exec: vi.fn(),
|
||||
prepare,
|
||||
} as unknown as D1Database;
|
||||
const db = new Kysely<unknown>({
|
||||
dialect: new D1SqliteDialect({ database }),
|
||||
});
|
||||
onTestFinished(() => db.destroy());
|
||||
|
||||
const tables = await db.introspection.getTables();
|
||||
|
||||
expect(
|
||||
tables.map((table) => [table.name, table.columns[0]?.isAutoIncrementing]),
|
||||
).toEqual([
|
||||
["plain's", true],
|
||||
["descending", false],
|
||||
["text", false],
|
||||
]);
|
||||
expect(batch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
batch.mock.calls[1]?.[0].map(
|
||||
(statement: { query: string }) => statement.query,
|
||||
),
|
||||
).toEqual([
|
||||
"PRAGMA index_list('plain''s')",
|
||||
"PRAGMA index_list('descending')",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createKyselyAdapter schema namespace", () => {
|
||||
|
||||
@@ -149,6 +149,43 @@ it("validates SQLite without dialect introspection", async ({
|
||||
expect(getTables).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.sqlite.org/lang_createtable.html#rowids_and_the_integer_primary_key
|
||||
*/
|
||||
it("distinguishes generated and required SQLite primary keys", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE generated (seq INTEGER PRIMARY KEY NOT NULL, id TEXT NOT NULL UNIQUE)",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE TABLE descending (seq INTEGER PRIMARY KEY DESC NOT NULL, id TEXT NOT NULL UNIQUE)",
|
||||
);
|
||||
sqlite.exec(
|
||||
"CREATE TABLE without_rowid (seq INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE) WITHOUT ROWID",
|
||||
);
|
||||
const db = new Kysely<unknown>({
|
||||
dialect: new NodeSqliteDialect({ database: sqlite }),
|
||||
});
|
||||
onTestFinished(() => db.destroy());
|
||||
|
||||
const findings = await findSchemaProblems(db, "sqlite", {
|
||||
generated: { fields: {} },
|
||||
descending: { fields: {} },
|
||||
without_rowid: { fields: {} },
|
||||
});
|
||||
|
||||
expect(findings).toEqual([
|
||||
{ kind: "unexpected-required-column", table: "descending", column: "seq" },
|
||||
{
|
||||
kind: "unexpected-required-column",
|
||||
table: "without_rowid",
|
||||
column: "seq",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH
|
||||
*/
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { SQLInputValue } from "node:sqlite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { Kysely } from "kysely";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BunSqliteDialect } from "./bun-sqlite-dialect";
|
||||
import {
|
||||
DEFAULT_MIGRATION_LOCK_TABLE,
|
||||
DEFAULT_MIGRATION_TABLE,
|
||||
} from "./kysely-migration-tables";
|
||||
import { NodeSqliteDialect } from "./node-sqlite-dialect";
|
||||
import { introspectSqliteTables } from "./sqlite-introspector";
|
||||
|
||||
function createSchema(db: DatabaseSync) {
|
||||
for (const sql of [
|
||||
@@ -172,3 +173,148 @@ describe("sqlite introspector", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://www.sqlite.org/autoinc.html
|
||||
* @see https://www.sqlite.org/lang_createtable.html#rowids_and_the_integer_primary_key
|
||||
*/
|
||||
describe("SQLite generated primary keys", () => {
|
||||
it.for([
|
||||
{
|
||||
name: "an explicit AUTOINCREMENT declaration after whitespace",
|
||||
definition: `CREATE TABLE entries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
id TEXT NOT NULL UNIQUE
|
||||
)`,
|
||||
generated: "seq",
|
||||
},
|
||||
{
|
||||
name: "an AUTOINCREMENT declaration separated by a comment",
|
||||
definition:
|
||||
"CREATE TABLE entries (seq INTEGER PRIMARY KEY/**/AUTOINCREMENT, id TEXT)",
|
||||
generated: "seq",
|
||||
},
|
||||
{
|
||||
name: "an INTEGER PRIMARY KEY without AUTOINCREMENT",
|
||||
definition: "CREATE TABLE entries (seq INTEGER PRIMARY KEY, id TEXT)",
|
||||
generated: "seq",
|
||||
},
|
||||
{
|
||||
name: "a quoted primary key identifier",
|
||||
definition:
|
||||
'CREATE TABLE entries ("sequence number" INTEGER PRIMARY KEY, id TEXT)',
|
||||
generated: "sequence number",
|
||||
},
|
||||
{
|
||||
name: "AUTOINCREMENT inside a default string",
|
||||
definition:
|
||||
"CREATE TABLE entries (ref INTEGER PRIMARY KEY,note TEXT DEFAULT 'use AUTOINCREMENT')",
|
||||
generated: "ref",
|
||||
},
|
||||
{
|
||||
name: "AUTOINCREMENT inside a default string on a WITHOUT ROWID table",
|
||||
definition:
|
||||
"CREATE TABLE entries (ref INTEGER PRIMARY KEY, note TEXT DEFAULT 'use AUTOINCREMENT') WITHOUT ROWID",
|
||||
generated: undefined,
|
||||
},
|
||||
{
|
||||
name: "an INTEGER PRIMARY KEY DESC column",
|
||||
definition:
|
||||
"CREATE TABLE entries (seq INTEGER PRIMARY KEY DESC, id TEXT)",
|
||||
generated: undefined,
|
||||
},
|
||||
{
|
||||
name: "a table-level PRIMARY KEY with DESC ordering",
|
||||
definition:
|
||||
"CREATE TABLE entries (seq INTEGER, id TEXT, PRIMARY KEY (seq DESC))",
|
||||
generated: "seq",
|
||||
},
|
||||
{
|
||||
name: "an INTEGER PRIMARY KEY on a WITHOUT ROWID table",
|
||||
definition:
|
||||
"CREATE TABLE entries (seq INTEGER PRIMARY KEY, id TEXT) WITHOUT ROWID",
|
||||
generated: undefined,
|
||||
},
|
||||
{
|
||||
name: "an INT PRIMARY KEY column",
|
||||
definition: "CREATE TABLE entries (seq INT PRIMARY KEY, id TEXT)",
|
||||
generated: undefined,
|
||||
},
|
||||
])("recognizes $name", async ({ definition, generated }, {
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
sqlite.exec(definition);
|
||||
const db = new Kysely({
|
||||
dialect: new NodeSqliteDialect({ database: sqlite }),
|
||||
});
|
||||
onTestFinished(() => db.destroy());
|
||||
|
||||
const tables = await db.introspection.getTables();
|
||||
const entries = tables.find((table) => table.name === "entries");
|
||||
expect(
|
||||
entries?.columns
|
||||
.filter((column) => column.isAutoIncrementing)
|
||||
.map((column) => column.name),
|
||||
).toEqual(generated ? [generated] : []);
|
||||
});
|
||||
|
||||
it("BunSqliteDialect recognizes an INTEGER PRIMARY KEY", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
sqlite.exec("CREATE TABLE entries (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
const db = new Kysely({
|
||||
dialect: new BunSqliteDialect({ database: asBunLikeDatabase(sqlite) }),
|
||||
});
|
||||
onTestFinished(() => db.destroy());
|
||||
|
||||
const tables = await db.introspection.getTables();
|
||||
const id = tables.find((table) => table.name === "entries")?.columns[0];
|
||||
expect(id?.isAutoIncrementing).toBe(true);
|
||||
});
|
||||
|
||||
/** @see https://github.com/better-auth/better-auth/issues/10551 */
|
||||
it("uses statement-form PRAGMA when dialect introspection is unavailable", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const sqlite = new DatabaseSync(":memory:");
|
||||
const generatedName = 'generated"name';
|
||||
sqlite.exec('CREATE TABLE "generated""name" (id INTEGER PRIMARY KEY)');
|
||||
sqlite.exec("CREATE TABLE descending (id INTEGER PRIMARY KEY DESC)");
|
||||
const prepare = vi.spyOn(sqlite, "prepare");
|
||||
const dialect = new NodeSqliteDialect({ database: sqlite });
|
||||
vi.spyOn(dialect, "createIntrospector").mockReturnValue({
|
||||
getMetadata: async () => {
|
||||
throw new Error("introspection unavailable");
|
||||
},
|
||||
getSchemas: async () => [],
|
||||
getTables: async () => {
|
||||
throw new Error("introspection unavailable");
|
||||
},
|
||||
});
|
||||
const db = new Kysely({ dialect });
|
||||
onTestFinished(() => db.destroy());
|
||||
|
||||
const tables = await introspectSqliteTables(db, [
|
||||
generatedName,
|
||||
"descending",
|
||||
]);
|
||||
expect(
|
||||
tables.map((table) => [table.name, table.columns[0]?.isAutoIncrementing]),
|
||||
).toEqual([
|
||||
[generatedName, true],
|
||||
["descending", false],
|
||||
]);
|
||||
const indexQueries = prepare.mock.calls
|
||||
.map(([query]) => query.trim())
|
||||
.filter((query) => query.includes("index_list"));
|
||||
expect(indexQueries).toHaveLength(2);
|
||||
expect(indexQueries).toEqual(
|
||||
expect.arrayContaining([
|
||||
'PRAGMA index_list("generated""name")',
|
||||
'PRAGMA index_list("descending")',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,30 +22,25 @@ export interface PragmaTableInfo {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PragmaIndexListRow {
|
||||
name: string;
|
||||
origin: "c" | "u" | "pk";
|
||||
partial: number;
|
||||
unique: number;
|
||||
}
|
||||
|
||||
interface SqliteTable {
|
||||
name: string;
|
||||
sql?: string | null | undefined;
|
||||
type?: string | undefined;
|
||||
}
|
||||
|
||||
interface SqliteSystemDatabase {
|
||||
sqlite_schema: {
|
||||
name: string;
|
||||
sql: string | null;
|
||||
type: "index" | "table" | "trigger" | "view";
|
||||
};
|
||||
}
|
||||
|
||||
function declaredAutoIncrementColumn(
|
||||
createSql: string | null | undefined,
|
||||
): string | undefined {
|
||||
return createSql
|
||||
?.split(/[(),]/)
|
||||
.find((part) => part.toLowerCase().includes("autoincrement"))
|
||||
?.split(/\s+/)[0]
|
||||
?.replace(/["`]/g, "");
|
||||
}
|
||||
|
||||
export function sqliteIntegerPrimaryKeyColumn(
|
||||
columns: readonly PragmaTableInfo[],
|
||||
): string | undefined {
|
||||
@@ -56,6 +51,19 @@ export function sqliteIntegerPrimaryKeyColumn(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function sqliteGeneratedPrimaryKeyColumn(
|
||||
db: Kysely<unknown>,
|
||||
name: string,
|
||||
columns: readonly PragmaTableInfo[],
|
||||
): Promise<string | undefined> {
|
||||
const column = sqliteIntegerPrimaryKeyColumn(columns);
|
||||
if (!column) return;
|
||||
const { rows } = await sql<PragmaIndexListRow>`
|
||||
PRAGMA index_list(${sql.id(name)})
|
||||
`.execute(db.withoutPlugins());
|
||||
return rows.some((index) => index.origin === "pk") ? undefined : column;
|
||||
}
|
||||
|
||||
export function toSqliteTableMetadata(
|
||||
table: SqliteTable,
|
||||
columns: readonly PragmaTableInfo[],
|
||||
@@ -82,11 +90,12 @@ async function readTableMetadata(
|
||||
PRAGMA table_info(${sql.id(name)})
|
||||
`.execute(db.withoutPlugins());
|
||||
if (columns.length === 0) return;
|
||||
return toSqliteTableMetadata(
|
||||
{ name },
|
||||
const generatedColumn = await sqliteGeneratedPrimaryKeyColumn(
|
||||
db,
|
||||
name,
|
||||
columns,
|
||||
sqliteIntegerPrimaryKeyColumn(columns),
|
||||
);
|
||||
return toSqliteTableMetadata({ name }, columns, generatedColumn);
|
||||
}
|
||||
|
||||
export async function introspectSqliteTables(
|
||||
@@ -132,22 +141,18 @@ export function createSqliteIntrospector(
|
||||
const tables = await query.execute();
|
||||
return Promise.all(
|
||||
tables.map(async ({ name }) => {
|
||||
const createTable = await systemDb
|
||||
.selectFrom("sqlite_schema")
|
||||
.where("name", "=", name)
|
||||
.select("sql")
|
||||
.executeTakeFirst();
|
||||
const columns = await db
|
||||
.selectFrom(
|
||||
sql<PragmaTableInfo>`pragma_table_info(${name})`.as("table_info"),
|
||||
)
|
||||
.select(["cid", "name", "type", "notnull", "dflt_value", "pk"])
|
||||
.execute();
|
||||
return toSqliteTableMetadata(
|
||||
{ name, sql: createTable?.sql },
|
||||
const generatedColumn = await sqliteGeneratedPrimaryKeyColumn(
|
||||
db,
|
||||
name,
|
||||
columns,
|
||||
declaredAutoIncrementColumn(createTable?.sql),
|
||||
);
|
||||
return toSqliteTableMetadata({ name }, columns, generatedColumn);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user