From 8e13850c9a57ceeaf9273888821b6b0e58a72a5d Mon Sep 17 00:00:00 2001 From: Jorg Date: Sun, 7 Jul 2024 12:48:59 +0200 Subject: [PATCH] feat: add options object where you can specify a schema feat: configure a schema for the tables add docs --- .../pages/getting-started/adapters/kysely.mdx | 6 + packages/adapter-kysely/src/index.ts | 22 +- packages/adapter-kysely/test/index.test.ts | 341 ++++++++++-------- 3 files changed, 223 insertions(+), 146 deletions(-) diff --git a/docs/pages/getting-started/adapters/kysely.mdx b/docs/pages/getting-started/adapters/kysely.mdx index f318bf182c..eebf04c7d6 100644 --- a/docs/pages/getting-started/adapters/kysely.mdx +++ b/docs/pages/getting-started/adapters/kysely.mdx @@ -254,6 +254,12 @@ export async function down(db: Kysely): Promise { For more information about creating and running migrations with Kysely, refer to the [Kysely migrations documentation](https://kysely.dev/docs/migrations). +If you'd prefer organising your tables under a separate schema you can initialise the Kysely adapter as follows: + +``` +KyselyAdapter(db, { schemaName: "auth" }) +``` + ### Naming conventions If mixed snake_case and camelCase column names is an issue for you and/or your underlying database system, we recommend using Kysely's `CamelCasePlugin` ([see the documentation here](https://kysely-org.github.io/kysely-apidoc/classes/CamelCasePlugin.html)) feature to change the field names. This won't affect NextAuth.js, but will allow you to have consistent casing when using Kysely. diff --git a/packages/adapter-kysely/src/index.ts b/packages/adapter-kysely/src/index.ts index e7023bcc7f..5c782961c5 100644 --- a/packages/adapter-kysely/src/index.ts +++ b/packages/adapter-kysely/src/index.ts @@ -33,6 +33,10 @@ export interface Database { VerificationToken: VerificationToken } +interface Options { + schemaName?: string +} + export const format = { from(object?: Record): T { const newObject: Record = {} @@ -51,14 +55,26 @@ export const format = { }, } -export function KyselyAdapter(db: Kysely): Adapter { - const { adapter } = db.getExecutor() - const { supportsReturning } = adapter +export function KyselyAdapter( + kyselyDb: Kysely, + options?: Options +): Adapter { + const { adapter } = kyselyDb.getExecutor() const isSqlite = adapter instanceof SqliteAdapter + if (options?.schemaName && isSqlite) { + console.warn( + 'SQLite does not support schemas. Ignoring "schemaName" option.' + ) + } + const { supportsReturning } = adapter /** If the database is SQLite, turn dates into an ISO string */ const to = isSqlite ? format.to : (x: T) => x as T /** If the database is SQLite, turn ISO strings into dates */ const from = isSqlite ? format.from : (x: T) => x as T + const db = + options?.schemaName && !isSqlite + ? kyselyDb.withSchema(options.schemaName) + : kyselyDb return { async createUser(data) { const user = { ...data, id: crypto.randomUUID() } diff --git a/packages/adapter-kysely/test/index.test.ts b/packages/adapter-kysely/test/index.test.ts index 40134e039e..0623ad65ea 100644 --- a/packages/adapter-kysely/test/index.test.ts +++ b/packages/adapter-kysely/test/index.test.ts @@ -2,6 +2,7 @@ import { describe } from "vitest" import { runBasicTests } from "utils/adapter" import { Pool } from "pg" import { + Dialect, MysqlDialect, PostgresDialect, SchemaModule, @@ -36,6 +37,170 @@ export function createTableWithId( } } +const withSchema = + (schemaName?: string) => + (db: KyselyAuth, isSqlite: boolean) => + schemaName && !isSqlite ? db.withSchema(schemaName) : db + +interface CreateDb { + schemaName?: string + db: KyselyAuth + dialect: Dialect +} +const createDb = ({ schemaName, db, dialect }: CreateDb) => { + const { adapter } = db.getExecutor() + const isSqlite = adapter instanceof SqliteAdapter + const isMysql = dialect instanceof MysqlDialect + + return { + async connect() { + const dialect = isSqlite ? "sqlite" : isMysql ? "mysql" : "postgres" + if (schemaName && dialect !== "sqlite") { + db.schema.createSchema(schemaName).ifNotExists().execute() + } + const schema = + schemaName && dialect !== "sqlite" + ? db.schema.withSchema(schemaName) + : db.schema + await Promise.all([ + schema.dropTable("Account").ifExists().execute(), + schema.dropTable("Session").ifExists().execute(), + schema.dropTable("User").ifExists().execute(), + schema.dropTable("VerificationToken").ifExists().execute(), + ]) + + const defaultTimestamp = { + postgres: sql`NOW()`, + mysql: sql`NOW(3)`, + sqlite: sql`CURRENT_TIMESTAMP`, + }[dialect] + const uuidColumnType = dialect === "mysql" ? "varchar(36)" : "uuid" + const dateColumnType = + dialect === "mysql" ? sql`DATETIME(3)` : "timestamptz" + const textColumnType = dialect === "mysql" ? "varchar(255)" : "text" + + await createTableWithId(schema, dialect, "User") + .addColumn("name", textColumnType) + .addColumn("email", textColumnType, (col) => col.unique().notNull()) + .addColumn("emailVerified", dateColumnType, (col) => + col.defaultTo(defaultTimestamp) + ) + .addColumn("image", textColumnType) + .execute() + + let createAccountTable = schema + .createTable("Account") + .addColumn("userId", uuidColumnType, (col) => + col.references("User.id").onDelete("cascade").notNull() + ) + .addColumn("type", textColumnType, (col) => col.notNull()) + .addColumn("provider", textColumnType, (col) => col.notNull()) + .addColumn("providerAccountId", textColumnType, (col) => col.notNull()) + .addColumn("refresh_token", textColumnType) + .addColumn("access_token", textColumnType) + .addColumn("expires_at", "bigint") + .addColumn("token_type", textColumnType) + .addColumn("scope", textColumnType) + .addColumn("id_token", textColumnType) + .addColumn("session_state", textColumnType) + if (dialect === "mysql") + createAccountTable = createAccountTable.addForeignKeyConstraint( + "Account_userId_fk", + ["userId"], + "User", + ["id"], + (cb) => cb.onDelete("cascade") + ) + await createAccountTable.execute() + + let createSessionTable = schema + .createTable("Session") + .addColumn("userId", uuidColumnType, (col) => + col.references("User.id").onDelete("cascade").notNull() + ) + .addColumn("sessionToken", textColumnType, (col) => + col.notNull().unique() + ) + .addColumn("expires", dateColumnType, (col) => col.notNull()) + + if (dialect === "mysql") + createSessionTable = createSessionTable.addForeignKeyConstraint( + "Session_userId_fk", + ["userId"], + "User", + ["id"], + (cb) => cb.onDelete("cascade") + ) + + await createSessionTable.execute() + + await schema + .createTable("VerificationToken") + .addColumn("identifier", textColumnType, (col) => col.notNull()) + .addColumn("token", textColumnType, (col) => col.notNull().unique()) + .addColumn("expires", dateColumnType, (col) => col.notNull()) + .execute() + + await schema + .createIndex("Account_userId_index") + .on("Account") + .column("userId") + .execute() + }, + async disconnect() { + await db.destroy() + }, + async user(userId) { + const _db = withSchema(schemaName)(db, isSqlite) + const user = await _db + .selectFrom("User") + .selectAll() + .where("id", "=", userId) + .executeTakeFirst() + if (isSqlite && user?.emailVerified) + user.emailVerified = new Date(user.emailVerified) + return user ?? null + }, + async account({ provider, providerAccountId }) { + const _db = withSchema(schemaName)(db, isSqlite) + const result = await _db + .selectFrom("Account") + .selectAll() + .where("provider", "=", provider) + .where("providerAccountId", "=", providerAccountId) + .executeTakeFirst() + if (!result) return null + const { ...account } = result + if (typeof account.expires_at === "string") + account.expires_at = Number(account.expires_at) + return account ?? null + }, + async session(sessionToken) { + const _db = withSchema(schemaName)(db, isSqlite) + const session = await _db + .selectFrom("Session") + .selectAll() + .where("sessionToken", "=", sessionToken) + .executeTakeFirst() + if (isSqlite && session?.expires) + session.expires = new Date(session.expires) + return session ?? null + }, + async verificationToken({ identifier, token }) { + const _db = withSchema(schemaName)(db, isSqlite) + const verificationToken = await _db + .selectFrom("VerificationToken") + .selectAll() + .where("identifier", "=", identifier) + .where("token", "=", token) + .executeTakeFirstOrThrow() + if (isSqlite) + verificationToken.expires = new Date(verificationToken.expires) + return verificationToken ?? null + }, + } +} + describe.each([ new PostgresDialect({ pool: new Pool({ @@ -63,150 +228,40 @@ describe.each([ }), ])("Testing %s dialect", (dialect) => { const db = new KyselyAuth({ dialect }) - - const { adapter } = db.getExecutor() - const isSqlite = adapter instanceof SqliteAdapter - const isMysql = dialect instanceof MysqlDialect - runBasicTests({ adapter: KyselyAdapter(db), - db: { - async connect() { - await Promise.all([ - db.schema.dropTable("Account").ifExists().execute(), - db.schema.dropTable("Session").ifExists().execute(), - db.schema.dropTable("User").ifExists().execute(), - db.schema.dropTable("VerificationToken").ifExists().execute(), - ]) - - const dialect = isSqlite ? "sqlite" : isMysql ? "mysql" : "postgres" - const defaultTimestamp = { - postgres: sql`NOW()`, - mysql: sql`NOW(3)`, - sqlite: sql`CURRENT_TIMESTAMP`, - }[dialect] - const uuidColumnType = dialect === "mysql" ? "varchar(36)" : "uuid" - const dateColumnType = - dialect === "mysql" ? sql`DATETIME(3)` : "timestamptz" - const textColumnType = dialect === "mysql" ? "varchar(255)" : "text" - - await createTableWithId(db.schema, dialect, "User") - .addColumn("name", textColumnType) - .addColumn("email", textColumnType, (col) => col.unique().notNull()) - .addColumn("emailVerified", dateColumnType, (col) => - col.defaultTo(defaultTimestamp) - ) - .addColumn("image", textColumnType) - .execute() - - let createAccountTable = db.schema - .createTable("Account") - .addColumn("userId", uuidColumnType, (col) => - col.references("User.id").onDelete("cascade").notNull() - ) - .addColumn("type", textColumnType, (col) => col.notNull()) - .addColumn("provider", textColumnType, (col) => col.notNull()) - .addColumn("providerAccountId", textColumnType, (col) => - col.notNull() - ) - .addColumn("refresh_token", textColumnType) - .addColumn("access_token", textColumnType) - .addColumn("expires_at", "bigint") - .addColumn("token_type", textColumnType) - .addColumn("scope", textColumnType) - .addColumn("id_token", textColumnType) - .addColumn("session_state", textColumnType) - if (dialect === "mysql") - createAccountTable = createAccountTable.addForeignKeyConstraint( - "Account_userId_fk", - ["userId"], - "User", - ["id"], - (cb) => cb.onDelete("cascade") - ) - await createAccountTable.execute() - - let createSessionTable = db.schema - .createTable("Session") - .addColumn("userId", uuidColumnType, (col) => - col.references("User.id").onDelete("cascade").notNull() - ) - .addColumn("sessionToken", textColumnType, (col) => - col.notNull().unique() - ) - .addColumn("expires", dateColumnType, (col) => col.notNull()) - - if (dialect === "mysql") - createSessionTable = createSessionTable.addForeignKeyConstraint( - "Session_userId_fk", - ["userId"], - "User", - ["id"], - (cb) => cb.onDelete("cascade") - ) - - await createSessionTable.execute() - - await db.schema - .createTable("VerificationToken") - .addColumn("identifier", textColumnType, (col) => col.notNull()) - .addColumn("token", textColumnType, (col) => col.notNull().unique()) - .addColumn("expires", dateColumnType, (col) => col.notNull()) - .execute() - - await db.schema - .createIndex("Account_userId_index") - .on("Account") - .column("userId") - .execute() - }, - async disconnect() { - await db.destroy() - }, - async user(userId) { - const user = await db - .selectFrom("User") - .selectAll() - .where("id", "=", userId) - .executeTakeFirst() - if (isSqlite && user?.emailVerified) - user.emailVerified = new Date(user.emailVerified) - return user ?? null - }, - async account({ provider, providerAccountId }) { - const result = await db - .selectFrom("Account") - .selectAll() - .where("provider", "=", provider) - .where("providerAccountId", "=", providerAccountId) - .executeTakeFirst() - if (!result) return null - const { ...account } = result - if (typeof account.expires_at === "string") - account.expires_at = Number(account.expires_at) - return account ?? null - }, - async session(sessionToken) { - const session = await db - .selectFrom("Session") - .selectAll() - .where("sessionToken", "=", sessionToken) - .executeTakeFirst() - if (isSqlite && session?.expires) - session.expires = new Date(session.expires) - return session ?? null - }, - async verificationToken({ identifier, token }) { - const verificationToken = await db - .selectFrom("VerificationToken") - .selectAll() - .where("identifier", "=", identifier) - .where("token", "=", token) - .executeTakeFirstOrThrow() - if (isSqlite) - verificationToken.expires = new Date(verificationToken.expires) - return verificationToken ?? null - }, - }, + db: createDb({ db, dialect }), + }) +}) +describe.each([ + new PostgresDialect({ + pool: new Pool({ + host: "localhost", + database: "kysely_test", + user: "kysely", + port: 5434, + max: 20, + }), + }), + new MysqlDialect({ + pool: createPool({ + database: "kysely_test", + host: "localhost", + user: "kysely", + password: "kysely", + port: 3308, + supportBigNumbers: true, + bigNumberStrings: true, + connectionLimit: 20, + }), + }), + new SqliteDialect({ + database: async () => new SqliteDatabase(":memory:"), + }), +])("Testing %s dialect", (dialect) => { + const db = new KyselyAuth({ dialect }) + runBasicTests({ + adapter: KyselyAdapter(db, { schemaName: "testSchema" }), + db: createDb({ schemaName: "testSchema", db, dialect }), }) })