From f5bbb96d4e56dcb8f9f2dd8d3ef4bd885ebee882 Mon Sep 17 00:00:00 2001
From: Maxwell <145994855+ping-maxwell@users.noreply.github.com>
Date: Sun, 16 Nov 2025 02:55:50 +1000
Subject: [PATCH] feat: adapter join support (#5730)
---
.github/workflows/adapter-tests.yml | 67 +
.github/workflows/ci.yml | 2 +-
docker-compose.yml | 15 +-
docs/content/docs/adapters/drizzle.mdx | 23 +
docs/content/docs/adapters/mongo.mdx | 16 +
docs/content/docs/adapters/mssql.mdx | 18 +
docs/content/docs/adapters/mysql.mdx | 19 +
docs/content/docs/adapters/postgresql.mdx | 18 +
docs/content/docs/adapters/prisma.mdx | 21 +
docs/content/docs/adapters/sqlite.mdx | 18 +
docs/content/docs/concepts/database.mdx | 31 +
.../docs/guides/create-a-db-adapter.mdx | 77 +-
docs/content/docs/reference/options.mdx | 5 +-
packages/better-auth/package.json | 3 +-
.../adapter-factory/get-default-field-name.ts | 63 +
.../adapter-factory/get-default-model-name.ts | 55 +
.../adapter-factory/get-field-attributes.ts | 62 +
.../adapter-factory/get-field-name.ts | 43 +
.../adapters/adapter-factory/get-id-field.ts | 150 ++
.../adapter-factory/get-model-name.ts | 36 +
.../src/adapters/adapter-factory/index.ts | 804 ++++++-----
.../test/adapter-factory.test.ts | 352 +++++
.../src/adapters/adapter-factory/types.ts | 2 +
.../src/adapters/create-test-suite.ts | 16 +-
.../drizzle-adapter/drizzle-adapter.ts | 135 +-
.../test/adapter.drizzle.mysql.test.ts | 6 +-
.../test/adapter.drizzle.pg.test.ts | 4 +-
.../test/adapter.drizzle.sqlite.test.ts | 4 +-
packages/better-auth/src/adapters/index.ts | 17 +-
.../adapters/kysely-adapter/kysely-adapter.ts | 381 ++++-
.../adapter.kysely.custom-schema-pg.test.ts | 6 +-
.../test/adapter.kysely.mssql.test.ts | 172 ++-
.../test/adapter.kysely.mysql.test.ts | 2 +
.../test/adapter.kysely.pg.test.ts | 4 +-
.../test/adapter.kysely.sqlite.test.ts | 6 +-
.../memory-adapter/adapter.memory.test.ts | 2 +
.../adapters/memory-adapter/memory-adapter.ts | 327 +++--
.../mongodb-adapter/adapter.mongo-db.test.ts | 2 +
.../mongodb-adapter/mongodb-adapter.ts | 280 +++-
.../adapters/prisma-adapter/prisma-adapter.ts | 195 ++-
.../prisma-adapter/test/prisma.mysql.test.ts | 2 +
.../prisma-adapter/test/prisma.pg.test.ts | 2 +
.../prisma-adapter/test/prisma.sqlite.test.ts | 6 +-
.../better-auth/src/adapters/test-adapter.ts | 4 +-
.../adapters/tests/{normal.ts => basic.ts} | 1228 ++++++++++++++++-
.../better-auth/src/adapters/tests/index.ts | 4 +-
.../better-auth/src/adapters/tests/joins.ts | 28 +
.../src/adapters/tests/number-id.ts | 2 +-
.../src/adapters/tests/performance.ts | 157 ---
.../better-auth/src/adapters/tests/uuid.ts | 2 +-
packages/better-auth/src/adapters/utils.ts | 2 +-
.../context/__snapshots__/base.test.ts.snap | 1 +
.../__snapshots__/create-context.test.ts.snap | 1 +
packages/better-auth/src/db/get-migration.ts | 39 +-
.../src/db/internal-adapter.test.ts | 2 +-
.../generic-oauth/generic-oauth.test.ts | 1 -
.../better-auth/vitest.config.adapters.ts | 13 +
packages/better-auth/vitest.config.ts | 6 +
packages/cli/src/generators/drizzle.ts | 135 +-
packages/cli/src/generators/prisma.ts | 55 +-
.../__snapshots__/auth-schema-mysql-enum.txt | 20 +
.../auth-schema-mysql-number-id.txt | 28 +
.../auth-schema-mysql-passkey-number-id.txt | 28 +
.../auth-schema-mysql-passkey.txt | 28 +
.../__snapshots__/auth-schema-mysql-uuid.txt | 28 +
.../test/__snapshots__/auth-schema-mysql.txt | 28 +
.../__snapshots__/auth-schema-number-id.txt | 28 +
.../__snapshots__/auth-schema-pg-enum.txt | 20 +
.../__snapshots__/auth-schema-pg-passkey.txt | 28 +
.../__snapshots__/auth-schema-pg-uuid.txt | 29 +-
.../__snapshots__/auth-schema-sqlite-enum.txt | 21 +-
.../auth-schema-sqlite-number-id.txt | 29 +-
.../auth-schema-sqlite-passkey-number-id.txt | 29 +-
.../auth-schema-sqlite-passkey.txt | 29 +-
.../__snapshots__/auth-schema-sqlite-uuid.txt | 29 +-
.../test/__snapshots__/auth-schema-sqlite.txt | 29 +-
.../cli/test/__snapshots__/auth-schema.txt | 28 +
.../cli/test/__snapshots__/schema-uuid.prisma | 10 +-
packages/core/src/db/adapter/index.ts | 64 +-
packages/core/src/types/init-options.ts | 14 +
pnpm-lock.yaml | 33 +-
81 files changed, 4750 insertions(+), 979 deletions(-)
create mode 100644 .github/workflows/adapter-tests.yml
create mode 100644 packages/better-auth/src/adapters/adapter-factory/get-default-field-name.ts
create mode 100644 packages/better-auth/src/adapters/adapter-factory/get-default-model-name.ts
create mode 100644 packages/better-auth/src/adapters/adapter-factory/get-field-attributes.ts
create mode 100644 packages/better-auth/src/adapters/adapter-factory/get-field-name.ts
create mode 100644 packages/better-auth/src/adapters/adapter-factory/get-id-field.ts
create mode 100644 packages/better-auth/src/adapters/adapter-factory/get-model-name.ts
rename packages/better-auth/src/adapters/tests/{normal.ts => basic.ts} (57%)
create mode 100644 packages/better-auth/src/adapters/tests/joins.ts
delete mode 100644 packages/better-auth/src/adapters/tests/performance.ts
create mode 100644 packages/better-auth/vitest.config.adapters.ts
diff --git a/.github/workflows/adapter-tests.yml b/.github/workflows/adapter-tests.yml
new file mode 100644
index 0000000000..55b61c1547
--- /dev/null
+++ b/.github/workflows/adapter-tests.yml
@@ -0,0 +1,67 @@
+name: Adapter Tests
+
+on:
+ pull_request:
+ types:
+ - opened
+ - synchronize
+ push:
+ branches:
+ - main
+ - canary
+ merge_group:
+
+jobs:
+ test-adapters:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ node-version: [22.x, 24.x, 25.x]
+ steps:
+ - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ fetch-depth: 0
+
+ - name: Cache turbo build setup
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: .turbo
+ key: ${{ runner.os }}-turbo-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-turbo-
+
+ - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ registry-url: 'https://registry.npmjs.org'
+ cache: pnpm
+
+ - name: Install
+ run: pnpm install
+
+ - name: Build
+ env:
+ TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
+ TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
+ TURBO_CACHE: remote:rw
+ run: pnpm build
+
+ - name: Start Docker Containers
+ run: |
+ docker compose up -d
+ # Wait for services to be ready
+ sleep 10
+
+ - name: Test Adapters
+ working-directory: packages/better-auth
+ env:
+ TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
+ TURBO_TEAM: ${{ vars.TURBO_TEAM || github.repository_owner }}
+ run: pnpm test:adapters
+
+ - name: Stop Docker Containers
+ run: docker compose down
+
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 73b835322a..a5ac4fe7a7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -143,4 +143,4 @@ jobs:
- name: Stop Docker Containers
run: docker compose down
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index f29df64bf0..030d93785f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -33,6 +33,18 @@ services:
- "5433:5432"
volumes:
- postgres-kysely_data:/var/lib/postgresql
+
+ postgres-kysely2:
+ image: postgres:latest
+ container_name: postgres-kysely2
+ environment:
+ POSTGRES_USER: user
+ POSTGRES_PASSWORD: password
+ POSTGRES_DB: better_auth
+ ports:
+ - "5435:5432"
+ volumes:
+ - postgres-kysely2_data:/var/lib/postgresql
postgres-prisma:
image: postgres:latest
@@ -107,4 +119,5 @@ volumes:
mysql_data:
mssql_data:
mysql-kysely_data:
- mysql-prisma_data:
\ No newline at end of file
+ mysql-prisma_data:
+ postgres-kysely2_data:
diff --git a/docs/content/docs/adapters/drizzle.mdx b/docs/content/docs/adapters/drizzle.mdx
index 4ee6b80400..c6e78a1c2f 100644
--- a/docs/content/docs/adapters/drizzle.mdx
+++ b/docs/content/docs/adapters/drizzle.mdx
@@ -43,6 +43,29 @@ npx drizzle-kit generate # generate the migration file
npx drizzle-kit migrate # apply the migration
```
+
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The Drizzle adapter supports joins out of the box since version `1.4.0`.
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+ Please make sure that your Drizzle schema has the necessary relations defined.
+ If you do not see any relations in your Drizzle schema, you can manually add them using the [`relation`](https://orm.drizzle.team/docs/relations) drizzle-orm function
+ or run our latest CLI version `npx auth@latest generate` to generate a new Drizzle schema with the relations.
+
+ Additionally, you're required to pass each [relation](https://orm.drizzle.team/docs/relations) through the drizzle adapter schema object.
+
+
## Modifying Table Names
The Drizzle adapter expects the schema you define to match the table names. For example, if your Drizzle schema maps the `user` table to `users`, you need to manually pass the schema and map it to the user table.
diff --git a/docs/content/docs/adapters/mongo.mdx b/docs/content/docs/adapters/mongo.mdx
index 1aa0673e56..178959d544 100644
--- a/docs/content/docs/adapters/mongo.mdx
+++ b/docs/content/docs/adapters/mongo.mdx
@@ -30,3 +30,19 @@ export const auth = betterAuth({
## Schema generation & migration
For MongoDB, we don't need to generate or migrate the schema.
+
+
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The MongoDB adapter supports joins out of the box since version `1.4.0`.
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
diff --git a/docs/content/docs/adapters/mssql.mdx b/docs/content/docs/adapters/mssql.mdx
index c096cb898c..f5453d66fa 100644
--- a/docs/content/docs/adapters/mssql.mdx
+++ b/docs/content/docs/adapters/mssql.mdx
@@ -94,6 +94,24 @@ npx @better-auth/cli@latest generate
npx @better-auth/cli@latest migrate
```
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The Kysely MS SQL dialect supports joins out of the box since version `1.4.0`.
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+ It's possible that you may need to run migrations after enabling this feature.
+
+
## Additional Information
MS SQL is supported under the hood via the [Kysely](https://kysely.dev/) adapter, any database supported by Kysely would also be supported. (Read more here)
diff --git a/docs/content/docs/adapters/mysql.mdx b/docs/content/docs/adapters/mysql.mdx
index b16851e8f8..ebb4d20746 100644
--- a/docs/content/docs/adapters/mysql.mdx
+++ b/docs/content/docs/adapters/mysql.mdx
@@ -63,6 +63,25 @@ npx @better-auth/cli@latest generate
npx @better-auth/cli@latest migrate
```
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The Kysely MySQL dialect supports joins out of the box since version `1.4.0`.
+
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+ It's possible that you may need to run migrations after enabling this feature.
+
+
## Additional Information
MySQL is supported under the hood via the [Kysely](https://kysely.dev/) adapter, any database supported by Kysely would also be supported. (Read more here)
diff --git a/docs/content/docs/adapters/postgresql.mdx b/docs/content/docs/adapters/postgresql.mdx
index 4c4d222e07..c2313f5c5d 100644
--- a/docs/content/docs/adapters/postgresql.mdx
+++ b/docs/content/docs/adapters/postgresql.mdx
@@ -59,6 +59,24 @@ npx @better-auth/cli@latest generate
npx @better-auth/cli@latest migrate
```
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The Kysely PostgreSQL dialect supports joins out of the box since version `1.4.0`.
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+ It's possible that you may need to run migrations after enabling this feature.
+
+
## Use a non-default schema
In most cases, the default schema is `public`. To have Better Auth use a
diff --git a/docs/content/docs/adapters/prisma.mdx b/docs/content/docs/adapters/prisma.mdx
index 9d615881f4..24dad9be58 100644
--- a/docs/content/docs/adapters/prisma.mdx
+++ b/docs/content/docs/adapters/prisma.mdx
@@ -57,6 +57,27 @@ your database schema based on your Better Auth configuration and plugins.
npx @better-auth/cli@latest generate
```
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The Prisma adapter supports joins out of the box since version `1.4.0`.
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+
+ Please make sure that your Prisma schema has the necessary relations defined.
+ If you do not see any relations in your Prisma schema, you can manually add them using the `@relation` directive
+ or run our latest CLI version `npx auth@latest generate` to generate a new Prisma schema with the relations.
+
+
## Additional Information
If you're looking for performance improvements or tips, take a look at our guide to performance optimizations.
diff --git a/docs/content/docs/adapters/sqlite.mdx b/docs/content/docs/adapters/sqlite.mdx
index 70241a2937..184a38400f 100644
--- a/docs/content/docs/adapters/sqlite.mdx
+++ b/docs/content/docs/adapters/sqlite.mdx
@@ -94,6 +94,24 @@ npx @better-auth/cli@latest generate
npx @better-auth/cli@latest migrate
```
+## Joins (Experimental)
+
+Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
+Endpoints like `/get-session`, `/get-full-organization` and many others benefit greatly from this feature,
+seeing upwards of 2x to 3x performance improvements depending on database latency.
+
+The Kysely SQLite dialect supports joins out of the box since version `1.4.0`.
+To enable this feature, you need to set the `experimental.joins` option to `true` in your auth configuration.
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+ It's possible that you may need to run migrations after enabling this feature.
+
+
## Additional Information
SQLite is supported under the hood via the [Kysely](https://kysely.dev/) adapter, any database supported by Kysely would also be supported. (Read more here)
diff --git a/docs/content/docs/concepts/database.mdx b/docs/content/docs/concepts/database.mdx
index 569269a068..5a41b55031 100644
--- a/docs/content/docs/concepts/database.mdx
+++ b/docs/content/docs/concepts/database.mdx
@@ -770,3 +770,34 @@ To add new tables and columns to your database, you have two options:
`Manual Method`: Follow the instructions in the plugin documentation to manually add tables and columns.
Both methods ensure your database schema stays up to date with your plugins' requirements.
+
+
+## Experimental Joins
+
+Since Better-Auth version `1.4` we've introduced experimental database joins support.
+This allows Better-Auth to perform multiple database queries in a single request, reducing the number of database roundtrips.
+Over 50 endpoints support joins, and we're constantly adding more.
+
+Under the hood, our adapter system supports joins natively, meaning even if you don't enable experimental joins,
+it will still fallback to making multiple database queries and combining the results.
+
+To enable joins, update your auth config with the following:
+
+```ts title="auth.ts"
+export const auth = betterAuth({
+ experimental: { joins: true }
+});
+```
+
+The Better-Auth `1.4` CLI will generate DrizzleORM and PrismaORM relationships for you so if you do not have those already
+be sure to update your schema by running our migrate or generate CLI commands to be up-to-date with the latest required schema.
+
+It's very important to read the documentation regarding experimental joins for your given adapter:
+
+- [DrizzleORM](/docs/adapters/drizzle#joins-experimental)
+- [PrismaORM](/docs/adapters/prisma#joins-experimental)
+- [SQLite](/docs/adapters/sqlite#joins-experimental)
+- [MySQL](/docs/adapters/mysql#joins-experimental)
+- [PostgreSQL](/docs/adapters/postgresql#joins-experimental)
+- [MSSQL](/docs/adapters/mssql#joins-experimental)
+- [MongoDB](/docs/adapters/mongodb#joins-experimental)
diff --git a/docs/content/docs/guides/create-a-db-adapter.mdx b/docs/content/docs/guides/create-a-db-adapter.mdx
index 901585af01..924f9ef4c9 100644
--- a/docs/content/docs/guides/create-a-db-adapter.mdx
+++ b/docs/content/docs/guides/create-a-db-adapter.mdx
@@ -8,7 +8,7 @@ Learn how to create a custom database adapter for Better-Auth using `createAdapt
Our `createAdapterFactory` function is designed to be very flexible, and we've done our best to make it easy to understand and use.
Our hope is to allow you to focus on writing database logic, and not have to worry about how the adapter is working with Better-Auth.
-Anything from custom schema configurations, custom ID generation, safe JSON parsing, and more is handled by the `createAdapterFactory` function.
+Anything from custom schema configurations, custom ID generation, safe JSON parsing, key mapping, joins, and more is handled by the `createAdapterFactory` function.
All you need to do is provide the database logic, and the `createAdapterFactory` function will handle the rest.
## Quick Start
@@ -119,19 +119,28 @@ Before we get into the adapter function, let's go over the parameters that are a
- `options`: The Better Auth options.
- `schema`: The schema from the user's Better Auth instance.
- `debugLog`: The debug log function.
-- `getField`: The get field function.
-- `getDefaultModelName`: The get default model name function.
-- `getDefaultFieldName`: The get default field name function.
-- `getFieldAttributes`: The get field attributes function.
+- `getFieldName`: Function to get the transformed field name for the database.
+- `getModelName`: Function to get the transformed model name for the database.
+- `getDefaultModelName`: Function to get the default model name from the schema.
+- `getDefaultFieldName`: Function to get the default field name from the schema.
+- `getFieldAttributes`: Function to get field attributes for a specific model and field.
+- `transformInput`: Function to transform input data before saving to the database.
+- `transformOutput`: Function to transform output data after retrieving from the database.
+- `transformWhereClause`: Function to transform where clauses for database queries.
```ts title="Example"
adapter: ({
options,
schema,
debugLog,
- getField,
+ getFieldName,
+ getModelName,
getDefaultModelName,
getDefaultFieldName,
+ getFieldAttributes,
+ transformInput,
+ transformOutput,
+ transformWhereClause,
}) => {
return {
// ...
@@ -256,11 +265,12 @@ parameters:
- `model`: The model/table name that the record will be found in.
- `where`: The `where` clause to find the record by.
- `select`: The `select` clause to return.
+- `join`: Optional join configuration to fetch related records in a single query.
Make sure to return the data that is found in the database.
```ts title="Example"
-findOne: async ({ model, where, select }) => {
+findOne: async ({ model, where, select, join }) => {
// Example of finding a single record in the database.
return await db.select().from(model).where(where).limit(1);
};
@@ -277,13 +287,14 @@ parameters:
- `limit`: The limit of records to return.
- `sortBy`: The `sortBy` clause to sort the records by.
- `offset`: The offset of records to return.
+- `join`: Optional join configuration to fetch related records in a single query.
Make sure to return the array of data that is found in the database.
```ts title="Example"
-findMany: async ({ model, where, limit, sortBy, offset }) => {
+findMany: async ({ model, where, limit, sortBy, offset, join }) => {
// Example of finding multiple records in the database.
return await db
.select()
@@ -499,10 +510,8 @@ The value represents the new key.
This can be a partial object that only transforms some keys.
```ts title="Example"
-mapKeysTransformInput: () => {
- return {
- id: "_id", // We want to replace `id` to `_id` to save into MongoDB
- };
+mapKeysTransformInput: {
+ id: "_id", // We want to replace `id` to `_id` to save into MongoDB
},
```
@@ -517,10 +526,8 @@ The value represents the new key.
This can be a partial object that only transforms some keys.
```ts title="Example"
-mapKeysTransformOutput: () => {
- return {
- _id: "id", // We want to replace `_id` (from MongoDB) to `id` (for Better-Auth)
- };
+mapKeysTransformOutput: {
+ _id: "id", // We want to replace `_id` (from MongoDB) to `id` (for Better-Auth)
},
```
@@ -538,7 +545,7 @@ The `customTransformInput` function receives the following arguments:
- `data`: The data to transform.
- `field`: The field that is being transformed.
- `fieldAttributes`: The field attributes of the field that is being transformed.
-- `select`: The `select` values which the query expects to return.
+- `action`: The action which was called from the adapter (`create` or `update`).
- `model`: The model that is being transformed.
- `schema`: The schema that is being transformed.
- `options`: Better Auth options.
@@ -581,3 +588,39 @@ const some_data = await adapter.create({
// The name will be "Bob"
console.log(some_data.name);
```
+
+### `disableTransformInput`
+
+Whether to disable input transformation. This should only be used if you know what you're doing and are manually handling all transformations.
+
+
+ Disabling input transformation can break important adapter functionality like ID generation, boolean/date/JSON conversion, and key mapping.
+
+
+### `disableTransformOutput`
+
+Whether to disable output transformation. This should only be used if you know what you're doing and are manually handling all transformations.
+
+
+ Disabling output transformation can break important adapter functionality like boolean/date/JSON parsing and key mapping.
+
+
+### `disableTransformJoin`
+
+Whether to disable join transformation. This should only be used if you know what you're doing and are manually handling joins.
+
+
+ Disabling join transformation can break join functionality.
+
+
+### `supportsJoin`
+
+Whether the adapter supports native joins. If set to `false` (the default), Better-Auth will handle joins by making multiple queries and combining the results. If set to `true`, the adapter is expected to handle joins natively (e.g., SQL JOIN operations).
+
+```ts title="Example"
+// For adapters that support native SQL joins
+const adapter = myAdapter({
+ supportsJoin: true,
+});
+```
+
\ No newline at end of file
diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx
index 4dac01a1da..3c1f1582fa 100644
--- a/docs/content/docs/reference/options.mdx
+++ b/docs/content/docs/reference/options.mdx
@@ -471,7 +471,9 @@ export const auth = betterAuth({
},
cookiePrefix: "myapp",
database: {
- // Use your own custom ID generator, disable generating IDS so your database will generate them, or use "serial" to use your database's auto-incrementing ID, or "uuid" to use a random UUID.
+ // Use your own custom ID generator,
+ // disable generating IDS so your database will generate them,
+ // or use "serial" to use your database's auto-incrementing ID, or "uuid" to use a random UUID.
generateId: (((options: {
model: LiteralUnion;
size?: number;
@@ -479,6 +481,7 @@ export const auth = betterAuth({
return "my-super-unique-id";
})) | false | "serial" | "uuid",
defaultFindManyLimit: 100,
+ experimentalJoins: false,
}
},
})
diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json
index 77cb15bc60..f4e77a175e 100644
--- a/packages/better-auth/package.json
+++ b/packages/better-auth/package.json
@@ -26,6 +26,7 @@
"scripts": {
"build": "tsdown",
"test": "vitest",
+ "test:adapters": "vitest run --config vitest.config.adapters.ts",
"prepare": "prisma generate --schema ./src/adapters/prisma-adapter/test/base.prisma",
"typecheck": "tsc --project tsconfig.json"
},
@@ -503,7 +504,7 @@
"concurrently": "^9.2.1",
"deepmerge": "^4.3.1",
"drizzle-kit": "^0.31.4",
- "drizzle-orm": "^0.38.2",
+ "drizzle-orm": "^0.41.0",
"happy-dom": "^20.0.0",
"hono": "^4.9.7",
"listhen": "^1.9.0",
diff --git a/packages/better-auth/src/adapters/adapter-factory/get-default-field-name.ts b/packages/better-auth/src/adapters/adapter-factory/get-default-field-name.ts
new file mode 100644
index 0000000000..a0fc4a8229
--- /dev/null
+++ b/packages/better-auth/src/adapters/adapter-factory/get-default-field-name.ts
@@ -0,0 +1,63 @@
+import type { BetterAuthDBSchema } from "@better-auth/core/db";
+import { logger } from "@better-auth/core/env";
+import { BetterAuthError } from "@better-auth/core/error";
+import { initGetDefaultModelName } from "./get-default-model-name";
+
+export const initGetDefaultFieldName = ({
+ schema,
+ usePlural,
+}: {
+ schema: BetterAuthDBSchema;
+ usePlural: boolean | undefined;
+}) => {
+ const getDefaultModelName = initGetDefaultModelName({
+ schema,
+ usePlural,
+ });
+
+ /**
+ * This function helps us get the default field name from the schema defined by devs.
+ * Often times, the user will be using the `fieldName` which could had been customized by the users.
+ * This function helps us get the actual field name useful to match against the schema. (eg: schema[model].fields[field])
+ *
+ * If it's still unclear what this does:
+ *
+ * 1. User can define a custom fieldName.
+ * 2. When using a custom fieldName, doing something like `schema[model].fields[field]` will not work.
+ */
+ const getDefaultFieldName = ({
+ field,
+ model: unsafeModel,
+ }: {
+ model: string;
+ field: string;
+ }) => {
+ // Plugin `schema`s can't define their own `id`. Better-auth auto provides `id` to every schema model.
+ // Given this, we can't just check if the `field` (that being `id`) is within the schema's fields, since it is never defined.
+ // So we check if the `field` is `id` and if so, we return `id` itself. Otherwise, we return the `field` from the schema.
+ if (field === "id" || field === "_id") {
+ return "id";
+ }
+ const model = getDefaultModelName(unsafeModel); // Just to make sure the model name is correct.
+
+ let f = schema[model]?.fields[field];
+ if (!f) {
+ const result = Object.entries(schema[model]!.fields!).find(
+ ([_, f]) => f.fieldName === field,
+ );
+ if (result) {
+ f = result[1];
+ field = result[0];
+ }
+ }
+ if (!f) {
+ logger.error(`Field "${field}" not found in schema`);
+ logger.error(`Schema:`, schema);
+ logger.error(`Error stack:`, new Error().stack?.replace("Error:", ""));
+ throw new BetterAuthError(`Field ${field} not found in model ${model}`);
+ }
+ return field;
+ };
+
+ return getDefaultFieldName;
+};
diff --git a/packages/better-auth/src/adapters/adapter-factory/get-default-model-name.ts b/packages/better-auth/src/adapters/adapter-factory/get-default-model-name.ts
new file mode 100644
index 0000000000..5e6a15ba34
--- /dev/null
+++ b/packages/better-auth/src/adapters/adapter-factory/get-default-model-name.ts
@@ -0,0 +1,55 @@
+import type { BetterAuthDBSchema } from "@better-auth/core/db";
+import { logger } from "@better-auth/core/env";
+import { BetterAuthError } from "@better-auth/core/error";
+
+export const initGetDefaultModelName = ({
+ usePlural,
+ schema,
+}: {
+ usePlural: boolean | undefined;
+ schema: BetterAuthDBSchema;
+}) => {
+ /**
+ * This function helps us get the default model name from the schema defined by devs.
+ * Often times, the user will be using the `modelName` which could had been customized by the users.
+ * This function helps us get the actual model name useful to match against the schema. (eg: schema[model])
+ *
+ * If it's still unclear what this does:
+ *
+ * 1. User can define a custom modelName.
+ * 2. When using a custom modelName, doing something like `schema[model]` will not work.
+ * 3. Using this function helps us get the actual model name based on the user's defined custom modelName.
+ */
+ const getDefaultModelName = (model: string) => {
+ // It's possible this `model` could had applied `usePlural`.
+ // Thus we'll try the search but without the trailing `s`.
+ if (usePlural && model.charAt(model.length - 1) === "s") {
+ let pluralessModel = model.slice(0, -1);
+ let m = schema[pluralessModel] ? pluralessModel : undefined;
+ if (!m) {
+ m = Object.entries(schema).find(
+ ([_, f]) => f.modelName === pluralessModel,
+ )?.[0];
+ }
+
+ if (m) {
+ return m;
+ }
+ }
+
+ let m = schema[model] ? model : undefined;
+ if (!m) {
+ m = Object.entries(schema).find(([_, f]) => f.modelName === model)?.[0];
+ }
+
+ if (!m) {
+ logger.error(`Model "${model}" not found in schema`);
+ logger.error(`Schema:`, schema);
+ logger.error(`Error stack:`, new Error().stack?.replace("Error:", ""));
+ throw new BetterAuthError(`Model "${model}" not found in schema!`);
+ }
+ return m;
+ };
+
+ return getDefaultModelName;
+};
diff --git a/packages/better-auth/src/adapters/adapter-factory/get-field-attributes.ts b/packages/better-auth/src/adapters/adapter-factory/get-field-attributes.ts
new file mode 100644
index 0000000000..38ba92061e
--- /dev/null
+++ b/packages/better-auth/src/adapters/adapter-factory/get-field-attributes.ts
@@ -0,0 +1,62 @@
+import type { BetterAuthOptions } from "@better-auth/core";
+import type { BetterAuthDBSchema } from "@better-auth/core/db";
+import { BetterAuthError } from "@better-auth/core/error";
+import { initGetDefaultFieldName } from "./get-default-field-name";
+import { initGetDefaultModelName } from "./get-default-model-name";
+import { initGetIdField } from "./get-id-field";
+
+export const initGetFieldAttributes = ({
+ usePlural,
+ schema,
+ options,
+ customIdGenerator,
+ disableIdGeneration,
+}: {
+ usePlural?: boolean;
+ schema: BetterAuthDBSchema;
+ options: BetterAuthOptions;
+ disableIdGeneration?: boolean;
+ customIdGenerator?: ((props: { model: string }) => string) | undefined;
+}) => {
+ const getDefaultModelName = initGetDefaultModelName({
+ usePlural,
+ schema,
+ });
+
+ const getDefaultFieldName = initGetDefaultFieldName({
+ usePlural,
+ schema,
+ });
+
+ const idField = initGetIdField({
+ usePlural,
+ schema,
+ options,
+ customIdGenerator,
+ disableIdGeneration,
+ });
+
+ const getFieldAttributes = ({
+ model,
+ field,
+ }: {
+ model: string;
+ field: string;
+ }) => {
+ const defaultModelName = getDefaultModelName(model);
+ const defaultFieldName = getDefaultFieldName({
+ field: field,
+ model: defaultModelName,
+ });
+
+ const fields = schema[defaultModelName]!.fields;
+ fields.id = idField({ customModelName: defaultModelName });
+ const fieldAttributes = fields[defaultFieldName];
+ if (!fieldAttributes) {
+ throw new BetterAuthError(`Field ${field} not found in model ${model}`);
+ }
+ return fieldAttributes;
+ };
+
+ return getFieldAttributes;
+};
diff --git a/packages/better-auth/src/adapters/adapter-factory/get-field-name.ts b/packages/better-auth/src/adapters/adapter-factory/get-field-name.ts
new file mode 100644
index 0000000000..d23741a5f9
--- /dev/null
+++ b/packages/better-auth/src/adapters/adapter-factory/get-field-name.ts
@@ -0,0 +1,43 @@
+import type { BetterAuthDBSchema } from "@better-auth/core/db";
+import { initGetDefaultFieldName } from "./get-default-field-name";
+import { initGetDefaultModelName } from "./get-default-model-name";
+
+export const initGetFieldName = ({
+ schema,
+ usePlural,
+}: {
+ schema: BetterAuthDBSchema;
+ usePlural: boolean | undefined;
+}) => {
+ const getDefaultModelName = initGetDefaultModelName({
+ schema,
+ usePlural,
+ });
+
+ const getDefaultFieldName = initGetDefaultFieldName({
+ schema,
+ usePlural,
+ });
+
+ /**
+ * Get the field name which is expected to be saved in the database based on the user's schema.
+ *
+ * This function is useful if you need to save the field name to the database.
+ *
+ * For example, if the user has defined a custom field name for the `user` model, then you can use this function to get the actual field name from the schema.
+ */
+ function getFieldName({
+ model: modelName,
+ field: fieldName,
+ }: {
+ model: string;
+ field: string;
+ }) {
+ const model = getDefaultModelName(modelName);
+ const field = getDefaultFieldName({ model, field: fieldName });
+
+ return schema[model]?.fields[field]?.fieldName || field;
+ }
+
+ return getFieldName;
+};
diff --git a/packages/better-auth/src/adapters/adapter-factory/get-id-field.ts b/packages/better-auth/src/adapters/adapter-factory/get-id-field.ts
new file mode 100644
index 0000000000..5fbf58494d
--- /dev/null
+++ b/packages/better-auth/src/adapters/adapter-factory/get-id-field.ts
@@ -0,0 +1,150 @@
+import type { BetterAuthOptions } from "@better-auth/core";
+import type {
+ BetterAuthDBSchema,
+ DBFieldAttribute,
+} from "@better-auth/core/db";
+import { logger } from "@better-auth/core/env";
+import { generateId as defaultGenerateId } from "../../utils";
+import { initGetDefaultModelName } from "./get-default-model-name";
+
+export const initGetIdField = ({
+ usePlural,
+ schema,
+ disableIdGeneration,
+ options,
+ customIdGenerator,
+ supportsUUIDs,
+}: {
+ usePlural?: boolean;
+ schema: BetterAuthDBSchema;
+ options: BetterAuthOptions;
+ disableIdGeneration?: boolean;
+ customIdGenerator?: ((props: { model: string }) => string) | undefined;
+ supportsUUIDs?: boolean;
+}) => {
+ const getDefaultModelName = initGetDefaultModelName({
+ usePlural: usePlural,
+ schema,
+ });
+
+ const idField = ({
+ customModelName,
+ forceAllowId,
+ }: {
+ customModelName?: string;
+ forceAllowId?: boolean;
+ }) => {
+ const useNumberId =
+ options.advanced?.database?.useNumberId ||
+ options.advanced?.database?.generateId === "serial";
+ const useUUIDs = options.advanced?.database?.generateId === "uuid";
+
+ let shouldGenerateId: boolean = (() => {
+ if (disableIdGeneration) {
+ return false;
+ } else if (useNumberId && !forceAllowId) {
+ // if force allow is true, then we should be using their custom provided id.
+ return false;
+ } else if (useUUIDs) {
+ // should only generate UUIDs via JS if the database doesn't support natively generating UUIDs.
+ return !supportsUUIDs;
+ } else {
+ return true;
+ }
+ })();
+
+ const model = getDefaultModelName(customModelName ?? "id");
+ return {
+ type: useNumberId ? "number" : "string",
+ required: shouldGenerateId ? true : false,
+ ...(shouldGenerateId
+ ? {
+ defaultValue() {
+ if (disableIdGeneration) return undefined;
+ let generateId = options.advanced?.database?.generateId;
+ if (options.advanced?.generateId !== undefined) {
+ logger.warn(
+ "Your Better Auth config includes advanced.generateId which is deprecated. Please use advanced.database.generateId instead. This will be removed in future releases.",
+ );
+ generateId = options.advanced?.generateId;
+ }
+ if (generateId === false || useNumberId) return undefined;
+ if (typeof generateId === "function") {
+ return generateId({
+ model,
+ });
+ }
+ if (customIdGenerator) {
+ return customIdGenerator({ model });
+ }
+ if (generateId === "uuid") {
+ return crypto.randomUUID();
+ }
+ return defaultGenerateId();
+ },
+ }
+ : {}),
+ transform: {
+ input: (value) => {
+ // Uncomment if need to debug id transformation
+ // console.log(`transforming id: `, {
+ // id: value,
+ // ...(useNumberId ? { useNumberId } : {}),
+ // ...(useUUIDs ? { useUUIDs } : {}),
+ // ...(forceAllowId ? { forceAllowId } : {}),
+ // });
+ if (!value) return undefined;
+
+ if (useNumberId) {
+ const numberValue = Number(value);
+ // if invalid number, fallback to DB generated number id.
+ if (isNaN(numberValue)) {
+ return undefined;
+ }
+ return numberValue;
+ }
+
+ if (useUUIDs) {
+ // if it's generated by us, then we should return the value as is.
+ if (shouldGenerateId && !forceAllowId) return value;
+ if (disableIdGeneration) return undefined;
+ // if DB will handle UUID generation, then we should return undefined.
+ if (supportsUUIDs) return undefined;
+ // if forceAllowId is true, it means we should be using the ID provided during the adapter call.
+ if (forceAllowId && typeof value === "string") {
+ const uuidRegex =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+ if (uuidRegex.test(value)) {
+ return value;
+ } else {
+ const err = new Error();
+ const stack = err.stack
+ ?.split("\n")
+ .filter((_, i) => i !== 1)
+ .join("\n")
+ .replace("Error:", "");
+ logger.warn(
+ "[Adapter Factory] - Invalid UUID value for field `id` provided when `forceAllowId` is true. Generating a new UUID.",
+ stack,
+ );
+ }
+ }
+ // if the value is not a string, and the database doesn't support generating it's own UUIDs, then we should be generating the UUID.
+ if (typeof value !== "string" && !supportsUUIDs) {
+ return crypto.randomUUID();
+ }
+ return undefined;
+ }
+
+ return value;
+ },
+ output: (value) => {
+ if (!value) return undefined;
+ return String(value);
+ },
+ },
+ } satisfies DBFieldAttribute;
+ };
+
+ return idField;
+};
diff --git a/packages/better-auth/src/adapters/adapter-factory/get-model-name.ts b/packages/better-auth/src/adapters/adapter-factory/get-model-name.ts
new file mode 100644
index 0000000000..a0fe2ee017
--- /dev/null
+++ b/packages/better-auth/src/adapters/adapter-factory/get-model-name.ts
@@ -0,0 +1,36 @@
+import type { BetterAuthDBSchema } from "@better-auth/core/db";
+import { initGetDefaultModelName } from "./get-default-model-name";
+
+export const initGetModelName = ({
+ usePlural,
+ schema,
+}: {
+ usePlural: boolean | undefined;
+ schema: BetterAuthDBSchema;
+}) => {
+ const getDefaultModelName = initGetDefaultModelName({
+ schema,
+ usePlural,
+ });
+ /**
+ * Users can overwrite the default model of some tables. This function helps find the correct model name.
+ * Furthermore, if the user passes `usePlural` as true in their adapter config,
+ * then we should return the model name ending with an `s`.
+ */
+ const getModelName = (model: string) => {
+ const defaultModelKey = getDefaultModelName(model);
+ const useCustomModelName =
+ schema &&
+ schema[defaultModelKey] &&
+ schema[defaultModelKey].modelName !== model;
+
+ if (useCustomModelName) {
+ return usePlural
+ ? `${schema[defaultModelKey]!.modelName}s`
+ : schema[defaultModelKey]!.modelName;
+ }
+
+ return usePlural ? `${model}s` : model;
+ };
+ return getModelName;
+};
diff --git a/packages/better-auth/src/adapters/adapter-factory/index.ts b/packages/better-auth/src/adapters/adapter-factory/index.ts
index ef68ab9229..aad5281b35 100644
--- a/packages/better-auth/src/adapters/adapter-factory/index.ts
+++ b/packages/better-auth/src/adapters/adapter-factory/index.ts
@@ -1,23 +1,37 @@
import type { BetterAuthOptions } from "@better-auth/core";
-import type { DBFieldAttribute } from "@better-auth/core/db";
import type {
CleanedWhere,
DBAdapter,
DBTransactionAdapter,
+ JoinConfig,
+ JoinOption,
Where,
} from "@better-auth/core/db/adapter";
import { getColorDepth, logger, TTY_COLORS } from "@better-auth/core/env";
import { BetterAuthError } from "@better-auth/core/error";
import { withApplyDefault } from "../../adapters/utils";
import { getAuthTables } from "../../db/get-tables";
-import { generateId as defaultGenerateId } from "../../utils";
import { safeJSONParse } from "../../utils/json";
+import { initGetDefaultFieldName } from "./get-default-field-name";
+import { initGetDefaultModelName } from "./get-default-model-name";
+import { initGetFieldAttributes } from "./get-field-attributes";
+import { initGetFieldName } from "./get-field-name";
+import { initGetIdField } from "./get-id-field";
+import { initGetModelName } from "./get-model-name";
import type {
AdapterFactoryConfig,
AdapterFactoryOptions,
AdapterTestDebugLogs,
} from "./types";
+export {
+ initGetDefaultModelName,
+ initGetDefaultFieldName,
+ initGetModelName,
+ initGetFieldName,
+ initGetFieldAttributes,
+ initGetIdField,
+};
export * from "./types";
let debugLogs: { instance: string; args: any[] }[] = [];
@@ -53,6 +67,7 @@ export const createAdapterFactory =
transaction: cfg.transaction ?? false,
disableTransformInput: cfg.disableTransformInput ?? false,
disableTransformOutput: cfg.disableTransformOutput ?? false,
+ disableTransformJoin: cfg.disableTransformJoin ?? false,
} satisfies AdapterFactoryConfig;
const useNumberId =
options.advanced?.database?.useNumberId === true ||
@@ -66,131 +81,6 @@ export const createAdapterFactory =
// End-user's Better-Auth instance's schema
const schema = getAuthTables(options);
- /**
- * This function helps us get the default field name from the schema defined by devs.
- * Often times, the user will be using the `fieldName` which could had been customized by the users.
- * This function helps us get the actual field name useful to match against the schema. (eg: schema[model].fields[field])
- *
- * If it's still unclear what this does:
- *
- * 1. User can define a custom fieldName.
- * 2. When using a custom fieldName, doing something like `schema[model].fields[field]` will not work.
- */
- const getDefaultFieldName = ({
- field,
- model: unsafe_model,
- }: {
- model: string;
- field: string;
- }) => {
- // Plugin `schema`s can't define their own `id`. Better-auth auto provides `id` to every schema model.
- // Given this, we can't just check if the `field` (that being `id`) is within the schema's fields, since it is never defined.
- // So we check if the `field` is `id` and if so, we return `id` itself. Otherwise, we return the `field` from the schema.
- if (field === "id" || field === "_id") {
- return "id";
- }
- const model = getDefaultModelName(unsafe_model); // Just to make sure the model name is correct.
-
- let f = schema[model]?.fields[field];
- if (!f) {
- const result = Object.entries(schema[model]!.fields!).find(
- ([_, f]) => f.fieldName === field,
- );
- if (result) {
- f = result[1];
- field = result[0];
- }
- }
- if (!f) {
- debugLog(`Field ${field} not found in model ${model}`);
- debugLog(`Schema:`, schema);
- throw new BetterAuthError(`Field ${field} not found in model ${model}`);
- }
- return field;
- };
-
- /**
- * This function helps us get the default model name from the schema defined by devs.
- * Often times, the user will be using the `modelName` which could had been customized by the users.
- * This function helps us get the actual model name useful to match against the schema. (eg: schema[model])
- *
- * If it's still unclear what this does:
- *
- * 1. User can define a custom modelName.
- * 2. When using a custom modelName, doing something like `schema[model]` will not work.
- * 3. Using this function helps us get the actual model name based on the user's defined custom modelName.
- */
- const getDefaultModelName = (model: string) => {
- // It's possible this `model` could had applied `usePlural`.
- // Thus we'll try the search but without the trailing `s`.
- if (config.usePlural && model.charAt(model.length - 1) === "s") {
- let pluralessModel = model.slice(0, -1);
- let m = schema[pluralessModel] ? pluralessModel : undefined;
- if (!m) {
- m = Object.entries(schema).find(
- ([_, f]) => f.modelName === pluralessModel,
- )?.[0];
- }
-
- if (m) {
- return m;
- }
- }
-
- let m = schema[model] ? model : undefined;
- if (!m) {
- m = Object.entries(schema).find(([_, f]) => f.modelName === model)?.[0];
- }
-
- if (!m) {
- debugLog(`Model "${model}" not found in schema`);
- debugLog(`Schema:`, schema);
- throw new BetterAuthError(`Model "${model}" not found in schema`);
- }
- return m;
- };
-
- /**
- * Users can overwrite the default model of some tables. This function helps find the correct model name.
- * Furthermore, if the user passes `usePlural` as true in their adapter config,
- * then we should return the model name ending with an `s`.
- */
- const getModelName = (model: string) => {
- const defaultModelKey = getDefaultModelName(model);
- const usePlural = config && config.usePlural;
- const useCustomModelName =
- schema &&
- schema[defaultModelKey] &&
- schema[defaultModelKey].modelName !== model;
-
- if (useCustomModelName) {
- return usePlural
- ? `${schema[defaultModelKey]!.modelName}s`
- : schema[defaultModelKey]!.modelName;
- }
-
- return usePlural ? `${model}s` : model;
- };
- /**
- * Get the field name which is expected to be saved in the database based on the user's schema.
- *
- * This function is useful if you need to save the field name to the database.
- *
- * For example, if the user has defined a custom field name for the `user` model, then you can use this function to get the actual field name from the schema.
- */
- function getFieldName({
- model: model_name,
- field: field_name,
- }: {
- model: string;
- field: string;
- }) {
- const model = getDefaultModelName(model_name);
- const field = getDefaultFieldName({ model, field: field_name });
-
- return schema[model]?.fields[field]?.fieldName || field;
- }
-
const debugLog = (...args: any[]) => {
if (config.debugLogs === true || typeof config.debugLogs === "object") {
// If we're running adapter tests, we'll keep debug logs in memory, then print them out if a test fails.
@@ -248,151 +138,46 @@ export const createAdapterFactory =
}
};
- const idField = ({
- customModelName,
- forceAllowId,
- }: {
- customModelName?: string;
- forceAllowId?: boolean;
- }) => {
- const useNumberId =
- options.advanced?.database?.useNumberId ||
- options.advanced?.database?.generateId === "serial";
- const useUUIDs = options.advanced?.database?.generateId === "uuid";
+ const getDefaultModelName = initGetDefaultModelName({
+ usePlural: config.usePlural,
+ schema,
+ });
- let shouldGenerateId: boolean = (() => {
- if (config.disableIdGeneration) {
- return false;
- } else if (useNumberId && !forceAllowId) {
- // if force allow is true, then we should be using their custom provided id.
- return false;
- } else if (useUUIDs) {
- // should only generate UUIDs via JS if the database doesn't support natively generating UUIDs.
- return !config.supportsUUIDs;
- } else {
- return true;
- }
- })();
+ const getDefaultFieldName = initGetDefaultFieldName({
+ usePlural: config.usePlural,
+ schema,
+ });
- const model = getDefaultModelName(customModelName ?? "id");
- return {
- type: useNumberId ? "number" : "string",
- required: shouldGenerateId ? true : false,
- ...(shouldGenerateId
- ? {
- defaultValue() {
- if (config.disableIdGeneration) return undefined;
- let generateId = options.advanced?.database?.generateId;
- if (options.advanced?.generateId !== undefined) {
- logger.warn(
- "Your Better Auth config includes advanced.generateId which is deprecated. Please use advanced.database.generateId instead. This will be removed in future releases.",
- );
- generateId = options.advanced?.generateId;
- }
- if (generateId === false || useNumberId) return undefined;
- if (typeof generateId === "function") {
- return generateId({
- model,
- });
- }
- if (config.customIdGenerator) {
- return config.customIdGenerator({ model });
- }
- if (generateId === "uuid") {
- return crypto.randomUUID();
- }
- return defaultGenerateId();
- },
- }
- : {}),
- transform: {
- input: (value) => {
- // Uncomment if need to debug id transformation
- // console.log(`transforming id: `, {
- // id: value,
- // ...(useNumberId ? { useNumberId } : {}),
- // ...(useUUIDs ? { useUUIDs } : {}),
- // ...(forceAllowId ? { forceAllowId } : {}),
- // });
- if (!value) return undefined;
+ const getModelName = initGetModelName({
+ usePlural: config.usePlural,
+ schema,
+ });
+ const getFieldName = initGetFieldName({
+ schema,
+ usePlural: config.usePlural,
+ });
- if (useNumberId) {
- const numberValue = Number(value);
- // if invalid number, fallback to DB generated number id.
- if (isNaN(numberValue)) {
- return undefined;
- }
- return numberValue;
- }
+ const idField = initGetIdField({
+ schema,
+ options,
+ usePlural: config.usePlural,
+ disableIdGeneration: config.disableIdGeneration,
+ customIdGenerator: config.customIdGenerator,
+ supportsUUIDs: config.supportsUUIDs,
+ });
- if (useUUIDs) {
- // if it's generated by us, then we should return the value as is.
- if (shouldGenerateId && !forceAllowId) return value;
- if (config.disableIdGeneration) return undefined;
- // if DB will handle UUID generation, then we should return undefined.
- if (config.supportsUUIDs) return undefined;
- // if forceAllowId is true, it means we should be using the ID provided during the adapter call.
- if (forceAllowId && typeof value === "string") {
- const uuidRegex =
- /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
- if (uuidRegex.test(value)) {
- return value;
- } else {
- const err = new Error();
- const stack = err.stack
- ?.split("\n")
- .filter((_, i) => i !== 1)
- .join("\n")
- .replace("Error:", "");
- logger.warn(
- "[Adapter Factory] - Invalid UUID value for field `id` provided when `forceAllowId` is true. Generating a new UUID.",
- stack,
- );
- }
- }
- // if the value is not a string, and the database doesn't support generating it's own UUIDs, then we should be generating the UUID.
- if (typeof value !== "string" && !config.supportsUUIDs) {
- return crypto.randomUUID();
- }
- return undefined;
- }
-
- return value;
- },
- output: (value) => {
- if (!value) return undefined;
- return String(value);
- },
- },
- } satisfies DBFieldAttribute;
- };
-
- const getFieldAttributes = ({
- model,
- field,
- }: {
- model: string;
- field: string;
- }) => {
- const defaultModelName = getDefaultModelName(model);
- const defaultFieldName = getDefaultFieldName({
- field: field,
- model: defaultModelName,
- });
-
- const fields = schema[defaultModelName]!.fields;
- fields.id = idField({ customModelName: defaultModelName });
- const fieldAttributes = fields[defaultFieldName];
- if (!fieldAttributes) {
- throw new BetterAuthError(`Field ${field} not found in model ${model}`);
- }
- return fieldAttributes;
- };
+ const getFieldAttributes = initGetFieldAttributes({
+ schema,
+ options,
+ usePlural: config.usePlural,
+ disableIdGeneration: config.disableIdGeneration,
+ customIdGenerator: config.customIdGenerator,
+ });
const transformInput = async (
data: Record,
defaultModelName: string,
- action: "create" | "update",
+ action: "create" | "update" | "findOne" | "findMany",
forceAllowId?: boolean,
) => {
const transformedData: Record = {};
@@ -498,81 +283,173 @@ export const createAdapterFactory =
data: Record | null,
unsafe_model: string,
select: string[] = [],
+ join: JoinConfig | undefined,
) => {
- if (!data) return null;
- const newMappedKeys = config.mapKeysTransformOutput ?? {};
- const transformedData: Record = {};
- const tableSchema = schema[unsafe_model]!.fields;
- const idKey = Object.entries(newMappedKeys).find(
- ([_, v]) => v === "id",
- )?.[0];
- const useNumberId =
- options.advanced?.database?.useNumberId ||
- options.advanced?.database?.generateId === "serial";
- tableSchema[idKey ?? "id"] = {
- type: useNumberId ? "number" : "string",
+ const transformSingleOutput = async (
+ data: Record | null,
+ unsafe_model: string,
+ select: string[] = [],
+ ) => {
+ if (!data) return null;
+ const newMappedKeys = config.mapKeysTransformOutput ?? {};
+ const transformedData: Record = {};
+ const tableSchema = schema[getDefaultModelName(unsafe_model)]!.fields;
+ const idKey = Object.entries(newMappedKeys).find(
+ ([_, v]) => v === "id",
+ )?.[0];
+ const useNumberId =
+ options.advanced?.database?.useNumberId ||
+ options.advanced?.database?.generateId === "serial";
+ tableSchema[idKey ?? "id"] = {
+ type: useNumberId ? "number" : "string",
+ };
+ for (const key in tableSchema) {
+ if (select.length && !select.includes(key)) {
+ continue;
+ }
+ const field = tableSchema[key];
+ if (field) {
+ const originalKey = field.fieldName || key;
+
+ // If the field is mapped, we'll use the mapped key. Otherwise, we'll use the original key.
+ let newValue =
+ data[
+ Object.entries(newMappedKeys).find(
+ ([_, v]) => v === originalKey,
+ )?.[0] || originalKey
+ ];
+
+ if (field.transform?.output) {
+ newValue = await field.transform.output(newValue);
+ }
+
+ let newFieldName: string = newMappedKeys[key] || key;
+
+ if (originalKey === "id" || field.references?.field === "id") {
+ // Even if `useNumberId` is true, we must always return a string `id` output.
+ if (typeof newValue !== "undefined" && newValue !== null)
+ newValue = String(newValue);
+ } else if (
+ config.supportsJSON === false &&
+ typeof newValue === "string" &&
+ field.type === "json"
+ ) {
+ newValue = safeJSONParse(newValue);
+ } else if (
+ config.supportsDates === false &&
+ typeof newValue === "string" &&
+ field.type === "date"
+ ) {
+ newValue = new Date(newValue);
+ } else if (
+ config.supportsBooleans === false &&
+ typeof newValue === "number" &&
+ field.type === "boolean"
+ ) {
+ newValue = newValue === 1;
+ }
+
+ if (config.customTransformOutput) {
+ newValue = config.customTransformOutput({
+ data: newValue,
+ field: newFieldName,
+ fieldAttributes: field,
+ select,
+ model: getModelName(unsafe_model),
+ schema,
+ options,
+ });
+ }
+
+ transformedData[newFieldName] = newValue;
+ }
+ }
+ return transformedData as any;
};
- for (const key in tableSchema) {
- if (select.length && !select.includes(key)) {
- continue;
- }
- const field = tableSchema[key];
- if (field) {
- const originalKey = field.fieldName || key;
- // If the field is mapped, we'll use the mapped key. Otherwise, we'll use the original key.
- let newValue =
- data[
- Object.entries(newMappedKeys).find(
- ([_, v]) => v === originalKey,
- )?.[0] || originalKey
- ];
-
- if (field.transform?.output) {
- newValue = await field.transform.output(newValue);
- }
-
- let newFieldName: string = newMappedKeys[key] || key;
-
- if (originalKey === "id" || field.references?.field === "id") {
- // Even if `useNumberId` is true, we must always return a string `id` output.
- if (typeof newValue !== "undefined" && newValue !== null)
- newValue = String(newValue);
- } else if (
- config.supportsJSON === false &&
- typeof newValue === "string" &&
- field.type === "json"
- ) {
- newValue = safeJSONParse(newValue);
- } else if (
- config.supportsDates === false &&
- typeof newValue === "string" &&
- field.type === "date"
- ) {
- newValue = new Date(newValue);
- } else if (
- config.supportsBooleans === false &&
- typeof newValue === "number" &&
- field.type === "boolean"
- ) {
- newValue = newValue === 1;
- }
-
- if (config.customTransformOutput) {
- newValue = config.customTransformOutput({
- data: newValue,
- field: newFieldName,
- fieldAttributes: field,
- select,
- model: getModelName(unsafe_model),
- schema,
- options,
- });
- }
-
- transformedData[newFieldName] = newValue;
- }
+ if (!join || Object.keys(join).length === 0) {
+ return await transformSingleOutput(data, unsafe_model, select);
}
+
+ unsafe_model = getDefaultModelName(unsafe_model);
+ // for now we just transform the base model
+ // later we append the joined models to this object.
+ let transformedData: Record = await transformSingleOutput(
+ data,
+ unsafe_model,
+ select,
+ );
+
+ // Get all the models that are required to be joined.
+ const requiredModels = Object.entries(join).map(
+ ([model, joinConfig]) => ({
+ modelName: getModelName(model),
+ defaultModelName: getDefaultModelName(model),
+ joinConfig,
+ }),
+ );
+
+ if (!data) return null;
+ // Data is now the base model object directly (not wrapped under a key)
+
+ for (const {
+ modelName,
+ defaultModelName,
+ joinConfig,
+ } of requiredModels) {
+ let joinedData = await (async () => {
+ if (options.experimental?.joins) {
+ const result = data[modelName];
+ return result;
+ } else {
+ // doesn't support joins, so fallback to handleFallbackJoin
+ const result = await handleFallbackJoin({
+ baseModel: unsafe_model,
+ baseData: transformedData,
+ joinModel: modelName,
+ specificJoinConfig: joinConfig,
+ });
+ return result;
+ }
+ })();
+
+ // If joinedData is undefined, initialize it based on relationship type
+ if (joinedData === undefined || joinedData === null) {
+ joinedData = joinConfig.relation === "one-to-one" ? null : [];
+ }
+
+ if (
+ joinConfig.relation === "one-to-many" &&
+ !Array.isArray(joinedData)
+ ) {
+ joinedData = [joinedData];
+ }
+
+ let transformed = [];
+
+ if (Array.isArray(joinedData)) {
+ for (const item of joinedData) {
+ const transformedItem = await transformSingleOutput(
+ item,
+ modelName,
+ [],
+ );
+ transformed.push(transformedItem);
+ }
+ } else {
+ const transformedItem = await transformSingleOutput(
+ joinedData,
+ modelName,
+ [],
+ );
+ transformed.push(transformedItem);
+ }
+
+ const result =
+ joinConfig.relation === "one-to-one" ? transformed[0] : transformed;
+ transformedData[defaultModelName] = result ?? null;
+ }
+
return transformedData as any;
};
@@ -676,6 +553,191 @@ export const createAdapterFactory =
}) as any;
};
+ const transformJoinClause = (
+ baseModel: string,
+ unsanitizedJoin: JoinOption | undefined,
+ select: string[] | undefined,
+ ): { join: JoinConfig; select: string[] | undefined } | undefined => {
+ if (!unsanitizedJoin) return undefined;
+ if (Object.keys(unsanitizedJoin).length === 0) return undefined;
+ const transformedJoin: JoinConfig = {};
+ for (const [model, join] of Object.entries(unsanitizedJoin)) {
+ if (!join) continue;
+ const defaultModelName = getDefaultModelName(model);
+ const defaultBaseModelName = getDefaultModelName(baseModel);
+
+ // First, check if the joined model has FKs to the base model (forward join)
+ let foreignKeys = Object.entries(
+ schema[defaultModelName]!.fields,
+ ).filter(
+ ([field, fieldAttributes]) =>
+ fieldAttributes.references &&
+ getDefaultModelName(fieldAttributes.references.model) ===
+ defaultBaseModelName,
+ );
+
+ let isForwardJoin = true;
+
+ // If no forward join found, check backwards: does the base model have FKs to the joined model?
+ if (!foreignKeys.length) {
+ foreignKeys = Object.entries(
+ schema[defaultBaseModelName]!.fields,
+ ).filter(
+ ([field, fieldAttributes]) =>
+ fieldAttributes.references &&
+ getDefaultModelName(fieldAttributes.references.model) ===
+ defaultModelName,
+ );
+ isForwardJoin = false;
+ }
+
+ if (!foreignKeys.length) {
+ throw new BetterAuthError(
+ `No foreign key found for model ${model} and base model ${baseModel} while performing join operation.`,
+ );
+ } else if (foreignKeys.length > 1) {
+ throw new BetterAuthError(
+ `Multiple foreign keys found for model ${model} and base model ${baseModel} while performing join operation. Only one foreign key is supported.`,
+ );
+ }
+
+ const [foreignKey, foreignKeyAttributes] = foreignKeys[0]!;
+ if (!foreignKeyAttributes.references) {
+ // this should never happen, as we filter for references in the foreign keys.
+ // it's here for typescript to be happy.
+ throw new BetterAuthError(
+ `No references found for foreign key ${foreignKey} on model ${model} while performing join operation.`,
+ );
+ }
+
+ let from: string;
+ let to: string;
+ let requiredSelectField: string;
+
+ if (isForwardJoin) {
+ // joined model has FK to base model
+ // The field we need in select is the referenced field in the base model
+ requiredSelectField = foreignKeyAttributes.references.field;
+ from = getFieldName({
+ model: baseModel,
+ field: requiredSelectField,
+ });
+
+ to = getFieldName({
+ model,
+ field: foreignKey,
+ });
+ } else {
+ // base model has FK to joined model
+ // The field we need in select is the foreign key field in the base model
+ requiredSelectField = foreignKey;
+ from = getFieldName({
+ model: baseModel,
+ field: requiredSelectField,
+ });
+
+ to = getFieldName({
+ model,
+ field: foreignKeyAttributes.references.field,
+ });
+ }
+
+ // Ensure the required field is in select if select is provided
+ if (select && !select.includes(requiredSelectField)) {
+ select.push(requiredSelectField);
+ }
+
+ const isUnique =
+ to === "id" ? true : (foreignKeyAttributes.unique ?? false);
+
+ let limit: number =
+ options.advanced?.database?.defaultFindManyLimit ?? 100;
+ if (isUnique) {
+ limit = 1;
+ } else if (typeof join === "object" && typeof join.limit === "number") {
+ limit = join.limit;
+ }
+
+ transformedJoin[getModelName(model)] = {
+ on: {
+ from,
+ to,
+ },
+ limit,
+ relation: isUnique ? "one-to-one" : "one-to-many",
+ };
+ }
+ return { join: transformedJoin, select };
+ };
+
+ /**
+ * Handle joins by making separate queries and combining results (fallback for adapters that don't support native joins).
+ */
+ const handleFallbackJoin = async | null>({
+ baseModel,
+ baseData,
+ joinModel,
+ specificJoinConfig: joinConfig,
+ }: {
+ baseModel: string;
+ baseData: T;
+ joinModel: string;
+ specificJoinConfig: JoinConfig[number];
+ }) => {
+ if (!baseData) return baseData;
+ const modelName = getModelName(joinModel);
+ const field = joinConfig.on.to;
+ const value =
+ baseData[
+ getDefaultFieldName({ field: joinConfig.on.from, model: baseModel })
+ ];
+
+ if (value === null || value === undefined) {
+ // If there is no value, it could mean that the query used a `select` clause that didn't include the field.
+ // or the query result is purely empty.
+ // In any case, we return null/empty array.
+ return joinConfig.relation === "one-to-one" ? null : [];
+ }
+ let result: Record | Record[] | null;
+ const where = transformWhereClause({
+ model: modelName,
+ where: [
+ {
+ field,
+ value,
+ operator: "eq",
+ connector: "AND",
+ },
+ ],
+ });
+ try {
+ if (joinConfig.relation === "one-to-one") {
+ result = await adapterInstance.findOne>({
+ model: modelName,
+ where: where,
+ });
+ } else {
+ const limit =
+ joinConfig.limit ??
+ options.advanced?.database?.defaultFindManyLimit ??
+ 100;
+ result = await adapterInstance.findMany>({
+ model: modelName,
+ where: where,
+ limit,
+ });
+ }
+ } catch (error) {
+ logger.error(`Failed to query fallback join for model ${modelName}:`, {
+ where,
+ limit: joinConfig.limit,
+ });
+ console.error(error);
+ throw error;
+ }
+ return result;
+ };
+
const adapterInstance = customAdapter({
options,
schema,
@@ -775,7 +837,12 @@ export const createAdapterFactory =
);
let transformed = res as any;
if (!config.disableTransformOutput) {
- transformed = await transformOutput(res as any, unsafeModel, select);
+ transformed = await transformOutput(
+ res as any,
+ unsafeModel,
+ select,
+ undefined,
+ );
}
debugLog(
{ method: "create" },
@@ -831,7 +898,12 @@ export const createAdapterFactory =
);
let transformed = res as any;
if (!config.disableTransformOutput) {
- transformed = await transformOutput(res as any, unsafeModel);
+ transformed = await transformOutput(
+ res as any,
+ unsafeModel,
+ undefined,
+ undefined,
+ );
}
debugLog(
{ method: "update" },
@@ -898,10 +970,12 @@ export const createAdapterFactory =
model: unsafeModel,
where: unsafeWhere,
select,
+ join: unsafeJoin,
}: {
model: string;
where: Where[];
select?: string[];
+ join?: JoinOption;
}) => {
transactionId++;
let thisTransactionId = transactionId;
@@ -911,16 +985,35 @@ export const createAdapterFactory =
where: unsafeWhere,
});
unsafeModel = getDefaultModelName(unsafeModel);
+ let join: JoinConfig | undefined;
+ let passJoinToAdapter = true;
+ if (!config.disableTransformJoin) {
+ const result = transformJoinClause(unsafeModel, unsafeJoin, select);
+ if (result) {
+ join = result.join;
+ select = result.select;
+ }
+ // If adapter doesn't support joins and we have joins, don't pass them to the adapter
+ const experimentalJoins = options.experimental?.joins;
+ if (!experimentalJoins && join && Object.keys(join).length > 0) {
+ passJoinToAdapter = false;
+ }
+ } else {
+ // assume it's already transformed if transformation is disabled
+ join = unsafeJoin as never as JoinConfig;
+ }
debugLog(
{ method: "findOne" },
`${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
`${formatMethod("findOne")}:`,
- { model, where, select },
+ { model, where, select, join },
);
+
const res = await adapterInstance.findOne({
model,
where,
select,
+ join: passJoinToAdapter ? join : undefined,
});
debugLog(
{ method: "findOne" },
@@ -928,9 +1021,11 @@ export const createAdapterFactory =
`${formatMethod("findOne")} ${formatAction("DB Result")}:`,
{ model, data: res },
);
+
+ // Handle fallback join if adapter doesn't support joins
let transformed = res as any;
if (!config.disableTransformOutput) {
- transformed = await transformOutput(res as any, unsafeModel, select);
+ transformed = await transformOutput(res, unsafeModel, select, join);
}
debugLog(
{ method: "findOne" },
@@ -946,12 +1041,14 @@ export const createAdapterFactory =
limit: unsafeLimit,
sortBy,
offset,
+ join: unsafeJoin,
}: {
model: string;
where?: Where[];
limit?: number;
sortBy?: { field: string; direction: "asc" | "desc" };
offset?: number;
+ join?: JoinOption;
}) => {
transactionId++;
let thisTransactionId = transactionId;
@@ -965,11 +1062,31 @@ export const createAdapterFactory =
where: unsafeWhere,
});
unsafeModel = getDefaultModelName(unsafeModel);
+ let join: JoinConfig | undefined;
+ let passJoinToAdapter = true;
+ if (!config.disableTransformJoin) {
+ const result = transformJoinClause(
+ unsafeModel,
+ unsafeJoin,
+ undefined,
+ );
+ if (result) {
+ join = result.join;
+ }
+ // If adapter doesn't support joins and we have joins, don't pass them to the adapter
+ const experimentalJoins = options.experimental?.joins;
+ if (!experimentalJoins && join && Object.keys(join).length > 0) {
+ passJoinToAdapter = false;
+ }
+ } else {
+ // assume it's already transformed if transformation is disabled
+ join = unsafeJoin as never as JoinConfig;
+ }
debugLog(
{ method: "findMany" },
`${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
`${formatMethod("findMany")}:`,
- { model, where, limit, sortBy, offset },
+ { model, where, limit, sortBy, offset, join },
);
const res = await adapterInstance.findMany({
model,
@@ -977,6 +1094,7 @@ export const createAdapterFactory =
limit: limit,
sortBy,
offset,
+ join: passJoinToAdapter ? join : undefined,
});
debugLog(
{ method: "findMany" },
@@ -984,12 +1102,16 @@ export const createAdapterFactory =
`${formatMethod("findMany")} ${formatAction("DB Result")}:`,
{ model, data: res },
);
+
let transformed = res as any;
if (!config.disableTransformOutput) {
transformed = await Promise.all(
- res.map(async (r) => await transformOutput(r as any, unsafeModel)),
+ res.map(async (r: Record) => {
+ return await transformOutput(r, unsafeModel, undefined, join);
+ }),
);
}
+
debugLog(
{ method: "findMany" },
`${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
diff --git a/packages/better-auth/src/adapters/adapter-factory/test/adapter-factory.test.ts b/packages/better-auth/src/adapters/adapter-factory/test/adapter-factory.test.ts
index 3d57c949eb..e458992c6b 100644
--- a/packages/better-auth/src/adapters/adapter-factory/test/adapter-factory.test.ts
+++ b/packages/better-auth/src/adapters/adapter-factory/test/adapter-factory.test.ts
@@ -1659,3 +1659,355 @@ describe("Create Adapter Helper", async () => {
});
});
});
+
+describe("Fallback JoinOption System", async () => {
+ describe("supportsJoin: false (Fallback mode)", () => {
+ test("findOne: Should handle forward joins (joined model has FK to base model) by making separate queries", async () => {
+ let adapterCalls: Array<{ method: string; model: string }> = [];
+
+ const adapter = await createTestAdapter({
+ config: {
+ debugLogs: {},
+ },
+ options: {
+ experimental: {
+ // explicitally defining since in the future `join` will likely be default which would break this test
+ joins: false,
+ },
+ },
+ adapter: () =>
+ ({
+ async findOne({ model, where, join }: any) {
+ adapterCalls.push({ method: "findOne", model });
+
+ if (model === "user") {
+ return {
+ id: "user-123",
+ email: "test@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test User",
+ };
+ }
+ return null;
+ },
+ async findMany({ model, where }: any) {
+ adapterCalls.push({ method: "findMany", model });
+
+ if (model === "session") {
+ return [
+ {
+ id: "session-1",
+ userId: "user-123",
+ expiresAt: new Date(),
+ createdAt: new Date(),
+ },
+ ];
+ }
+ return [];
+ },
+ }) as any,
+ });
+
+ adapterCalls = [];
+ const res = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: "user-123" }],
+ join: { session: true },
+ });
+
+ // Should make two separate queries: one for user, one for sessions
+ expect(adapterCalls.length).toBeGreaterThanOrEqual(2);
+ expect(adapterCalls.some((c) => c.model === "user")).toBe(true);
+ expect(adapterCalls.some((c) => c.model === "session")).toBe(true);
+ expect(res).toHaveProperty("id");
+ // Adapter-factory's fallback join system should have added session data
+ expect(res).toHaveProperty("session");
+ });
+
+ test("findMany: Should handle forward joins efficiently using IN operator", async () => {
+ let adapterCalls: Array<{ method: string; model: string; where?: any }> =
+ [];
+
+ const adapter = await createTestAdapter({
+ config: {
+ debugLogs: {},
+ },
+ options: {
+ experimental: {
+ // explicitally defining since in the future `join` will likely be default which would break this test
+ joins: false,
+ },
+ },
+ adapter: () =>
+ ({
+ async findMany({ model, where }: any) {
+ adapterCalls.push({ method: "findMany", model, where });
+
+ if (model === "user") {
+ return [
+ {
+ id: "user-1",
+ email: "test1@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "User 1",
+ },
+ {
+ id: "user-2",
+ email: "test2@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "User 2",
+ },
+ ];
+ }
+
+ if (model === "session") {
+ // Should receive an IN query with user IDs
+ return [
+ {
+ id: "session-1",
+ userId: "user-1",
+ expiresAt: new Date(),
+ createdAt: new Date(),
+ },
+ {
+ id: "session-2",
+ userId: "user-2",
+ expiresAt: new Date(),
+ createdAt: new Date(),
+ },
+ ];
+ }
+ return [];
+ },
+ }) as any,
+ });
+
+ adapterCalls = [];
+ const res = await adapter.findMany({
+ model: "user",
+ where: [],
+ join: { session: true },
+ });
+
+ // Should use IN operator for joined data query, not individual queries
+ const sessionCall = adapterCalls.find((c) => c.model === "session");
+ expect(sessionCall).toBeDefined();
+ expect(sessionCall?.where).toBeDefined();
+
+ // Result should have session data attached by fallback join system
+ expect(res).toBeInstanceOf(Array);
+ expect(res.length).toBeGreaterThan(0);
+ res.forEach((item) => {
+ expect(item).toHaveProperty("session");
+ });
+ });
+
+ test("findOne: Should not pass join to adapter when supportsJoin is false", async () => {
+ let joinPassedToAdapter = null;
+
+ const adapter = await createTestAdapter({
+ config: {
+ debugLogs: {},
+ },
+ options: {
+ experimental: {
+ // explicitally defining since in the future `join` will likely be default which would break this test
+ joins: false,
+ },
+ },
+ adapter: () =>
+ ({
+ async findOne({ model, join }: any) {
+ joinPassedToAdapter = join;
+ return {
+ id: "user-123",
+ email: "test@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test User",
+ };
+ },
+ }) as any,
+ });
+
+ await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: "user-123" }],
+ join: { session: true },
+ });
+
+ // JoinOption should NOT be passed to adapter when supportsJoin is false
+ expect(joinPassedToAdapter).toBeUndefined();
+ });
+
+ test("findMany: Should not pass join to adapter when supportsJoin is false", async () => {
+ let joinPassedToAdapter = null;
+
+ const adapter = await createTestAdapter({
+ config: {
+ debugLogs: {},
+ },
+ options: {
+ experimental: {
+ // explicitally defining since in the future `join` will likely be default which would break this test
+ joins: false,
+ },
+ },
+ adapter: () =>
+ ({
+ async findMany({ model, join }: any) {
+ joinPassedToAdapter = join;
+ return [
+ {
+ id: "user-123",
+ email: "test@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test User",
+ },
+ ];
+ },
+ }) as any,
+ });
+
+ await adapter.findMany({
+ model: "user",
+ where: [],
+ join: { session: true },
+ });
+
+ // JoinOption should NOT be passed to adapter when supportsJoin is false
+ expect(joinPassedToAdapter).toBeUndefined();
+ });
+ });
+
+ describe("supportsJoin: true (Native join support)", () => {
+ test("findOne: Should pass join to adapter when supportsJoin is true", async () => {
+ let joinPassedToAdapter = null;
+
+ const adapter = await createTestAdapter({
+ config: {
+ debugLogs: {},
+ },
+ options: {
+ experimental: {
+ joins: true,
+ },
+ },
+ adapter: () =>
+ ({
+ async findOne({ model, join }: any) {
+ joinPassedToAdapter = join;
+ // When adapter supports joins, it returns data with joined structure
+ return {
+ id: "user-123",
+ email: "test@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test User",
+ };
+ },
+ }) as any,
+ });
+
+ await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: "user-123" }],
+ join: { session: true },
+ });
+
+ // JoinOption SHOULD be passed to adapter when supportsJoin is true
+ // It's then the adapter's responsibility to handle the join
+ expect(joinPassedToAdapter).not.toBeUndefined();
+ expect(joinPassedToAdapter).toHaveProperty("session");
+ });
+
+ test("findMany: Should pass join to adapter when supportsJoin is true", async () => {
+ let joinPassedToAdapter = null;
+
+ const adapter = await createTestAdapter({
+ config: {
+ debugLogs: {},
+ },
+ options: {
+ experimental: {
+ joins: true,
+ },
+ },
+ adapter: () =>
+ ({
+ async findMany({ model, join }: any) {
+ joinPassedToAdapter = join;
+ // When adapter supports joins, it returns data with joined structure
+ return [
+ {
+ id: "user-123",
+ email: "test@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test User",
+ },
+ ];
+ },
+ }) as any,
+ });
+
+ await adapter.findMany({
+ model: "user",
+ where: [],
+ join: { session: true },
+ });
+
+ // JoinOption SHOULD be passed to adapter when supportsJoin is true
+ // It's then the adapter's responsibility to handle the join
+ expect(joinPassedToAdapter).not.toBeUndefined();
+ expect(joinPassedToAdapter).toHaveProperty("session");
+ });
+ });
+
+ describe("Default behavior (supportsJoin not specified)", () => {
+ test("Should default to supportsJoin: false and use fallback join system", async () => {
+ let joinPassedToAdapter = null;
+
+ const adapter = await createTestAdapter({
+ config: {
+ // supportsJoin not specified, should default to false
+ debugLogs: {},
+ },
+ adapter: () =>
+ ({
+ async findOne({ model, join }: any) {
+ joinPassedToAdapter = join;
+ return {
+ id: "user-123",
+ email: "test@test.com",
+ emailVerified: false,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ name: "Test User",
+ };
+ },
+ }) as any,
+ });
+
+ await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: "user-123" }],
+ join: { session: true },
+ });
+
+ // Since supportsJoin defaults to false, join should NOT be passed
+ expect(joinPassedToAdapter).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/better-auth/src/adapters/adapter-factory/types.ts b/packages/better-auth/src/adapters/adapter-factory/types.ts
index ce0ad36e2e..af9f7c15c7 100644
--- a/packages/better-auth/src/adapters/adapter-factory/types.ts
+++ b/packages/better-auth/src/adapters/adapter-factory/types.ts
@@ -6,6 +6,7 @@ import type {
import type {
CustomAdapter as CoreCustomAdapter,
DBAdapterFactoryConfig,
+ JoinConfig,
} from "@better-auth/core/db/adapter";
import type {
AdapterSchemaCreation,
@@ -110,6 +111,7 @@ export type AdapterFactoryCustomizeAdapterCreator = (config: {
data: Record,
defaultModelName: string,
select?: string[] | undefined,
+ joinConfig?: JoinConfig | undefined,
) => Promise>;
transformWhereClause: ({
model,
diff --git a/packages/better-auth/src/adapters/create-test-suite.ts b/packages/better-auth/src/adapters/create-test-suite.ts
index bb3d8a54a0..3772bf628a 100644
--- a/packages/better-auth/src/adapters/create-test-suite.ts
+++ b/packages/better-auth/src/adapters/create-test-suite.ts
@@ -3,6 +3,7 @@ import type { DBAdapter } from "@better-auth/core/db/adapter";
import { TTY_COLORS } from "@better-auth/core/env";
import { test } from "vitest";
import { betterAuth } from "../auth";
+import { getAuthTables } from "../db/get-tables";
import type { Account, Session, User, Verification } from "../types";
import { generateId } from "../utils";
import { createAdapterFactory } from "./adapter-factory";
@@ -214,6 +215,7 @@ export const createTestSuite = <
adapterName: `Wrapped ${adapter.options?.adapterConfig.adapterName}`,
disableTransformOutput: true,
disableTransformInput: true,
+ disableTransformJoin: true,
};
const adapterCreator = (
options: BetterAuthOptions,
@@ -229,8 +231,14 @@ export const createTestSuite = <
count: adapter.count,
deleteMany: adapter.deleteMany,
delete: adapter.delete,
- findOne: adapter.findOne,
- findMany: adapter.findMany,
+ findOne: async (args) => {
+ const res = (await adapter.findOne(args)) as any;
+ return res;
+ },
+ findMany: async (args) => {
+ const res = (await adapter.findMany(args)) as any;
+ return res;
+ },
update: adapter.update as any,
updateMany: adapter.updateMany,
@@ -269,6 +277,8 @@ export const createTestSuite = <
adapter = await helpers.adapter();
for (const model of Object.keys(createdRows)) {
for (const row of createdRows[model]!) {
+ const schema = getAuthTables(helpers.getBetterAuthOptions());
+ if (!schema[model]) continue; // model doesn't exist in the schema anymore, so we skip it
try {
await adapter.delete({
model,
@@ -578,7 +588,7 @@ export const createTestSuite = <
test.skipIf(shouldSkip)(
testName,
- { timeout: 10000 },
+ { timeout: 30000 },
async ({ onTestFailed, skip }) => {
resetDebugLogs();
onTestFailed(async () => {
diff --git a/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts b/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts
index 6cb5d6cdc2..dc860b077e 100644
--- a/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts
+++ b/packages/better-auth/src/adapters/drizzle-adapter/drizzle-adapter.ts
@@ -76,7 +76,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
let lazyOptions: BetterAuthOptions | null = null;
const createCustomAdapter =
(db: DB): AdapterFactoryCustomizeAdapterCreator =>
- ({ getFieldName, debugLog }) => {
+ ({ getFieldName, options }) => {
function getSchema(model: string) {
const schema = config.schema || db._.fullSchema;
if (!schema) {
@@ -352,6 +352,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
}
}
}
+
return {
async create({ model, data: values }) {
const schemaModel = getSchema(model);
@@ -360,34 +361,144 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
const returned = await withReturning(model, builder, values);
return returned;
},
- async findOne({ model, where }) {
+ async findOne({ model, where, join }) {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
- const res = await db
+
+ if (options.experimental?.joins) {
+ if (!db.query || !db.query[model]) {
+ logger.error(
+ `[# Drizzle Adapter]: The model "${model}" was not found in the query object. Please update your schema to include relations or re-generate using "npx auth generate".`,
+ );
+ logger.info("Falling back to regular query");
+ } else {
+ let includes:
+ | Record
+ | undefined;
+
+ const pluralJoinResults: string[] = [];
+ if (join) {
+ includes = {};
+ const joinEntries = Object.entries(join);
+ for (const [model, joinAttr] of joinEntries) {
+ const limit =
+ joinAttr.limit ??
+ options.advanced?.database?.defaultFindManyLimit ??
+ 100;
+ const isUnique = joinAttr.relation === "one-to-one";
+ includes[`${model}${isUnique ? "" : "s"}`] = isUnique
+ ? true
+ : { limit };
+ if (!isUnique) pluralJoinResults.push(`${model}s`);
+ }
+ }
+ let query = db.query[model].findFirst({
+ where: clause[0],
+ with: includes,
+ });
+ const res = await query;
+ if (res) {
+ for (const pluralJoinResult of pluralJoinResults) {
+ const singularJoinResultName = pluralJoinResult.slice(0, -1);
+ res[singularJoinResultName] = res[pluralJoinResult];
+ delete res[pluralJoinResult];
+ }
+ }
+ return res;
+ }
+ }
+
+ let query = db
.select()
.from(schemaModel)
.where(...clause);
+
+ const res = await query;
if (!res.length) return null;
return res[0];
},
- async findMany({ model, where, sortBy, limit, offset }) {
+ async findMany({ model, where, sortBy, limit, offset, join }) {
const schemaModel = getSchema(model);
const clause = where ? convertWhereClause(where, model) : [];
-
const sortFn = sortBy?.direction === "desc" ? desc : asc;
- const builder = db
- .select()
- .from(schemaModel)
- .limit(limit || 100)
- .offset(offset || 0);
+
+ if (options.experimental?.joins) {
+ if (!db.query[model]) {
+ logger.error(
+ `[# Drizzle Adapter]: The model "${model}" was not found in the query object. Please update your schema to include relations or re-generate using "npx auth generate".`,
+ );
+ logger.info("Falling back to regular query");
+ } else {
+ let includes:
+ | Record
+ | undefined;
+
+ const pluralJoinResults: string[] = [];
+ if (join) {
+ includes = {};
+ const joinEntries = Object.entries(join);
+ for (const [model, joinAttr] of joinEntries) {
+ const isUnique = joinAttr.relation === "one-to-one";
+ const limit =
+ joinAttr.limit ??
+ options.advanced?.database?.defaultFindManyLimit ??
+ 100;
+ includes[`${model}${isUnique ? "" : "s"}`] = isUnique
+ ? true
+ : { limit };
+ if (!isUnique) pluralJoinResults.push(`${model}s`);
+ }
+ }
+ let orderBy: SQL[] | undefined = undefined;
+ if (sortBy?.field) {
+ orderBy = [
+ sortFn(
+ schemaModel[getFieldName({ model, field: sortBy?.field })],
+ ),
+ ];
+ }
+ let query = db.query[model].findMany({
+ where: clause[0],
+ with: includes,
+ limit: limit ?? 100,
+ offset: offset ?? 0,
+ orderBy,
+ });
+ let res = await query;
+ if (res) {
+ for (const item of res) {
+ for (const pluralJoinResult of pluralJoinResults) {
+ const singularJoinResultName = pluralJoinResult.slice(
+ 0,
+ -1,
+ );
+ item[singularJoinResultName] = item[pluralJoinResult];
+ delete item[pluralJoinResult];
+ }
+ }
+ }
+ return res;
+ }
+ }
+
+ let builder = db.select().from(schemaModel);
+
+ const effectiveLimit = limit ?? 100;
+ const effectiveOffset = offset ?? 0;
+
+ builder = builder.limit(effectiveLimit);
+ builder = builder.offset(effectiveOffset);
+
if (sortBy?.field) {
- builder.orderBy(
+ builder = builder.orderBy(
sortFn(
schemaModel[getFieldName({ model, field: sortBy?.field })],
),
);
}
- return (await builder.where(...clause)) as any[];
+
+ const res = await builder.where(...clause);
+ return res;
},
async count({ model, where }) {
const schemaModel = getSchema(model);
diff --git a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts
index d1194cf9a1..8a803e171d 100644
--- a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts
+++ b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.mysql.test.ts
@@ -5,6 +5,7 @@ import { assert } from "vitest";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -14,14 +15,14 @@ import { drizzleAdapter } from "../drizzle-adapter";
import { generateDrizzleSchema, resetGenerationCount } from "./generate-schema";
const mysqlDB = createPool({
- uri: "mysql://user:password@localhost:3306",
+ uri: "mysql://user:password@localhost:3306/better_auth",
timezone: "Z",
});
const { execute } = await testAdapter({
adapter: async (options) => {
const { schema } = await generateDrizzleSchema(mysqlDB, options, "mysql");
- return drizzleAdapter(drizzle(mysqlDB), {
+ return drizzleAdapter(drizzle(mysqlDB, { schema, mode: "default" }), {
debugLogs: { isRunningAdapterTests: true },
schema,
provider: "mysql",
@@ -66,6 +67,7 @@ const { execute } = await testAdapter({
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
async onFinish() {
diff --git a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts
index 75f01f6182..31de8d15d8 100644
--- a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts
+++ b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.pg.test.ts
@@ -4,6 +4,7 @@ import { Pool } from "pg";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -26,7 +27,7 @@ const cleanupDatabase = async (shouldDestroy = false) => {
const { execute } = await testAdapter({
adapter: async (options) => {
const { schema } = await generateDrizzleSchema(pgDB, options, "pg");
- return drizzleAdapter(drizzle(pgDB), {
+ return drizzleAdapter(drizzle(pgDB, { schema }), {
debugLogs: { isRunningAdapterTests: true },
schema,
provider: "pg",
@@ -62,6 +63,7 @@ const { execute } = await testAdapter({
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
async onFinish() {
diff --git a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts
index 9b93592b06..cdf0df7945 100644
--- a/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts
+++ b/packages/better-auth/src/adapters/drizzle-adapter/test/adapter.drizzle.sqlite.test.ts
@@ -6,6 +6,7 @@ import path from "path";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -24,7 +25,7 @@ let sqliteDB = new Database(dbFilePath);
const { execute } = await testAdapter({
adapter: async (options) => {
const { schema } = await generateDrizzleSchema(sqliteDB, options, "sqlite");
- return drizzleAdapter(drizzle(sqliteDB), {
+ return drizzleAdapter(drizzle(sqliteDB, { schema }), {
debugLogs: { isRunningAdapterTests: true },
schema,
provider: "sqlite",
@@ -66,6 +67,7 @@ const { execute } = await testAdapter({
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
async onFinish() {
diff --git a/packages/better-auth/src/adapters/index.ts b/packages/better-auth/src/adapters/index.ts
index 81a57fd877..8720ed80d3 100644
--- a/packages/better-auth/src/adapters/index.ts
+++ b/packages/better-auth/src/adapters/index.ts
@@ -6,6 +6,12 @@ import {
type AdapterTestDebugLogs,
type CustomAdapter,
createAdapterFactory,
+ initGetDefaultFieldName,
+ initGetDefaultModelName,
+ initGetFieldAttributes,
+ initGetFieldName,
+ initGetIdField,
+ initGetModelName,
} from "./adapter-factory";
export * from "@better-auth/core/db/adapter";
@@ -18,8 +24,15 @@ export type {
CustomAdapter,
AdapterFactoryCustomizeAdapterCreator,
};
-
-export { createAdapterFactory };
+export {
+ createAdapterFactory,
+ initGetDefaultFieldName,
+ initGetDefaultModelName,
+ initGetFieldName,
+ initGetModelName,
+ initGetFieldAttributes,
+ initGetIdField,
+};
/**
* @deprecated Use `createAdapterFactory` instead. This export will be removed in the next major version.
diff --git a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts
index 4a05f5cfa2..99d311e098 100644
--- a/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts
+++ b/packages/better-auth/src/adapters/kysely-adapter/kysely-adapter.ts
@@ -2,12 +2,14 @@ import type { BetterAuthOptions } from "@better-auth/core";
import type {
DBAdapter,
DBAdapterDebugLogOption,
+ JoinConfig,
Where,
} from "@better-auth/core/db/adapter";
import {
type InsertQueryBuilder,
type Kysely,
type RawBuilder,
+ sql,
type UpdateQueryBuilder,
} from "kysely";
import {
@@ -52,7 +54,37 @@ export const kyselyAdapter = (
const createCustomAdapter = (
db: Kysely,
): AdapterFactoryCustomizeAdapterCreator => {
- return ({ getFieldName }) => {
+ return ({
+ getFieldName,
+ schema,
+ getDefaultFieldName,
+ getDefaultModelName,
+ getFieldAttributes,
+ getModelName,
+ }) => {
+ const selectAllJoins = (join: JoinConfig | undefined) => {
+ // Use selectAll which will handle column naming appropriately
+ const allSelects: RawBuilder[] = [];
+ const allSelectsStr: { joinModel: string; fieldName: string }[] = [];
+ if (join) {
+ for (const [joinModel, _] of Object.entries(join)) {
+ const fields = schema[getDefaultModelName(joinModel)]?.fields;
+ if (!fields) continue;
+ fields.id = { type: "string" }; // make sure there is at least an id field
+ for (const [field, fieldAttr] of Object.entries(fields)) {
+ allSelects.push(
+ sql`${sql.ref(`join_${joinModel}`)}.${sql.ref(fieldAttr.fieldName || field)} as ${sql.ref(`_joined_${joinModel}_${fieldAttr.fieldName || field}`)}`,
+ );
+ allSelectsStr.push({
+ joinModel,
+ fieldName: fieldAttr.fieldName || field,
+ });
+ }
+ }
+ }
+ return { allSelectsStr, allSelects };
+ };
+
const withReturning = async (
values: Record,
builder:
@@ -119,61 +151,58 @@ export const kyselyAdapter = (
connector = "AND",
} = condition;
let value: any = _value;
- let field: string | RawBuilder = getFieldName({
+ let field: string | any = getFieldName({
model,
field: _field,
});
const expr = (eb: any) => {
+ const f = `${model}.${field}`;
if (operator.toLowerCase() === "in") {
- return eb(field, "in", Array.isArray(value) ? value : [value]);
+ return eb(f, "in", Array.isArray(value) ? value : [value]);
}
if (operator.toLowerCase() === "not_in") {
- return eb(
- field,
- "not in",
- Array.isArray(value) ? value : [value],
- );
+ return eb(f, "not in", Array.isArray(value) ? value : [value]);
}
if (operator === "contains") {
- return eb(field, "like", `%${value}%`);
+ return eb(f, "like", `%${value}%`);
}
if (operator === "starts_with") {
- return eb(field, "like", `${value}%`);
+ return eb(f, "like", `${value}%`);
}
if (operator === "ends_with") {
- return eb(field, "like", `%${value}`);
+ return eb(f, "like", `%${value}`);
}
if (operator === "eq") {
- return eb(field, "=", value);
+ return eb(f, "=", value);
}
if (operator === "ne") {
- return eb(field, "<>", value);
+ return eb(f, "<>", value);
}
if (operator === "gt") {
- return eb(field, ">", value);
+ return eb(f, ">", value);
}
if (operator === "gte") {
- return eb(field, ">=", value);
+ return eb(f, ">=", value);
}
if (operator === "lt") {
- return eb(field, "<", value);
+ return eb(f, "<", value);
}
if (operator === "lte") {
- return eb(field, "<=", value);
+ return eb(f, "<=", value);
}
- return eb(field, operator, value);
+ return eb(f, operator, value);
};
if (connector === "OR") {
@@ -188,61 +217,291 @@ export const kyselyAdapter = (
or: conditions.or.length ? conditions.or : null,
};
}
+
+ function processJoinedResults(
+ rows: any[],
+ joinConfig: JoinConfig | undefined,
+ allSelectsStr: { joinModel: string; fieldName: string }[],
+ ) {
+ if (!joinConfig || !rows.length) {
+ return rows;
+ }
+
+ // Group rows by main model ID
+ const groupedByMainId = new Map();
+
+ for (const currentRow of rows) {
+ // Separate main model columns from joined columns
+ const mainModelFields: Record = {};
+ const joinedModelFields: Record> = {};
+
+ // Initialize joined model fields map
+ for (const [joinModel] of Object.entries(joinConfig)) {
+ joinedModelFields[getModelName(joinModel)] = {};
+ }
+
+ // Distribute all columns - collect complete objects per model
+ for (const [key, value] of Object.entries(currentRow)) {
+ const keyStr = String(key);
+ let assigned = false;
+
+ // Check if this is a joined column
+ for (const { joinModel, fieldName } of allSelectsStr) {
+ if (keyStr === `_joined_${joinModel}_${fieldName}`) {
+ joinedModelFields[getModelName(joinModel)]![
+ getFieldName({
+ model: joinModel,
+ field: fieldName,
+ })
+ ] = value;
+ assigned = true;
+ break;
+ }
+ }
+
+ if (!assigned) {
+ mainModelFields[key] = value;
+ }
+ }
+
+ const mainId = mainModelFields.id;
+ if (!mainId) continue;
+
+ // Initialize or get existing entry for this main model
+ if (!groupedByMainId.has(mainId)) {
+ const entry: Record = { ...mainModelFields };
+
+ // Initialize joined models based on uniqueness
+ for (const [joinModel, joinAttr] of Object.entries(joinConfig)) {
+ entry[getModelName(joinModel)] =
+ joinAttr.relation === "one-to-one" ? null : [];
+ }
+
+ groupedByMainId.set(mainId, entry);
+ }
+
+ const entry = groupedByMainId.get(mainId)!;
+
+ // Add joined records to the entry
+ for (const [joinModel, joinAttr] of Object.entries(joinConfig)) {
+ const isUnique = joinAttr.relation === "one-to-one";
+ const limit = joinAttr.limit ?? 100;
+
+ const joinedObj = joinedModelFields[getModelName(joinModel)];
+
+ const hasData =
+ joinedObj &&
+ Object.keys(joinedObj).length > 0 &&
+ Object.values(joinedObj).some(
+ (value) => value !== null && value !== undefined,
+ );
+
+ if (isUnique) {
+ entry[getModelName(joinModel)] = hasData ? joinedObj : null;
+ } else {
+ // For arrays, append if not already there (deduplicate by id) and respect limit
+ const joinModelName = getModelName(joinModel);
+ if (Array.isArray(entry[joinModelName]) && hasData) {
+ // Check if we've reached the limit before processing
+ if (entry[joinModelName].length >= limit) {
+ continue;
+ }
+
+ // Get the id field name using getFieldName to ensure correct transformation
+ const idFieldName = getFieldName({
+ model: joinModel,
+ field: "id",
+ });
+ const joinedId = joinedObj[idFieldName];
+
+ // Only deduplicate if we have an id field
+ if (joinedId) {
+ const exists = entry[joinModelName].some(
+ (item: any) => item[idFieldName] === joinedId,
+ );
+ if (!exists && entry[joinModelName].length < limit) {
+ entry[joinModelName].push(joinedObj);
+ }
+ } else {
+ // If no id field, still add the object if it has data and limit not reached
+ if (entry[joinModelName].length < limit) {
+ entry[joinModelName].push(joinedObj);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ let result = Array.from(groupedByMainId.values());
+
+ // Apply final limit to non-unique join arrays as a safety measure
+ for (const entry of result) {
+ for (const [joinModel, joinAttr] of Object.entries(joinConfig)) {
+ if (joinAttr.relation !== "one-to-one") {
+ const joinModelName = getModelName(joinModel);
+ if (Array.isArray(entry[joinModelName])) {
+ const limit = joinAttr.limit ?? 100;
+ if (entry[joinModelName].length > limit) {
+ entry[joinModelName] = entry[joinModelName].slice(0, limit);
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
return {
async create({ data, model }) {
const builder = db.insertInto(model).values(data);
const returned = await withReturning(data, builder, model, []);
return returned;
},
- async findOne({ model, where, select }) {
+ async findOne({ model, where, select, join }) {
const { and, or } = convertWhereClause(model, where);
- let query = db.selectFrom(model).selectAll();
- if (and) {
- query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
- }
- if (or) {
- query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
- }
- const res = await query.executeTakeFirst();
- if (!res) return null;
- return res as any;
- },
- async findMany({ model, where, limit, offset, sortBy }) {
- const { and, or } = convertWhereClause(model, where);
- let query = db.selectFrom(model);
- if (and) {
- query = query.where((eb) => eb.and(and.map((expr) => expr(eb))));
- }
- if (or) {
- query = query.where((eb) => eb.or(or.map((expr) => expr(eb))));
- }
- if (config?.type === "mssql") {
- if (!offset) {
- query = query.top(limit || 100);
- }
- } else {
- query = query.limit(limit || 100);
- }
- if (sortBy) {
- query = query.orderBy(
- getFieldName({ model, field: sortBy.field }),
- sortBy.direction,
- );
- }
- if (offset) {
- if (config?.type === "mssql") {
- if (!sortBy) {
- query = query.orderBy(getFieldName({ model, field: "id" }));
+ let query: any = db
+ .selectFrom((eb) => {
+ let b = eb.selectFrom(model);
+ if (and) {
+ b = b.where((eb: any) =>
+ eb.and(and.map((expr: any) => expr(eb))),
+ );
}
- query = query.offset(offset).fetch(limit || 100);
- } else {
- query = query.offset(offset);
+ if (or) {
+ b = b.where((eb: any) =>
+ eb.or(or.map((expr: any) => expr(eb))),
+ );
+ }
+ return b.selectAll().as(model);
+ })
+ .selectAll(model);
+
+ if (join) {
+ // Add joins
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
+ let joinQuery = db
+ .selectFrom(joinModel)
+ .selectAll()
+ .as(`join_${joinModel}`);
+
+ query = query.leftJoin(
+ () => joinQuery,
+ (join: any) =>
+ join.onRef(
+ `join_${joinModel}.${joinAttr.on.to}`,
+ "=",
+ `${model}.${joinAttr.on.from}`,
+ ),
+ );
}
}
- const res = await query.selectAll().execute();
+ const { allSelectsStr, allSelects } = selectAllJoins(join);
+ query = query.select(allSelects);
+
+ const res = await query.execute();
+ if (!res || !Array.isArray(res) || res.length === 0) return null;
+
+ // Get the first row from the result array
+ const row = res[0];
+
+ if (join) {
+ const processedRows = processJoinedResults(
+ res,
+ join,
+ allSelectsStr,
+ );
+
+ return processedRows[0] as any;
+ }
+
+ return row as any;
+ },
+ async findMany({ model, where, limit, offset, sortBy, join }) {
+ const { and, or } = convertWhereClause(model, where);
+ let query: any = db
+ .selectFrom((eb) => {
+ let b = eb.selectFrom(model);
+
+ if (config?.type === "mssql") {
+ if (offset !== undefined) {
+ if (!sortBy) {
+ b = b.orderBy(getFieldName({ model, field: "id" }));
+ }
+ b = b.offset(offset).fetch(limit || 100);
+ } else if (limit !== undefined) {
+ b = b.top(limit);
+ }
+ } else {
+ if (limit !== undefined) {
+ b = b.limit(limit);
+ }
+ if (offset !== undefined) {
+ b = b.offset(offset);
+ }
+ }
+
+ if (sortBy?.field) {
+ b = b.orderBy(
+ `${getFieldName({ model, field: sortBy.field })}`,
+ sortBy.direction,
+ );
+ }
+
+ if (and) {
+ b = b.where((eb: any) =>
+ eb.and(and.map((expr: any) => expr(eb))),
+ );
+ }
+
+ if (or) {
+ b = b.where((eb: any) =>
+ eb.or(or.map((expr: any) => expr(eb))),
+ );
+ }
+
+ return b.selectAll().as(model);
+ })
+ .selectAll(model);
+
+ if (join) {
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
+ let joinQueryBuilder = db
+ .selectFrom(joinModel)
+ .selectAll()
+ .as(`join_${joinModel}`);
+
+ query = query.leftJoin(
+ () => joinQueryBuilder,
+ (join: any) =>
+ join.onRef(
+ `join_${joinModel}.${joinAttr.on.to}`,
+ "=",
+ `${model}.${joinAttr.on.from}`,
+ ),
+ );
+ }
+ }
+
+ const { allSelectsStr, allSelects } = selectAllJoins(join);
+ query = query.select(allSelects);
+
+ if (sortBy?.field) {
+ query = query.orderBy(
+ `${getFieldName({ model, field: sortBy.field })}`,
+ sortBy.direction,
+ );
+ }
+
+ // console.log("sql query:", await query.compile());
+ const res = await query.execute();
+ // console.log(`raw unparsed result:`, res);
+
if (!res) return [];
- return res as any;
+ if (join) return processJoinedResults(res, join, allSelectsStr);
+ return res;
},
async update({ model, where, update: values }) {
const { and, or } = convertWhereClause(model, where);
diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.custom-schema-pg.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.custom-schema-pg.test.ts
index 23671172e5..ad600f41ea 100644
--- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.custom-schema-pg.test.ts
+++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.custom-schema-pg.test.ts
@@ -5,9 +5,11 @@ import { getMigrations } from "../../../db";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
+ uuidTestSuite,
} from "../../tests";
import { kyselyAdapter } from "../kysely-adapter";
@@ -20,7 +22,7 @@ const CUSTOM_SCHEMA = "auth";
// Connection string with custom schema in search_path
const pgDB = new Pool({
- connectionString: `postgres://user:password@localhost:5433/better_auth?options=-c search_path=${CUSTOM_SCHEMA}`,
+ connectionString: `postgres://user:password@localhost:5435/better_auth?options=-c search_path=${CUSTOM_SCHEMA}`,
});
let kyselyDB = new Kysely({
@@ -96,6 +98,8 @@ const { execute } = await testAdapter({
transactionsTestSuite(),
authFlowTestSuite(),
numberIdTestSuite(),
+ uuidTestSuite(),
+ joinsTestSuite(),
],
async onFinish() {
// Clean up
diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts
index ee846bffac..8b3193202e 100644
--- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts
+++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mssql.test.ts
@@ -6,6 +6,7 @@ import { getMigrations } from "../../../db";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -16,49 +17,57 @@ import { kyselyAdapter } from "../kysely-adapter";
// We are not allowed to handle the mssql connection
// we must let kysely handle it. This is because if kysely is already
// handling it, and we were to connect it ourselves, it will create bugs.
-const dialect = new MssqlDialect({
- tarn: {
- ...Tarn,
- options: {
- min: 0,
- max: 10,
- },
- },
- tedious: {
- ...Tedious,
- connectionFactory: () =>
- new Tedious.Connection({
- authentication: {
- options: {
- password: "Password123!",
- userName: "sa",
- },
- type: "default",
- },
- options: {
- database: "master", // Start with master database, will create better_auth if needed
- port: 1433,
- trustServerCertificate: true,
- encrypt: false,
- },
- server: "localhost",
- }),
- TYPES: {
- ...Tedious.TYPES,
- DateTime: Tedious.TYPES.DateTime2,
- },
- },
-});
-const kyselyDB = new Kysely({
- dialect: dialect,
-});
+// Helper function to create a connection factory for a specific database
+const createConnectionFactory = (database: string) => () =>
+ new Tedious.Connection({
+ authentication: {
+ options: {
+ password: "Password123!",
+ userName: "sa",
+ },
+ type: "default",
+ },
+ options: {
+ database: database,
+ port: 1433,
+ trustServerCertificate: true,
+ encrypt: false,
+ connectTimeout: 30000,
+ requestTimeout: 30000,
+ },
+ server: "localhost",
+ });
// Create better_auth database if it doesn't exist
+// We need to connect to 'master' database first since 'better_auth' may not exist yet
const ensureDatabaseExists = async () => {
try {
- console.log("Ensuring better_auth database exists...");
- await kyselyDB.getExecutor().executeQuery({
+ // console.log("Ensuring better_auth database exists...");
+ // Create a temporary connection to 'master' database to create 'better_auth'
+ const masterDialect = new MssqlDialect({
+ tarn: {
+ ...Tarn,
+ options: {
+ min: 0,
+ max: 5,
+ },
+ },
+ tedious: {
+ ...Tedious,
+ connectionFactory: createConnectionFactory("master"),
+ TYPES: {
+ ...Tedious.TYPES,
+ DateTime: Tedious.TYPES.DateTime2,
+ },
+ },
+ });
+
+ const masterDB = new Kysely({
+ dialect: masterDialect,
+ });
+
+ await masterDB.getExecutor().executeQuery({
sql: `
IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'better_auth')
BEGIN
@@ -74,23 +83,43 @@ const ensureDatabaseExists = async () => {
query: { kind: "SelectQueryNode" },
queryId: { queryId: "ensure-db" },
});
- console.log("Database check/creation completed");
+
+ await masterDB.destroy();
+ // console.log("Database check/creation completed");
} catch (error) {
console.error("Failed to ensure database exists:", error);
throw error;
}
};
+// Create dialect for better_auth database (after ensuring it exists)
+const dialect = new MssqlDialect({
+ tarn: {
+ ...Tarn,
+ options: {
+ min: 0,
+ max: 50,
+ },
+ },
+ tedious: {
+ ...Tedious,
+ connectionFactory: createConnectionFactory("better_auth"),
+ TYPES: {
+ ...Tedious.TYPES,
+ DateTime: Tedious.TYPES.DateTime2,
+ },
+ },
+});
+
+const kyselyDB = new Kysely({
+ dialect: dialect,
+});
+
// Warm up connection for CI environments
const warmupConnection = async () => {
const isCI =
process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
if (isCI) {
- console.log("Warming up MSSQL connection for CI environment...");
- console.log(
- `Environment: CI=${process.env.CI}, GITHUB_ACTIONS=${process.env.GITHUB_ACTIONS}`,
- );
-
try {
await ensureDatabaseExists();
@@ -101,7 +130,6 @@ const warmupConnection = async () => {
query: { kind: "SelectQueryNode" },
queryId: { queryId: "warmup" },
});
- console.log("Connection warmup successful");
} catch (error) {
console.warn(
"Connection warmup failed, will retry during validation:",
@@ -128,14 +156,10 @@ const validateConnection = async (retries: number = 10): Promise => {
const maxRetries = isCI ? 15 : retries; // More retries in CI
const baseDelay = isCI ? 2000 : 1000; // Longer delays in CI
- console.log(
- `Validating connection (CI: ${isCI}, max retries: ${maxRetries})`,
- );
-
for (let i = 0; i < maxRetries; i++) {
try {
await query("SELECT 1 as test", isCI ? 10000 : 5000);
- console.log("Connection validated successfully");
+ // console.log("Connection validated successfully");
return true;
} catch (error) {
console.warn(
@@ -161,10 +185,6 @@ const query = async (sql: string, timeoutMs: number = 30000) => {
const actualTimeout = isCI ? Math.max(timeoutMs, 60000) : timeoutMs; // Minimum 60s timeout in CI
try {
- console.log(
- `Executing SQL: ${sql.substring(0, 100)}... (timeout: ${actualTimeout}ms, CI: ${isCI})`,
- );
-
// Ensure we're using the better_auth database for queries
const sqlWithContext = sql.includes("USE ")
? sql
@@ -184,7 +204,6 @@ const query = async (sql: string, timeoutMs: number = 30000) => {
),
),
])) as any;
- console.log(`Query completed successfully`);
return { rows: result.rows, rowCount: result.rows.length };
} catch (error) {
console.error(`Query failed: ${error}`);
@@ -193,11 +212,18 @@ const query = async (sql: string, timeoutMs: number = 30000) => {
};
const showDB = async () => {
+ const tables = await query(`SELECT TABLE_NAME, TABLE_SCHEMA
+FROM INFORMATION_SCHEMA.TABLES
+WHERE TABLE_TYPE = 'BASE TABLE'
+ORDER BY TABLE_SCHEMA, TABLE_NAME;`);
+
+ console.log("Available tables:", tables);
+
const DB = {
- users: await query("SELECT * FROM [user]"),
- sessions: await query("SELECT * FROM [session]"),
- accounts: await query("SELECT * FROM [account]"),
- verifications: await query("SELECT * FROM [verification]"),
+ user: await query("SELECT * FROM [user]"),
+ session: await query("SELECT * FROM [session]"),
+ account: await query("SELECT * FROM [account]"),
+ verification: await query("SELECT * FROM [verification]"),
};
console.log(`DB`, DB);
};
@@ -208,10 +234,6 @@ const resetDB = async (retryCount: number = 0) => {
const maxRetries = isCI ? 3 : 1; // Allow retries in CI
try {
- console.log(
- `Starting database reset... (attempt ${retryCount + 1}/${maxRetries + 1})`,
- );
-
// Warm up connection first (especially important for CI)
await warmupConnection();
@@ -257,8 +279,6 @@ const resetDB = async (retryCount: number = 0) => {
`,
15000,
);
-
- console.log("Database reset completed successfully");
} catch (error) {
console.error("Database reset failed:", error);
@@ -274,7 +294,6 @@ const resetDB = async (retryCount: number = 0) => {
// Final fallback - try to recreate the database
try {
- console.log("Attempting database recreation...");
// This would require a separate connection to master database
// For now, just throw the error with better context
throw new Error(`Database reset failed completely: ${error}`);
@@ -295,19 +314,36 @@ const { execute } = await testAdapter({
});
},
async runMigrations(betterAuthOptions) {
+ console.log(`Starting MSSQL migrations`);
await resetDB();
+ console.log(`Finished resetting MSSQL database`);
const opts = Object.assign(betterAuthOptions, {
database: { db: kyselyDB, type: "mssql" },
} satisfies BetterAuthOptions);
- const { runMigrations } = await getMigrations(opts);
+ console.log(`Running MSSQL migrations`);
+ const { runMigrations, compileMigrations } = await getMigrations(opts);
+ const CI =
+ process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
+ // Helpful for debugging mssql enviroment issues on Github actions
+ if (CI) {
+ console.log(`Compiling MSSQL migrations`);
+ const migrations = await compileMigrations();
+ console.log(`Migrations:`, migrations);
+ }
await runMigrations();
+ console.log(`Finished running MSSQL migrations`);
},
prefixTests: "mssql",
tests: [
- normalTestSuite(),
+ normalTestSuite({
+ async showDB() {
+ await showDB();
+ },
+ }),
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite({ showDB }),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
async onFinish() {
diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts
index b3c05521f8..c6a95bac60 100644
--- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts
+++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.mysql.test.ts
@@ -5,6 +5,7 @@ import { getMigrations } from "../../../db";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -48,6 +49,7 @@ const { execute } = await testAdapter({
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
async onFinish() {
diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts
index eb204d4bb3..07779e1070 100644
--- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts
+++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.pg.test.ts
@@ -5,6 +5,7 @@ import { getMigrations } from "../../../db";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -45,7 +46,8 @@ const { execute } = await testAdapter({
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite(),
numberIdTestSuite(),
- uuidTestSuite({}),
+ joinsTestSuite(),
+ uuidTestSuite(),
],
async onFinish() {
await pgDB.end();
diff --git a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts
index 8ee2139a3b..480a656e6f 100644
--- a/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts
+++ b/packages/better-auth/src/adapters/kysely-adapter/test/adapter.kysely.sqlite.test.ts
@@ -6,6 +6,7 @@ import { getMigrations } from "../../../db";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -42,10 +43,11 @@ const { execute } = await testAdapter({
await runMigrations();
},
tests: [
- normalTestSuite({}),
- transactionsTestSuite({ disableTests: { ALL: true } }),
+ normalTestSuite(),
+ transactionsTestSuite(),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite({}),
],
async onFinish() {
diff --git a/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts b/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts
index 29873577a9..96adda7347 100644
--- a/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts
+++ b/packages/better-auth/src/adapters/memory-adapter/adapter.memory.test.ts
@@ -2,6 +2,7 @@ import { getAuthTables } from "../../db";
import { testAdapter } from "../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -29,6 +30,7 @@ const { execute } = await testAdapter({
transactionsTestSuite({ disableTests: { ALL: true } }),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
async onFinish() {},
diff --git a/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts b/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts
index 63edbd3e47..7f057995e3 100644
--- a/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts
+++ b/packages/better-auth/src/adapters/memory-adapter/memory-adapter.ts
@@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core";
import type {
CleanedWhere,
DBAdapterDebugLogOption,
+ JoinConfig,
} from "@better-auth/core/db/adapter";
import { logger } from "@better-auth/core/env";
import { createAdapterFactory } from "../adapter-factory";
@@ -48,71 +49,194 @@ export const memoryAdapter = (
}
},
},
- adapter: ({ getFieldName, options, debugLog, getModelName }) => {
- function convertWhereClause(where: CleanedWhere[], model: string) {
- const table = db[model];
- if (!table) {
- logger.error(
- `[MemoryAdapter] Model ${model} not found in the DB`,
- Object.keys(db),
- );
- throw new Error(`Model ${model} not found`);
- }
+ adapter: ({ getFieldName, options, getModelName }) => {
+ const applySortToRecords = (
+ records: any[],
+ sortBy: { field: string; direction: "asc" | "desc" } | undefined,
+ model: string,
+ ) => {
+ if (!sortBy) return records;
+ return records.sort((a: any, b: any) => {
+ const field = getFieldName({ model, field: sortBy.field });
+ const aValue = a[field];
+ const bValue = b[field];
- const evalClause = (record: any, clause: CleanedWhere): boolean => {
- const { field, value, operator } = clause;
- switch (operator) {
- case "in":
- if (!Array.isArray(value)) {
- throw new Error("Value must be an array");
- }
- // @ts-expect-error
- return value.includes(record[field]);
- case "not_in":
- if (!Array.isArray(value)) {
- throw new Error("Value must be an array");
- }
- // @ts-expect-error
- return !value.includes(record[field]);
- case "contains":
- return record[field].includes(value);
- case "starts_with":
- return record[field].startsWith(value);
- case "ends_with":
- return record[field].endsWith(value);
- case "ne":
- return record[field] !== value;
- case "gt":
- return value != null && Boolean(record[field] > value);
- case "gte":
- return value != null && Boolean(record[field] >= value);
- case "lt":
- return value != null && Boolean(record[field] < value);
- case "lte":
- return value != null && Boolean(record[field] <= value);
- default:
- return record[field] === value;
+ let comparison = 0;
+
+ // Handle null/undefined values
+ if (aValue == null && bValue == null) {
+ comparison = 0;
+ } else if (aValue == null) {
+ comparison = -1;
+ } else if (bValue == null) {
+ comparison = 1;
}
+ // Handle string comparison
+ else if (typeof aValue === "string" && typeof bValue === "string") {
+ comparison = aValue.localeCompare(bValue);
+ }
+ // Handle date comparison
+ else if (aValue instanceof Date && bValue instanceof Date) {
+ comparison = aValue.getTime() - bValue.getTime();
+ }
+ // Handle numeric comparison
+ else if (typeof aValue === "number" && typeof bValue === "number") {
+ comparison = aValue - bValue;
+ }
+ // Handle boolean comparison
+ else if (typeof aValue === "boolean" && typeof bValue === "boolean") {
+ comparison = aValue === bValue ? 0 : aValue ? 1 : -1;
+ }
+ // Fallback to string comparison
+ else {
+ comparison = String(aValue).localeCompare(String(bValue));
+ }
+
+ return sortBy.direction === "asc" ? comparison : -comparison;
+ });
+ };
+
+ function convertWhereClause(
+ where: CleanedWhere[],
+ model: string,
+ join?: JoinConfig,
+ ): any[] {
+ const execute = (where: CleanedWhere[], model: string) => {
+ const table = db[model];
+ if (!table) {
+ logger.error(
+ `[MemoryAdapter] Model ${model} not found in the DB`,
+ Object.keys(db),
+ );
+ throw new Error(`Model ${model} not found`);
+ }
+
+ const evalClause = (record: any, clause: CleanedWhere): boolean => {
+ const { field, value, operator } = clause;
+ switch (operator) {
+ case "in":
+ if (!Array.isArray(value)) {
+ throw new Error("Value must be an array");
+ }
+ // @ts-expect-error
+ return value.includes(record[field]);
+ case "not_in":
+ if (!Array.isArray(value)) {
+ throw new Error("Value must be an array");
+ }
+ // @ts-expect-error
+ return !value.includes(record[field]);
+ case "contains":
+ return record[field].includes(value);
+ case "starts_with":
+ return record[field].startsWith(value);
+ case "ends_with":
+ return record[field].endsWith(value);
+ case "ne":
+ return record[field] !== value;
+ case "gt":
+ return value != null && Boolean(record[field] > value);
+ case "gte":
+ return value != null && Boolean(record[field] >= value);
+ case "lt":
+ return value != null && Boolean(record[field] < value);
+ case "lte":
+ return value != null && Boolean(record[field] <= value);
+ default:
+ return record[field] === value;
+ }
+ };
+
+ return table.filter((record: any) => {
+ if (!where.length || where.length === 0) {
+ return true;
+ }
+
+ let result = evalClause(record, where[0]!);
+ for (const clause of where) {
+ const clauseResult = evalClause(record, clause);
+
+ if (clause.connector === "OR") {
+ result = result || clauseResult;
+ } else {
+ result = result && clauseResult;
+ }
+ }
+
+ return result;
+ });
};
- return table.filter((record: any) => {
- if (!where.length || where.length === 0) {
- return true;
+ if (!join) return execute(where, model);
+
+ const baseRecords = execute(where, model);
+
+ // Group results by base model and nest joined data as arrays
+ const grouped = new Map();
+ // Track seen IDs per joined model for O(1) deduplication
+ const seenIds = new Map>();
+
+ for (const baseRecord of baseRecords) {
+ const baseId = String(baseRecord.id);
+
+ if (!grouped.has(baseId)) {
+ const nested: Record = { ...baseRecord };
+
+ // Initialize joined data structures based on isUnique
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
+ const joinModelName = getModelName(joinModel);
+ if (joinAttr.relation === "one-to-one") {
+ nested[joinModelName] = null;
+ } else {
+ nested[joinModelName] = [];
+ seenIds.set(`${baseId}-${joinModel}`, new Set());
+ }
+ }
+
+ grouped.set(baseId, nested);
}
- let result = evalClause(record, where[0]!);
- for (const clause of where) {
- const clauseResult = evalClause(record, clause);
+ const nestedEntry = grouped.get(baseId)!;
- if (clause.connector === "OR") {
- result = result || clauseResult;
+ // Add joined data
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
+ const joinModelName = getModelName(joinModel);
+ const joinTable = db[joinModelName];
+ if (!joinTable) {
+ logger.error(
+ `[MemoryAdapter] JoinOption model ${joinModelName} not found in the DB`,
+ Object.keys(db),
+ );
+ throw new Error(`JoinOption model ${joinModelName} not found`);
+ }
+
+ const matchingRecords = joinTable.filter(
+ (joinRecord: any) =>
+ joinRecord[joinAttr.on.to] === baseRecord[joinAttr.on.from],
+ );
+
+ if (joinAttr.relation === "one-to-one") {
+ // For unique relationships, store a single object (or null)
+ nestedEntry[joinModelName] = matchingRecords[0] || null;
} else {
- result = result && clauseResult;
+ // For non-unique relationships, store array with limit
+ const seenSet = seenIds.get(`${baseId}-${joinModel}`)!;
+ const limit = joinAttr.limit ?? 100;
+ let count = 0;
+
+ for (const matchingRecord of matchingRecords) {
+ if (count >= limit) break;
+ if (!seenSet.has(matchingRecord.id)) {
+ nestedEntry[joinModelName].push(matchingRecord);
+ seenSet.add(matchingRecord.id);
+ count++;
+ }
+ }
}
}
+ }
- return result;
- });
+ return Array.from(grouped.values());
}
return {
create: async ({ model, data }) => {
@@ -129,65 +253,50 @@ export const memoryAdapter = (
db[model]!.push(data);
return data;
},
- findOne: async ({ model, where }) => {
- const res = convertWhereClause(where, model);
- const record = res[0] || null;
+ findOne: async ({ model, where, join }) => {
+ const res = convertWhereClause(where, model, join);
+ if (join) {
+ // When join is present, res is an array of nested objects
+ const resArray = res as any[];
+ if (!resArray.length) {
+ return null;
+ }
+ // Return the first nested object
+ return resArray[0];
+ }
+ // Without join, res is an array
+ const resArray = res as any[];
+ const record = resArray[0] || null;
return record;
},
- findMany: async ({ model, where, sortBy, limit, offset }) => {
- let table = db[model];
- if (where) {
- table = convertWhereClause(where, model);
+ findMany: async ({ model, where, sortBy, limit, offset, join }) => {
+ let res = convertWhereClause(where || [], model, join);
+
+ if (join) {
+ // When join is present, res is an array of nested objects
+ const resArray = res as any[];
+ if (!resArray.length) {
+ return [];
+ }
+
+ // Apply sorting to nested objects
+ applySortToRecords(resArray, sortBy, model);
+
+ // Apply offset and limit
+ let paginatedRecords = resArray;
+ if (offset !== undefined) {
+ paginatedRecords = paginatedRecords.slice(offset);
+ }
+ if (limit !== undefined) {
+ paginatedRecords = paginatedRecords.slice(0, limit);
+ }
+
+ return paginatedRecords;
}
- if (sortBy) {
- table = table!.sort((a, b) => {
- const field = getFieldName({ model, field: sortBy.field });
- const aValue = a[field];
- const bValue = b[field];
- let comparison = 0;
-
- // Handle null/undefined values
- if (aValue == null && bValue == null) {
- comparison = 0;
- } else if (aValue == null) {
- comparison = -1;
- } else if (bValue == null) {
- comparison = 1;
- }
- // Handle string comparison
- else if (
- typeof aValue === "string" &&
- typeof bValue === "string"
- ) {
- comparison = aValue.localeCompare(bValue);
- }
- // Handle date comparison
- else if (aValue instanceof Date && bValue instanceof Date) {
- comparison = aValue.getTime() - bValue.getTime();
- }
- // Handle numeric comparison
- else if (
- typeof aValue === "number" &&
- typeof bValue === "number"
- ) {
- comparison = aValue - bValue;
- }
- // Handle boolean comparison
- else if (
- typeof aValue === "boolean" &&
- typeof bValue === "boolean"
- ) {
- comparison = aValue === bValue ? 0 : aValue ? 1 : -1;
- }
- // Fallback to string comparison
- else {
- comparison = String(aValue).localeCompare(String(bValue));
- }
-
- return sortBy.direction === "asc" ? comparison : -comparison;
- });
- }
+ // Without join - original logic
+ const resArray = res as any[];
+ let table = applySortToRecords(resArray, sortBy, model);
if (offset !== undefined) {
table = table!.slice(offset);
}
diff --git a/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts b/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts
index eab841cc23..d963360557 100644
--- a/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts
+++ b/packages/better-auth/src/adapters/mongodb-adapter/adapter.mongo-db.test.ts
@@ -2,6 +2,7 @@ import { MongoClient, ObjectId } from "mongodb";
import { testAdapter } from "../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
transactionsTestSuite,
} from "../tests";
@@ -30,6 +31,7 @@ const { execute } = await testAdapter({
normalTestSuite(),
authFlowTestSuite(),
transactionsTestSuite(),
+ joinsTestSuite(),
// numberIdTestSuite(), // no support
// uuidTestSuite() // no support
],
diff --git a/packages/better-auth/src/adapters/mongodb-adapter/mongodb-adapter.ts b/packages/better-auth/src/adapters/mongodb-adapter/mongodb-adapter.ts
index 3c6c53ecf7..83e2aa2802 100644
--- a/packages/better-auth/src/adapters/mongodb-adapter/mongodb-adapter.ts
+++ b/packages/better-auth/src/adapters/mongodb-adapter/mongodb-adapter.ts
@@ -4,7 +4,6 @@ import type {
DBAdapterDebugLogOption,
Where,
} from "@better-auth/core/db/adapter";
-import { createLogger } from "@better-auth/core/env";
import { ClientSession, type Db, type MongoClient, ObjectId } from "mongodb";
import {
type AdapterFactoryCustomizeAdapterCreator,
@@ -60,9 +59,14 @@ export const mongodbAdapter = (
db: Db,
session?: ClientSession | undefined,
): AdapterFactoryCustomizeAdapterCreator =>
- ({ options, getFieldName, schema, getDefaultModelName }) => {
+ ({
+ getFieldAttributes,
+ getFieldName,
+ schema,
+ getDefaultModelName,
+ options,
+ }) => {
const customIdGen = getCustomIdGenerator(options);
- const logger = createLogger(options.logger);
function serializeID({
field,
@@ -104,10 +108,14 @@ export const mongodbAdapter = (
if (v instanceof ObjectId) {
return v;
}
- throw new Error("Invalid id value");
+ throw new Error(
+ "Invalid id value, recieved: " + JSON.stringify(v),
+ );
});
}
- throw new Error("Invalid id value");
+ throw new Error(
+ "Invalid id value, recieved: " + JSON.stringify(value),
+ );
}
try {
return new ObjectId(value);
@@ -267,38 +275,234 @@ export const mongodbAdapter = (
const insertedData = { _id: res.insertedId.toString(), ...values };
return insertedData as any;
},
- async findOne({ model, where, select }) {
- const clause = convertWhereClause({ where, model });
- const projection = select
- ? Object.fromEntries(
- select.map((field) => [getFieldName({ field, model }), 1]),
- )
- : undefined;
+ async findOne({ model, where, select, join }) {
+ const matchStage = where
+ ? { $match: convertWhereClause({ where, model }) }
+ : { $match: {} };
+ const pipeline: any[] = [matchStage];
+
+ if (join) {
+ for (const [joinedModel, joinConfig] of Object.entries(join)) {
+ const localField = getFieldName({
+ field: joinConfig.on.from,
+ model,
+ });
+ const foreignField = getFieldName({
+ field: joinConfig.on.to,
+ model: joinedModel,
+ });
+
+ const localFieldName = localField === "id" ? "_id" : localField;
+ const foreignFieldName =
+ foreignField === "id" ? "_id" : foreignField;
+
+ // Only unwind if the foreign field has a unique constraint (one-to-one relationship)
+ const joinedModelSchema =
+ schema[getDefaultModelName(joinedModel)];
+ const foreignFieldAttribute =
+ joinedModelSchema?.fields[joinConfig.on.to];
+ const isUnique = foreignFieldAttribute?.unique === true;
+
+ // For unique relationships, limit is ignored (as per JoinConfig type)
+ // For non-unique relationships, apply limit if specified
+ const shouldLimit = !isUnique && joinConfig.limit !== undefined;
+ let limit =
+ joinConfig.limit ??
+ options.advanced?.database?.defaultFindManyLimit ??
+ 100;
+ if (shouldLimit && limit > 0) {
+ // Use pipeline syntax to support limit
+ // Construct the field reference string for the foreign field
+ const foreignFieldRef = `$${foreignFieldName}`;
+ pipeline.push({
+ $lookup: {
+ from: joinedModel,
+ let: { localFieldValue: `$${localFieldName}` },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $eq: [foreignFieldRef, "$$localFieldValue"],
+ },
+ },
+ },
+ { $limit: limit },
+ ],
+ as: joinedModel,
+ },
+ });
+ } else {
+ // Use simple syntax when no limit is needed
+ pipeline.push({
+ $lookup: {
+ from: joinedModel,
+ localField: localFieldName,
+ foreignField: foreignFieldName,
+ as: joinedModel,
+ },
+ });
+ }
+
+ if (isUnique) {
+ // For one-to-one relationships, unwind to flatten to a single object
+ pipeline.push({
+ $unwind: {
+ path: `$${joinedModel}`,
+ preserveNullAndEmptyArrays: true,
+ },
+ });
+ }
+ // For one-to-many, keep as array - no unwind
+ }
+ }
+
+ if (select) {
+ const projection: any = {};
+ select.forEach((field) => {
+ projection[getFieldName({ field, model })] = 1;
+ });
+
+ // Include joined collections in projection
+ if (join) {
+ for (const joinedModel of Object.keys(join)) {
+ projection[joinedModel] = 1;
+ }
+ }
+
+ pipeline.push({ $project: projection });
+ }
+
+ pipeline.push({ $limit: 1 });
+
const res = await db
.collection(model)
- .findOne(clause, { session, projection });
- if (!res) return null;
- return res as any;
+ .aggregate(pipeline, { session })
+ .toArray();
+
+ if (!res || res.length === 0) return null;
+ return res[0] as any;
},
- async findMany({ model, where, limit, offset, sortBy }) {
- const clause = where ? convertWhereClause({ where, model }) : {};
- const cursor = db.collection(model).find(clause, { session });
- if (limit) cursor.limit(limit);
- if (offset) cursor.skip(offset);
- if (sortBy)
- cursor.sort(
- getFieldName({ field: sortBy.field, model }),
- sortBy.direction === "desc" ? -1 : 1,
- );
- const res = await cursor.toArray();
+ async findMany({ model, where, limit, offset, sortBy, join }) {
+ const matchStage = where
+ ? { $match: convertWhereClause({ where, model }) }
+ : { $match: {} };
+ const pipeline: any[] = [matchStage];
+
+ if (join) {
+ for (const [joinedModel, joinConfig] of Object.entries(join)) {
+ const localField = getFieldName({
+ field: joinConfig.on.from,
+ model,
+ });
+ const foreignField = getFieldName({
+ field: joinConfig.on.to,
+ model: joinedModel,
+ });
+
+ const localFieldName = localField === "id" ? "_id" : localField;
+ const foreignFieldName =
+ foreignField === "id" ? "_id" : foreignField;
+
+ // Only unwind if the foreign field has a unique constraint (one-to-one relationship)
+ const foreignFieldAttribute = getFieldAttributes({
+ model: joinedModel,
+ field: joinConfig.on.to,
+ });
+ const isUnique = foreignFieldAttribute?.unique === true;
+
+ // For unique relationships, limit is ignored (as per JoinConfig type)
+ // For non-unique relationships, apply limit if specified
+ const shouldLimit =
+ joinConfig.relation !== "one-to-one" &&
+ joinConfig.limit !== undefined;
+
+ let limit =
+ joinConfig.limit ??
+ options.advanced?.database?.defaultFindManyLimit ??
+ 100;
+ if (shouldLimit && limit > 0) {
+ // Use pipeline syntax to support limit
+ // Construct the field reference string for the foreign field
+ const foreignFieldRef = `$${foreignFieldName}`;
+ pipeline.push({
+ $lookup: {
+ from: joinedModel,
+ let: { localFieldValue: `$${localFieldName}` },
+ pipeline: [
+ {
+ $match: {
+ $expr: {
+ $eq: [foreignFieldRef, "$$localFieldValue"],
+ },
+ },
+ },
+ { $limit: limit },
+ ],
+ as: joinedModel,
+ },
+ });
+ } else {
+ // Use simple syntax when no limit is needed
+ pipeline.push({
+ $lookup: {
+ from: joinedModel,
+ localField: localFieldName,
+ foreignField: foreignFieldName,
+ as: joinedModel,
+ },
+ });
+ }
+
+ if (isUnique) {
+ // For one-to-one relationships, unwind to flatten to a single object
+ pipeline.push({
+ $unwind: {
+ path: `$${joinedModel}`,
+ preserveNullAndEmptyArrays: true,
+ },
+ });
+ }
+ // For one-to-many, keep as array - no unwind
+ }
+ }
+
+ if (sortBy) {
+ pipeline.push({
+ $sort: {
+ [getFieldName({ field: sortBy.field, model })]:
+ sortBy.direction === "desc" ? -1 : 1,
+ },
+ });
+ }
+
+ if (offset) {
+ pipeline.push({ $skip: offset });
+ }
+
+ if (limit) {
+ pipeline.push({ $limit: limit });
+ }
+
+ const res = await db
+ .collection(model)
+ .aggregate(pipeline, { session })
+ .toArray();
+
return res as any;
},
async count({ model, where }) {
- const clause = where ? convertWhereClause({ where, model }) : {};
+ const matchStage = where
+ ? { $match: convertWhereClause({ where, model }) }
+ : { $match: {} };
+ const pipeline: any[] = [matchStage, { $count: "total" }];
+
const res = await db
.collection(model)
- .countDocuments(clause, { session });
- return res;
+ .aggregate(pipeline, { session })
+ .toArray();
+
+ if (!res || res.length === 0) return 0;
+ return res[0]?.total ?? 0;
},
async update({ model, where, update: values }) {
const clause = convertWhereClause({ where, model });
@@ -404,11 +608,22 @@ export const mongodbAdapter = (
return data;
}
if (Array.isArray(data)) {
- return data.map((v) => new ObjectId());
+ return data.map((v) => {
+ if (typeof v === "string") {
+ try {
+ const oid = new ObjectId(v);
+ return oid;
+ } catch (error) {
+ return v;
+ }
+ }
+ return v;
+ });
}
if (typeof data === "string") {
try {
- return new ObjectId(data);
+ const oid = new ObjectId(data);
+ return oid;
} catch (error) {
return new ObjectId();
}
@@ -420,7 +635,8 @@ export const mongodbAdapter = (
) {
return null;
}
- return new ObjectId();
+ const oid = new ObjectId();
+ return oid;
}
return data;
},
diff --git a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts
index c060de8c2a..57c120c179 100644
--- a/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts
+++ b/packages/better-auth/src/adapters/prisma-adapter/prisma-adapter.ts
@@ -2,6 +2,7 @@ import type { BetterAuthOptions } from "@better-auth/core";
import type {
DBAdapter,
DBAdapterDebugLogOption,
+ JoinConfig,
Where,
} from "@better-auth/core/db/adapter";
import { BetterAuthError } from "@better-auth/core/error";
@@ -69,20 +70,106 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => {
let lazyOptions: BetterAuthOptions | null = null;
const createCustomAdapter =
(prisma: PrismaClient): AdapterFactoryCustomizeAdapterCreator =>
- ({ getFieldName }) => {
+ ({
+ getFieldName,
+ getModelName,
+ getFieldAttributes,
+ getDefaultModelName,
+ schema,
+ }) => {
const db = prisma as PrismaClientInternal;
const convertSelect = (
- select?: string[] | undefined,
- model?: string | undefined,
+ select: string[] | undefined,
+ model: string,
+ join?: JoinConfig | undefined,
) => {
- if (!select || !model) return undefined;
- return select.reduce((prev, cur) => {
- return {
- ...prev,
- [getFieldName({ model, field: cur })]: true,
- };
- }, {});
+ if (!select && !join) return undefined;
+
+ let result: Record | boolean> = {};
+
+ if (select) {
+ for (const field of select) {
+ result[getFieldName({ model, field })] = true;
+ }
+ }
+
+ if (join) {
+ // when joining that has a limit, we need to use Prisma's `select` syntax to append the limit to the field
+ // because of such, it also means we need to select all base-model fields as well
+ // should check if `select` is not provided, because then we should select all base-model fields
+ if (!select) {
+ const fields = schema[getDefaultModelName(model)]?.fields || {};
+ fields.id = { type: "string" }; // make sure there is at least an id field
+ for (const field of Object.keys(fields)) {
+ result[getFieldName({ model, field })] = true;
+ }
+ }
+
+ for (const [joinModel, joinAttr] of Object.entries(join)) {
+ const key = getJoinKeyName(model, getModelName(joinModel), schema);
+ if (joinAttr.relation === "one-to-one") {
+ result[key] = true;
+ } else {
+ result[key] = { take: joinAttr.limit };
+ }
+ }
+ }
+
+ return result;
+ };
+
+ /**
+ * Build the join key name based on whether the foreign field is unique or not.
+ * If unique, use singular. Otherwise, pluralize (add 's').
+ */
+ const getJoinKeyName = (
+ baseModel: string,
+ joinedModel: string,
+ schema: any,
+ ): string => {
+ try {
+ const defaultBaseModelName = getDefaultModelName(baseModel);
+ const defaultJoinedModelName = getDefaultModelName(joinedModel);
+ const key = getModelName(joinedModel).toLowerCase();
+
+ // First, check if the joined model has FKs to the base model (forward join)
+ let foreignKeys = Object.entries(
+ schema[defaultJoinedModelName]?.fields || {},
+ ).filter(
+ ([_field, fieldAttributes]: any) =>
+ fieldAttributes.references &&
+ getDefaultModelName(fieldAttributes.references.model) ===
+ defaultBaseModelName,
+ );
+
+ if (foreignKeys.length > 0) {
+ // Forward join: joined model has FK to base model
+ // This is typically a one-to-many relationship (plural)
+ // Unless the FK is unique, then it's one-to-one (singular)
+ const [_foreignKey, foreignKeyAttributes] = foreignKeys[0] as any;
+ // Only check if field is explicitly marked as unique
+ const isUnique = foreignKeyAttributes?.unique === true;
+ return isUnique ? key : `${key}s`;
+ }
+
+ // Check backwards: does the base model have FKs to the joined model?
+ foreignKeys = Object.entries(
+ schema[defaultBaseModelName]?.fields || {},
+ ).filter(
+ ([_field, fieldAttributes]: any) =>
+ fieldAttributes.references &&
+ getDefaultModelName(fieldAttributes.references.model) ===
+ defaultJoinedModelName,
+ );
+
+ if (foreignKeys.length > 0) {
+ return key;
+ }
+ } catch {
+ // Fallback to pluralizing if we can't determine uniqueness
+ }
+ return `${getModelName(joinedModel).toLowerCase()}s`;
};
function operatorToPrismaOperator(operator: string) {
switch (operator) {
@@ -163,24 +250,14 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => {
`Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`,
);
}
- return await db[model]!.create({
+ const result = await db[model]!.create({
data: values,
select: convertSelect(select, model),
});
+ return result;
},
- async findOne({ model, where, select }) {
- const whereClause = convertWhereClause(model, where);
- if (!db[model]) {
- throw new BetterAuthError(
- `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`,
- );
- }
- return await db[model]!.findFirst({
- where: whereClause,
- select: convertSelect(select, model),
- });
- },
- async findMany({ model, where, limit, offset, sortBy }) {
+ async findOne({ model, where, select, join }) {
+ // this is just "JoinOption" type because we disabled join transformation in adapter config
const whereClause = convertWhereClause(model, where);
if (!db[model]) {
throw new BetterAuthError(
@@ -188,7 +265,57 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => {
);
}
- return (await db[model]!.findMany({
+ // transform join keys to use Prisma expected field names
+ let map = new Map();
+ if (join) {
+ for (const [joinModel, value] of Object.entries(join)) {
+ const key = getJoinKeyName(model, joinModel, schema);
+ map.set(key, getModelName(joinModel));
+ }
+ }
+
+ const selects = convertSelect(select, model, join);
+
+ let result = (
+ await db[model]!.findMany({
+ where: whereClause,
+ select: selects,
+ take: 1,
+ })
+ )[0];
+
+ // transform the resulting `include` items to use better-auth expected field names
+ if (join && result) {
+ for (const [includeKey, originalKey] of map.entries()) {
+ if (includeKey === originalKey) continue;
+ if (includeKey in result) {
+ result[originalKey] = result[includeKey];
+ delete result[includeKey];
+ }
+ }
+ }
+ return result;
+ },
+ async findMany({ model, where, limit, offset, sortBy, join }) {
+ // this is just "JoinOption" type because we disabled join transformation in adapter config
+ const whereClause = convertWhereClause(model, where);
+ if (!db[model]) {
+ throw new BetterAuthError(
+ `Model ${model} does not exist in the database. If you haven't generated the Prisma client, you need to run 'npx prisma generate'`,
+ );
+ }
+ // transform join keys to use Prisma expected field names
+ let map = new Map();
+ if (join) {
+ for (const [joinModel, value] of Object.entries(join)) {
+ const key = getJoinKeyName(model, joinModel, schema);
+ map.set(key, getModelName(joinModel));
+ }
+ }
+
+ const selects = convertSelect(undefined, model, join);
+
+ const result = await db[model]!.findMany({
where: whereClause,
take: limit || 100,
skip: offset || 0,
@@ -200,7 +327,23 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => {
},
}
: {}),
- })) as any[];
+ select: selects,
+ });
+
+ // transform the resulting join items to use better-auth expected field names
+ if (join && Array.isArray(result)) {
+ for (const item of result) {
+ for (const [includeKey, originalKey] of map.entries()) {
+ if (includeKey === originalKey) continue;
+ if (includeKey in item) {
+ item[originalKey] = item[includeKey];
+ delete item[includeKey];
+ }
+ }
+ }
+ }
+
+ return result;
},
async count({ model, where }) {
const whereClause = convertWhereClause(model, where);
diff --git a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts
index aa7f5f9c02..9d4e68b360 100644
--- a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts
+++ b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.mysql.test.ts
@@ -3,6 +3,7 @@ import { createPool } from "mysql2/promise";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -47,6 +48,7 @@ const { execute } = await testAdapter({
transactionsTestSuite(),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
onFinish: async () => {},
diff --git a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts
index 174d76f465..c909f386f0 100644
--- a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts
+++ b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.pg.test.ts
@@ -3,6 +3,7 @@ import { Pool } from "pg";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -45,6 +46,7 @@ const { execute } = await testAdapter({
transactionsTestSuite(),
authFlowTestSuite(),
numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite({}),
],
onFinish: async () => {},
diff --git a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts
index 58e14f3ea9..991ad31e74 100644
--- a/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts
+++ b/packages/better-auth/src/adapters/prisma-adapter/test/prisma.sqlite.test.ts
@@ -4,6 +4,7 @@ import { join } from "path";
import { testAdapter } from "../../test-adapter";
import {
authFlowTestSuite,
+ joinsTestSuite,
normalTestSuite,
numberIdTestSuite,
transactionsTestSuite,
@@ -44,10 +45,11 @@ const { execute } = await testAdapter({
destroyPrismaClient({ migrationCount: migrationCount - 1, dialect });
},
tests: [
- normalTestSuite({}),
+ normalTestSuite(),
transactionsTestSuite(),
authFlowTestSuite(),
- numberIdTestSuite({}),
+ numberIdTestSuite(),
+ joinsTestSuite(),
uuidTestSuite(),
],
onFinish: async () => {},
diff --git a/packages/better-auth/src/adapters/test-adapter.ts b/packages/better-auth/src/adapters/test-adapter.ts
index 3b70541f21..3b6c49d6c2 100644
--- a/packages/better-auth/src/adapters/test-adapter.ts
+++ b/packages/better-auth/src/adapters/test-adapter.ts
@@ -197,12 +197,12 @@ export const testAdapter = async ({
describe(adapterDisplayName, async () => {
beforeAll(async () => {
await migrate();
- }, 20000);
+ }, 60000);
afterAll(async () => {
await cleanup();
await onFinish?.();
- }, 20000);
+ }, 60000);
for (const testSuite of tests) {
await testSuite({
diff --git a/packages/better-auth/src/adapters/tests/normal.ts b/packages/better-auth/src/adapters/tests/basic.ts
similarity index 57%
rename from packages/better-auth/src/adapters/tests/normal.ts
rename to packages/better-auth/src/adapters/tests/basic.ts
index 698da3d04d..507eea6588 100644
--- a/packages/better-auth/src/adapters/tests/normal.ts
+++ b/packages/better-auth/src/adapters/tests/basic.ts
@@ -1,39 +1,54 @@
import type { BetterAuthPlugin } from "@better-auth/core";
import { expect } from "vitest";
-import type { User } from "../../types";
+import {
+ type Invitation,
+ type Member,
+ type Organization,
+ organization,
+ type Team,
+} from "../../plugins";
+import type { Account, Session, User } from "../../types";
import { createTestSuite } from "../create-test-suite";
/**
* This test suite tests the basic CRUD operations of the adapter.
*/
-export const normalTestSuite = createTestSuite("normal", {}, (helpers) => {
- const tests = getNormalTestSuiteTests(helpers);
- return {
- "init - tests": async () => {
- const opts = helpers.getBetterAuthOptions();
- expect(
- !opts.advanced?.database?.useNumberId &&
- opts.advanced?.database?.generateId !== "serial",
- ).toBeTruthy();
- },
- ...tests,
- };
-});
+export const normalTestSuite = createTestSuite(
+ "normal",
+ {},
+ (helpers, debugTools?: { showDB?: () => Promise }) => {
+ const tests = getNormalTestSuiteTests(helpers, debugTools);
+ return {
+ "init - tests": async () => {
+ const opts = helpers.getBetterAuthOptions();
+ expect(
+ !opts.advanced?.database?.useNumberId &&
+ opts.advanced?.database?.generateId !== "serial",
+ ).toBeTruthy();
+ },
+ ...tests,
+ };
+ },
+);
-export const getNormalTestSuiteTests = ({
- adapter,
- generate,
- insertRandom,
- modifyBetterAuthOptions,
- sortModels,
- customIdGenerator,
- getBetterAuthOptions,
- transformGeneratedModel,
- transformIdOutput,
-}: Parameters[2]>[0]) => {
+export const getNormalTestSuiteTests = (
+ {
+ adapter,
+ generate,
+ insertRandom,
+ modifyBetterAuthOptions,
+ sortModels,
+ customIdGenerator,
+ getBetterAuthOptions,
+ transformGeneratedModel,
+ transformIdOutput,
+ }: Parameters[2]>[0],
+ debugTools?: { showDB?: () => Promise },
+) => {
return {
"create - should create a model": async () => {
const user = await generate("user");
+ // console.log(`pre-transformed:`, user);
const result = await adapter.create({
model: "user",
data: user,
@@ -50,7 +65,6 @@ export const getNormalTestSuiteTests = ({
expect(typeof result.id).toEqual("string");
const transformed = transformGeneratedModel(user);
- // console.log(`pre-transformed:`, user);
// console.log(`transformed:`, transformed);
// console.log(`result:`, result);
expect(result).toEqual(transformed);
@@ -319,6 +333,62 @@ export const getNormalTestSuiteTests = ({
});
expect(result).toEqual(user);
},
+ "findOne - should find a model with join": async () => {
+ const users: User[] = [];
+ const sessions: Session[] = [];
+ const accounts: Account[] = [];
+ let i = -1;
+ for (const _ of Array.from({ length: 3 })) {
+ i++;
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ },
+ forceAllowId: true,
+ });
+ users.push(user);
+ const userId = users[0]!.id;
+ const session = await adapter.create({
+ model: "session",
+ data: {
+ ...(await generate("session")),
+ userId,
+ },
+ forceAllowId: true,
+ });
+ sessions.push(session);
+ const account = await adapter.create({
+ model: "account",
+ data: {
+ ...(await generate("account")),
+ userId,
+ },
+ forceAllowId: true,
+ });
+ accounts.push(account);
+ }
+
+ type ExpectedResult = User & { session: Session[]; account: Account[] };
+
+ const result = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: users[0]!.id }],
+ join: {
+ session: true,
+ account: true,
+ },
+ });
+ expect({
+ ...result,
+ session: result?.session.sort((a, b) => a.id.localeCompare(b.id)),
+ account: result?.account.sort((a, b) => a.id.localeCompare(b.id)),
+ }).toEqual({
+ ...users[0]!,
+ session: sessions.sort((a, b) => a.id.localeCompare(b.id)),
+ account: accounts.sort((a, b) => a.id.localeCompare(b.id)),
+ });
+ },
"findOne - should find a model with modified field name": async () => {
await modifyBetterAuthOptions(
{
@@ -396,6 +466,134 @@ export const getNormalTestSuiteTests = ({
});
expect(result).toEqual({ email: user.email, name: user.name });
},
+ "findOne - should select fields with one-to-many join": async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+
+ type ResultType = Pick & {
+ session: Session[];
+ };
+
+ const result = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: user.id }],
+ select: ["email", "name"],
+ join: { session: true },
+ });
+
+ expect(result).toBeDefined();
+ expect(result?.email).toEqual(user.email);
+ expect(result?.name).toEqual(user.name);
+ expect(result?.session).toBeDefined();
+ expect(Array.isArray(result?.session)).toBe(true);
+ expect(result?.session).toHaveLength(1);
+ expect(result?.session[0]).toEqual(session);
+ },
+ "findOne - should select fields with one-to-one join": async () => {
+ await modifyBetterAuthOptions(
+ {
+ plugins: [
+ {
+ id: "one-to-one-test",
+ schema: {
+ oneToOneTable: {
+ fields: {
+ oneToOne: {
+ type: "string",
+ required: true,
+ references: { field: "id", model: "user" },
+ unique: true,
+ },
+ },
+ },
+ },
+ } satisfies BetterAuthPlugin,
+ ],
+ },
+ true,
+ );
+ type OneToOneTable = { oneToOne: string };
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ },
+ forceAllowId: true,
+ });
+
+ const oneToOne = await adapter.create({
+ model: "oneToOneTable",
+ data: {
+ oneToOne: user.id,
+ },
+ });
+
+ type ResultType = Pick & {
+ oneToOneTable: OneToOneTable;
+ };
+
+ const result = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: user.id }],
+ select: ["email", "name"],
+ join: { oneToOneTable: true },
+ });
+
+ expect(result).toBeDefined();
+ expect(result?.email).toEqual(user.email);
+ expect(result?.name).toEqual(user.name);
+ expect(result?.oneToOneTable).toBeDefined();
+ expect(result?.oneToOneTable).toEqual(oneToOne);
+ },
+ "findOne - should select fields with multiple joins": async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ const account = await adapter.create({
+ model: "account",
+ data: { ...(await generate("account")), userId: user.id },
+ forceAllowId: true,
+ });
+
+ type ResultType = Pick & {
+ session: Session[];
+ account: Account[];
+ };
+
+ const result = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: user.id }],
+ select: ["email", "name"],
+ join: { session: true, account: true },
+ });
+
+ expect(result).toBeDefined();
+ expect(result?.email).toEqual(user.email);
+ expect(result?.name).toEqual(user.name);
+ expect(result?.session).toBeDefined();
+ expect(Array.isArray(result?.session)).toBe(true);
+ expect(result?.session).toHaveLength(1);
+ expect(result?.session[0]).toEqual(session);
+ expect(result?.account).toBeDefined();
+ expect(Array.isArray(result?.account)).toBe(true);
+ expect(result?.account).toHaveLength(1);
+ expect(result?.account[0]).toEqual(account);
+ },
"findOne - should find model with date field": async () => {
const [user] = await insertRandom("user");
const result = await adapter.findOne({
@@ -406,6 +604,239 @@ export const getNormalTestSuiteTests = ({
expect(result?.createdAt).toBeInstanceOf(Date);
expect(result?.createdAt).toEqual(user.createdAt);
},
+ "findOne - should perform backwards joins": async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ const result = await adapter.findOne({
+ model: "session",
+ where: [{ field: "token", value: session.token }],
+ join: { user: true },
+ });
+ expect(result).toEqual({
+ ...session,
+ user: user,
+ });
+ },
+ "findOne - should return an object for one-to-one joins": async () => {
+ await modifyBetterAuthOptions(
+ {
+ plugins: [
+ {
+ id: "one-to-one-test",
+ schema: {
+ oneToOneTable: {
+ fields: {
+ oneToOne: {
+ type: "string",
+ required: true,
+ references: { field: "id", model: "user" },
+ unique: true,
+ },
+ },
+ },
+ },
+ } satisfies BetterAuthPlugin,
+ ],
+ },
+ true,
+ );
+ type OneToOneTable = { oneToOne: string };
+ const users = (await insertRandom("user", 2)).map((x) => x[0]);
+ const oneToOne = await adapter.create({
+ model: "oneToOneTable",
+ data: {
+ oneToOne: users[0]!.id,
+ },
+ });
+ // decoy second table that shouldn't be included in the result
+ await adapter.create({
+ model: "oneToOneTable",
+ data: {
+ oneToOne: users[1]!.id,
+ },
+ });
+ const result = await adapter.findOne<
+ User & { oneToOneTable: OneToOneTable }
+ >({
+ model: "user",
+ where: [{ field: "id", value: users[0]!.id }],
+ join: { oneToOneTable: true },
+ });
+ expect(result).toEqual({
+ ...users[0]!,
+ oneToOneTable: oneToOne,
+ });
+ },
+ "findOne - should return an array for one-to-many joins": async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ const result = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: user.id }],
+ join: { session: true },
+ });
+ expect(result).toEqual({
+ ...user,
+ session: [session],
+ });
+ },
+ "findOne - should work with both one-to-one and one-to-many joins":
+ async () => {
+ await modifyBetterAuthOptions(
+ {
+ plugins: [
+ {
+ id: "one-to-one-test",
+ schema: {
+ oneToOneTable: {
+ fields: {
+ oneToOne: {
+ type: "string",
+ required: true,
+ references: { field: "id", model: "user" },
+ unique: true,
+ },
+ },
+ },
+ },
+ } satisfies BetterAuthPlugin,
+ ],
+ },
+ true,
+ );
+ type OneToOneTable = { oneToOne: string };
+ const users = (await insertRandom("user", 2)).map((x) => x[0]);
+ const oneToOne = await adapter.create({
+ model: "oneToOneTable",
+ data: {
+ oneToOne: users[0]!.id,
+ },
+ });
+ const session1 = await adapter.create({
+ model: "session",
+ data: {
+ ...(await generate("session")),
+ userId: users[0]!.id,
+ createdAt: new Date(Date.now() - 3000),
+ },
+ forceAllowId: true,
+ });
+ const session2 = await adapter.create({
+ model: "session",
+ data: {
+ ...(await generate("session")),
+ userId: users[0]!.id,
+ createdAt: new Date(Date.now() - 1000),
+ },
+ forceAllowId: true,
+ });
+ let result = await adapter.findOne<
+ User & { oneToOneTable: OneToOneTable; session: Session[] }
+ >({
+ model: "user",
+ where: [{ field: "id", value: users[0]!.id }],
+ join: { oneToOneTable: true, session: true },
+ });
+ if (result?.session?.length) {
+ result.session = result.session.sort(
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
+ );
+ }
+
+ expect(result).toEqual({
+ ...users[0]!,
+ oneToOneTable: oneToOne,
+ session: [session1, session2],
+ });
+ },
+ "findOne - should return null for failed base model lookup that has joins":
+ async () => {
+ const options = getBetterAuthOptions();
+ const useUUIDs = options.advanced?.database?.generateId === "uuid";
+ const result = await adapter.findOne({
+ model: "user",
+ where: [
+ { field: "id", value: useUUIDs ? crypto.randomUUID() : "100000" },
+ ],
+ join: { session: true, account: true },
+ });
+ expect(result).toBeNull();
+ },
+ "findOne - should join a model with modified field name": async () => {
+ await modifyBetterAuthOptions(
+ {
+ user: {
+ fields: {
+ email: "email_address",
+ },
+ },
+ plugins: [
+ {
+ id: "one-to-one-test",
+ schema: {
+ oneToOneTable: {
+ modelName: "one_to_one_table",
+ fields: {
+ oneToOne: {
+ type: "string",
+ required: true,
+ references: { field: "email", model: "user" },
+ unique: true,
+ fieldName: "one_to_one",
+ },
+ },
+ },
+ },
+ } satisfies BetterAuthPlugin,
+ ],
+ },
+ true,
+ );
+
+ type OneToOneTable = { oneToOne: string; id: string };
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ },
+ forceAllowId: true,
+ });
+
+ const oneToOne = await adapter.create({
+ model: "oneToOneTable",
+ data: {
+ oneToOne: user.email,
+ },
+ });
+
+ const result = await adapter.findOne<
+ User & { oneToOneTable: OneToOneTable }
+ >({
+ model: "user",
+ where: [{ field: "email", value: user.email }],
+ join: { oneToOneTable: true },
+ });
+ expect(result).toEqual({
+ ...user,
+ oneToOneTable: oneToOne,
+ });
+ },
"findMany - should find many models with date fields": async () => {
const users = (await insertRandom("user", 3)).map((x) => x[0]);
const youngestUser = users.sort(
@@ -430,6 +861,375 @@ export const getNormalTestSuiteTests = ({
});
expect(sortModels(result)).toEqual(sortModels(users));
},
+ "findMany - should find many models with join": async () => {
+ type ExpectedResult = User & { session: Session[]; account: Account[] };
+ let expectedResult: ExpectedResult[] = [];
+
+ for (let i = 0; i < 10; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ ...(i < 3 ? { name: `join-user-${i}` } : {}),
+ },
+ forceAllowId: true,
+ });
+ let sessions: Session[] = [];
+ for (let index = 0; index < 3; index++) {
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ sessions.push(session);
+ }
+ let accounts: Account[] = [];
+ for (let index = 0; index < 3; index++) {
+ const account = await adapter.create({
+ model: "account",
+ data: { ...(await generate("account")), userId: user.id },
+ forceAllowId: true,
+ });
+ accounts.push(account);
+ }
+
+ if (i < 3) {
+ expectedResult.push({
+ ...user,
+ session: sessions,
+ account: accounts,
+ });
+ }
+ }
+
+ let result = await adapter.findMany({
+ model: "user",
+ where: [{ field: "name", value: "join-user", operator: "starts_with" }],
+ join: {
+ session: true,
+ account: true,
+ },
+ });
+
+ // sort both results since order in this case doesn't matter
+ // but the test requires the order to be consistent
+ const sort = (a: ExpectedResult, b: ExpectedResult) =>
+ a.id.localeCompare(b.id);
+ result = result.sort(sort);
+ result = result.map((x) => ({
+ ...x,
+ session: x.session.sort((a, b) => a.id.localeCompare(b.id)),
+ account: x.account.sort((a, b) => a.id.localeCompare(b.id)),
+ }));
+
+ expectedResult = expectedResult.sort(sort);
+ expectedResult = expectedResult.map((x) => ({
+ ...x,
+ session: x.session.sort((a, b) => a.id.localeCompare(b.id)),
+ account: x.account.sort((a, b) => a.id.localeCompare(b.id)),
+ }));
+ expect(result).toEqual(expectedResult);
+ },
+ "findMany - should find many with join and limit": async () => {
+ const users: User[] = [];
+ const sessionsByUser: Map = new Map();
+
+ for (let i = 0; i < 5; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ users.push(user);
+ sessionsByUser.set(user.id, []);
+
+ for (let j = 0; j < 3; j++) {
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ sessionsByUser.get(user.id)!.push(session);
+ }
+ }
+
+ const result = await adapter.findMany({
+ model: "user",
+ join: { session: true },
+ limit: 2,
+ });
+
+ expect(result).toHaveLength(2);
+ result.forEach((user) => {
+ expect(user.session).toHaveLength(3);
+ });
+ },
+ "findMany - should find many with join and offset": async () => {
+ const users: User[] = [];
+
+ for (let i = 0; i < 5; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ name: `user-${i.toString().padStart(2, "0")}`,
+ },
+ forceAllowId: true,
+ });
+ users.push(user);
+
+ for (let j = 0; j < 2; j++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+ }
+
+ const result = await adapter.findMany({
+ model: "user",
+ join: { session: true },
+ offset: 2,
+ });
+
+ expect(result.length).toBe(3);
+ },
+ "findMany - should find many with join and sortBy": async () => {
+ let n = -1;
+ await modifyBetterAuthOptions(
+ {
+ user: {
+ additionalFields: {
+ numericField: {
+ type: "number",
+ defaultValue() {
+ return ++n;
+ },
+ },
+ },
+ },
+ },
+ true,
+ );
+
+ const users: (User & { numericField: number })[] = [];
+ for (let i = 0; i < 5; i++) {
+ const user = (await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ } as any)) as User & { numericField: number };
+ users.push(user);
+
+ for (let j = 0; j < 2; j++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+ }
+
+ const result = await adapter.findMany<
+ (User & { session: Session[] }) & { numericField: number }
+ >({
+ model: "user",
+ join: { session: true },
+ sortBy: { field: "numericField", direction: "desc" },
+ });
+
+ expect(result[0]!.numericField).toBeGreaterThan(
+ result[result.length - 1]!.numericField,
+ );
+ result.forEach((user) => {
+ expect(user.session.length).toBeGreaterThan(0);
+ });
+ },
+ "findMany - should find many with join and where clause": async () => {
+ const users: User[] = [];
+
+ for (let i = 0; i < 5; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ name: i < 2 ? `target-user-${i}` : `other-user-${i}`,
+ },
+ forceAllowId: true,
+ });
+ users.push(user);
+
+ for (let j = 0; j < 2; j++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+ }
+
+ const result = await adapter.findMany({
+ model: "user",
+ where: [
+ { field: "name", value: "target-user", operator: "starts_with" },
+ ],
+ join: { session: true },
+ });
+
+ expect(result).toHaveLength(2);
+ result.forEach((user) => {
+ expect(user.name.startsWith("target-user")).toBe(true);
+ expect(user.session).toHaveLength(2);
+ });
+ },
+ "findMany - should find many with join, where, limit, and offset":
+ async () => {
+ const users: User[] = [];
+
+ for (let i = 0; i < 10; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: {
+ ...(await generate("user")),
+ name: `target-${i.toString().padStart(2, "0")}`,
+ },
+ forceAllowId: true,
+ });
+ users.push(user);
+
+ for (let j = 0; j < 2; j++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+ }
+
+ const result = await adapter.findMany({
+ model: "user",
+ where: [{ field: "name", value: "target", operator: "starts_with" }],
+ join: { session: true },
+ limit: 3,
+ offset: 2,
+ });
+
+ expect(result).toHaveLength(3);
+ result.forEach((user) => {
+ expect(user.session).toHaveLength(2);
+ });
+ },
+ "findMany - should find many with one-to-one join": async () => {
+ await modifyBetterAuthOptions(
+ {
+ plugins: [
+ {
+ id: "one-to-one-test",
+ schema: {
+ oneToOneTable: {
+ fields: {
+ oneToOne: {
+ type: "string",
+ required: true,
+ references: { field: "id", model: "user" },
+ unique: true,
+ },
+ },
+ },
+ },
+ } satisfies BetterAuthPlugin,
+ ],
+ },
+ true,
+ );
+
+ type OneToOneTable = { oneToOne: string; id: string };
+ const users = (await insertRandom("user", 3)).map((x) => x[0]);
+ const oneToOneRecords: OneToOneTable[] = [];
+
+ for (const user of users) {
+ const record = await adapter.create({
+ model: "oneToOneTable",
+ data: { oneToOne: user.id },
+ });
+ oneToOneRecords.push(record);
+ }
+
+ const result = await adapter.findMany<
+ User & { oneToOneTable: OneToOneTable }
+ >({
+ model: "user",
+ join: { oneToOneTable: true },
+ });
+
+ const resultsWithJoin = result.filter((r) => r.oneToOneTable);
+ expect(resultsWithJoin).toHaveLength(3);
+ resultsWithJoin.forEach((user) => {
+ expect(user.oneToOneTable).toBeDefined();
+ expect(user.oneToOneTable.oneToOne).toBe(user.id);
+ });
+ },
+ "findMany - should find many with both one-to-one and one-to-many joins":
+ async () => {
+ await modifyBetterAuthOptions(
+ {
+ plugins: [
+ {
+ id: "one-to-one-test",
+ schema: {
+ oneToOneTable: {
+ fields: {
+ oneToOne: {
+ type: "string",
+ required: true,
+ references: { field: "id", model: "user" },
+ unique: true,
+ },
+ },
+ },
+ },
+ } satisfies BetterAuthPlugin,
+ ],
+ },
+ true,
+ );
+
+ type OneToOneTable = { oneToOne: string; id: string };
+ const users = (await insertRandom("user", 2)).map((x) => x[0]);
+
+ for (const user of users) {
+ await adapter.create({
+ model: "oneToOneTable",
+ data: { oneToOne: user.id },
+ });
+
+ for (let i = 0; i < 2; i++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+ }
+
+ const result = await adapter.findMany<
+ User & { oneToOneTable: OneToOneTable; session: Session[] }
+ >({
+ model: "user",
+ join: { oneToOneTable: true, session: true },
+ });
+
+ const resultsWithBothJoins = result.filter(
+ (r) => r.oneToOneTable && r.session?.length > 0,
+ );
+ expect(resultsWithBothJoins.length).toBeGreaterThanOrEqual(2);
+ resultsWithBothJoins.forEach((user) => {
+ expect(user.oneToOneTable).toBeDefined();
+ expect(Array.isArray(user.session)).toBe(true);
+ expect(user.session.length).toBeGreaterThan(0);
+ });
+ },
"findMany - should return an empty array when no models are found":
async () => {
const options = getBetterAuthOptions();
@@ -693,48 +1493,6 @@ export const getNormalTestSuiteTests = ({
});
expect(sortModels(result)).toEqual(sortModels(users.slice(1)));
},
- "findMany - should find many models with ne operator on id field":
- async () => {
- const users = (await insertRandom("user", 3)).map((x) => x[0]);
- const result = await adapter.findMany({
- model: "user",
- where: [{ field: "id", value: users[0]!.id, operator: "ne" }],
- });
- expect(sortModels(result)).toEqual(sortModels(users.slice(1)));
- },
- "findMany - should find many models with ne operator on _id field (MongoDB)":
- async () => {
- const users = (await insertRandom("user", 3)).map((x) => x[0]);
- const result = await adapter.findMany({
- model: "user",
- where: [{ field: "_id", value: users[0]!.id, operator: "ne" }],
- });
- expect(sortModels(result)).toEqual(sortModels(users.slice(1)));
- },
- "findMany - should handle ne operator with null value on id field":
- async () => {
- await insertRandom("user", 3);
- const result = await adapter.findMany({
- model: "user",
- where: [{ field: "id", value: null, operator: "ne" }],
- });
- expect(Array.isArray(result)).toBe(true);
- },
- "findMany - should handle in operator with null value in array":
- async () => {
- const users = (await insertRandom("user", 3)).map((x) => x[0]);
- const result = await adapter.findMany({
- model: "user",
- where: [
- {
- field: "id",
- value: [users[0]!.id, null, users[1]!.id] as any,
- operator: "in",
- },
- ],
- });
- expect(result.length).toBeGreaterThanOrEqual(2);
- },
"findMany - should find many models with gt operator": async () => {
const users = (await insertRandom("user", 3)).map((x) => x[0]);
const oldestUser = users.sort(
@@ -884,9 +1642,9 @@ export const getNormalTestSuiteTests = ({
const users = (await insertRandom("user", 3)).map((x) => x[0]);
const result = await adapter.findMany({
model: "user",
- limit: 1,
+ limit: 2,
});
- expect(result.length).toEqual(1);
+ expect(result.length).toEqual(2);
expect(users.find((x) => x.id === result[0]!.id)).not.toBeNull();
},
"findMany - should find many models with offset": async () => {
@@ -1480,5 +2238,335 @@ export const getNormalTestSuiteTests = ({
expect(result!.email).toBe(user.email); // Should remain unchanged
expect(result!.id).toBe(user.id);
},
+ "findOne - backwards join should only return single record not array":
+ async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+
+ const result = await adapter.findOne({
+ model: "session",
+ where: [{ field: "id", value: session.id }],
+ join: { user: true },
+ });
+
+ expect(result?.user).toBeDefined();
+ expect(Array.isArray(result?.user)).toBe(false);
+ expect(result?.user?.id).toBe(user.id);
+ },
+ "findMany - backwards join should only return single record not array":
+ async () => {
+ const users = (await insertRandom("user", 2)).map((x) => x[0]);
+ const sessions: Session[] = [];
+
+ for (const user of users) {
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ sessions.push(session);
+ }
+
+ const result = await adapter.findMany({
+ model: "session",
+ join: { user: true },
+ });
+
+ result.forEach((session) => {
+ expect(session.user).toBeDefined();
+ expect(Array.isArray(session.user)).toBe(false);
+ expect(session.user?.id).toBeDefined();
+ });
+ },
+ "findOne - backwards join with modified field name (session base, users-table join)":
+ async () => {
+ await modifyBetterAuthOptions(
+ {
+ user: {
+ modelName: "user_table",
+ },
+ },
+ true,
+ );
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+
+ const result = await adapter.findOne({
+ model: "session",
+ where: [{ field: "id", value: session.id }],
+ join: { user: true },
+ });
+
+ expect(result).toEqual({
+ ...session,
+ user: user,
+ });
+ expect(result?.user).toBeDefined();
+ expect(Array.isArray(result?.user)).toBe(false);
+ expect(result?.user?.id).toBe(user.id);
+ },
+ "findOne - multiple joins should return result even when some joined tables have no matching rows":
+ async () => {
+ await modifyBetterAuthOptions(
+ {
+ plugins: [organization({ teams: { enabled: true } })],
+ },
+ true,
+ );
+
+ // Create a user and organization
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+
+ const organizationData = await adapter.create({
+ model: "organization",
+ data: {
+ name: "Test Organization",
+ slug: "test-org-" + Math.random(),
+ createdAt: new Date(),
+ },
+ forceAllowId: true,
+ });
+
+ // Add a member to the organization
+ const member = await adapter.create({
+ model: "member",
+ data: {
+ organizationId: organizationData.id,
+ userId: user.id,
+ role: "owner",
+ createdAt: new Date(),
+ },
+ forceAllowId: true,
+ });
+
+ // Create a team for the organization
+ const team = await adapter.create({
+ model: "team",
+ data: {
+ name: "Test Team",
+ organizationId: organizationData.id,
+ createdAt: new Date(),
+ },
+ forceAllowId: true,
+ });
+
+ // Do NOT create any invitations - leave it empty
+
+ // Query the organization with joins to member, team, and invitation
+ type ResultType = Organization & {
+ member: Member[];
+ team: Team[];
+ invitation: Invitation[];
+ };
+
+ const result = await adapter.findOne({
+ model: "organization",
+ where: [{ field: "id", value: organizationData.id }],
+ join: {
+ member: true,
+ team: true,
+ invitation: true,
+ },
+ });
+
+ expect(result).toBeDefined();
+ expect(result?.id).toBe(organizationData.id);
+ expect(result?.name).toBe("Test Organization");
+
+ // Verify member join worked
+ expect(Array.isArray(result?.member)).toBe(true);
+ expect(result?.member).toHaveLength(1);
+ expect(result?.member[0]?.userId).toBe(user.id);
+ expect(result?.member[0]?.role).toBe("owner");
+
+ // Verify team join worked
+ expect(Array.isArray(result?.team)).toBe(true);
+ expect(result?.team).toHaveLength(1);
+ expect(result?.team[0]?.name).toBe("Test Team");
+
+ // Verify invitation is empty array
+ expect(Array.isArray(result?.invitation)).toBe(true);
+ expect(result?.invitation).toHaveLength(0);
+ },
+ "findOne - should be able to perform a limited join": async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+
+ for (let i = 0; i < 5; i++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+
+ const result = await adapter.findOne({
+ model: "user",
+ where: [{ field: "id", value: user.id }],
+ join: {
+ session: { limit: 2 },
+ },
+ });
+
+ expect(result).toBeDefined();
+ expect(result?.session).toBeDefined();
+ expect(result?.session).toHaveLength(2);
+ expect(result?.session[0]?.userId).toBe(user.id);
+ },
+ "findOne - should be able to perform a complex limited join": async () => {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+
+ for (let i = 0; i < 5; i++) {
+ await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ await adapter.create({
+ model: "account",
+ data: { ...(await generate("account")), userId: user.id },
+ forceAllowId: true,
+ });
+ }
+
+ const result = await adapter.findOne<
+ User & { session: Session[]; account: Account[] }
+ >({
+ model: "user",
+ where: [{ field: "id", value: user.id }],
+ join: {
+ session: { limit: 2 },
+ account: { limit: 3 },
+ },
+ });
+ expect(result).toBeDefined();
+ expect(result?.session).toBeDefined();
+ expect(result?.session).toHaveLength(2);
+ expect(result?.account).toBeDefined();
+ expect(result?.account).toHaveLength(3);
+ },
+ "findMany - should be able to perform a limited join": async () => {
+ const users: User[] = [];
+
+ for (let i = 0; i < 5; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ users.push(user);
+ }
+ const sessionsByUser: Map = new Map();
+ for (const user of users) {
+ sessionsByUser.set(user.id, []);
+ for (let i = 0; i < 5; i++) {
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ sessionsByUser.get(user.id)!.push(session);
+ }
+ }
+ const result = await adapter.findMany({
+ model: "user",
+ join: { session: { limit: 2 } },
+ });
+ expect(result).toBeDefined();
+ expect(result).toHaveLength(5);
+ result.forEach((user) => {
+ expect(user.session).toBeDefined();
+ expect(user.session).toHaveLength(2);
+ expect(user.session[0]?.userId).toBe(user.id);
+ });
+ },
+ "findMany - should be able to perform a complex limited join": async () => {
+ const users: User[] = [];
+ const sessionsByUser: Map = new Map();
+ const accountsByUser: Map = new Map();
+ for (let i = 0; i < 5; i++) {
+ const user = await adapter.create({
+ model: "user",
+ data: { ...(await generate("user")) },
+ forceAllowId: true,
+ });
+ users.push(user);
+ sessionsByUser.set(user.id, []);
+ accountsByUser.set(user.id, []);
+ for (let i = 0; i < 5; i++) {
+ const session = await adapter.create({
+ model: "session",
+ data: { ...(await generate("session")), userId: user.id },
+ forceAllowId: true,
+ });
+ sessionsByUser.get(user.id)!.push(session);
+ const account = await adapter.create({
+ model: "account",
+ data: { ...(await generate("account")), userId: user.id },
+ forceAllowId: true,
+ });
+ accountsByUser.get(user.id)!.push(account);
+ }
+ }
+ type ResultType = User & { session: Session[]; account: Account[] };
+ const result = await adapter.findMany({
+ model: "user",
+ join: {
+ session: { limit: 2 },
+ account: { limit: 3 },
+ },
+ limit: 2,
+ offset: 2,
+ });
+ expect(result).toBeDefined();
+ expect(result).toHaveLength(2);
+ result.forEach((user) => {
+ expect(user.session).toBeDefined();
+ expect(user.session).toHaveLength(2);
+ expect(user.session[0]?.userId).toBe(user.id);
+ expect(user.account).toBeDefined();
+ expect(user.account).toHaveLength(3);
+ expect(user.account[0]?.userId).toBe(user.id);
+ });
+ },
};
};
+
+const getTestKeys = () => Object.keys(getNormalTestSuiteTests({} as any));
+type TestKeys = Partial<
+ Record, boolean>
+>;
+
+export const enableJoinTests = getTestKeys().reduce((acc, test) => {
+ if (test.includes("join")) {
+ acc[test as keyof TestKeys] = false;
+ }
+ return acc;
+}, {} as TestKeys);
diff --git a/packages/better-auth/src/adapters/tests/index.ts b/packages/better-auth/src/adapters/tests/index.ts
index 6aac29d60c..c60974851e 100644
--- a/packages/better-auth/src/adapters/tests/index.ts
+++ b/packages/better-auth/src/adapters/tests/index.ts
@@ -1,6 +1,6 @@
export * from "./auth-flow";
-export * from "./normal";
+export * from "./basic";
+export * from "./joins";
export * from "./number-id";
-export * from "./performance";
export * from "./transactions";
export * from "./uuid";
diff --git a/packages/better-auth/src/adapters/tests/joins.ts b/packages/better-auth/src/adapters/tests/joins.ts
new file mode 100644
index 0000000000..974c07a0ed
--- /dev/null
+++ b/packages/better-auth/src/adapters/tests/joins.ts
@@ -0,0 +1,28 @@
+import { expect } from "vitest";
+import { createTestSuite } from "../create-test-suite";
+import { getNormalTestSuiteTests } from "./basic";
+
+export const joinsTestSuite = createTestSuite(
+ "joins",
+ {
+ defaultBetterAuthOptions: {
+ experimental: {
+ joins: true,
+ },
+ },
+ alwaysMigrate: true,
+ prefixTests: "joins",
+ },
+ (helpers) => {
+ const { "create - should use generateId if provided": _, ...normalTests } =
+ getNormalTestSuiteTests({ ...helpers });
+
+ return {
+ "init - tests": async () => {
+ const opts = helpers.getBetterAuthOptions();
+ expect(opts.experimental?.joins).toBe(true);
+ },
+ ...normalTests,
+ };
+ },
+);
diff --git a/packages/better-auth/src/adapters/tests/number-id.ts b/packages/better-auth/src/adapters/tests/number-id.ts
index 6a5266fc3a..b2251fa27e 100644
--- a/packages/better-auth/src/adapters/tests/number-id.ts
+++ b/packages/better-auth/src/adapters/tests/number-id.ts
@@ -1,7 +1,7 @@
import type { User } from "better-auth/types";
import { expect } from "vitest";
import { createTestSuite } from "../create-test-suite";
-import { getNormalTestSuiteTests } from "./normal";
+import { getNormalTestSuiteTests } from "./basic";
export const numberIdTestSuite = createTestSuite(
"number-id",
diff --git a/packages/better-auth/src/adapters/tests/performance.ts b/packages/better-auth/src/adapters/tests/performance.ts
deleted file mode 100644
index 90eac47871..0000000000
--- a/packages/better-auth/src/adapters/tests/performance.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { assert, expect } from "vitest";
-import { createTestSuite } from "../create-test-suite";
-
-/**
- * This test suite tests the performance of the adapter and logs the results.
- */
-export const performanceTestSuite = createTestSuite(
- "performance",
- {},
- (
- { adapter, generate, cleanup },
- config?:
- | { iterations?: number; userSeedCount?: number; dialect?: string }
- | undefined,
- ) => {
- const tests = {
- create: [] as number[],
- update: [] as number[],
- delete: [] as number[],
- count: [] as number[],
- findOne: [] as number[],
- findMany: [] as number[],
- };
-
- const iterations = config?.iterations ?? 10;
- const userSeedCount = config?.userSeedCount ?? 15;
-
- assert(
- userSeedCount >= iterations,
- "userSeedCount must be greater than iterations",
- );
-
- const seedUser = async () => {
- const user = await generate("user");
- return await adapter.create({
- model: "user",
- data: user,
- forceAllowId: true,
- });
- };
- const seedManyUsers = async () => {
- const users = [];
- for (let i = 0; i < userSeedCount; i++) {
- users.push(await seedUser());
- }
- return users;
- };
-
- const performanceTests = {
- create: async () => {
- for (let i = 0; i < iterations; i++) {
- const start = performance.now();
- await seedUser();
- const end = performance.now();
- tests.create.push(end - start);
- }
- },
- update: async () => {
- const users = await seedManyUsers();
- for (let i = 0; i < iterations; i++) {
- const start = performance.now();
- await adapter.update({
- model: "user",
- where: [{ field: "id", value: users[i]!.id }],
- update: {
- name: `user-${i}`,
- },
- });
- const end = performance.now();
- tests.update.push(end - start);
- }
- },
- delete: async () => {
- const users = await seedManyUsers();
- for (let i = 0; i < iterations; i++) {
- const start = performance.now();
- await adapter.delete({
- model: "user",
- where: [{ field: "id", value: users[i]!.id }],
- });
- const end = performance.now();
- tests.delete.push(end - start);
- }
- },
- count: async () => {
- const users = await seedManyUsers();
- for (let i = 0; i < iterations; i++) {
- const start = performance.now();
- const c = await adapter.count({
- model: "user",
- });
- const end = performance.now();
- tests.count.push(end - start);
- expect(c).toEqual(users.length);
- }
- },
- findOne: async () => {
- const users = await seedManyUsers();
- for (let i = 0; i < iterations; i++) {
- const start = performance.now();
- await adapter.findOne({
- model: "user",
- where: [{ field: "id", value: users[i]!.id }],
- });
- const end = performance.now();
- tests.findOne.push(end - start);
- }
- },
- findMany: async () => {
- const users = await seedManyUsers();
- for (let i = 0; i < iterations; i++) {
- const start = performance.now();
- const result = await adapter.findMany({
- model: "user",
- where: [{ field: "name", value: "user", operator: "starts_with" }],
- limit: users.length,
- });
- const end = performance.now();
- tests.findMany.push(end - start);
- expect(result.length).toBe(users.length);
- }
- },
- };
-
- return {
- "run performance test": async () => {
- for (const test of Object.keys(performanceTests)) {
- await performanceTests[test as keyof typeof performanceTests]();
- await cleanup();
- }
-
- // Calculate averages for each test
- const averages = Object.entries(tests).reduce(
- (acc, [key, values]) => {
- const average =
- values.length > 0
- ? values.reduce((sum, val) => sum + val, 0) / values.length
- : 0;
- acc[key] = `${average.toFixed(3)}ms`;
- return acc;
- },
- {} as Record,
- );
-
- console.log(`Performance tests results, counting averages:`);
- console.table(averages);
- console.log({
- iterations,
- userSeedCount,
- adapter: adapter.options?.adapterConfig.adapterId,
- ...(config?.dialect ? { dialect: config.dialect } : {}),
- });
- expect(1).toBe(1);
- },
- };
- },
-);
diff --git a/packages/better-auth/src/adapters/tests/uuid.ts b/packages/better-auth/src/adapters/tests/uuid.ts
index 8302d81e65..c09745cabb 100644
--- a/packages/better-auth/src/adapters/tests/uuid.ts
+++ b/packages/better-auth/src/adapters/tests/uuid.ts
@@ -1,7 +1,7 @@
import { expect } from "vitest";
import type { User } from "../../../../core/src/db/schema/user";
import { createTestSuite } from "../create-test-suite";
-import { getNormalTestSuiteTests } from "./normal";
+import { getNormalTestSuiteTests } from "./basic";
export const uuidTestSuite = createTestSuite(
"uuid",
diff --git a/packages/better-auth/src/adapters/utils.ts b/packages/better-auth/src/adapters/utils.ts
index 977b238219..af0311a6e1 100644
--- a/packages/better-auth/src/adapters/utils.ts
+++ b/packages/better-auth/src/adapters/utils.ts
@@ -3,7 +3,7 @@ import type { DBFieldAttribute } from "@better-auth/core/db";
export function withApplyDefault(
value: any,
field: DBFieldAttribute,
- action: "create" | "update",
+ action: "create" | "update" | "findOne" | "findMany",
) {
if (action === "update") {
// Apply onUpdate if value is undefined
diff --git a/packages/better-auth/src/context/__snapshots__/base.test.ts.snap b/packages/better-auth/src/context/__snapshots__/base.test.ts.snap
index 80107f6019..51903e667d 100644
--- a/packages/better-auth/src/context/__snapshots__/base.test.ts.snap
+++ b/packages/better-auth/src/context/__snapshots__/base.test.ts.snap
@@ -18,6 +18,7 @@ exports[`base context creation > should match config 1`] = `
"customTransformInput": [Function],
"debugLogs": false,
"disableTransformInput": false,
+ "disableTransformJoin": false,
"disableTransformOutput": false,
"supportsBooleans": true,
"supportsDates": true,
diff --git a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap
index a8f617c748..0eb74bbc4d 100644
--- a/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap
+++ b/packages/better-auth/src/context/__snapshots__/create-context.test.ts.snap
@@ -18,6 +18,7 @@ exports[`base context creation > should match config 1`] = `
"customTransformInput": [Function],
"debugLogs": false,
"disableTransformInput": false,
+ "disableTransformJoin": false,
"disableTransformOutput": false,
"supportsBooleans": true,
"supportsDates": true,
diff --git a/packages/better-auth/src/db/get-migration.ts b/packages/better-auth/src/db/get-migration.ts
index 62c08d8382..974c9cc39e 100644
--- a/packages/better-auth/src/db/get-migration.ts
+++ b/packages/better-auth/src/db/get-migration.ts
@@ -8,9 +8,12 @@ import type {
Kysely,
} from "kysely";
import { sql } from "kysely";
+import { initGetFieldName } from "../adapters/adapter-factory/get-field-name";
+import { initGetModelName } from "../adapters/adapter-factory/get-model-name";
import { createKyselyAdapter } from "../adapters/kysely-adapter/dialect";
import type { KyselyDatabaseType } from "../adapters/kysely-adapter/types";
import { getSchema } from "./get-schema";
+import { getAuthTables } from "./get-tables";
const postgresMap = {
string: ["character varying", "varchar", "text", "uuid"],
@@ -365,6 +368,28 @@ export async function getMigrations(config: BetterAuthOptions) {
}
return typeMap[type]![dbType || "sqlite"];
}
+ const getModelName = initGetModelName({
+ schema: getAuthTables(config),
+ usePlural: false,
+ });
+ const getFieldName = initGetFieldName({
+ schema: getAuthTables(config),
+ usePlural: false,
+ });
+
+ // Helper function to safely resolve model and field names, falling back to
+ // user-supplied strings for external tables not in the BetterAuth schema
+ function getReferencePath(model: string, field: string): string {
+ try {
+ const modelName = getModelName(model);
+ const fieldName = getFieldName({ model, field });
+ return `${modelName}.${fieldName}`;
+ } catch {
+ // If resolution fails (external table), fall back to user-supplied references
+ return `${model}.${field}`;
+ }
+ }
+
if (toBeAdded.length) {
for (const table of toBeAdded) {
for (const [fieldName, field] of Object.entries(table.fields)) {
@@ -380,7 +405,12 @@ export async function getMigrations(config: BetterAuthOptions) {
col = field.required !== false ? col.notNull() : col;
if (field.references) {
col = col
- .references(`${field.references.model}.${field.references.field}`)
+ .references(
+ getReferencePath(
+ field.references.model,
+ field.references.field,
+ ),
+ )
.onDelete(field.references.onDelete || "cascade");
}
if (field.unique) {
@@ -451,7 +481,12 @@ export async function getMigrations(config: BetterAuthOptions) {
col = field.required !== false ? col.notNull() : col;
if (field.references) {
col = col
- .references(`${field.references.model}.${field.references.field}`)
+ .references(
+ getReferencePath(
+ field.references.model,
+ field.references.field,
+ ),
+ )
.onDelete(field.references.onDelete || "cascade");
}
diff --git a/packages/better-auth/src/db/internal-adapter.test.ts b/packages/better-auth/src/db/internal-adapter.test.ts
index ea317ea418..4505ff253b 100644
--- a/packages/better-auth/src/db/internal-adapter.test.ts
+++ b/packages/better-auth/src/db/internal-adapter.test.ts
@@ -15,7 +15,7 @@ import type {
import { safeJSONParse } from "../utils/json";
import { getMigrations } from "./get-migration";
-describe("adapter test", async () => {
+describe("internal adapter test", async () => {
const sqliteDialect = new SqliteDialect({
database: new Database(":memory:"),
});
diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts
index c87e6bc4ea..e9ff8e4de4 100644
--- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts
+++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts
@@ -159,7 +159,6 @@ describe("oauth2", async () => {
headers: newHeaders,
},
});
- console.log(session.data, newHeaders);
const ctx = await auth.$context;
const accounts = await ctx.internalAdapter.findAccounts(
session.data?.user.id!,
diff --git a/packages/better-auth/vitest.config.adapters.ts b/packages/better-auth/vitest.config.adapters.ts
new file mode 100644
index 0000000000..43db305068
--- /dev/null
+++ b/packages/better-auth/vitest.config.adapters.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ poolOptions: {
+ forks: {
+ execArgv: ["--expose-gc"],
+ },
+ },
+ // No exclude for adapter tests - this config is specifically for adapter tests
+ include: ["src/adapters/**/*.test.ts"],
+ },
+});
diff --git a/packages/better-auth/vitest.config.ts b/packages/better-auth/vitest.config.ts
index 9b231fc614..86793cdda9 100644
--- a/packages/better-auth/vitest.config.ts
+++ b/packages/better-auth/vitest.config.ts
@@ -7,5 +7,11 @@ export default defineConfig({
execArgv: ["--expose-gc"],
},
},
+ // Exclude adapter tests by default - they are run separately via test:adapters
+ exclude: [
+ "**/node_modules/**",
+ "**/dist/**",
+ "**/src/adapters/**/*.test.ts",
+ ],
},
});
diff --git a/packages/cli/src/generators/drizzle.ts b/packages/cli/src/generators/drizzle.ts
index 773b86ca6f..d9ec4a358a 100644
--- a/packages/cli/src/generators/drizzle.ts
+++ b/packages/cli/src/generators/drizzle.ts
@@ -1,3 +1,4 @@
+import { initGetFieldName, initGetModelName } from "better-auth/adapters";
import {
type BetterAuthDBSchema,
type DBFieldAttribute,
@@ -36,11 +37,25 @@ export const generateDrizzleSchema: SchemaGenerator = async ({
}
const fileExist = existsSync(filePath);
- let code: string = generateImport({ databaseType, tables, options });
+ let code: string = generateImport({
+ databaseType,
+ tables,
+ options,
+ });
+
+ const getModelName = initGetModelName({
+ schema: tables,
+ usePlural: adapter.options?.adapterConfig?.usePlural,
+ });
+
+ const getFieldName = initGetFieldName({
+ schema: tables,
+ usePlural: adapter.options?.adapterConfig?.usePlural,
+ });
for (const tableKey in tables) {
const table = tables[tableKey]!;
- const modelName = getModelName(table.modelName, adapter.options);
+ const modelName = getModelName(tableKey);
const fields = table.fields;
function getType(name: string, field: DBFieldAttribute) {
@@ -257,10 +272,8 @@ export const generateDrizzleSchema: SchemaGenerator = async ({
}${
attr.references
? `.references(()=> ${getModelName(
- tables[attr.references.model]?.modelName ||
- attr.references.model,
- adapter.options,
- )}.${fields[attr.references.field]?.fieldName || attr.references.field}, { onDelete: '${
+ attr.references.model,
+ )}.${getFieldName({ model: attr.references.model, field: attr.references.field })}, { onDelete: '${
attr.references.onDelete || "cascade"
}' })`
: ""
@@ -270,6 +283,107 @@ export const generateDrizzleSchema: SchemaGenerator = async ({
}${assignIndexes(indexes)});`;
code += `\n${schema}\n`;
}
+
+ let relationsString: string = "";
+ for (const tableKey in tables) {
+ const table = tables[tableKey]!;
+
+ type Relation = {
+ /**
+ * The key of the relation that will be defined in the Drizzle schema.
+ * For "one" relations: singular (e.g., "user")
+ * For "many" relations: plural (e.g., "posts")
+ */
+ key: string;
+ /**
+ * The model name being referenced.
+ */
+ model: string;
+ /**
+ * The type of the relation: "one" (many-to-one) or "many" (one-to-many).
+ */
+ type: "one" | "many";
+ /**
+ * Foreign key field name and reference details (only for "one" relations).
+ */
+ reference?: {
+ field: string;
+ references: string;
+ };
+ };
+
+ const relations: Relation[] = [];
+
+ // 1. Find all foreign keys in THIS table (creates "one" relations)
+ const fields = Object.entries(table.fields);
+ const foreignFields = fields.filter(([_, field]) => field.references);
+
+ for (const [fieldName, field] of foreignFields) {
+ const referencedModel = field.references!.model;
+ relations.push({
+ key: getModelName(referencedModel),
+ model: getModelName(referencedModel),
+ type: "one",
+ reference: {
+ field: `${getModelName(tableKey)}.${getFieldName({ model: tableKey, field: fieldName })}`,
+ references: `${getModelName(referencedModel)}.${getFieldName({ model: referencedModel, field: field.references!.field || "id" })}`,
+ },
+ });
+ }
+
+ // 2. Find all OTHER tables that reference THIS table (creates "many" relations)
+ const otherModels = Object.entries(tables).filter(
+ ([modelName]) => modelName !== tableKey,
+ );
+
+ for (const [modelName, otherTable] of otherModels) {
+ const foreignKeysPointingHere = Object.entries(otherTable.fields).filter(
+ ([_, field]) =>
+ field.references?.model === tableKey ||
+ field.references?.model === getModelName(tableKey),
+ );
+
+ for (const [fieldName, field] of foreignKeysPointingHere) {
+ const isUnique = !!field.unique;
+ const relationKey = isUnique
+ ? getModelName(modelName)
+ : `${getModelName(modelName)}s`;
+
+ relations.push({
+ key: relationKey,
+ model: getModelName(modelName),
+ type: isUnique ? "one" : "many",
+ });
+ }
+ }
+
+ const hasOne = relations.some((relation) => relation.type === "one");
+ const hasMany = relations.some((relation) => relation.type === "many");
+
+ let tableRelation = `export const ${table.modelName}Relations = relations(${getModelName(
+ table.modelName,
+ )}, ({ ${hasOne ? "one" : ""}${hasMany ? `${hasOne ? ", " : ""}many` : ""} }) => ({
+ ${relations
+ .map(
+ ({ key, type, model, reference }) =>
+ ` ${key}: ${type}(${model}${
+ !reference
+ ? ""
+ : `, {
+ fields: [${reference.field}],
+ references: [${reference.references}],
+ }`
+ })`,
+ )
+ .join(",\n ")}
+ }))`;
+
+ if (relations.length > 0) {
+ relationsString += `\n${tableRelation}\n`;
+ }
+ }
+ code += `\n${relationsString}`;
+
const formattedCode = await prettier.format(code, {
parser: "typescript",
});
@@ -289,7 +403,7 @@ function generateImport({
tables: BetterAuthDBSchema;
options: BetterAuthOptions;
}) {
- const rootImports: string[] = [];
+ const rootImports: string[] = ["relations"];
const coreImports: string[] = [];
let hasBigint = false;
@@ -422,10 +536,3 @@ function generateImport({
.filter((x) => x !== "")
.join(", ")} } from "drizzle-orm/${databaseType}-core";\n`;
}
-
-function getModelName(
- modelName: string,
- options: Record | undefined,
-) {
- return options?.usePlural ? `${modelName}s` : modelName;
-}
diff --git a/packages/cli/src/generators/prisma.ts b/packages/cli/src/generators/prisma.ts
index cc4945ac53..07095aa2b5 100644
--- a/packages/cli/src/generators/prisma.ts
+++ b/packages/cli/src/generators/prisma.ts
@@ -1,5 +1,6 @@
import { produceSchema } from "@mrleebo/prisma-ast";
import { capitalizeFirstLetter } from "better-auth";
+import { initGetFieldName, initGetModelName } from "better-auth/adapters";
import { type FieldType, getAuthTables } from "better-auth/db";
import { existsSync } from "fs";
import fs from "fs/promises";
@@ -139,15 +140,21 @@ export const generatePrismaSchema: SchemaGenerator = async ({
.model(modelName)
.field("id", "String")
.attribute("id")
- .attribute("db.Uuid")
- .attribute(
- 'default(dbgenerated("pg_catalog.gen_random_uuid()"))',
- );
+ .attribute('default(dbgenerated("pg_catalog.gen_random_uuid()"))')
+ .attribute("db.Uuid");
} else {
builder.model(modelName).field("id", "String").attribute("id");
}
}
}
+ const getModelName = initGetModelName({
+ schema: getAuthTables(options),
+ usePlural: adapter.options?.adapterConfig?.usePlural,
+ });
+ const getFieldName = initGetFieldName({
+ schema: getAuthTables(options),
+ usePlural: false,
+ });
for (const field in fields) {
const attr = fields[field]!;
@@ -282,11 +289,17 @@ export const generatePrismaSchema: SchemaGenerator = async ({
}
if (attr.references) {
- if (useUUIDs && provider === "postgresql") {
- fieldBuilder.attribute(`db.Uuid`);
+ if (
+ useUUIDs &&
+ provider === "postgresql" &&
+ attr.references?.field === "id"
+ ) {
+ builder.model(modelName).field(fieldName).attribute(`db.Uuid`);
}
- const referencedOriginalModelName = attr.references.model;
+ const referencedOriginalModelName = getModelName(
+ attr.references.model,
+ );
const referencedCustomModelName =
tables[referencedOriginalModelName]?.modelName ||
referencedOriginalModelName;
@@ -297,7 +310,7 @@ export const generatePrismaSchema: SchemaGenerator = async ({
action = "SetDefault";
else if (attr.references.onDelete === "restrict") action = "Restrict";
- const relationField = `relation(fields: [${fieldName}], references: [${attr.references.field}], onDelete: ${action})`;
+ const relationField = `relation(fields: [${getFieldName({ model: originalTableName, field: fieldName })}], references: [${getFieldName({ model: attr.references.model, field: attr.references.field })}], onDelete: ${action})`;
builder
.model(modelName)
.field(
@@ -321,13 +334,35 @@ export const generatePrismaSchema: SchemaGenerator = async ({
// Add many-to-many fields
if (manyToManyRelations.has(modelName)) {
for (const relatedModel of manyToManyRelations.get(modelName)) {
- const fieldName = `${relatedModel.toLowerCase()}s`;
+ // Find the FK field on the related model that points to this model
+ const relatedTableName = Object.keys(tables).find(
+ (key) =>
+ capitalizeFirstLetter(tables[key]?.modelName || key) ===
+ relatedModel,
+ );
+ const relatedFields = relatedTableName
+ ? tables[relatedTableName]?.fields
+ : {};
+ const fkField = Object.entries(relatedFields || {}).find(
+ ([_fieldName, fieldAttr]: any) =>
+ fieldAttr.references &&
+ getModelName(fieldAttr.references.model) ===
+ getModelName(originalTableName),
+ );
+ const [_fieldKey, fkFieldAttr] = fkField || [];
+ const isUnique = fkFieldAttr?.unique === true;
+
+ const fieldName = isUnique
+ ? `${relatedModel.toLowerCase()}`
+ : `${relatedModel.toLowerCase()}s`;
const existingField = builder.findByType("field", {
name: fieldName,
within: prismaModel?.properties,
});
if (!existingField) {
- builder.model(modelName).field(fieldName, `${relatedModel}[]`);
+ builder
+ .model(modelName)
+ .field(fieldName, `${relatedModel}${isUnique ? "?" : "[]"}`);
}
}
}
diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt
index 12097c5196..88edcd5d3d 100644
--- a/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-mysql-enum.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
mysqlTable,
varchar,
@@ -80,3 +81,22 @@ export const verification = mysqlTable(
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
+
+export const userRelations = relations(user, ({ many }) => ({
+ sessions: many(session),
+ accounts: many(account),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+ user: one(user, {
+ fields: [session.userId],
+ references: [user.id],
+ }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+ user: one(user, {
+ fields: [account.userId],
+ references: [user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt
index 79b5038648..d945e2e6d3 100644
--- a/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-mysql-number-id.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
mysqlTable,
varchar,
@@ -98,3 +99,30 @@ export const twoFactor = mysqlTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt
index d92743b295..ee27259fa1 100644
--- a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey-number-id.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
mysqlTable,
varchar,
@@ -102,3 +103,30 @@ export const passkey = mysqlTable(
index("passkey_credentialID_idx").on(table.credentialID),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ passkeys: many(passkey),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const passkeyRelations = relations(passkey, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [passkey.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt
index 1c84023314..49cb006d0c 100644
--- a/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-mysql-passkey.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
mysqlTable,
varchar,
@@ -102,3 +103,30 @@ export const passkey = mysqlTable(
index("passkey_credentialID_idx").on(table.credentialID),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ passkeys: many(passkey),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const passkeyRelations = relations(passkey, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [passkey.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt
index 2ff7fdf2b8..3554ec496c 100644
--- a/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-mysql-uuid.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
mysqlTable,
varchar,
@@ -97,3 +98,30 @@ export const twoFactor = mysqlTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-mysql.txt b/packages/cli/test/__snapshots__/auth-schema-mysql.txt
index 2ff7fdf2b8..3554ec496c 100644
--- a/packages/cli/test/__snapshots__/auth-schema-mysql.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-mysql.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
mysqlTable,
varchar,
@@ -97,3 +98,30 @@ export const twoFactor = mysqlTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-number-id.txt
index 995e366094..2cdcd5e651 100644
--- a/packages/cli/test/__snapshots__/auth-schema-number-id.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-number-id.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
pgTable,
text,
@@ -97,3 +98,30 @@ export const twoFactor = pgTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt b/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt
index 7217c51921..7ea874960c 100644
--- a/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-pg-enum.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
@@ -72,3 +73,22 @@ export const verification = pgTable(
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
+
+export const userRelations = relations(user, ({ many }) => ({
+ sessions: many(session),
+ accounts: many(account),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+ user: one(user, {
+ fields: [session.userId],
+ references: [user.id],
+ }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+ user: one(user, {
+ fields: [account.userId],
+ references: [user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt b/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt
index 376f11dcf0..c584a3be6f 100644
--- a/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-pg-passkey.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import {
pgTable,
text,
@@ -101,3 +102,30 @@ export const passkey = pgTable(
index("passkey_credentialID_idx").on(table.credentialID),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ passkeys: many(passkey),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const passkeyRelations = relations(passkey, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [passkey.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt
index 815ae0a9bc..361d29f90b 100644
--- a/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-pg-uuid.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import {
pgTable,
text,
@@ -108,3 +108,30 @@ export const twoFactor = pgTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt
index ddfa4472ad..7e73390d36 100644
--- a/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-enum.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
@@ -87,3 +87,22 @@ export const verification = sqliteTable(
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
+
+export const userRelations = relations(user, ({ many }) => ({
+ sessions: many(session),
+ accounts: many(account),
+}));
+
+export const sessionRelations = relations(session, ({ one }) => ({
+ user: one(user, {
+ fields: [session.userId],
+ references: [user.id],
+ }),
+}));
+
+export const accountRelations = relations(account, ({ one }) => ({
+ user: one(user, {
+ fields: [account.userId],
+ references: [user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt
index c465722bfa..d0186cb13c 100644
--- a/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-number-id.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const custom_user = sqliteTable("custom_user", {
@@ -107,3 +107,30 @@ export const twoFactor = sqliteTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt
index 7edb5a5bda..e35b12d217 100644
--- a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey-number-id.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const custom_user = sqliteTable("custom_user", {
@@ -109,3 +109,30 @@ export const passkey = sqliteTable(
index("passkey_credentialID_idx").on(table.credentialID),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ passkeys: many(passkey),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const passkeyRelations = relations(passkey, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [passkey.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt
index 7edad98c6d..14aa0d40a6 100644
--- a/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-passkey.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const custom_user = sqliteTable("custom_user", {
@@ -109,3 +109,30 @@ export const passkey = sqliteTable(
index("passkey_credentialID_idx").on(table.credentialID),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ passkeys: many(passkey),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const passkeyRelations = relations(passkey, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [passkey.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt
index 2f175c2dea..23f7f7f458 100644
--- a/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-sqlite-uuid.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const custom_user = sqliteTable("custom_user", {
@@ -107,3 +107,30 @@ export const twoFactor = sqliteTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema-sqlite.txt b/packages/cli/test/__snapshots__/auth-schema-sqlite.txt
index 2f175c2dea..23f7f7f458 100644
--- a/packages/cli/test/__snapshots__/auth-schema-sqlite.txt
+++ b/packages/cli/test/__snapshots__/auth-schema-sqlite.txt
@@ -1,4 +1,4 @@
-import { sql } from "drizzle-orm";
+import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const custom_user = sqliteTable("custom_user", {
@@ -107,3 +107,30 @@ export const twoFactor = sqliteTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/auth-schema.txt b/packages/cli/test/__snapshots__/auth-schema.txt
index a79d07ae8f..96b8814a7d 100644
--- a/packages/cli/test/__snapshots__/auth-schema.txt
+++ b/packages/cli/test/__snapshots__/auth-schema.txt
@@ -1,3 +1,4 @@
+import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
export const custom_user = pgTable("custom_user", {
@@ -90,3 +91,30 @@ export const twoFactor = pgTable(
index("twoFactor_userId_idx").on(table.userId),
],
);
+
+export const custom_userRelations = relations(custom_user, ({ many }) => ({
+ custom_sessions: many(custom_session),
+ custom_accounts: many(custom_account),
+ twoFactors: many(twoFactor),
+}));
+
+export const custom_sessionRelations = relations(custom_session, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_session.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const custom_accountRelations = relations(custom_account, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [custom_account.userId],
+ references: [custom_user.id],
+ }),
+}));
+
+export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
+ custom_user: one(custom_user, {
+ fields: [twoFactor.userId],
+ references: [custom_user.id],
+ }),
+}));
diff --git a/packages/cli/test/__snapshots__/schema-uuid.prisma b/packages/cli/test/__snapshots__/schema-uuid.prisma
index 8ba1ca9c1b..8529eab802 100644
--- a/packages/cli/test/__snapshots__/schema-uuid.prisma
+++ b/packages/cli/test/__snapshots__/schema-uuid.prisma
@@ -9,7 +9,7 @@ datasource db {
}
model User {
- id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()"))
+ id String @id @default(dbgenerated("pg_catalog.gen_random_uuid()")) @db.Uuid
name String
email String
emailVerified Boolean @default(false)
@@ -29,7 +29,7 @@ model User {
}
model Session {
- id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()"))
+ id String @id @default(dbgenerated("pg_catalog.gen_random_uuid()")) @db.Uuid
expiresAt DateTime
token String
createdAt DateTime @default(now())
@@ -45,7 +45,7 @@ model Session {
}
model Account {
- id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()"))
+ id String @id @default(dbgenerated("pg_catalog.gen_random_uuid()")) @db.Uuid
accountId String
providerId String
userId String @db.Uuid
@@ -65,7 +65,7 @@ model Account {
}
model Verification {
- id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()"))
+ id String @id @default(dbgenerated("pg_catalog.gen_random_uuid()")) @db.Uuid
identifier String
value String
expiresAt DateTime
@@ -77,7 +77,7 @@ model Verification {
}
model TwoFactor {
- id String @id @db.Uuid @default(dbgenerated("pg_catalog.gen_random_uuid()"))
+ id String @id @default(dbgenerated("pg_catalog.gen_random_uuid()")) @db.Uuid
secret String
backupCodes String
userId String @db.Uuid
diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts
index 40ec9f91ee..fea8cd6378 100644
--- a/packages/core/src/db/adapter/index.ts
+++ b/packages/core/src/db/adapter/index.ts
@@ -195,7 +195,7 @@ export interface DBAdapterFactoryConfig<
/**
* The action which was called from the adapter.
*/
- action: "create" | "update";
+ action: "create" | "update" | "findOne" | "findMany";
/**
* The model name.
*/
@@ -277,6 +277,12 @@ export interface DBAdapterFactoryConfig<
* @default false
*/
disableTransformInput?: boolean | undefined;
+ /**
+ * Whether to disable the transform join.
+ * Do not use this option unless you know what you are doing.
+ * @default false
+ */
+ disableTransformJoin?: boolean | undefined;
}
export type Where = {
@@ -306,6 +312,56 @@ export type Where = {
connector?: ("AND" | "OR") | undefined;
};
+/**
+ * JoinOption configuration for relational queries.
+ *
+ * Allows you to join related tables/models in a single query operation.
+ * Each key represents the name of the joined table/model, and the value
+ * configures how the join should be performed.
+ */
+export type JoinOption = {
+ [model: string]: boolean | { limit?: number };
+};
+
+/**
+ * Once `JoinOption` has gone through the adapter factory, it will be transformed into a `JoinConfig`.
+ */
+export type JoinConfig = {
+ [model: string]: {
+ /**
+ * The joining column names.
+ */
+ on: {
+ /**
+ * Column name from the main table
+ */
+ from: string;
+ /**
+ * Column name from the joined table
+ */
+ to: string;
+ };
+ /**
+ * Limit the number of rows to return.
+ *
+ * If the relation has `unique` constraint, then this option will be ignored and limit will be set to 1.
+ *
+ * @default 100
+ */
+ limit?: number;
+ /**
+ * The relation type. Determines the ouput joined model data.
+ *
+ * `one-to-one` would have a single object in the output.
+ * `one-to-many` would have an array of objects in the output.
+ * `many-to-many` would have an array of objects in the output.
+ *
+ * @default "one-to-many"
+ */
+ relation?: "one-to-one" | "one-to-many" | "many-to-many";
+ };
+};
+
export type DBTransactionAdapter<
Options extends BetterAuthOptions = BetterAuthOptions,
> = Omit, "transaction">;
@@ -327,6 +383,7 @@ export type DBAdapter = {
model: string;
where: Where[];
select?: string[] | undefined;
+ join?: JoinOption | undefined;
}) => Promise;
findMany: (data: {
model: string;
@@ -339,6 +396,7 @@ export type DBAdapter = {
}
| undefined;
offset?: number | undefined;
+ join?: JoinOption | undefined;
}) => Promise;
count: (data: {
model: string;
@@ -408,10 +466,12 @@ export interface CustomAdapter {
model,
where,
select,
+ join,
}: {
model: string;
where: CleanedWhere[];
select?: string[] | undefined;
+ join?: JoinConfig | undefined;
}) => Promise;
findMany: ({
model,
@@ -419,12 +479,14 @@ export interface CustomAdapter {
limit,
sortBy,
offset,
+ join,
}: {
model: string;
where?: CleanedWhere[] | undefined;
limit: number;
sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
offset?: number | undefined;
+ join?: JoinConfig | undefined;
}) => Promise;
delete: ({
model,
diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts
index f3ddfbf957..d2bb01a82c 100644
--- a/packages/core/src/types/init-options.ts
+++ b/packages/core/src/types/init-options.ts
@@ -1345,4 +1345,18 @@ export type BetterAuthOptions = {
debug?: boolean;
}
| undefined;
+ /**
+ * Experimental features
+ */
+ experimental?: {
+ /**
+ * Enable experimental joins for your database adapter.
+ *
+ * Please read the adapter documentation for more information regarding joins before enabling this.
+ * Not all adapters support joins.
+ *
+ * @default false
+ */
+ joins?: boolean;
+ };
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f7fc1491d..ef58478a21 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1014,8 +1014,8 @@ importers:
specifier: ^0.31.4
version: 0.31.4
drizzle-orm:
- specifier: ^0.38.2
- version: 0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0)
+ specifier: ^0.41.0
+ version: 0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)
happy-dom:
specifier: ^20.0.0
version: 20.0.2
@@ -7847,8 +7847,8 @@ packages:
sqlite3:
optional: true
- drizzle-orm@0.38.4:
- resolution: {integrity: sha512-s7/5BpLKO+WJRHspvpqTydxFob8i1vo2rEx4pY6TGY7QSMuUfWUuzaY0DIpXCkgHOo37BaFC+SJQb99dDUXT3Q==}
+ drizzle-orm@0.41.0:
+ resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==}
peerDependencies:
'@aws-sdk/client-rds-data': '>=3'
'@cloudflare/workers-types': '>=4'
@@ -7863,20 +7863,19 @@ packages:
'@tidbcloud/serverless': '*'
'@types/better-sqlite3': '*'
'@types/pg': '*'
- '@types/react': '>=18'
'@types/sql.js': '*'
'@vercel/postgres': '>=0.8.0'
'@xata.io/client': '*'
better-sqlite3: '>=7'
bun-types: '*'
expo-sqlite: '>=14.0.0'
+ gel: '>=2'
knex: '*'
kysely: '*'
mysql2: '>=2'
pg: '>=8'
postgres: '>=3'
prisma: '*'
- react: '>=18'
sql.js: '>=1'
sqlite3: '>=5'
peerDependenciesMeta:
@@ -7906,8 +7905,6 @@ packages:
optional: true
'@types/pg':
optional: true
- '@types/react':
- optional: true
'@types/sql.js':
optional: true
'@vercel/postgres':
@@ -7920,6 +7917,8 @@ packages:
optional: true
expo-sqlite:
optional: true
+ gel:
+ optional: true
knex:
optional: true
kysely:
@@ -7932,8 +7931,6 @@ packages:
optional: true
prisma:
optional: true
- react:
- optional: true
sql.js:
optional: true
sqlite3:
@@ -20695,11 +20692,11 @@ snapshots:
dayjs@1.11.19:
optional: true
- db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0))(mysql2@3.14.4):
+ db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4):
optionalDependencies:
'@libsql/client': 0.15.14
better-sqlite3: 12.4.1
- drizzle-orm: 0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0)
+ drizzle-orm: 0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)
mysql2: 3.14.4
db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.44.5(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4):
@@ -20912,7 +20909,7 @@ snapshots:
prisma: 5.22.0
react: 19.2.0
- drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0):
+ drizzle-orm@0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0):
optionalDependencies:
'@cloudflare/workers-types': 4.20250903.0
'@libsql/client': 0.15.14
@@ -20920,7 +20917,6 @@ snapshots:
'@prisma/client': 5.22.0(prisma@5.22.0)
'@types/better-sqlite3': 7.6.13
'@types/pg': 8.15.5
- '@types/react': 19.2.2
better-sqlite3: 12.4.1
bun-types: 1.3.1(@types/react@19.2.2)
kysely: 0.28.5
@@ -20928,7 +20924,6 @@ snapshots:
pg: 8.16.3
postgres: 3.4.7
prisma: 5.22.0
- react: 19.2.0
drizzle-orm@0.44.5(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0):
optionalDependencies:
@@ -24603,7 +24598,7 @@ snapshots:
cookie-es: 2.0.0
croner: 9.1.0
crossws: 0.3.5
- db0: 0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0))(mysql2@3.14.4)
+ db0: 0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4)
defu: 6.1.4
destr: 2.0.5
dot-prop: 9.0.0
@@ -24649,7 +24644,7 @@ snapshots:
unenv: 2.0.0-rc.19
unimport: 5.2.0
unplugin-utils: 0.2.5
- unstorage: 1.16.1(db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0))(mysql2@3.14.4))(ioredis@5.7.0)
+ unstorage: 1.16.1(db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4))(ioredis@5.7.0)
untyped: 2.0.0
unwasm: 0.3.11
youch: 4.1.0-beta.8
@@ -27357,7 +27352,7 @@ snapshots:
db0: 0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.44.5(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4)
ioredis: 5.7.0
- unstorage@1.16.1(db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0))(mysql2@3.14.4))(ioredis@5.7.0):
+ unstorage@1.16.1(db0@0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4))(ioredis@5.7.0):
dependencies:
anymatch: 3.1.3
chokidar: 4.0.3
@@ -27368,7 +27363,7 @@ snapshots:
ofetch: 1.4.1
ufo: 1.6.1
optionalDependencies:
- db0: 0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.38.4(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(@types/react@19.2.2)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0)(react@19.2.0))(mysql2@3.14.4)
+ db0: 0.3.2(@libsql/client@0.15.14)(better-sqlite3@12.4.1)(drizzle-orm@0.41.0(@cloudflare/workers-types@4.20250903.0)(@libsql/client@0.15.14)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.5)(better-sqlite3@12.4.1)(bun-types@1.3.1(@types/react@19.2.2))(kysely@0.28.5)(mysql2@3.14.4)(pg@8.16.3)(postgres@3.4.7)(prisma@5.22.0))(mysql2@3.14.4)
ioredis: 5.7.0
until-async@3.0.2: {}