From 07dbba67cc6a64fe620f62b4c4240409ad248aa6 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:48:59 +0100 Subject: [PATCH] feat(wabe): add custom where input --- packages/wabe/generated/schema.graphql | 22 +- .../wabe/src/database/DatabaseController.ts | 112 +++++++--- packages/wabe/src/database/interface.ts | 6 + .../wabe/src/graphql/GraphQLSchema.test.ts | 196 +++++++++++++++++- packages/wabe/src/graphql/GraphQLSchema.ts | 6 + packages/wabe/src/graphql/parser.ts | 28 ++- 6 files changed, 336 insertions(+), 34 deletions(-) diff --git a/packages/wabe/generated/schema.graphql b/packages/wabe/generated/schema.graphql index db93a043..d3e830a0 100644 --- a/packages/wabe/generated/schema.graphql +++ b/packages/wabe/generated/schema.graphql @@ -800,7 +800,7 @@ input UserWhereInput { isOauth: BooleanWhereInput verifiedEmail: BooleanWhereInput role: RoleWhereInput - sessions: _SessionWhereInput + sessions: _SessionRelationWhereInput secondFA: UserSecondFAWhereInput OR: [UserWhereInput] AND: [UserWhereInput] @@ -963,7 +963,7 @@ scalar Any input RoleWhereInput { id: IdWhereInput name: StringWhereInput - users: UserWhereInput + users: UserRelationWhereInput acl: RoleACLObjectWhereInput createdAt: DateWhereInput updatedAt: DateWhereInput @@ -972,6 +972,14 @@ input RoleWhereInput { AND: [RoleWhereInput] } +""" +Filter on relation to User +""" +input UserRelationWhereInput { + have: UserWhereInput + isEmpty: Boolean +} + input RoleACLObjectWhereInput { users: [RoleACLObjectUsersACLWhereInput] roles: [RoleACLObjectRolesACLWhereInput] @@ -995,6 +1003,14 @@ input RoleACLObjectRolesACLWhereInput { AND: [RoleACLObjectRolesACLWhereInput] } +""" +Filter on relation to _Session +""" +input _SessionRelationWhereInput { + have: _SessionWhereInput + isEmpty: Boolean +} + input _SessionWhereInput { id: IdWhereInput user: UserWhereInput @@ -1085,7 +1101,7 @@ input PostWhereInput { id: IdWhereInput name: StringWhereInput test2: AnyWhereInput - test3: UserWhereInput + test3: UserRelationWhereInput test4: UserWhereInput experiences: [PostExperienceWhereInput!] acl: PostACLObjectWhereInput diff --git a/packages/wabe/src/database/DatabaseController.ts b/packages/wabe/src/database/DatabaseController.ts index d628a959..be4cc949 100644 --- a/packages/wabe/src/database/DatabaseController.ts +++ b/packages/wabe/src/database/DatabaseController.ts @@ -5,24 +5,44 @@ import type { SchemaInterface } from '../schema' import type { WabeContext } from '../server/interface' import { contextWithRoot, notEmpty } from '../utils/export' import type { DevWabeTypes } from '../utils/helper' -import type { - CountOptions, - CreateObjectOptions, - CreateObjectsOptions, - DatabaseAdapter, - DeleteObjectOptions, - DeleteObjectsOptions, - GetObjectOptions, - GetObjectsOptions, - OutputType, - UpdateObjectOptions, - UpdateObjectsOptions, - WhereType, +import { + type CountOptions, + type CreateObjectOptions, + type CreateObjectsOptions, + type DatabaseAdapter, + type DeleteObjectOptions, + type DeleteObjectsOptions, + type GetObjectOptions, + type GetObjectsOptions, + type OutputType, + type UpdateObjectOptions, + type UpdateObjectsOptions, + type WhereType, } from './interface' export type Select = Record type SelectWithObject = Record +const scalarWhereOperators = new Set([ + 'equalTo', + 'notEqualTo', + 'greaterThan', + 'lessThan', + 'greaterThanOrEqualTo', + 'lessThanOrEqualTo', + 'in', + 'notIn', + 'contains', + 'notContains', + 'exists', +]) + +const isScalarWhereFilter = (value: unknown): boolean => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).some((key) => scalarWhereOperators.has(key)) + type RuntimeVirtualField = { type: 'Virtual' dependsOn: string[] @@ -292,7 +312,44 @@ export class DatabaseController { // @ts-expect-error const fieldTargetClass = field.class - const defaultWhere = where[typedWhereKey] + const relationValue = where[typedWhereKey] + + // Relation where can already be transformed (e.g. { in: [...] }) + // when reused across count/getObjects; keep scalar filters unchanged. + if (field?.type === 'Relation' && isScalarWhereFilter(relationValue)) { + return { + ...currentAcc, + [typedWhereKey]: relationValue, + } + } + + // For Relation: unwrap have/isEmpty structure + let defaultWhere = relationValue + if (field?.type === 'Relation' && relationValue) { + // @ts-expect-error + if (relationValue.isEmpty !== undefined) { + // In storage, an empty relation can be either [] or an absent field. + // Model both cases explicitly so the filter behaves consistently. + // @ts-expect-error + return relationValue.isEmpty === true + ? { + ...currentAcc, + OR: [{ [typedWhereKey]: { equalTo: [] } }, { [typedWhereKey]: { exists: false } }], + } + : { + ...currentAcc, + AND: [ + { [typedWhereKey]: { exists: true } }, + { [typedWhereKey]: { notEqualTo: [] } }, + ], + } + } + + // @ts-expect-error + if (relationValue.have) + // @ts-expect-error + defaultWhere = relationValue.have as typeof defaultWhere + } const objects = await this.getObjects({ className: fieldTargetClass, @@ -302,19 +359,16 @@ export class DatabaseController { where: defaultWhere, context, }) - - return { - ...acc, - // If we don't found any object we just execute the query with the default where - // Without any transformation for pointer or relation - // Ensure the 'in' condition is not empty to avoid unauthorized access - ...(objects.length > 0 + // When no objects match, use impossible condition to return no results + const relationWhere = + objects.length > 0 ? { - [typedWhereKey]: { - in: objects.map((object) => object?.id).filter(notEmpty), - }, + in: objects.map((object) => object?.id).filter(notEmpty), } - : {}), + : { equalTo: '__no_match__' } + return { + ...currentAcc, + [typedWhereKey]: relationWhere, } }, Promise.resolve({})) @@ -606,7 +660,13 @@ export class DatabaseController { context, where, }: CountOptions): Promise { - const whereWithACLCondition = this._buildWhereWithACL(where || {}, context, 'read') + const whereWithPointer = await this._getWhereObjectWithPointerOrRelation( + className, + where || {}, + context, + ) + + const whereWithACLCondition = this._buildWhereWithACL(whereWithPointer, context, 'read') const hook = initializeHook({ className, diff --git a/packages/wabe/src/database/interface.ts b/packages/wabe/src/database/interface.ts index 79f82998..13df453b 100644 --- a/packages/wabe/src/database/interface.ts +++ b/packages/wabe/src/database/interface.ts @@ -56,6 +56,12 @@ type WhereConditional = { export type WhereType = Partial> & WhereConditional +/** Structure for relation where input (have / isEmpty) */ +export type RelationWhereInput = { + have?: THave + isEmpty?: boolean +} + type SelectObject = { [P in keyof T]: IsScalar extends true ? boolean diff --git a/packages/wabe/src/graphql/GraphQLSchema.test.ts b/packages/wabe/src/graphql/GraphQLSchema.test.ts index 670fb273..fc4ce815 100644 --- a/packages/wabe/src/graphql/GraphQLSchema.test.ts +++ b/packages/wabe/src/graphql/GraphQLSchema.test.ts @@ -2069,7 +2069,9 @@ describe('GraphqlSchema', () => { } } `, - { id: created.createVirtualPersonWithArray.virtualPersonWithArray.id }, + { + id: created.createVirtualPersonWithArray.virtualPersonWithArray.id, + }, ) expect(read.virtualPersonWithArray.nameParts).toEqual(['Ada', 'Lovelace']) @@ -2099,7 +2101,10 @@ describe('GraphqlSchema', () => { // @ts-expect-error dependsOn: ['firstName', 'lastName'], callback: (object: any) => [ - { label: 'First', value: object.firstName || '' }, + { + label: 'First', + value: object.firstName || '', + }, { label: 'Last', value: object.lastName || '' }, ], }, @@ -2147,7 +2152,9 @@ describe('GraphqlSchema', () => { } } `, - { id: created.createVirtualPersonWithObjectArray.virtualPersonWithObjectArray.id }, + { + id: created.createVirtualPersonWithObjectArray.virtualPersonWithObjectArray.id, + }, ) expect(read.virtualPersonWithObjectArray.nameInfos).toEqual([ @@ -2188,6 +2195,189 @@ describe('GraphqlSchema', () => { ).toEqual('SixthClassRelationInput') }) + it('should have RelationWhereInput with have and isEmpty for relation fields', () => { + expect( + getTypeFromGraphQLSchema({ + schema, + type: 'Type', + name: 'FifthClassWhereInput', + }).input.relation, + ).toEqual('SixthClassRelationWhereInput') + + expect( + getTypeFromGraphQLSchema({ + schema, + type: 'Type', + name: 'SixthClassRelationWhereInput', + }).input, + ).toEqual({ + have: 'SixthClassWhereInput', + isEmpty: 'Boolean', + }) + }) + + it('should filter by relation with have', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'TestClass', + fields: { + field1: { type: 'String' }, + }, + }, + { + name: 'TestClass2', + fields: { + name: { type: 'String' }, + field2: { + type: 'Relation', + // @ts-expect-error + class: 'TestClass', + }, + }, + }, + ], + }) + + await client.request(gql` + mutation createTestClass2 { + createTestClass2( + input: { fields: { name: "matchParent", field2: { createAndAdd: [{ field1: "match" }] } } } + ) { + testClass2 { + id + } + } + } + `) + + await client.request(gql` + mutation createTestClass2 { + createTestClass2( + input: { fields: { name: "otherParent", field2: { createAndAdd: [{ field1: "other" }] } } } + ) { + testClass2 { + id + } + } + } + `) + + const res = await client.request(gql` + query testClass2s { + testClass2s(where: { field2: { have: { field1: { equalTo: "match" } } } }) { + totalCount + edges { + node { + name + } + } + } + } + `) + + expect(res.testClass2s.totalCount).toBe(1) + expect(res.testClass2s.edges[0]?.node.name).toBe('matchParent') + + const res2 = await client.request(gql` + query testClass2s { + testClass2s(where: { field2: { have: { field1: { notEqualTo: "match" } } } }) { + totalCount + edges { + node { + name + } + } + } + } + `) + + expect(res2.testClass2s.totalCount).toBe(1) + expect(res2.testClass2s.edges[0]?.node.name).toBe('otherParent') + + await wabe.close() + }) + + it('should filter by relation with isEmpty', async () => { + const { client, wabe } = await createWabe({ + classes: [ + { + name: 'TestClass', + fields: { + field1: { type: 'String' }, + }, + }, + { + name: 'TestClass2', + fields: { + name: { type: 'String' }, + field2: { + type: 'Relation', + // @ts-expect-error + class: 'TestClass', + }, + }, + }, + ], + }) + + await client.request(gql` + mutation createTestClass2 { + createTestClass2(input: { fields: { name: "empty" } }) { + testClass2 { + id + } + } + } + `) + + await client.request(gql` + mutation createTestClass2 { + createTestClass2( + input: { fields: { name: "withItems", field2: { createAndAdd: [{ field1: "x" }] } } } + ) { + testClass2 { + id + } + } + } + `) + + const resEmpty = await client.request(gql` + query testClass2s { + testClass2s(where: { field2: { isEmpty: true } }) { + totalCount + edges { + node { + name + } + } + } + } + `) + + expect(resEmpty.testClass2s.totalCount).toBe(1) + expect(resEmpty.testClass2s.edges[0]?.node.name).toBe('empty') + + const resNonEmpty = await client.request(gql` + query testClass2s { + testClass2s(where: { field2: { isEmpty: false } }) { + totalCount + edges { + node { + name + } + } + } + } + `) + + expect(resNonEmpty.testClass2s.totalCount).toBe(1) + expect(resNonEmpty.testClass2s.edges[0]?.node.name).toBe('withItems') + + await wabe.close() + }) + it('should have the pointer in the object when there is a circular dependency in pointer', () => { expect( getTypeFromGraphQLSchema({ diff --git a/packages/wabe/src/graphql/GraphQLSchema.ts b/packages/wabe/src/graphql/GraphQLSchema.ts index 5add47ea..83b9dbe9 100644 --- a/packages/wabe/src/graphql/GraphQLSchema.ts +++ b/packages/wabe/src/graphql/GraphQLSchema.ts @@ -43,6 +43,7 @@ type AllPossibleObject = | 'connectionObject' | 'pointerInputObject' | 'relationInputObject' + | 'relationWhereInputObject' | 'updateInputObject' | 'createInputObject' | 'orderEnumType' @@ -133,6 +134,11 @@ export class GraphQLSchema { acc.objects.push(pointerInputObject) acc.objects.push(relationInputObject) + const relationWhereInputObject = currentObject.relationWhereInputObject + if (relationWhereInputObject) { + acc.objects.push(relationWhereInputObject) + } + return acc }, { diff --git a/packages/wabe/src/graphql/parser.ts b/packages/wabe/src/graphql/parser.ts index 1f677ec2..a14062e6 100644 --- a/packages/wabe/src/graphql/parser.ts +++ b/packages/wabe/src/graphql/parser.ts @@ -502,8 +502,32 @@ export const GraphqlParser: GraphqlParserConstructor = break } case 'WhereInputObject': { - acc[key] = { - type: allObjects[currentField.class]?.whereInputObject, + if (isRelation) { + const relatedClass = currentField.class as string + const relatedWhereInput = allObjects[relatedClass]?.whereInputObject + + let relationWhereInput = allObjects[relatedClass]?.relationWhereInputObject + + if (!relationWhereInput && relatedWhereInput) { + relationWhereInput = new GraphQLInputObjectType({ + name: `${relatedClass}RelationWhereInput`, + description: `Filter on relation to ${relatedClass}`, + fields: { + have: { type: relatedWhereInput }, + isEmpty: { type: GraphQLBoolean }, + }, + }) + if (!allObjects[relatedClass]) allObjects[relatedClass] = {} + allObjects[relatedClass].relationWhereInputObject = relationWhereInput + } + + acc[key] = { + type: relationWhereInput || relatedWhereInput, + } + } else { + acc[key] = { + type: allObjects[currentField.class]?.whereInputObject, + } } break