From 37ae122bcea50e3f055cd9c20190a136b9d2d162 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 15 Oct 2026 12:04:05 +0100 Subject: [PATCH] fix(prisma-adapter): keep insensitive filters case-insensitive on keyed updates The update where-builder flattened every AND eq predicate to a plain value, which Prisma matches case-sensitively. On postgresql and mongodb, incrementOne with a non-id insensitive guard found a mixed-case row with findFirst but the keyed update missed it and returned null; update with an id plus an insensitive filter did the same. Insensitive eq predicates now stay in AND with mode, and plain eq keys stay flat for WhereUniqueInput. --- ...prisma-adapter-insensitive-keyed-update.md | 5 ++ .../prisma-adapter/src/prisma-adapter.test.ts | 77 +++++++++++++++++++ packages/prisma-adapter/src/prisma-adapter.ts | 46 +++++------ 3 files changed, 100 insertions(+), 28 deletions(-) create mode 100644 .changeset/prisma-adapter-insensitive-keyed-update.md diff --git a/.changeset/prisma-adapter-insensitive-keyed-update.md b/.changeset/prisma-adapter-insensitive-keyed-update.md new file mode 100644 index 0000000000..aefb2b912e --- /dev/null +++ b/.changeset/prisma-adapter-insensitive-keyed-update.md @@ -0,0 +1,5 @@ +--- +"@better-auth/prisma-adapter": patch +--- + +If you use the Prisma adapter with PostgreSQL or MongoDB, `incrementOne` and `update` calls that filter with `mode: "insensitive"` now match rows whose stored value differs only in case. Previously `incrementOne` with a non-id filter, or `update` that combined an id or unique field with an insensitive filter, compared that filter case-sensitively and returned `null` for a mixed-case row. Other providers, which ignore `mode`, are unaffected. diff --git a/packages/prisma-adapter/src/prisma-adapter.test.ts b/packages/prisma-adapter/src/prisma-adapter.test.ts index c312a805df..fcd0944a38 100644 --- a/packages/prisma-adapter/src/prisma-adapter.test.ts +++ b/packages/prisma-adapter/src/prisma-adapter.test.ts @@ -556,6 +556,83 @@ describe("prisma-adapter", () => { expect(txClient.verification.update).toHaveBeenCalledTimes(1); }); + it("incrementOne keeps an insensitive guard case-insensitive on the keyed update", async () => { + const target = { id: "row-1", identifier: "Mixed@Example.com" }; + const update = vi.fn().mockResolvedValue({ id: "row-1", remaining: 4 }); + const txClient = { + verification: { + findFirst: vi.fn().mockResolvedValue(target), + update, + }, + }; + const adapter = prismaAdapter( + { + $transaction: vi.fn(async (cb) => cb(txClient)), + verification: {}, + } as never, + { provider: "postgresql" }, + )({ + verification: { + additionalFields: { remaining: { type: "number" } }, + }, + } as BetterAuthOptions); + + await adapter.incrementOne({ + model: "verification", + where: [ + { + field: "identifier", + value: "mixed@example.com", + mode: "insensitive", + }, + ], + increment: { remaining: -1 }, + }); + + expect(txClient.verification.findFirst).toHaveBeenCalledWith({ + where: { + identifier: { equals: "mixed@example.com", mode: "insensitive" }, + }, + }); + expect(update).toHaveBeenCalledWith({ + where: { + id: "row-1", + AND: [ + { + identifier: { equals: "mixed@example.com", mode: "insensitive" }, + }, + ], + }, + data: { remaining: { increment: -1 } }, + }); + }); + + it("update keeps an insensitive predicate next to a unique key", async () => { + const update = vi.fn().mockResolvedValue({ id: "user-id" }); + const adapter = prismaAdapter( + { $transaction: vi.fn(), user: { update } }, + { provider: "postgresql" }, + )({}); + + await adapter.update({ + model: "user", + where: [ + { field: "id", value: "user-id" }, + { field: "email", value: "test@example.com", mode: "insensitive" }, + ], + update: { name: "Updated" }, + }); + + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: "user-id", + AND: [{ email: { equals: "test@example.com", mode: "insensitive" } }], + }, + }), + ); + }); + it("consumeOne does not open a nested transaction from a transaction adapter", async () => { const target = { id: "verification-id", diff --git a/packages/prisma-adapter/src/prisma-adapter.ts b/packages/prisma-adapter/src/prisma-adapter.ts index 2bc06bdd98..8acf194212 100644 --- a/packages/prisma-adapter/src/prisma-adapter.ts +++ b/packages/prisma-adapter/src/prisma-adapter.ts @@ -206,6 +206,13 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { return operator; } } + // Only postgresql and mongodb honor Prisma's `mode`, and only on strings. + const isInsensitiveForPrisma = (w: Where) => + w.mode === "insensitive" && + (config.provider === "postgresql" || config.provider === "mongodb") && + (typeof w.value === "string" || + (Array.isArray(w.value) && + w.value.every((v) => typeof v === "string"))); const hasRootUniqueWhereCondition = ( model: string, where?: Where[] | undefined, @@ -223,16 +230,8 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { return false; } - if (condition.mode === "insensitive") { - const providerSupportsMode = - config.provider === "postgresql" || config.provider === "mongodb"; - const isStringValue = - typeof condition.value === "string" || - (Array.isArray(condition.value) && - condition.value.every((v) => typeof v === "string")); - if (providerSupportsMode && isStringValue) { - return false; - } + if (isInsensitiveForPrisma(condition)) { + return false; } if (condition.field === "id") { @@ -267,17 +266,9 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { if (!where || !where.length) return {}; const buildSingleCondition = (w: Where) => { const fieldName = getFieldName({ model, field: w.field }); - const mode = w.mode ?? "sensitive"; - const isInsensitive = - mode === "insensitive" && - (typeof w.value === "string" || - (Array.isArray(w.value) && - w.value.every((v) => typeof v === "string"))); - const providerSupportsMode = - config.provider === "postgresql" || config.provider === "mongodb"; - const prismaMode = - isInsensitive && providerSupportsMode ? "insensitive" : undefined; - const modeFilter = prismaMode ? { mode: prismaMode } : {}; + const modeFilter = isInsensitiveForPrisma(w) + ? { mode: "insensitive" } + : {}; // Special handling for Prisma null semantics, for non-nullable fields this is a tautology. Skip condition. if (w.operator === "ne" && w.value === null) { @@ -331,13 +322,12 @@ export const prismaAdapter = (prisma: PrismaClient, config: PrismaConfig) => { ); const or = where.filter((w) => w.connector === "OR"); - // Separate AND conditions into simple eq (can extract) and complex (must stay in AND) - const andSimple = and.filter( - (w) => w.operator === "eq" || !w.operator, - ); - const andComplex = and.filter( - (w) => w.operator !== "eq" && w.operator !== undefined, - ); + // Separate AND conditions into simple eq (can extract) and complex (must stay in AND). + // A flat value is a case-sensitive match, so insensitive eq stays in AND. + const isSimple = (w: Where) => + (w.operator === "eq" || !w.operator) && !isInsensitiveForPrisma(w); + const andSimple = and.filter(isSimple); + const andComplex = and.filter((w) => !isSimple(w)); const andComplexClause = andComplex.map((w) => buildSingleCondition(w),