chore!: move joins to advanced.database.joins (#10359)

This commit is contained in:
Maxwell
2026-07-14 22:21:38 +00:00
committed by GitHub
parent e4818b5459
commit 8784c1c1f4
25 changed files with 298 additions and 295 deletions
+19
View File
@@ -0,0 +1,19 @@
---
"better-auth": minor
"@better-auth/core": minor
"@better-auth/drizzle-adapter": minor
---
Database joins have moved out of `experimental` into a stable option at `advanced.database.joins` (default: `false`).
If you previously set `experimental: { joins: true }`, update your config to:
```ts
advanced: {
database: {
joins: true,
},
}
```
Adapters that support native joins use them when enabled. If an adapter cannot return joined data for a query, Better Auth falls back to additional queries and combines the results. Drizzle and Prisma users should ensure their schema includes the required relations (`npx auth@latest generate`).
+8 -6
View File
@@ -109,27 +109,29 @@ const auth = betterAuth({
***
### Database Joins Improvements (experimental)
### Database Joins Improvements
Better-Auth now supports database joins, improving over 50 endpoints by 2 to 3x in latency. This is achieved by using database joins under the hood.
To enable, simply set the `joins` flag to `true` under `experimental` in your auth config.
To enable, set `advanced.database.joins` to `true` in your auth config.
Then, re-run migrations or schema generation to get the updated schema which supports joins!
[👉 Read more about database joins](/docs/concepts/database#experimental-joins)
[👉 Read more about database joins](/docs/concepts/database#joins)
```ts
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: {
joins: true,
advanced: {
database: {
joins: true,
},
},
});
```
<Callout type="info">
Although joins are experimental, they are supported by all adapters and will be enabled by default in the next release.
Joins are supported by all adapters. When disabled (the default), Better Auth falls back to separate queries.
</Callout>
***
+7 -3
View File
@@ -59,20 +59,24 @@ To generate and apply the migration, run the following commands:
</Tab>
</Tabs>
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
+7 -3
View File
@@ -39,20 +39,24 @@ export const auth = betterAuth({
For MongoDB, we don't need to generate or migrate the schema.
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
+7 -7
View File
@@ -103,27 +103,27 @@ your database schema based on your Better Auth configuration and plugins.
</Tab>
</Tabs>
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
<Callout type="warn">
It's possible that you may need to run migrations after enabling this feature.
</Callout>
## 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. (<Link href="/docs/adapters/other-relational-databases">Read more here</Link>)
+7 -8
View File
@@ -81,28 +81,27 @@ your database schema based on your Better Auth configuration and plugins.
</Tab>
</Tabs>
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
<Callout type="warn">
It's possible that you may need to run migrations after enabling this feature.
</Callout>
## Additional Information
MySQL is supported under the hood via the [Kysely](https://kysely.dev/) adapter, any database supported by Kysely would also be supported. (<Link href="/docs/adapters/other-relational-databases">Read more here</Link>)
+7 -7
View File
@@ -67,27 +67,27 @@ your database schema based on your Better Auth configuration and plugins.
</Tab>
</Tabs>
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
<Callout type="warn">
It's possible that you may need to run migrations after enabling this feature.
</Callout>
## Use a non-default schema
In most cases, the default schema is `public`. To have Better Auth use a
+7 -3
View File
@@ -67,20 +67,24 @@ your database schema based on your Better Auth configuration and plugins.
npx auth@latest generate
```
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
+9 -7
View File
@@ -108,25 +108,27 @@ your database schema based on your Better Auth configuration and plugins.
</Tab>
</Tabs>
## Joins (Experimental)
## Joins
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.
To enable this feature, set `advanced.database.joins` to `true` in your auth configuration.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
joins: true,
},
},
});
```
<Callout type="warn">
It's possible that you may need to run migrations after enabling this feature.
</Callout>
## Additional Information
SQLite is supported under the hood via the [Kysely](https://kysely.dev/) adapter, any database supported by Kysely would also be supported. (<Link href="/docs/adapters/other-relational-databases">Read more here</Link>)
+18 -15
View File
@@ -1013,14 +1013,14 @@ To add new tables and columns to your database, you have two options:
Both methods ensure your database schema stays up to date with your plugins' requirements.
## Experimental Joins
## Joins
Since Better-Auth version `1.4` we've introduced experimental database joins support.
Since Better-Auth version `1.4` we've introduced 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.
Under the hood, our adapter system supports joins natively. When joins are disabled (the default),
Better-Auth falls back to making multiple database queries and combining the results.
To enable joins, update your auth config with the following:
@@ -1028,19 +1028,22 @@ To enable joins, update your auth config with the following:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
experimental: { joins: true }
advanced: {
database: {
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.
Make sure your DrizzleORM or PrismaORM schema includes the necessary relationships — run our migrate or generate CLI commands to stay up-to-date.
It's very important to read the documentation regarding experimental joins for your given adapter:
Read the documentation regarding 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/mongo#joins-experimental)
* [DrizzleORM](/docs/adapters/drizzle#joins)
* [PrismaORM](/docs/adapters/prisma#joins)
* [SQLite](/docs/adapters/sqlite#joins)
* [MySQL](/docs/adapters/mysql#joins)
* [PostgreSQL](/docs/adapters/postgresql#joins)
* [MSSQL](/docs/adapters/mssql#joins)
* [MongoDB](/docs/adapters/mongo#joins)
+4 -2
View File
@@ -648,7 +648,7 @@ export const auth = betterAuth({
return "my-super-unique-id";
})) | false | "serial" | "uuid",
defaultFindManyLimit: 100,
experimentalJoins: false,
joins: false,
},
backgroundTasks: {
handler: (promise) => { /* e.g. waitUntil(promise) */ }
@@ -676,10 +676,11 @@ export const auth = betterAuth({
<h3 id="advanced-database"><code>database</code></h3>
Set custom strategies for ID generation and findMany queries.
Set custom strategies for ID generation, findMany queries, and database joins.
* `generateId`: Controls how record IDs are generated. Accepts a custom function, `false`, `"serial"`, or `"uuid"` (default: [random base62 string](https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/id.ts)). See the [Database documentation](/docs/concepts/database#id-generation) for more info.
* `defaultFindManyLimit`: The default maximum number of records returned by the `findMany` adapter method. (default: `100`)
* `joins`: Enable database joins for adapters that support them. When disabled (default), related data is fetched via separate queries. See the [Database documentation](/docs/concepts/database#joins) for more info. (default: `false`)
```ts
import { betterAuth } from "better-auth";
@@ -689,6 +690,7 @@ export const auth = betterAuth({
database: {
generateId: "uuid",
defaultFindManyLimit: 50,
joins: true,
},
},
});
@@ -1754,7 +1754,7 @@ describe("Create Adapter Helper", async () => {
});
describe("Fallback JoinOption System", async () => {
describe("supportsJoin: false (Fallback mode)", () => {
describe("fallback when adapter returns flat rows (no join keys)", () => {
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 }> = [];
@@ -1763,9 +1763,10 @@ describe("Fallback JoinOption System", async () => {
debugLogs: {},
},
options: {
experimental: {
// explicitally defining since in the future `join` will likely be default which would break this test
joins: false,
advanced: {
database: {
joins: true,
},
},
},
adapter: () =>
@@ -1828,9 +1829,10 @@ describe("Fallback JoinOption System", async () => {
debugLogs: {},
},
options: {
experimental: {
// explicitally defining since in the future `join` will likely be default which would break this test
joins: false,
advanced: {
database: {
joins: true,
},
},
},
adapter: () =>
@@ -1900,8 +1902,10 @@ describe("Fallback JoinOption System", async () => {
expect(item).toHaveProperty("session");
});
});
});
test("findOne: Should not pass join to adapter when supportsJoin is false", async () => {
describe("native join support (advanced.database.joins: true)", () => {
test("findOne: Should pass join to adapter and use nested data when present", async () => {
let joinPassedToAdapter = null;
const adapter = await createTestAdapter({
@@ -1909,9 +1913,10 @@ describe("Fallback JoinOption System", async () => {
debugLogs: {},
},
options: {
experimental: {
// explicitally defining since in the future `join` will likely be default which would break this test
joins: false,
advanced: {
database: {
joins: true,
},
},
},
adapter: () =>
@@ -1925,22 +1930,31 @@ describe("Fallback JoinOption System", async () => {
createdAt: new Date(),
updatedAt: new Date(),
name: "Test User",
session: [
{
id: "session-1",
userId: "user-123",
expiresAt: new Date(),
createdAt: new Date(),
},
],
};
},
}) as any,
});
await adapter.findOne({
const res = 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();
expect(joinPassedToAdapter).not.toBeUndefined();
expect(joinPassedToAdapter).toHaveProperty("session");
expect(res).toHaveProperty("session");
});
test("findMany: Should not pass join to adapter when supportsJoin is false", async () => {
test("findMany: Should pass join to adapter and use nested data when present", async () => {
let joinPassedToAdapter = null;
const adapter = await createTestAdapter({
@@ -1948,9 +1962,10 @@ describe("Fallback JoinOption System", async () => {
debugLogs: {},
},
options: {
experimental: {
// explicitally defining since in the future `join` will likely be default which would break this test
joins: false,
advanced: {
database: {
joins: true,
},
},
},
adapter: () =>
@@ -1965,118 +1980,41 @@ describe("Fallback JoinOption System", async () => {
createdAt: new Date(),
updatedAt: new Date(),
name: "Test User",
session: [
{
id: "session-1",
userId: "user-123",
expiresAt: new Date(),
createdAt: new Date(),
},
],
},
];
},
}) as any,
});
await adapter.findMany({
const res = await adapter.findMany({
model: "user",
where: [],
join: { session: true },
});
// JoinOption should NOT be passed to adapter when supportsJoin is false
expect(joinPassedToAdapter).toBeUndefined();
expect(joinPassedToAdapter).not.toBeUndefined();
expect(joinPassedToAdapter).toHaveProperty("session");
expect(res).toBeInstanceOf(Array);
expect(res[0]).toHaveProperty("session");
});
});
describe("supportsJoin: true (Native join support)", () => {
test("findOne: Should pass join to adapter when supportsJoin is true", async () => {
describe("default behavior (joins disabled)", () => {
test("Should not pass join to adapter by default", 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) {
@@ -2090,6 +2028,12 @@ describe("Fallback JoinOption System", async () => {
name: "Test User",
};
},
async findMany({ model }: any) {
if (model === "session") {
return [];
}
return [];
},
}) as any,
});
@@ -2099,7 +2043,6 @@ describe("Fallback JoinOption System", async () => {
join: { session: true },
});
// Since supportsJoin defaults to false, join should NOT be passed
expect(joinPassedToAdapter).toBeUndefined();
});
});
@@ -208,7 +208,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
/**
* @see https://github.com/better-auth/better-auth/issues/7271
*
* Same query on the experimental joins path.
* Same query on the joins path.
* The bug: `clause[0]` is used, dropping the OR group entirely.
* Only the AND clause (email LIKE '%company.com%') is applied,
* returning u1 AND u2 instead of just u1.
@@ -219,7 +219,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
provider: "sqlite",
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
const result = await adapter.findMany<User>({
@@ -233,6 +233,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
connector: "OR",
},
],
join: { session: true },
});
expect(result).toHaveLength(1);
@@ -263,7 +264,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
provider: "sqlite",
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
const result = await adapter.findOne<User>({
@@ -277,6 +278,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
connector: "OR",
},
],
join: { session: true },
});
expect(result).not.toBeNull();
@@ -301,7 +303,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
provider: "sqlite",
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
const result = await adapter.findMany<User>({
@@ -315,6 +317,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
connector: "OR",
},
],
join: { session: true },
});
expect(result).toHaveLength(0);
@@ -337,7 +340,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
provider: "sqlite",
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
const result = await adapter.findMany<User>({
@@ -358,6 +361,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
connector: "OR",
},
],
join: { session: true },
});
expect(result).toHaveLength(3);
@@ -383,7 +387,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
provider: "sqlite",
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
const result = await adapter.findOne<User>({
@@ -397,6 +401,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => {
connector: "OR",
},
],
join: { session: true },
});
expect(result).toBeNull();
@@ -117,7 +117,7 @@ const adapterSchema = {
// ── Tests ──
describe("drizzle adapter: singular config.schema keys with plural db.query keys + experimental.joins", () => {
describe("drizzle adapter: singular config.schema keys with plural db.query keys + joins", () => {
let sqliteDb: InstanceType<typeof Database>;
let db: ReturnType<typeof drizzle>;
@@ -184,7 +184,7 @@ describe("drizzle adapter: singular config.schema keys with plural db.query keys
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
const now = new Date();
@@ -238,7 +238,7 @@ describe("drizzle adapter: singular config.schema keys with plural db.query keys
});
const adapter = adapterFactory({
experimental: { joins: true },
advanced: { database: { joins: true } },
});
// findMany with join — exercises the findMany join path
@@ -1,7 +1,7 @@
/**
* The adapter skipped the relational query for case-insensitive where clauses,
* falling back to the non-relational path. Under `experimental.joins` that
* returned empty joins, so insensitive conditions are now routed through `RAW`.
* falling back to the non-relational path and returning empty joins.
* Insensitive conditions are now routed through `RAW`.
*/
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import Database from "better-sqlite3";
@@ -46,7 +46,9 @@ describe("drizzle relations-v2 adapter: case-insensitive where on the joins path
const adapter = drizzleAdapter(db, {
schema: { ...tables, relations },
provider: "sqlite",
})({ experimental: { joins: true } });
})({
advanced: { database: { joins: true } },
});
beforeEach(() => {
sqliteDb.exec("DROP TABLE IF EXISTS session;");
@@ -1,5 +1,5 @@
/**
* Under `experimental.joins`, a `where` with `mode: "insensitive"` must fall back
* With joins, a `where` with `mode: "insensitive"` must fall back
* to the SQL builder instead of silently degrading to a case-sensitive match.
*/
import type { User } from "@better-auth/core/db";
@@ -31,7 +31,9 @@ describe("drizzle relations-v2 adapter: joins path honors insensitive mode", ()
const adapter = drizzleAdapter(db, {
schema,
provider: "sqlite",
})({ experimental: { joins: true } });
})({
advanced: { database: { joins: true } },
});
beforeEach(() => {
sqliteDb.exec("DROP TABLE IF EXISTS user;");
@@ -1,5 +1,5 @@
/**
* With `experimental.joins`, `findMany` filters through Drizzle's relational
* With joins, `findMany` filters through Drizzle's relational
* query object, whose `like` filter cannot carry `ESCAPE`. LIKE is routed
* through `RAW`, so this checks `%` and `_` still match literally.
*
@@ -33,7 +33,9 @@ describe("drizzle relations-v2 adapter: LIKE escaping on the joins path", () =>
const adapter = drizzleAdapter(db, {
schema: { ...tables, relations },
provider: "sqlite",
})({ experimental: { joins: true } });
})({
advanced: { database: { joins: true } },
});
beforeEach(() => {
sqliteDb.exec("DROP TABLE IF EXISTS user;");
@@ -2,7 +2,7 @@
* Drizzle keys `db.query` by the schema export names, commonly plural ("users"),
* while Better Auth passes singular model names. The adapter read
* `db.query[model]` directly, so a plural-keyed schema fell back to the
* non-relational query, returning empty joins under `experimental.joins`.
* non-relational query, returning empty joins.
*/
import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
import Database from "better-sqlite3";
@@ -49,7 +49,9 @@ describe("drizzle relations-v2 adapter: plural db.query keys", () => {
const adapter = drizzleAdapter(db, {
schema: { user: users, session: sessions, relations },
provider: "sqlite",
})({ experimental: { joins: true } });
})({
advanced: { database: { joins: true } },
});
beforeEach(() => {
sqliteDb.exec("DROP TABLE IF EXISTS session;");
@@ -128,7 +130,9 @@ describe("drizzle relations-v2 adapter: missing relational query namespace", ()
const adapter = drizzleAdapter(db, {
schema: { user: users, session: sessions, relations },
provider: "sqlite",
})({ experimental: { joins: true } });
})({
advanced: { database: { joins: true } },
});
beforeEach(() => {
sqliteDb.exec("DROP TABLE IF EXISTS user;");
@@ -174,7 +178,9 @@ describe("drizzle relations-v2 adapter: query key via relations internal", () =>
const adapter = drizzleAdapter(db, {
schema: { user: users, session: sessions, relations },
provider: "sqlite",
})({ experimental: { joins: true } });
})({
advanced: { database: { joins: true } },
});
beforeEach(() => {
sqliteDb.exec("DROP TABLE IF EXISTS session;");
@@ -53,7 +53,7 @@ describe("drizzle relations-v2 adapter: join model name ending in 's'", () => {
schema: { user: users, address, relations },
provider: "sqlite",
})({
experimental: { joins: true },
advanced: { database: { joins: true } },
plugins: [
{
id: "test-address",
@@ -44,8 +44,10 @@ export const schemaRefJoinTestSuite = createTestSuite(
{
defaultBetterAuthOptions: {
...DEFAULT_BETTER_AUTH_OPTIONS,
experimental: {
joins: true,
advanced: {
database: {
joins: true,
},
},
},
alwaysMigrate: true,
+24 -18
View File
@@ -425,19 +425,19 @@ export const createAdapterFactory =
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 (options.advanced?.database?.joins) {
// Use native joined data when the adapter included the key;
// otherwise fall back to separate queries.
if (modelName in data) {
return data[modelName];
}
}
return await handleFallbackJoin({
baseModel: unsafe_model,
baseData: transformedData,
joinModel: modelName,
specificJoinConfig: joinConfig,
});
})();
// If joinedData is undefined, initialize it based on relationship type
@@ -1118,9 +1118,12 @@ export const createAdapterFactory =
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) {
// If joins are disabled and we have joins, don't pass them to the adapter
if (
!options.advanced?.database?.joins &&
join &&
Object.keys(join).length > 0
) {
passJoinToAdapter = false;
}
} else {
@@ -1206,9 +1209,12 @@ export const createAdapterFactory =
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) {
// If joins are disabled and we have joins, don't pass them to the adapter
if (
!options.advanced?.database?.joins &&
join &&
Object.keys(join).length > 0
) {
passJoinToAdapter = false;
}
} else {
+15 -14
View File
@@ -454,6 +454,21 @@ export type BetterAuthAdvancedOptions = {
* function.
*/
generateId?: GenerateIdFn | false | "serial" | "uuid";
/**
* Enable database joins for adapters that support them.
*
* When disabled (default), related data is fetched via
* separate queries. When enabled, adapters that support
* native joins use them; otherwise Better Auth falls back
* to separate queries.
*
* Please read the adapter documentation for more
* information regarding joins before enabling this.
* Not all adapters support joins.
*
* @default false
*/
joins?: boolean;
}
| undefined;
/**
@@ -1783,18 +1798,4 @@ 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;
};
};
+32 -35
View File
@@ -722,6 +722,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
* corresponds to the same table object
*/
function getQueryModel(model: string): string | null {
if (!db.query) return null;
if (db.query[model]) return model;
if (config.usePlural) {
@@ -758,7 +759,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
if (options.experimental?.joins) {
if (join) {
const queryModel = getQueryModel(model);
if (!db.query || !queryModel) {
logger.error(
@@ -771,22 +772,20 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
| 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";
const pluralSuffix = isUnique || config.usePlural ? "" : "s";
includes[`${model}${pluralSuffix}`] = isUnique
? true
: { limit };
if (!isUnique) {
pluralJoinResults.push(`${model}${pluralSuffix}`);
}
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";
const pluralSuffix = isUnique || config.usePlural ? "" : "s";
includes[`${model}${pluralSuffix}`] = isUnique
? true
: { limit };
if (!isUnique) {
pluralJoinResults.push(`${model}${pluralSuffix}`);
}
}
const query = db.query[queryModel].findFirst({
@@ -845,9 +844,9 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
const clause = where ? convertWhereClause(where, model) : [];
const sortFn = sortBy?.direction === "desc" ? desc : asc;
if (options.experimental?.joins) {
if (join) {
const queryModel = getQueryModel(model);
if (!queryModel) {
if (!db.query || !queryModel) {
logger.error(
`[# Drizzle Adapter]: The model "${model}" was not found in the query object. Please update your Drizzle schema to include relations or re-generate using "npx auth@latest generate".`,
);
@@ -858,22 +857,20 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
| 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;
const pluralSuffix = isUnique || config.usePlural ? "" : "s";
includes[`${model}${pluralSuffix}`] = isUnique
? true
: { limit };
if (!isUnique)
pluralJoinResults.push(`${model}${pluralSuffix}`);
}
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;
const pluralSuffix = isUnique || config.usePlural ? "" : "s";
includes[`${model}${pluralSuffix}`] = isUnique
? true
: { limit };
if (!isUnique)
pluralJoinResults.push(`${model}${pluralSuffix}`);
}
let orderBy: SQL<unknown>[] | undefined = undefined;
if (sortBy?.field) {
@@ -693,7 +693,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
const schemaModel = getSchema(model);
const clause = convertWhereClause(where, model);
if (options.experimental?.joins) {
if (join) {
const queryModel = getQueryModel(model);
if (!db.query || !queryModel) {
logger.error(
@@ -706,20 +706,18 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
| undefined;
const pluralJoinResults: { key: string; target: 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";
const relationKey = getJoinRelationKey(model, isUnique);
includes[relationKey] = isUnique ? true : { limit };
if (!isUnique) {
pluralJoinResults.push({ key: relationKey, target: model });
}
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";
const relationKey = getJoinRelationKey(model, isUnique);
includes[relationKey] = isUnique ? true : { limit };
if (!isUnique) {
pluralJoinResults.push({ key: relationKey, target: model });
}
}
const clause = convertNewWhereClause(where, model);
@@ -775,7 +773,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
const clause = where ? convertWhereClause(where, model) : [];
const sortFn = sortBy?.direction === "desc" ? desc : asc;
if (options.experimental?.joins) {
if (join) {
const queryModel = getQueryModel(model);
if (!db.query || !queryModel) {
logger.error(
@@ -788,20 +786,18 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => {
| undefined;
const pluralJoinResults: { key: string; target: 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;
const relationKey = getJoinRelationKey(model, isUnique);
includes[relationKey] = isUnique ? true : { limit };
if (!isUnique)
pluralJoinResults.push({ key: relationKey, target: model });
}
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;
const relationKey = getJoinRelationKey(model, isUnique);
includes[relationKey] = isUnique ? true : { limit };
if (!isUnique)
pluralJoinResults.push({ key: relationKey, target: model });
}
let orderBy: Record<string, "asc" | "desc"> | undefined =
undefined;
@@ -6,8 +6,10 @@ export const joinsTestSuite = createTestSuite(
"joins",
{
defaultBetterAuthOptions: {
experimental: {
joins: true,
advanced: {
database: {
joins: true,
},
},
},
alwaysMigrate: true,
@@ -20,7 +22,7 @@ export const joinsTestSuite = createTestSuite(
return {
"init - tests": async () => {
const opts = helpers.getBetterAuthOptions();
expect(opts.experimental?.joins).toBe(true);
expect(opts.advanced?.database?.joins).toBe(true);
},
...normalTests,
};