From 8784c1c1f4301acf96d980e5bf81ff56435e2545 Mon Sep 17 00:00:00 2001 From: Maxwell <145994855+ping-maxwell@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:21:38 +1000 Subject: [PATCH] chore!: move joins to advanced.database.joins (#10359) --- .changeset/joins-out-of-experimental.md | 19 ++ docs/content/blogs/1-4.mdx | 14 +- docs/content/docs/adapters/drizzle.mdx | 10 +- docs/content/docs/adapters/mongo.mdx | 10 +- docs/content/docs/adapters/mssql.mdx | 14 +- docs/content/docs/adapters/mysql.mdx | 15 +- docs/content/docs/adapters/postgresql.mdx | 14 +- docs/content/docs/adapters/prisma.mdx | 10 +- docs/content/docs/adapters/sqlite.mdx | 16 +- docs/content/docs/concepts/database.mdx | 33 ++-- docs/content/docs/reference/options.mdx | 6 +- .../adapter-factory/adapter-factory.test.ts | 165 ++++++------------ .../adapter.drizzle.mixed-where.test.ts | 17 +- .../adapter.drizzle.plural-joins.test.ts | 6 +- .../adapter.drizzle.insensitive-join.test.ts | 8 +- .../adapter.drizzle.joins-where.test.ts | 6 +- .../adapter.drizzle.like-escape-joins.test.ts | 6 +- .../adapter.drizzle.plural-query-key.test.ts | 14 +- ...r.drizzle.plural-suffix-trailing-s.test.ts | 2 +- .../schema-reference-test-suite.ts | 6 +- packages/core/src/db/adapter/factory.ts | 42 +++-- packages/core/src/types/init-options.ts | 29 +-- .../drizzle-adapter/src/drizzle-adapter.ts | 67 ++++--- .../drizzle-adapter/src/relations-v2/index.ts | 56 +++--- .../test-utils/src/adapter/suites/joins.ts | 8 +- 25 files changed, 298 insertions(+), 295 deletions(-) create mode 100644 .changeset/joins-out-of-experimental.md diff --git a/.changeset/joins-out-of-experimental.md b/.changeset/joins-out-of-experimental.md new file mode 100644 index 0000000000..9fd91efe8d --- /dev/null +++ b/.changeset/joins-out-of-experimental.md @@ -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`). diff --git a/docs/content/blogs/1-4.mdx b/docs/content/blogs/1-4.mdx index 4c2e5385fb..e89ca469b8 100644 --- a/docs/content/blogs/1-4.mdx +++ b/docs/content/blogs/1-4.mdx @@ -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, + }, }, }); ``` - 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. *** diff --git a/docs/content/docs/adapters/drizzle.mdx b/docs/content/docs/adapters/drizzle.mdx index eed1d3dc74..6df7388041 100644 --- a/docs/content/docs/adapters/drizzle.mdx +++ b/docs/content/docs/adapters/drizzle.mdx @@ -59,20 +59,24 @@ To generate and apply the migration, run the following commands: -## 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, + }, + }, }); ``` diff --git a/docs/content/docs/adapters/mongo.mdx b/docs/content/docs/adapters/mongo.mdx index abeff8db86..064e62a2fe 100644 --- a/docs/content/docs/adapters/mongo.mdx +++ b/docs/content/docs/adapters/mongo.mdx @@ -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, + }, + }, }); ``` diff --git a/docs/content/docs/adapters/mssql.mdx b/docs/content/docs/adapters/mssql.mdx index ba1b4d3c0e..c190de911b 100644 --- a/docs/content/docs/adapters/mssql.mdx +++ b/docs/content/docs/adapters/mssql.mdx @@ -103,27 +103,27 @@ your database schema based on your Better Auth configuration and plugins. -## 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, + }, + }, }); ``` - - 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 e9c557f8d6..076ec949d8 100644 --- a/docs/content/docs/adapters/mysql.mdx +++ b/docs/content/docs/adapters/mysql.mdx @@ -81,28 +81,27 @@ your database schema based on your Better Auth configuration and plugins. -## 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, + }, + }, }); ``` - - 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 0bf2da2402..3e6b4e0ff6 100644 --- a/docs/content/docs/adapters/postgresql.mdx +++ b/docs/content/docs/adapters/postgresql.mdx @@ -67,27 +67,27 @@ your database schema based on your Better Auth configuration and plugins. -## 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, + }, + }, }); ``` - - 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 c5fb942939..5b5ff4274f 100644 --- a/docs/content/docs/adapters/prisma.mdx +++ b/docs/content/docs/adapters/prisma.mdx @@ -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, + }, + }, }); ``` diff --git a/docs/content/docs/adapters/sqlite.mdx b/docs/content/docs/adapters/sqlite.mdx index 3473743f96..22884a6819 100644 --- a/docs/content/docs/adapters/sqlite.mdx +++ b/docs/content/docs/adapters/sqlite.mdx @@ -108,25 +108,27 @@ your database schema based on your Better Auth configuration and plugins. -## 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, + }, + }, }); ``` - - 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 d7037e63e2..6aecbc25dd 100644 --- a/docs/content/docs/concepts/database.mdx +++ b/docs/content/docs/concepts/database.mdx @@ -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) diff --git a/docs/content/docs/reference/options.mdx b/docs/content/docs/reference/options.mdx index 14824faadb..57718fdc32 100644 --- a/docs/content/docs/reference/options.mdx +++ b/docs/content/docs/reference/options.mdx @@ -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({

database

-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, }, }, }); diff --git a/e2e/adapter/test/adapter-factory/adapter-factory.test.ts b/e2e/adapter/test/adapter-factory/adapter-factory.test.ts index 6d1c56bbe8..d9d8741003 100644 --- a/e2e/adapter/test/adapter-factory/adapter-factory.test.ts +++ b/e2e/adapter/test/adapter-factory/adapter-factory.test.ts @@ -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(); }); }); diff --git a/e2e/adapter/test/drizzle-adapter/adapter.drizzle.mixed-where.test.ts b/e2e/adapter/test/drizzle-adapter/adapter.drizzle.mixed-where.test.ts index f3e42c5932..3e643a3868 100644 --- a/e2e/adapter/test/drizzle-adapter/adapter.drizzle.mixed-where.test.ts +++ b/e2e/adapter/test/drizzle-adapter/adapter.drizzle.mixed-where.test.ts @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -397,6 +401,7 @@ describe("drizzle adapter: mixed AND/OR connectors in where clauses", () => { connector: "OR", }, ], + join: { session: true }, }); expect(result).toBeNull(); diff --git a/e2e/adapter/test/drizzle-adapter/adapter.drizzle.plural-joins.test.ts b/e2e/adapter/test/drizzle-adapter/adapter.drizzle.plural-joins.test.ts index 5399e5c330..7a7dca3175 100644 --- a/e2e/adapter/test/drizzle-adapter/adapter.drizzle.plural-joins.test.ts +++ b/e2e/adapter/test/drizzle-adapter/adapter.drizzle.plural-joins.test.ts @@ -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; let db: ReturnType; @@ -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 diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.insensitive-join.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.insensitive-join.test.ts index a628c8f97d..8213675e15 100644 --- a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.insensitive-join.test.ts +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.insensitive-join.test.ts @@ -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;"); diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.joins-where.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.joins-where.test.ts index a786aeeb19..4645f169b1 100644 --- a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.joins-where.test.ts +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.joins-where.test.ts @@ -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;"); diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.like-escape-joins.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.like-escape-joins.test.ts index 8b7de2c95e..942cff153a 100644 --- a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.like-escape-joins.test.ts +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.like-escape-joins.test.ts @@ -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;"); diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-query-key.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-query-key.test.ts index c181ff5506..cf8613d388 100644 --- a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-query-key.test.ts +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-query-key.test.ts @@ -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;"); diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-suffix-trailing-s.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-suffix-trailing-s.test.ts index 573b24be20..bfc7045f2d 100644 --- a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-suffix-trailing-s.test.ts +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.plural-suffix-trailing-s.test.ts @@ -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", diff --git a/e2e/adapter/test/kysely-adapter/schema-reference-test-suite.ts b/e2e/adapter/test/kysely-adapter/schema-reference-test-suite.ts index 3289103f99..dadc194711 100644 --- a/e2e/adapter/test/kysely-adapter/schema-reference-test-suite.ts +++ b/e2e/adapter/test/kysely-adapter/schema-reference-test-suite.ts @@ -44,8 +44,10 @@ export const schemaRefJoinTestSuite = createTestSuite( { defaultBetterAuthOptions: { ...DEFAULT_BETTER_AUTH_OPTIONS, - experimental: { - joins: true, + advanced: { + database: { + joins: true, + }, }, }, alwaysMigrate: true, diff --git a/packages/core/src/db/adapter/factory.ts b/packages/core/src/db/adapter/factory.ts index 1ffaa8c3f9..fddfb8959c 100644 --- a/packages/core/src/db/adapter/factory.ts +++ b/packages/core/src/db/adapter/factory.ts @@ -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 { diff --git a/packages/core/src/types/init-options.ts b/packages/core/src/types/init-options.ts index c0cf3d14eb..13107c84c9 100644 --- a/packages/core/src/types/init-options.ts +++ b/packages/core/src/types/init-options.ts @@ -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; - }; }; diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index 429a538ca2..1103657997 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -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[] | undefined = undefined; if (sortBy?.field) { diff --git a/packages/drizzle-adapter/src/relations-v2/index.ts b/packages/drizzle-adapter/src/relations-v2/index.ts index 762aaa2390..125e8f349b 100644 --- a/packages/drizzle-adapter/src/relations-v2/index.ts +++ b/packages/drizzle-adapter/src/relations-v2/index.ts @@ -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 | undefined = undefined; diff --git a/packages/test-utils/src/adapter/suites/joins.ts b/packages/test-utils/src/adapter/suites/joins.ts index 974c07a0ed..304e7ce461 100644 --- a/packages/test-utils/src/adapter/suites/joins.ts +++ b/packages/test-utils/src/adapter/suites/joins.ts @@ -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, };