diff --git a/.changeset/drizzle-compound-where-fields.md b/.changeset/drizzle-compound-where-fields.md new file mode 100644 index 0000000000..5210e498b3 --- /dev/null +++ b/.changeset/drizzle-compound-where-fields.md @@ -0,0 +1,5 @@ +--- +"@better-auth/drizzle-adapter": patch +--- + +Reject missing Drizzle schema fields before building compound `where` clauses, preventing malformed SQL when an application schema is out of date. diff --git a/packages/drizzle-adapter/src/drizzle-adapter.test.ts b/packages/drizzle-adapter/src/drizzle-adapter.test.ts index 4eb7fb0d81..9e81306d63 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.test.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.test.ts @@ -1,4 +1,11 @@ -import { is, Param, SQL } from "drizzle-orm"; +import { is, Param, SQL, sql } from "drizzle-orm"; +import { + boolean, + integer, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; import { describe, expect, it, vi } from "vitest"; import { drizzleAdapter } from "./drizzle-adapter"; @@ -206,17 +213,70 @@ describe("drizzle-adapter", () => { }); }); + describe("where field validation", () => { + it("rejects a missing field in multiple AND conditions before querying", async () => { + const account = pgTable("account", { + accountId: text("account_id").notNull(), + }); + const select = vi.fn(); + const adapter = drizzleAdapter( + { _: { fullSchema: { account } }, select }, + { provider: "pg", schema: { account } }, + )({ secret: "test-secret-that-is-at-least-32-chars-long!!" }); + + await expect( + adapter.findOne({ + model: "account", + where: [ + { field: "issuer", value: "https://issuer.example" }, + { field: "accountId", value: "subject" }, + ], + }), + ).rejects.toThrow( + 'The field "issuer" does not exist in the schema for the model "account"', + ); + expect(select).not.toHaveBeenCalled(); + }); + + it("rejects inherited field names before querying", async () => { + const account = pgTable("account", { + accountId: text("account_id").notNull(), + }); + const select = vi.fn(); + const adapter = drizzleAdapter( + { _: { fullSchema: { account } }, select }, + { provider: "pg", schema: { account } }, + )({ + secret: "test-secret-that-is-at-least-32-chars-long!!", + account: { fields: { issuer: "constructor" } }, + }); + + await expect( + adapter.findOne({ + model: "account", + where: [ + { field: "issuer", value: "https://issuer.example" }, + { field: "accountId", value: "subject" }, + ], + }), + ).rejects.toThrow( + 'The field "constructor" does not exist in the schema for the model "account"', + ); + expect(select).not.toHaveBeenCalled(); + }); + }); + describe("updateMany affected-row count", () => { const defaultSecret = "test-secret-that-is-at-least-32-chars-long!!"; - const userTable = { - id: { name: "id" }, - name: { name: "name" }, - email: { name: "email" }, - emailVerified: { name: "emailVerified" }, - image: { name: "image" }, - createdAt: { name: "createdAt" }, - updatedAt: { name: "updatedAt" }, - }; + const userTable = pgTable("user", { + id: text("id"), + name: text("name"), + email: text("email"), + emailVerified: boolean("emailVerified"), + image: text("image"), + createdAt: timestamp("createdAt"), + updatedAt: timestamp("updatedAt"), + }); /** * Builds a mock db whose `update().set().where()` chain resolves to the @@ -310,14 +370,14 @@ describe("drizzle-adapter", () => { describe("consumeOne affected-row count", () => { const defaultSecret = "test-secret-that-is-at-least-32-chars-long!!"; - const verificationTable = { - id: { name: "id" }, - identifier: { name: "identifier" }, - value: { name: "value" }, - expiresAt: { name: "expiresAt" }, - createdAt: { name: "createdAt" }, - updatedAt: { name: "updatedAt" }, - }; + const verificationTable = pgTable("verification", { + id: text("id"), + identifier: text("identifier"), + value: text("value"), + expiresAt: timestamp("expiresAt"), + createdAt: timestamp("createdAt"), + updatedAt: timestamp("updatedAt"), + }); const verificationRow = { id: "verification-1", identifier: "reset-password:token", @@ -381,18 +441,16 @@ describe("drizzle-adapter", () => { describe("incrementOne", () => { const defaultSecret = "test-secret-that-is-at-least-32-chars-long!!"; - // `attempts` is a plain numeric column the increment targets; the rest - // mirror the default user table so the factory's schema validation passes. - const userTable = { - id: { name: "id" }, - name: { name: "name" }, - email: { name: "email" }, - emailVerified: { name: "emailVerified" }, - image: { name: "image" }, - attempts: { name: "attempts" }, - createdAt: { name: "createdAt" }, - updatedAt: { name: "updatedAt" }, - }; + const userTable = pgTable("user", { + id: text("id"), + name: text("name"), + email: text("email"), + emailVerified: boolean("emailVerified"), + image: text("image"), + attempts: integer("attempts"), + createdAt: timestamp("createdAt"), + updatedAt: timestamp("updatedAt"), + }); /** * Builds a mock db that mirrors the adapter's single-row update: a @@ -417,7 +475,7 @@ describe("drizzle-adapter", () => { calls.set = payload; return { where: updateWhere }; }); - const targetIds = { __subquery: true }; + const targetIds = sql`select id from user`; const selectLimit = vi.fn().mockReturnValue(targetIds); const selectWhere = vi.fn((...args: unknown[]) => { calls.selectGuard = args; diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index c60c32507a..f4c90d9201 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -13,12 +13,14 @@ import type { SQL } from "drizzle-orm"; import { and, asc, + Column, count, desc, eq, gt, gte, inArray, + is, isNotNull, isNull, like, @@ -343,18 +345,22 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { }; function convertWhereClause(where: Where[], model: string) { const schemaModel = getSchema(model); + const resolveFieldName = (where: Where) => { + const field = getFieldName({ model, field: where.field }); + if (!is(schemaModel[field], Column)) { + throw new BetterAuthError( + `The field "${where.field}" does not exist in the schema for the model "${model}". Please update your schema.`, + ); + } + return field; + }; if (!where) return []; if (where.length === 1) { const w = where[0]; if (!w) { return []; } - const field = getFieldName({ model, field: w.field }); - if (!schemaModel[field]) { - throw new BetterAuthError( - `The field "${w.field}" does not exist in the schema for the model "${model}". Please update your schema.`, - ); - } + const field = resolveFieldName(w); const mode = w.mode ?? "sensitive"; const isInsensitive = mode === "insensitive" && @@ -468,7 +474,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { const andClause = and( ...andGroup.map((w) => { - const field = getFieldName({ model, field: w.field }); + const field = resolveFieldName(w); const mode = w.mode ?? "sensitive"; const isInsensitive = mode === "insensitive" && @@ -571,12 +577,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { ); const orClause = or( ...orGroup.map((w) => { - const field = getFieldName({ model, field: w.field }); - if (!schemaModel[field]) { - throw new BetterAuthError( - `The field "${w.field}" does not exist in the schema for the model "${model}". Please update your schema.`, - ); - } + const field = resolveFieldName(w); const mode = w.mode ?? "sensitive"; const isInsensitive = mode === "insensitive" &&