diff --git a/e2e/adapter/test/adapter-factory/basic.ts b/e2e/adapter/test/adapter-factory/basic.ts index 1654a2550a5..7fcce563789 100644 --- a/e2e/adapter/test/adapter-factory/basic.ts +++ b/e2e/adapter/test/adapter-factory/basic.ts @@ -3174,6 +3174,146 @@ export const getNormalTestSuiteTests = ( expect(result!.email).toBe(user.email); expect(result!.id).toBe(user.id); }, + "findMany - eq operator with null value (single condition) should use IS NULL": + async () => { + const withNull = await adapter.create({ + model: "user", + data: { ...(await generate("user")), image: null }, + forceAllowId: true, + }); + const withImage = await adapter.create({ + model: "user", + data: { + ...(await generate("user")), + image: "https://example.com/avatar.png", + }, + forceAllowId: true, + }); + + const nullResult = await adapter.findMany({ + model: "user", + where: [{ field: "image", operator: "eq", value: null }], + }); + const nullIds = nullResult.map((u) => u.id); + expect(nullIds).toContain(withNull.id); + expect(nullIds).not.toContain(withImage.id); + + const notNullResult = await adapter.findMany({ + model: "user", + where: [{ field: "image", operator: "ne", value: null }], + }); + const notNullIds = notNullResult.map((u) => u.id); + expect(notNullIds).not.toContain(withNull.id); + expect(notNullIds).toContain(withImage.id); + }, + + "findMany - eq and ne operators with null value in AND group should use IS NULL / IS NOT NULL": + async () => { + const nullVerified = await adapter.create({ + model: "user", + data: { + ...(await generate("user")), + image: null, + emailVerified: true, + }, + forceAllowId: true, + }); + const nullUnverified = await adapter.create({ + model: "user", + data: { + ...(await generate("user")), + image: null, + emailVerified: false, + }, + forceAllowId: true, + }); + const imageVerified = await adapter.create({ + model: "user", + data: { + ...(await generate("user")), + image: "https://example.com/avatar.png", + emailVerified: true, + }, + forceAllowId: true, + }); + + // image IS NULL AND emailVerified = true → only nullVerified + const eqResult = await adapter.findMany({ + model: "user", + where: [ + { field: "image", operator: "eq", value: null, connector: "AND" }, + { field: "emailVerified", value: true, connector: "AND" }, + ], + }); + const eqIds = eqResult.map((u) => u.id); + expect(eqIds).toContain(nullVerified.id); + expect(eqIds).not.toContain(nullUnverified.id); + expect(eqIds).not.toContain(imageVerified.id); + + // image IS NOT NULL AND emailVerified = true → only imageVerified + const neResult = await adapter.findMany({ + model: "user", + where: [ + { field: "image", operator: "ne", value: null, connector: "AND" }, + { field: "emailVerified", value: true, connector: "AND" }, + ], + }); + const neIds = neResult.map((u) => u.id); + expect(neIds).not.toContain(nullVerified.id); + expect(neIds).not.toContain(nullUnverified.id); + expect(neIds).toContain(imageVerified.id); + }, + + "findMany - eq and ne operators with null value in OR group should use IS NULL / IS NOT NULL": + async () => { + const withNull = await adapter.create({ + model: "user", + data: { ...(await generate("user")), image: null }, + forceAllowId: true, + }); + const targetImage = await adapter.create({ + model: "user", + data: { + ...(await generate("user")), + image: "https://example.com/target.png", + }, + forceAllowId: true, + }); + const otherImage = await adapter.create({ + model: "user", + data: { + ...(await generate("user")), + image: "https://example.com/other.png", + }, + forceAllowId: true, + }); + + // image IS NULL OR email = targetImage.email → withNull + targetImage + const eqResult = await adapter.findMany({ + model: "user", + where: [ + { field: "image", operator: "eq", value: null, connector: "OR" }, + { field: "email", value: targetImage.email, connector: "OR" }, + ], + }); + const eqIds = eqResult.map((u) => u.id); + expect(eqIds).toContain(withNull.id); + expect(eqIds).toContain(targetImage.id); + expect(eqIds).not.toContain(otherImage.id); + + // image IS NOT NULL OR email = withNull.email → targetImage + otherImage + withNull (by email) + const neResult = await adapter.findMany({ + model: "user", + where: [ + { field: "image", operator: "ne", value: null, connector: "OR" }, + { field: "email", value: withNull.email, connector: "OR" }, + ], + }); + const neIds = neResult.map((u) => u.id); + expect(neIds).toContain(withNull.id); // matched by email OR clause + expect(neIds).toContain(targetImage.id); + expect(neIds).toContain(otherImage.id); + }, }; }; diff --git a/packages/btst/adapter-drizzle/package.json b/packages/btst/adapter-drizzle/package.json index 3f584fb64b6..fef7e92d662 100644 --- a/packages/btst/adapter-drizzle/package.json +++ b/packages/btst/adapter-drizzle/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-drizzle", - "version": "2.1.0", + "version": "2.1.1", "description": "Drizzle adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-kysely/package.json b/packages/btst/adapter-kysely/package.json index 15307f57aef..b81ec4cbb3e 100644 --- a/packages/btst/adapter-kysely/package.json +++ b/packages/btst/adapter-kysely/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-kysely", - "version": "2.1.0", + "version": "2.1.1", "description": "Kysely adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-memory/package.json b/packages/btst/adapter-memory/package.json index 38a8dc6a379..32cf2f5a9c5 100644 --- a/packages/btst/adapter-memory/package.json +++ b/packages/btst/adapter-memory/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-memory", - "version": "2.1.0", + "version": "2.1.1", "description": "In-memory adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-mongodb/package.json b/packages/btst/adapter-mongodb/package.json index 733224672ca..923615bd6f2 100644 --- a/packages/btst/adapter-mongodb/package.json +++ b/packages/btst/adapter-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-mongodb", - "version": "2.1.0", + "version": "2.1.1", "description": "MongoDB adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/adapter-prisma/package.json b/packages/btst/adapter-prisma/package.json index 8bc6bb4d94f..1340f32f862 100644 --- a/packages/btst/adapter-prisma/package.json +++ b/packages/btst/adapter-prisma/package.json @@ -1,6 +1,6 @@ { "name": "@btst/adapter-prisma", - "version": "2.1.0", + "version": "2.1.1", "description": "Prisma adapter for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/cli/package.json b/packages/btst/cli/package.json index 278504c5064..a7307b6a2a2 100644 --- a/packages/btst/cli/package.json +++ b/packages/btst/cli/package.json @@ -1,6 +1,6 @@ { "name": "@btst/cli", - "version": "2.1.0", + "version": "2.1.1", "description": "CLI for btst schema generation and migration", "type": "module", "license": "MIT", diff --git a/packages/btst/db/package.json b/packages/btst/db/package.json index cc576621f59..70309762dfb 100644 --- a/packages/btst/db/package.json +++ b/packages/btst/db/package.json @@ -1,6 +1,6 @@ { "name": "@btst/db", - "version": "2.1.0", + "version": "2.1.1", "description": "Core database utilities and schema definition for btst", "type": "module", "license": "MIT", diff --git a/packages/btst/plugins/package.json b/packages/btst/plugins/package.json index ddcd3e903ae..145c060e136 100644 --- a/packages/btst/plugins/package.json +++ b/packages/btst/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@btst/plugins", - "version": "2.1.0", + "version": "2.1.1", "description": "Plugin utilities and common plugins for btst", "type": "module", "license": "MIT", diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index b92167a1e00..491d9f2d986 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -19,6 +19,8 @@ import { gt, gte, inArray, + isNotNull, + isNull, like, lt, lte, @@ -215,7 +217,11 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { } if (w.operator === "ne") { - return [ne(schemaModel[field], w.value)]; + return [ + w.value === null + ? isNotNull(schemaModel[field]) + : ne(schemaModel[field], w.value), + ]; } if (w.operator === "gt") { @@ -226,7 +232,11 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { return [gte(schemaModel[field], w.value)]; } - return [eq(schemaModel[field], w.value)]; + return [ + w.value === null + ? isNull(schemaModel[field]) + : eq(schemaModel[field], w.value), + ]; } const andGroup = where.filter( (w) => w.connector === "AND" || !w.connector, @@ -274,9 +284,13 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { return gte(schemaModel[field], w.value); } if (w.operator === "ne") { - return ne(schemaModel[field], w.value); + return w.value === null + ? isNotNull(schemaModel[field]) + : ne(schemaModel[field], w.value); } - return eq(schemaModel[field], w.value); + return w.value === null + ? isNull(schemaModel[field]) + : eq(schemaModel[field], w.value); }), ); const orClause = or( @@ -320,9 +334,13 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { return gte(schemaModel[field], w.value); } if (w.operator === "ne") { - return ne(schemaModel[field], w.value); + return w.value === null + ? isNotNull(schemaModel[field]) + : ne(schemaModel[field], w.value); } - return eq(schemaModel[field], w.value); + return w.value === null + ? isNull(schemaModel[field]) + : eq(schemaModel[field], w.value); }), ); diff --git a/packages/kysely-adapter/src/kysely-adapter.ts b/packages/kysely-adapter/src/kysely-adapter.ts index 7934ddec619..1e46b29ce78 100644 --- a/packages/kysely-adapter/src/kysely-adapter.ts +++ b/packages/kysely-adapter/src/kysely-adapter.ts @@ -187,11 +187,13 @@ export const kyselyAdapter = ( } if (operator === "eq") { - return eb(f, "=", value); + return value === null ? eb(f, "is", null) : eb(f, "=", value); } if (operator === "ne") { - return eb(f, "<>", value); + return value === null + ? eb(f, "is not", null) + : eb(f, "<>", value); } if (operator === "gt") {