mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-23 07:18:56 -05:00
feat(adapters): add Bun SQL adapter for PostgreSQL
Add a native Bun SQL adapter using Bun's built-in PostgreSQL driver (`bun:sql`). Features: - Full CRUD operations (create, findOne, findMany, update, updateMany, delete, deleteMany, count) - Transaction support with automatic rollback on error - Support for all WHERE operators (eq, ne, gt, gte, lt, lte, in, not_in, contains, starts_with, ends_with) - Support for numeric IDs (useNumberId option) - Support for JSON, dates, and booleans Test results: - 70/70 main tests passed - 60/60 number-id tests passed Also fixes a bug in create-test-suite.ts where transaction function was incorrectly referenced, causing transaction tests to fail on subsequent calls.
This commit is contained in:
@@ -87,6 +87,17 @@ services:
|
||||
volumes:
|
||||
- mysql-prisma_data:/var/lib/mysql
|
||||
|
||||
postgres-bun-sql:
|
||||
image: postgres:latest
|
||||
container_name: postgres-bun-sql
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: better_auth
|
||||
ports:
|
||||
- "5435:5432"
|
||||
volumes:
|
||||
- postgres-bun-sql_data:/var/lib/postgresql
|
||||
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:latest
|
||||
@@ -104,7 +115,8 @@ volumes:
|
||||
postgres_data:
|
||||
postgres-kysely_data:
|
||||
postgres-prisma_data:
|
||||
postgres-bun-sql_data:
|
||||
mysql_data:
|
||||
mssql_data:
|
||||
mysql-kysely_data:
|
||||
mysql-prisma_data:
|
||||
mysql-prisma_data:
|
||||
@@ -317,6 +317,17 @@
|
||||
"default": "./dist/adapters/memory-adapter/index.cjs"
|
||||
}
|
||||
},
|
||||
"./adapters/bun-sql": {
|
||||
"better-auth-dev-source": "./src/adapters/bun-sql-adapter/index.ts",
|
||||
"import": {
|
||||
"types": "./dist/adapters/bun-sql-adapter/index.d.ts",
|
||||
"default": "./dist/adapters/bun-sql-adapter/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/bun-sql-adapter/index.d.cts",
|
||||
"default": "./dist/adapters/bun-sql-adapter/index.cjs"
|
||||
}
|
||||
},
|
||||
"./adapters/test": {
|
||||
"better-auth-dev-source": "./src/adapters/test.ts",
|
||||
"import": {
|
||||
@@ -670,6 +681,9 @@
|
||||
"adapters/memory": [
|
||||
"./dist/adapters/memory-adapter/index.d.ts"
|
||||
],
|
||||
"adapters/bun-sql": [
|
||||
"./dist/adapters/bun-sql-adapter/index.d.ts"
|
||||
],
|
||||
"plugins": [
|
||||
"./dist/plugins/index.d.ts"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import type {
|
||||
CleanedWhere,
|
||||
DBAdapter,
|
||||
DBAdapterDebugLogOption,
|
||||
} from "@better-auth/core/db/adapter";
|
||||
import type { SQL } from "bun";
|
||||
import {
|
||||
type AdapterFactoryCustomizeAdapterCreator,
|
||||
type AdapterFactoryOptions,
|
||||
createAdapterFactory,
|
||||
} from "../adapter-factory";
|
||||
|
||||
export interface BunSqlAdapterConfig {
|
||||
/**
|
||||
* The Bun SQL instance to use for database operations.
|
||||
*/
|
||||
sql: SQL;
|
||||
/**
|
||||
* Enable debug logs for the adapter
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
debugLogs?: DBAdapterDebugLogOption;
|
||||
/**
|
||||
* Use plural for table names.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
usePlural?: boolean;
|
||||
/**
|
||||
* Whether to execute multiple operations in a transaction.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
transaction?: boolean;
|
||||
}
|
||||
|
||||
export const bunSqlAdapter = (config: BunSqlAdapterConfig) => {
|
||||
const { sql } = config;
|
||||
let lazyOptions: BetterAuthOptions | null = null;
|
||||
|
||||
const createCustomAdapter = (
|
||||
sqlInstance: SQL,
|
||||
): AdapterFactoryCustomizeAdapterCreator => {
|
||||
return ({ getFieldName }) => {
|
||||
function buildWhereClause(
|
||||
model: string,
|
||||
where: CleanedWhere[] | undefined,
|
||||
startIdx: number,
|
||||
): { whereClause: string; whereValues: unknown[] } {
|
||||
if (!where || where.length === 0) {
|
||||
return { whereClause: "", whereValues: [] };
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = startIdx;
|
||||
|
||||
for (let i = 0; i < where.length; i++) {
|
||||
const condition = where[i]!;
|
||||
const { field: _field, value, operator = "eq", connector } = condition;
|
||||
const field = getFieldName({ model, field: _field });
|
||||
|
||||
let conditionStr = "";
|
||||
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
if (value === null) {
|
||||
conditionStr = `"${field}" IS NULL`;
|
||||
} else {
|
||||
conditionStr = `"${field}" = $${idx++}`;
|
||||
values.push(value);
|
||||
}
|
||||
break;
|
||||
case "ne":
|
||||
if (value === null) {
|
||||
conditionStr = `"${field}" IS NOT NULL`;
|
||||
} else {
|
||||
conditionStr = `"${field}" != $${idx++}`;
|
||||
values.push(value);
|
||||
}
|
||||
break;
|
||||
case "gt":
|
||||
conditionStr = `"${field}" > $${idx++}`;
|
||||
values.push(value);
|
||||
break;
|
||||
case "gte":
|
||||
conditionStr = `"${field}" >= $${idx++}`;
|
||||
values.push(value);
|
||||
break;
|
||||
case "lt":
|
||||
conditionStr = `"${field}" < $${idx++}`;
|
||||
values.push(value);
|
||||
break;
|
||||
case "lte":
|
||||
conditionStr = `"${field}" <= $${idx++}`;
|
||||
values.push(value);
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
// Empty array: nothing can match, return no rows
|
||||
conditionStr = "FALSE";
|
||||
} else {
|
||||
const inPlaceholders = value
|
||||
.map(() => `$${idx++}`)
|
||||
.join(", ");
|
||||
conditionStr = `"${field}" IN (${inPlaceholders})`;
|
||||
values.push(...value);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "not_in":
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
// Empty array: nothing to exclude, match all (skip condition)
|
||||
} else {
|
||||
const notInPlaceholders = value
|
||||
.map(() => `$${idx++}`)
|
||||
.join(", ");
|
||||
conditionStr = `"${field}" NOT IN (${notInPlaceholders})`;
|
||||
values.push(...value);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "contains":
|
||||
conditionStr = `"${field}" LIKE $${idx++}`;
|
||||
values.push(`%${value}%`);
|
||||
break;
|
||||
case "starts_with":
|
||||
conditionStr = `"${field}" LIKE $${idx++}`;
|
||||
values.push(`${value}%`);
|
||||
break;
|
||||
case "ends_with":
|
||||
conditionStr = `"${field}" LIKE $${idx++}`;
|
||||
values.push(`%${value}`);
|
||||
break;
|
||||
default:
|
||||
conditionStr = `"${field}" = $${idx++}`;
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
if (conditionStr) {
|
||||
if (i === 0) {
|
||||
conditions.push(conditionStr);
|
||||
} else {
|
||||
const logicalOp = connector === "OR" ? "OR" : "AND";
|
||||
conditions.push(`${logicalOp} ${conditionStr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause:
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" ")}` : "",
|
||||
whereValues: values,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async create({ model, data }) {
|
||||
const record = data as Record<string, unknown>;
|
||||
const columns = Object.keys(record);
|
||||
const values = Object.values(record);
|
||||
const placeholders = columns.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
const result = await sqlInstance.unsafe(
|
||||
`INSERT INTO "${model}" (${columns.map((c) => `"${c}"`).join(", ")})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return result[0];
|
||||
},
|
||||
|
||||
async findOne({ model, where }) {
|
||||
const { whereClause, whereValues } = buildWhereClause(model, where, 1);
|
||||
const result = await sqlInstance.unsafe(
|
||||
`SELECT * FROM "${model}" ${whereClause} LIMIT 1`,
|
||||
whereValues,
|
||||
);
|
||||
return result[0] ?? null;
|
||||
},
|
||||
|
||||
async findMany({ model, where, limit, sortBy, offset }) {
|
||||
const { whereClause, whereValues } = buildWhereClause(model, where, 1);
|
||||
let query = `SELECT * FROM "${model}" ${whereClause}`;
|
||||
|
||||
if (sortBy) {
|
||||
const field = getFieldName({ model, field: sortBy.field });
|
||||
const dir = sortBy.direction === "desc" ? "DESC" : "ASC";
|
||||
query += ` ORDER BY "${field}" ${dir}`;
|
||||
}
|
||||
if (limit) query += ` LIMIT ${limit}`;
|
||||
if (offset) query += ` OFFSET ${offset}`;
|
||||
|
||||
return await sqlInstance.unsafe(query, whereValues);
|
||||
},
|
||||
|
||||
async update({ model, where, update }) {
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(
|
||||
update as Record<string, unknown>,
|
||||
)) {
|
||||
const field = getFieldName({ model, field: key });
|
||||
setClauses.push(`"${field}" = $${idx++}`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
const { whereClause, whereValues } = buildWhereClause(
|
||||
model,
|
||||
where,
|
||||
idx,
|
||||
);
|
||||
values.push(...whereValues);
|
||||
|
||||
const result = await sqlInstance.unsafe(
|
||||
`UPDATE "${model}" SET ${setClauses.join(", ")} ${whereClause} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return result[0] ?? null;
|
||||
},
|
||||
|
||||
async updateMany({ model, where, update }) {
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(
|
||||
update as Record<string, unknown>,
|
||||
)) {
|
||||
const field = getFieldName({ model, field: key });
|
||||
setClauses.push(`"${field}" = $${idx++}`);
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
const { whereClause, whereValues } = buildWhereClause(
|
||||
model,
|
||||
where,
|
||||
idx,
|
||||
);
|
||||
values.push(...whereValues);
|
||||
|
||||
const result = await sqlInstance.unsafe(
|
||||
`UPDATE "${model}" SET ${setClauses.join(", ")} ${whereClause}`,
|
||||
values,
|
||||
);
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async delete({ model, where }) {
|
||||
const { whereClause, whereValues } = buildWhereClause(model, where, 1);
|
||||
await sqlInstance.unsafe(
|
||||
`DELETE FROM "${model}" ${whereClause}`,
|
||||
whereValues,
|
||||
);
|
||||
},
|
||||
|
||||
async deleteMany({ model, where }) {
|
||||
const { whereClause, whereValues } = buildWhereClause(model, where, 1);
|
||||
const result = await sqlInstance.unsafe(
|
||||
`DELETE FROM "${model}" ${whereClause}`,
|
||||
whereValues,
|
||||
);
|
||||
return result.count;
|
||||
},
|
||||
|
||||
async count({ model, where }) {
|
||||
const { whereClause, whereValues } = buildWhereClause(model, where, 1);
|
||||
const result = await sqlInstance.unsafe(
|
||||
`SELECT COUNT(*)::int as count FROM "${model}" ${whereClause}`,
|
||||
whereValues,
|
||||
);
|
||||
return result[0]?.count ?? 0;
|
||||
},
|
||||
|
||||
options: config,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
let adapterOptions: AdapterFactoryOptions | null = null;
|
||||
|
||||
adapterOptions = {
|
||||
config: {
|
||||
adapterId: "bun-sql",
|
||||
adapterName: "Bun SQL Adapter",
|
||||
usePlural: config.usePlural,
|
||||
debugLogs: config.debugLogs,
|
||||
supportsJSON: true,
|
||||
supportsDates: true,
|
||||
supportsBooleans: true,
|
||||
supportsNumericIds: true,
|
||||
transaction: config.transaction
|
||||
? (cb) =>
|
||||
sql.begin(async (trx) => {
|
||||
const txAdapter = createCustomAdapter(trx as unknown as SQL);
|
||||
const adapter = createAdapterFactory({
|
||||
config: { ...adapterOptions!.config, transaction: false },
|
||||
adapter: txAdapter,
|
||||
})(lazyOptions!);
|
||||
return await cb(adapter);
|
||||
})
|
||||
: false,
|
||||
},
|
||||
adapter: createCustomAdapter(sql),
|
||||
};
|
||||
|
||||
const adapter = createAdapterFactory(adapterOptions);
|
||||
|
||||
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
|
||||
lazyOptions = options;
|
||||
return adapter(options);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { bunSqlAdapter, type BunSqlAdapterConfig } from "./bun-sql-adapter";
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import { SQL } from "bun";
|
||||
import { Pool } from "pg";
|
||||
import { getMigrations } from "../../../db";
|
||||
import { testAdapter } from "../../test-adapter";
|
||||
import { numberIdTestSuite } from "../../tests";
|
||||
import { bunSqlAdapter } from "../bun-sql-adapter";
|
||||
|
||||
// Use pg Pool for migrations (getMigrations uses Kysely internally)
|
||||
const pgPool = new Pool({
|
||||
connectionString: "postgres://user:password@localhost:5435/better_auth",
|
||||
});
|
||||
|
||||
// Use Bun SQL for the actual adapter operations
|
||||
// prepare: false disables prepared statement caching which causes
|
||||
// "cached plan must not change result type" errors when schema changes mid-test
|
||||
const bunSql = new SQL({
|
||||
hostname: "localhost",
|
||||
port: 5435,
|
||||
database: "better_auth",
|
||||
username: "user",
|
||||
password: "password",
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
const cleanupDatabase = async () => {
|
||||
await pgPool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
|
||||
};
|
||||
|
||||
const { execute } = await testAdapter({
|
||||
adapter: () =>
|
||||
bunSqlAdapter({
|
||||
sql: bunSql,
|
||||
debugLogs: { isRunningAdapterTests: true },
|
||||
}),
|
||||
prefixTests: "pg",
|
||||
async runMigrations(betterAuthOptions) {
|
||||
await cleanupDatabase();
|
||||
const opts = Object.assign(betterAuthOptions, {
|
||||
database: pgPool,
|
||||
} satisfies BetterAuthOptions);
|
||||
const { runMigrations } = await getMigrations(opts);
|
||||
await runMigrations();
|
||||
},
|
||||
tests: [numberIdTestSuite()],
|
||||
async onFinish() {
|
||||
await pgPool.end();
|
||||
bunSql.close();
|
||||
},
|
||||
});
|
||||
|
||||
execute();
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { BetterAuthOptions } from "@better-auth/core";
|
||||
import { SQL } from "bun";
|
||||
import { Pool } from "pg";
|
||||
import { getMigrations } from "../../../db";
|
||||
import { testAdapter } from "../../test-adapter";
|
||||
import {
|
||||
authFlowTestSuite,
|
||||
normalTestSuite,
|
||||
numberIdTestSuite,
|
||||
performanceTestSuite,
|
||||
transactionsTestSuite,
|
||||
} from "../../tests";
|
||||
import { bunSqlAdapter } from "../bun-sql-adapter";
|
||||
|
||||
// Use pg Pool for migrations (getMigrations uses Kysely internally)
|
||||
const pgPool = new Pool({
|
||||
connectionString: "postgres://user:password@localhost:5435/better_auth",
|
||||
});
|
||||
|
||||
// Use Bun SQL for the actual adapter operations
|
||||
// prepare: false disables prepared statement caching which causes
|
||||
// "cached plan must not change result type" errors when schema changes mid-test
|
||||
const bunSql = new SQL({
|
||||
hostname: "localhost",
|
||||
port: 5435,
|
||||
database: "better_auth",
|
||||
username: "user",
|
||||
password: "password",
|
||||
prepare: false,
|
||||
});
|
||||
|
||||
const cleanupDatabase = async () => {
|
||||
await pgPool.query(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
|
||||
};
|
||||
|
||||
const { execute } = await testAdapter({
|
||||
adapter: () =>
|
||||
bunSqlAdapter({
|
||||
sql: bunSql,
|
||||
debugLogs: { isRunningAdapterTests: true },
|
||||
transaction: true,
|
||||
}),
|
||||
prefixTests: "pg",
|
||||
async runMigrations(betterAuthOptions) {
|
||||
await cleanupDatabase();
|
||||
const opts = Object.assign(betterAuthOptions, {
|
||||
database: pgPool,
|
||||
} satisfies BetterAuthOptions);
|
||||
const { runMigrations } = await getMigrations(opts);
|
||||
await runMigrations();
|
||||
},
|
||||
tests: [
|
||||
normalTestSuite(),
|
||||
transactionsTestSuite(),
|
||||
authFlowTestSuite(),
|
||||
// number-id tests are in a separate file (adapter.bun-sql.pg.number-id.test.ts)
|
||||
// to avoid Bun SQL's prepared statement cache conflicts when schema changes
|
||||
performanceTestSuite({ dialect: "pg" }),
|
||||
],
|
||||
async onFinish() {
|
||||
await pgPool.end();
|
||||
bunSql.close();
|
||||
},
|
||||
});
|
||||
|
||||
execute();
|
||||
@@ -181,7 +181,9 @@ export const createTestSuite = <
|
||||
createAdapterFactory({
|
||||
config: {
|
||||
...adapterConfig,
|
||||
transaction: adapter.transaction,
|
||||
// Use adapterConfig.transaction instead of adapter.transaction
|
||||
// because adapter.transaction gets set to undefined after first call
|
||||
transaction: adapter.options?.adapterConfig.transaction,
|
||||
},
|
||||
adapter: ({ getDefaultModelName }) => {
|
||||
adapter.transaction = undefined as any;
|
||||
|
||||
Reference in New Issue
Block a user