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.
This commit is contained in:
Gustavo Valverde
2026-10-15 12:04:05 +01:00
parent 99cdfe7c2e
commit 37ae122bce
3 changed files with 100 additions and 28 deletions
@@ -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.
@@ -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",
+18 -28
View File
@@ -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),