diff --git a/docs/queries/overview.mdx b/docs/queries/overview.mdx index 53296406e59..4adcb09707b 100644 --- a/docs/queries/overview.mdx +++ b/docs/queries/overview.mdx @@ -63,18 +63,17 @@ The following operators are available for use in queries: field immensely. [More details](../database/indexes). -### And / Or Logic +### Logical Operators (And / Or / Not) -In addition to defining simple queries, you can join multiple queries together using AND / OR logic. These can be nested as deeply as you need to create complex queries. +In addition to defining simple queries, you can join multiple queries together using AND / OR / NOT logic. These can be nested as deeply as you need to create complex queries. -To join queries, use the `and` or `or` keys in your query object: +To join queries, use the `and`, `or`, or `not` keys in your query object: ```ts import type { Where } from 'payload' const query: Where = { or: [ - // highlight-line { color: { equals: 'mint', @@ -82,15 +81,16 @@ const query: Where = { }, { and: [ - // highlight-line { color: { equals: 'white', }, }, { - featured: { - equals: false, + not: { + featured: { + equals: true, + }, }, }, ], @@ -99,7 +99,7 @@ const query: Where = { } ``` -Written in plain English, if the above query were passed to a `find` operation, it would translate to finding posts where either the `color` is `mint` OR the `color` is `white` AND `featured` is set to false. +Written in plain English, if the above query were passed to a `find` operation, it would translate to finding posts where either the `color` is `mint` OR the `color` is `white` AND `featured` is NOT set to true. ### Nested properties diff --git a/packages/db-mongodb/src/queries/parseParams.ts b/packages/db-mongodb/src/queries/parseParams.ts index ff7bae805a9..408b31fc4c3 100644 --- a/packages/db-mongodb/src/queries/parseParams.ts +++ b/packages/db-mongodb/src/queries/parseParams.ts @@ -30,11 +30,13 @@ export async function parseParams({ // We need to determine if the whereKey is an AND, OR, or a schema path for (const relationOrPath of Object.keys(where)) { const condition = where[relationOrPath] - let conditionOperator: '$and' | '$or' | null = null + let conditionOperator: '$and' | '$not' | '$or' | null = null if (relationOrPath.toLowerCase() === 'and') { conditionOperator = '$and' } else if (relationOrPath.toLowerCase() === 'or') { conditionOperator = '$or' + } else if (relationOrPath.toLowerCase() === 'not') { + conditionOperator = '$not' } if (Array.isArray(condition)) { const builtConditions = await buildAndOrConditions({ @@ -49,6 +51,19 @@ export async function parseParams({ if (builtConditions.length > 0 && conditionOperator !== null) { result[conditionOperator] = builtConditions } + } else if (conditionOperator === '$not' && typeof condition === 'object') { + const builtCondition = await parseParams({ + collectionSlug, + fields, + globalSlug, + locale, + parentIsLocalized, + payload, + where: condition as Where, + }) + if (Object.keys(builtCondition).length > 0) { + result.$nor = [builtCondition] + } } else { // It's a path - and there can be multiple comparisons on a single path. // For example - title like 'test' and title not equal to 'tester' diff --git a/packages/drizzle/src/queries/parseParams.ts b/packages/drizzle/src/queries/parseParams.ts index 4287e02fa5a..5fe743c182e 100644 --- a/packages/drizzle/src/queries/parseParams.ts +++ b/packages/drizzle/src/queries/parseParams.ts @@ -1,7 +1,7 @@ import type { SQL, Table } from 'drizzle-orm' import type { FlattenedField, Operator, Sort, Where } from 'payload' -import { and, getTableName, isNotNull, isNull, ne, notInArray, or, sql } from 'drizzle-orm' +import { and, getTableName, isNotNull, isNull, ne, not, notInArray, or, sql } from 'drizzle-orm' import { PgUUID } from 'drizzle-orm/pg-core' import { APIError, QueryError } from 'payload' import { validOperatorSet } from 'payload/shared' @@ -54,11 +54,13 @@ export function parseParams({ for (const relationOrPath of Object.keys(where)) { if (relationOrPath) { const condition = where[relationOrPath] - let conditionOperator: typeof and | typeof or + let conditionOperator: typeof and | typeof not | typeof or if (relationOrPath.toLowerCase() === 'and') { conditionOperator = and } else if (relationOrPath.toLowerCase() === 'or') { conditionOperator = or + } else if (relationOrPath.toLowerCase() === 'not') { + conditionOperator = not } if (Array.isArray(condition)) { const builtConditions = buildAndOrConditions({ @@ -75,7 +77,24 @@ export function parseParams({ where: condition, }) if (builtConditions.length > 0) { - result = conditionOperator(...builtConditions) + result = (conditionOperator as typeof and | typeof or)(...builtConditions) + } + } else if (conditionOperator === not && typeof condition === 'object') { + const builtCondition = parseParams({ + adapter, + aliasTable, + context, + fields, + joins, + locale, + parentIsLocalized, + selectFields, + selectLocale, + tableName, + where: condition as Where, + }) + if (builtCondition) { + result = not(builtCondition) } } else { // It's a path - and there can be multiple comparisons on a single path. diff --git a/packages/graphql/src/schema/buildWhereInputType.ts b/packages/graphql/src/schema/buildWhereInputType.ts index ee8fe47af4c..dc3a9c8aa03 100644 --- a/packages/graphql/src/schema/buildWhereInputType.ts +++ b/packages/graphql/src/schema/buildWhereInputType.ts @@ -88,6 +88,15 @@ export const buildWhereInputType = ({ name, fields, parentName }: Args): GraphQL }), ), }, + NOT: { + type: new GraphQLInputObjectType({ + name: `${fieldName}_where_not`, + fields: () => ({ + ...fieldTypes, + ...recursiveFields, + }), + }), + }, OR: { type: new GraphQLList( new GraphQLInputObjectType({ diff --git a/packages/payload/src/database/queryValidation/validateQueryPaths.ts b/packages/payload/src/database/queryValidation/validateQueryPaths.ts index 389f53ebae9..e2a77650bac 100644 --- a/packages/payload/src/database/queryValidation/validateQueryPaths.ts +++ b/packages/payload/src/database/queryValidation/validateQueryPaths.ts @@ -49,8 +49,9 @@ export async function validateQueryPaths({ const promises: Promise[] = [] for (const path in where) { const constraint = where[path] + const lowercasePath = path.toLowerCase() - if ((path === 'and' || path === 'or') && Array.isArray(constraint)) { + if ((lowercasePath === 'and' || lowercasePath === 'or') && Array.isArray(constraint)) { for (const item of constraint) { if (collectionConfig) { promises.push( @@ -80,6 +81,38 @@ export async function validateQueryPaths({ ) } } + } else if ( + lowercasePath === 'not' && + typeof constraint === 'object' && + !Array.isArray(constraint) + ) { + if (collectionConfig) { + promises.push( + validateQueryPaths({ + collectionConfig, + errors, + overrideAccess, + policies, + polymorphicJoin, + req, + versionFields, + where: constraint as Where, + }), + ) + } else { + promises.push( + validateQueryPaths({ + errors, + globalConfig, + overrideAccess, + policies, + polymorphicJoin, + req, + versionFields, + where: constraint as Where, + }), + ) + } } else if (!Array.isArray(constraint)) { for (const operator in constraint) { const val = constraint[operator as keyof typeof constraint] diff --git a/packages/payload/src/database/sanitizeWhereQuery.ts b/packages/payload/src/database/sanitizeWhereQuery.ts index c018bd827a7..686ff648252 100644 --- a/packages/payload/src/database/sanitizeWhereQuery.ts +++ b/packages/payload/src/database/sanitizeWhereQuery.ts @@ -23,6 +23,11 @@ export const sanitizeWhereQuery = ({ continue } + if (key.toLowerCase() === 'not' && typeof value === 'object' && !Array.isArray(value)) { + sanitizeWhereQuery({ fields, payload, where: value as Where }) + continue + } + const paths = key.split('.') let pathHasChanged = false diff --git a/packages/payload/src/types/index.ts b/packages/payload/src/types/index.ts index a42f8837da3..456fc7ed33a 100644 --- a/packages/payload/src/types/index.ts +++ b/packages/payload/src/types/index.ts @@ -142,10 +142,12 @@ export type WhereField = { } export type Where = { - [key: string]: Where[] | WhereField + [key: string]: Where | Where[] | WhereField // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve and?: Where[] // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve + not?: Where + // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve or?: Where[] } diff --git a/test/db-not/config.ts b/test/db-not/config.ts new file mode 100644 index 00000000000..ef0a8508efa --- /dev/null +++ b/test/db-not/config.ts @@ -0,0 +1,60 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export const postsSlug = 'posts' +export const categoriesSlug = 'categories' + +export default buildConfigWithDefaults({ + collections: [ + { + slug: postsSlug, + access: { + read: () => true, + create: () => true, + update: () => true, + delete: () => true, + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'description', + type: 'text', + }, + { + name: 'category', + type: 'relationship', + relationTo: categoriesSlug, + }, + ], + }, + { + slug: categoriesSlug, + access: { + read: () => true, + create: () => true, + update: () => true, + delete: () => true, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, + ], + graphQL: { + schemaOutputFile: path.resolve(dirname, 'schema.graphql'), + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/db-not/int.spec.ts b/test/db-not/int.spec.ts new file mode 100644 index 00000000000..2789a6a5fec --- /dev/null +++ b/test/db-not/int.spec.ts @@ -0,0 +1,256 @@ +import type { Payload } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import type { NextRESTClient } from '../__helpers/shared/NextRESTClient.js' + +import { initPayloadInt } from '../__helpers/shared/initPayloadInt.js' +import { categoriesSlug, postsSlug } from './config.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +let payload: Payload +let restClient: NextRESTClient + +describe('db-not', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname)) + }) + + afterAll(async () => { + await payload.destroy() + }) + + it('should support not operator', async () => { + await payload.create({ + collection: postsSlug, + data: { title: 'post1', description: 'desc1' }, + }) + await payload.create({ + collection: postsSlug, + data: { title: 'post2', description: 'desc2' }, + }) + await payload.create({ + collection: postsSlug, + data: { title: 'post3', description: 'desc3' }, + }) + + const { docs } = await payload.find({ + collection: postsSlug, + where: { + not: { + title: { + equals: 'post1', + }, + }, + }, + }) + + expect(docs).toHaveLength(2) + const titles = docs.map((d) => d.title) + expect(titles).toContain('post2') + expect(titles).toContain('post3') + expect(titles).not.toContain('post1') + }) + + it('should support nested not in and', async () => { + const { docs } = await payload.find({ + collection: postsSlug, + where: { + and: [ + { + title: { + equals: 'post1', + }, + }, + { + not: { + description: { + equals: 'desc1', + }, + }, + }, + ], + }, + }) + + expect(docs).toHaveLength(0) + + const { docs: docs2 } = await payload.find({ + collection: postsSlug, + where: { + and: [ + { + title: { + equals: 'post1', + }, + }, + { + not: { + description: { + equals: 'desc2', + }, + }, + }, + ], + }, + }) + + expect(docs2).toHaveLength(1) + expect(docs2[0].title).toBe('post1') + }) + + it('should support not with and inside', async () => { + const { docs } = await payload.find({ + collection: postsSlug, + where: { + not: { + and: [ + { + title: { + equals: 'post1', + }, + }, + { + description: { + equals: 'desc1', + }, + }, + ], + }, + }, + }) + + expect(docs).toHaveLength(2) + const titles = docs.map((d) => d.title) + expect(titles).toContain('post2') + expect(titles).toContain('post3') + expect(titles).not.toContain('post1') + }) + + it('should support double not', async () => { + const { docs } = await payload.find({ + collection: postsSlug, + where: { + not: { + not: { + title: { + equals: 'post1', + }, + }, + }, + }, + }) + + expect(docs).toHaveLength(1) + expect(docs[0].title).toBe('post1') + }) + + it('should support not operator via REST', async () => { + const response = await restClient + .GET(`/${postsSlug}`, { + query: { + where: { + not: { + title: { + equals: 'post1', + }, + }, + }, + }, + }) + .then((res) => res.json()) + + expect(response.docs).toHaveLength(2) + const titles = response.docs.map((d: any) => d.title) + expect(titles).toContain('post2') + expect(titles).toContain('post3') + expect(titles).not.toContain('post1') + }) + + it('should support not operator via GraphQL', async () => { + const query = `query { + Posts(where: { + NOT: { + title: { + equals: "post1" + } + } + }) { + docs { + title + } + } + }` + + const response: any = await restClient + .GRAPHQL_POST({ + body: JSON.stringify({ query }), + }) + .then((res) => res.json()) + + expect(response.errors).toBeUndefined() + + const docs = response.data.Posts.docs + expect(docs).toHaveLength(2) + const titles = docs.map((d: any) => d.title) + expect(titles).toContain('post2') + expect(titles).toContain('post3') + expect(titles).not.toContain('post1') + }) + + it('should support not operator with relationships', async () => { + const cat1 = await payload.create({ + collection: categoriesSlug, + data: { name: 'cat1' }, + }) + const cat2 = await payload.create({ + collection: categoriesSlug, + data: { name: 'cat2' }, + }) + + await payload.create({ + collection: postsSlug, + data: { title: 'rel-post1', category: cat1.id }, + }) + await payload.create({ + collection: postsSlug, + data: { title: 'rel-post2', category: cat2.id }, + }) + + const { docs } = await payload.find({ + collection: postsSlug, + where: { + not: { + category: { + equals: cat1.id, + }, + }, + }, + }) + + const titles = docs.map((d) => d.title) + expect(titles).not.toContain('rel-post1') + expect(titles).toContain('rel-post2') + }) + + it('should support not operator with nested relationship properties', async () => { + const { docs } = await payload.find({ + collection: postsSlug, + where: { + not: { + 'category.name': { + equals: 'cat1', + }, + }, + }, + }) + + const titles = docs.map((d) => d.title) + expect(titles).not.toContain('rel-post1') + expect(titles).toContain('rel-post2') + }) +})