From af4765effec22cbc62a3c216b89b91abdf469657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 14 May 2026 14:45:32 +0200 Subject: [PATCH] feat(twenty-server): one-hop relation filters in GraphQL API (#20527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds support for filtering records by fields on a related MANY_TO_ONE object via the GraphQL API. Backend only — no frontend, no REST, no view-filter persistence yet. ```graphql { people(filter: { company: { name: { like: "%Airbnb%" } } }) { edges { node { id } } } } ``` ### Where the work lands - **Schema** — `relation-field-metadata-gql-type.generator.ts` now emits `{relationName}: TargetFilterInput` alongside the existing `{joinColumnName}: UUIDFilter` for MANY_TO_ONE relations. Mirrors the order-by generator that already does this for sort. Lazy thunks in `object-metadata-filter-gql-input-type.generator.ts` handle the cycle between filter inputs. - **Arg processor** — `FilterArgProcessorService` no longer hard-rejects accessing a relation by its name. When the value is a nested object on a MANY_TO_ONE field, it recurses into the target object's metadata so each leaf still gets validated and coerced. Depth-capped at 1. - **Query parser** — new `parseRelationSubFilter` branch in `graphql-query-filter-field.parser.ts`. When triggered: looks up the target object metadata, calls `ensureRelationJoin` against the outer query builder, and recurses via a child `GraphqlQueryFilterConditionParser` scoped to the target. `and`/`or`/`not` inside the relation filter keep working because the child dispatches through the same `parseKeyFilter`. - **Shared join utility** — `ensureRelationJoin.util.ts` is a single function that inspects `queryBuilder.expressionMap.joinAttributes` for the alias before adding a `LEFT JOIN`. Rewired the existing inline `qb.leftJoin` calls in the order parser and group-by service to use it, so filter-driven joins no longer collide with sort-driven joins on the same relation. ### Out of scope (explicit) - ONE_TO_MANY reverse traversal (needs EXISTS subqueries) - Aggregates (`company.people.count > 5` — needs HAVING) - View-filter storage (no `relationPath` column on `ViewFilterEntity`) - REST DSL changes - Frontend filter-picker UX - Nesting deeper than one hop (parser and arg-processor both reject) ### Open question for review Permissions. The order-by-on-relation code path already lets users sort People by Company.name without a Company read-permission check, and this PR matches that behavior for filters — felt wrong to add a stricter gate only on the filter side. If we want object-permission gating on the relation target, it should be a follow-up that covers both paths consistently. The only attack surface today is existence inference via timing, identical to what sort already exposes. ## Test plan - [x] `tsc --noEmit` — clean for changed files (5 unrelated pre-existing errors on main untouched) - [x] `oxlint --type-aware` + `prettier --check` — 0 errors on all 17 changed/new files - [x] `jest filter-arg-processor.service.spec` — 229 tests pass (the new optional `flatObjectMetadataMaps` arg is backwards-compatible) - [x] Integration test (`filter-by-relation-field.integration-spec.ts`, 6 cases) — needs to be verified against a seeded test DB. Could not exercise the happy path in my isolated worktree; depth-2 rejection passed there. - [ ] EXPLAIN ANALYZE on the integration test query to confirm the FK on `person.companyId` is indexed for both standard and custom MANY_TO_ONE relations. ### Integration test cases 1. Filter People by `company.name = "Airbnb"` (exact match) 2. Filter People by `company.name like "%irbnb%"` 3. Non-matching filter returns empty 4. Combined with a scalar filter at root via `and` 5. **Combined with `orderBy` on the same relation** — proves the join-dedupe works (without `ensureRelationJoin`, TypeORM throws "duplicate alias") 6. Depth-2 nesting (`company.accountOwner.name`) returns `INVALID_ARGS_FILTER` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 --- .../filter-arg-processor.service.spec.ts | 183 ++++++- .../max-relation-filter-depth.constant.ts | 1 + .../filter-arg-processor.service.ts | 194 ++++++-- ...common-delete-many-query-runner.service.ts | 23 +- ...ommon-destroy-many-query-runner.service.ts | 23 +- .../common-find-many-query-runner.service.ts | 7 +- .../common-find-one-query-runner.service.ts | 7 +- .../common-group-by-query-runner.service.ts | 7 +- ...ommon-restore-many-query-runner.service.ts | 23 +- ...common-update-many-query-runner.service.ts | 17 +- .../build-mutation-query-builder.util.ts | 50 ++ .../graphql-query-filter-condition.parser.ts | 49 +- .../graphql-query-filter-field.parser.ts | 117 ++++- .../graphql-query.parser.ts | 29 +- .../utils/add-relation-join-alias.util.ts | 25 + .../services/group-by-with-records.service.ts | 18 +- ...etadata-filter-gql-input-type.generator.ts | 11 +- ...bject-metadata-gql-input-type.generator.ts | 1 + ...object-metadata-order-by-base.generator.ts | 2 +- ...ation-field-metadata-gql-type.generator.ts | 148 +++--- ...ly-row-level-permission-predicates.util.ts | 12 + ...lter-by-relation-field.integration-spec.ts | 460 ++++++++++++++++++ ...-input-validation.integration-spec.ts.snap | 4 +- ...-input-validation.integration-spec.ts.snap | 4 +- ...tate-by-relation-field.integration-spec.ts | 341 +++++++++++++ 25 files changed, 1565 insertions(+), 191 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant.ts create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/filter-by-relation-field.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/mutate-by-relation-field.integration-spec.ts diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/filter-arg-processor.service.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/filter-arg-processor.service.spec.ts index d83899f487b..2b7ddb587c6 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/filter-arg-processor.service.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/__tests__/filter-arg-processor.service.spec.ts @@ -1,6 +1,6 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationType } from 'twenty-shared/types'; import { fieldMetadataConfigByFieldName } from 'src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/field-metadata-config-by-field-name.constant'; import { FilterArgProcessorService } from 'src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service'; @@ -280,4 +280,185 @@ describe('FilterArgProcessorService', () => { expect(result).toEqual({ numberField: { in: [1, null, 3] } }); }); }); + + describe('relation traversal', () => { + // `source` has a MANY_TO_ONE `target` relation; `target` carries a + // CURRENCY composite and a TEXT field. + const createRelationFixture = () => { + const sourceObjectId = 'source-obj-id'; + const targetObjectId = 'target-obj-id'; + const sourceUniversalId = 'source-obj-universal-id'; + const targetUniversalId = 'target-obj-universal-id'; + + const relationFieldId = 'relation-field-id'; + const targetTextFieldId = 'target-text-field-id'; + const targetCurrencyFieldId = 'target-currency-field-id'; + + const flatFieldMetadataMaps = { + byUniversalIdentifier: { + 'relation-field-uid': { + id: relationFieldId, + name: 'target', + type: FieldMetadataType.RELATION, + isNullable: true, + objectMetadataId: sourceObjectId, + universalIdentifier: 'relation-field-uid', + relationTargetObjectMetadataId: targetObjectId, + settings: { + relationType: RelationType.MANY_TO_ONE, + joinColumnName: 'targetId', + }, + }, + 'target-text-uid': { + id: targetTextFieldId, + name: 'name', + type: FieldMetadataType.TEXT, + isNullable: true, + objectMetadataId: targetObjectId, + universalIdentifier: 'target-text-uid', + }, + 'target-currency-uid': { + id: targetCurrencyFieldId, + name: 'annualRecurringRevenue', + type: FieldMetadataType.CURRENCY, + isNullable: true, + objectMetadataId: targetObjectId, + universalIdentifier: 'target-currency-uid', + }, + }, + universalIdentifierById: { + [relationFieldId]: 'relation-field-uid', + [targetTextFieldId]: 'target-text-uid', + [targetCurrencyFieldId]: 'target-currency-uid', + }, + universalIdentifiersByApplicationId: {}, + } as unknown as FlatEntityMaps; + + const sourceObjectMetadata = { + id: sourceObjectId, + nameSingular: 'sourceObject', + namePlural: 'sourceObjects', + isCustom: false, + fieldIds: [relationFieldId], + universalIdentifier: sourceUniversalId, + labelIdentifierFieldMetadataUniversalIdentifier: null, + imageIdentifierFieldMetadataUniversalIdentifier: null, + } as unknown as FlatObjectMetadata; + + const targetObjectMetadata = { + id: targetObjectId, + nameSingular: 'targetObject', + namePlural: 'targetObjects', + isCustom: false, + fieldIds: [targetTextFieldId, targetCurrencyFieldId], + universalIdentifier: targetUniversalId, + labelIdentifierFieldMetadataUniversalIdentifier: null, + imageIdentifierFieldMetadataUniversalIdentifier: null, + } as unknown as FlatObjectMetadata; + + const flatObjectMetadataMaps = { + byUniversalIdentifier: { + [sourceUniversalId]: sourceObjectMetadata, + [targetUniversalId]: targetObjectMetadata, + }, + universalIdentifierById: { + [sourceObjectId]: sourceUniversalId, + [targetObjectId]: targetUniversalId, + }, + universalIdentifiersByApplicationId: {}, + } as unknown as FlatEntityMaps; + + return { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + sourceObjectMetadata, + }; + }; + + it('should accept a relation traversal onto a scalar field on the target', () => { + const { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + sourceObjectMetadata, + } = createRelationFixture(); + + const filter = { target: { name: { eq: 'Airbnb' } } }; + + const result = filterArgProcessorService.process({ + filter, + flatObjectMetadata: sourceObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ target: { name: { eq: 'Airbnb' } } }); + }); + + it('should accept a relation traversal onto a composite sub-field without tripping the depth cap', () => { + // Composite sub-field navigation is not a relation hop, so it must + // not count against MAX_RELATION_FILTER_DEPTH = 1. + const { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + sourceObjectMetadata, + } = createRelationFixture(); + + const filter = { + target: { + annualRecurringRevenue: { amountMicros: { gte: 1_000_000 } }, + }, + }; + + const result = filterArgProcessorService.process({ + filter, + flatObjectMetadata: sourceObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + expect(result).toEqual({ + target: { + annualRecurringRevenue: { amountMicros: { gte: 1_000_000 } }, + }, + }); + }); + + it('should surface a field-not-found error when a relation filter uses an operator key as if it were a target field', () => { + // `{ target: { eq: ... } }` recurses into the target object's + // metadata and fails like any unknown field on that object. + const { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + sourceObjectMetadata, + } = createRelationFixture(); + + expect(() => + filterArgProcessorService.process({ + filter: { + target: { eq: '00000000-0000-0000-0000-000000000000' }, + }, + flatObjectMetadata: sourceObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }), + ).toThrow(/targetObject doesn't have any "eq" field/); + }); + + it('should fall back to the FK-column hint when no object metadata maps are available', () => { + // Without object metadata maps the recursion can't resolve the target, + // so the only useful guidance left is "use the FK column". + const { flatFieldMetadataMaps, sourceObjectMetadata } = + createRelationFixture(); + + expect(() => + filterArgProcessorService.process({ + filter: { + target: { name: { eq: 'Anything' } }, + }, + flatObjectMetadata: sourceObjectMetadata, + flatFieldMetadataMaps, + }), + ).toThrow(/use "targetId" instead/); + }); + }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant.ts new file mode 100644 index 00000000000..22c5e2c6e14 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant.ts @@ -0,0 +1 @@ +export const MAX_RELATION_FILTER_DEPTH = 1; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service.ts index 8b96628608d..de338823eae 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service.ts @@ -11,6 +11,7 @@ import { isDefined } from 'twenty-shared/utils'; import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util'; import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { MAX_RELATION_FILTER_DEPTH } from 'src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant'; import { validateAndTransformOperatorAndValue } from 'src/engine/api/common/common-args-processors/filter-arg-processor/utils/validate-and-transform-operator-and-value.util'; import { CommonQueryRunnerException, @@ -26,15 +27,31 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +function throwUseJoinColumnInstead(key: string): never { + const joinColumnName = computeMorphOrRelationFieldJoinColumnName({ + name: key, + }); + + throw new CommonQueryRunnerException( + `Cannot filter by relation field "${key}": use "${joinColumnName}" instead`, + CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER, + { + userFriendlyMessage: msg`Invalid filter: use "${joinColumnName}" to filter by this relation field`, + }, + ); +} + @Injectable() export class FilterArgProcessorService { process({ filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }: { filter: T; flatObjectMetadata: FlatObjectMetadata; + flatObjectMetadataMaps?: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; }): T { if (!isDefined(filter)) { @@ -50,18 +67,22 @@ export class FilterArgProcessorService { return this.validateAndTransformFilter( filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, fieldIdByName, fieldIdByJoinColumnName, + 0, ) as T; } private validateAndTransformFilter( filterObject: ObjectRecordFilter, flatObjectMetadata: FlatObjectMetadata, + flatObjectMetadataMaps: FlatEntityMaps | undefined, flatFieldMetadataMaps: FlatEntityMaps, fieldIdByName: Record, fieldIdByJoinColumnName: Record, + depth: number, ): ObjectRecordFilter { const transformedFilter: ObjectRecordFilter = {}; @@ -72,9 +93,11 @@ export class FilterArgProcessorService { this.validateAndTransformFilter( nestedFilter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, fieldIdByName, fieldIdByJoinColumnName, + depth, ), ); continue; @@ -84,9 +107,30 @@ export class FilterArgProcessorService { transformedFilter[key] = this.validateAndTransformFilter( value as ObjectRecordFilter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, fieldIdByName, fieldIdByJoinColumnName, + depth, + ); + continue; + } + + const fieldMetadataForRelation = this.resolveRelationFieldMetadataByName({ + key, + fieldIdByName, + fieldIdByJoinColumnName, + flatFieldMetadataMaps, + }); + + if (isDefined(fieldMetadataForRelation)) { + transformedFilter[key] = this.validateAndTransformRelationFilter( + key, + value, + fieldMetadataForRelation, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + depth, ); continue; } @@ -104,6 +148,120 @@ export class FilterArgProcessorService { return transformedFilter; } + private resolveRelationFieldMetadataByName({ + key, + fieldIdByName, + fieldIdByJoinColumnName, + flatFieldMetadataMaps, + }: { + key: string; + fieldIdByName: Record; + fieldIdByJoinColumnName: Record; + flatFieldMetadataMaps: FlatEntityMaps; + }): + | FlatFieldMetadata< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + > + | undefined { + const resolvedByName = fieldIdByName[key]; + + if (!isDefined(resolvedByName) || isDefined(fieldIdByJoinColumnName[key])) { + return undefined; + } + + const fieldMetadata = findFlatEntityByIdInFlatEntityMaps( + { + flatEntityId: resolvedByName, + flatEntityMaps: flatFieldMetadataMaps, + }, + ); + + if (!isDefined(fieldMetadata)) { + return undefined; + } + + if ( + isFlatFieldMetadataOfType(fieldMetadata, FieldMetadataType.RELATION) || + isFlatFieldMetadataOfType(fieldMetadata, FieldMetadataType.MORPH_RELATION) + ) { + return fieldMetadata; + } + + return undefined; + } + + private validateAndTransformRelationFilter( + key: string, + filterValue: ObjectRecordFilter, + fieldMetadata: FlatFieldMetadata< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >, + flatObjectMetadataMaps: FlatEntityMaps | undefined, + flatFieldMetadataMaps: FlatEntityMaps, + depth: number, + ): ObjectRecordFilter { + if (fieldMetadata.settings?.relationType !== RelationType.MANY_TO_ONE) { + throw new CommonQueryRunnerException( + `Cannot filter by relation field "${key}"`, + CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER, + { + userFriendlyMessage: msg`Invalid filter: filtering by relation field "${key}" is not supported`, + }, + ); + } + + if (typeof filterValue !== 'object' || filterValue === null) { + throwUseJoinColumnInstead(key); + } + + const targetObjectMetadataId = fieldMetadata.relationTargetObjectMetadataId; + + if ( + !isDefined(flatObjectMetadataMaps) || + !isDefined(targetObjectMetadataId) + ) { + throwUseJoinColumnInstead(key); + } + + const targetObjectMetadata = + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: targetObjectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(targetObjectMetadata)) { + throwUseJoinColumnInstead(key); + } + + if (depth >= MAX_RELATION_FILTER_DEPTH) { + throw new CommonQueryRunnerException( + `Relation filter nesting deeper than ${MAX_RELATION_FILTER_DEPTH} hop is not supported`, + CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER, + { + userFriendlyMessage: msg`Relation filters can only traverse one relation deep`, + }, + ); + } + + const { + fieldIdByName: targetFieldIdByName, + fieldIdByJoinColumnName: targetFieldIdByJoinColumnName, + } = buildFieldMapsFromFlatObjectMetadata( + flatFieldMetadataMaps, + targetObjectMetadata, + ); + + return this.validateAndTransformFilter( + filterValue, + targetObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + targetFieldIdByName, + targetFieldIdByJoinColumnName, + depth + 1, + ); + } + private validateAndTransformFieldFilter( key: string, filterValue: Record, @@ -112,9 +270,7 @@ export class FilterArgProcessorService { fieldIdByName: Record, fieldIdByJoinColumnName: Record, ): Record { - const resolvedByName = fieldIdByName[key]; - const resolvedByJoinColumn = fieldIdByJoinColumnName[key]; - const fieldMetadataId = resolvedByName ?? resolvedByJoinColumn; + const fieldMetadataId = fieldIdByName[key] ?? fieldIdByJoinColumnName[key]; if (!isDefined(fieldMetadataId)) { const nameSingular = flatObjectMetadata.nameSingular; @@ -143,38 +299,6 @@ export class FilterArgProcessorService { ); } - if ( - isDefined(resolvedByName) && - !isDefined(resolvedByJoinColumn) && - (isFlatFieldMetadataOfType(fieldMetadata, FieldMetadataType.RELATION) || - isFlatFieldMetadataOfType( - fieldMetadata, - FieldMetadataType.MORPH_RELATION, - )) - ) { - if (fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE) { - const joinColumnName = computeMorphOrRelationFieldJoinColumnName({ - name: key, - }); - - throw new CommonQueryRunnerException( - `Cannot filter by relation field "${key}": use "${joinColumnName}" instead`, - CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER, - { - userFriendlyMessage: msg`Invalid filter: use "${joinColumnName}" to filter by this relation field`, - }, - ); - } - - throw new CommonQueryRunnerException( - `Cannot filter by relation field "${key}"`, - CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER, - { - userFriendlyMessage: msg`Invalid filter: filtering by relation field "${key}" is not supported`, - }, - ); - } - if (isCompositeFieldMetadataType(fieldMetadata.type)) { return this.validateAndTransformCompositeFieldFilter( fieldMetadata, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts index 97956f02934..caf264c548d 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-delete-many-query-runner.service.ts @@ -12,6 +12,7 @@ import { CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util'; import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { @@ -49,15 +50,12 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer commonQueryParser, } = queryRunnerContext; - const queryBuilder = repository.createQueryBuilder( - flatObjectMetadata.nameSingular, - ); - - commonQueryParser.applyFilterToBuilder( - queryBuilder, - flatObjectMetadata.nameSingular, - args.filter, - ); + const queryBuilder = buildMutationQueryBuilder({ + repository, + alias: flatObjectMetadata.nameSingular, + filter: args.filter, + commonQueryParser, + }); const columnsToReturn = buildColumnsToReturn({ select: args.selectedFieldsResult.select, @@ -99,13 +97,18 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, ): Promise> { - const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + const { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = queryRunnerContext; return { ...args, filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts index 5948a7737ea..437cda05d70 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-destroy-many-query-runner.service.ts @@ -12,6 +12,7 @@ import { CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util'; import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { @@ -49,15 +50,12 @@ export class CommonDestroyManyQueryRunnerService extends CommonBaseQueryRunnerSe commonQueryParser, } = queryRunnerContext; - const queryBuilder = repository.createQueryBuilder( - flatObjectMetadata.nameSingular, - ); - - commonQueryParser.applyFilterToBuilder( - queryBuilder, - flatObjectMetadata.nameSingular, - args.filter, - ); + const queryBuilder = buildMutationQueryBuilder({ + repository, + alias: flatObjectMetadata.nameSingular, + filter: args.filter, + commonQueryParser, + }); const columnsToReturn = buildColumnsToReturn({ select: args.selectedFieldsResult.select, @@ -100,13 +98,18 @@ export class CommonDestroyManyQueryRunnerService extends CommonBaseQueryRunnerSe args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, ): Promise> { - const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + const { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = queryRunnerContext; return { ...args, filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts index 41f97c6f7d8..27d69594cd0 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-many-query-runner.service.ts @@ -231,7 +231,11 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, ): Promise> { - const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + const { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = queryRunnerContext; return { ...args, @@ -241,6 +245,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts index fcb950bb330..e10101b7b07 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-find-one-query-runner.service.ts @@ -116,13 +116,18 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, ): Promise> { - const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + const { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = queryRunnerContext; return { ...args, filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index 67ddea65d96..e3cb5c3fe8b 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -412,7 +412,11 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, ): Promise> { - const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + const { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = queryRunnerContext; return { ...args, @@ -428,6 +432,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts index c7add34150f..25076802396 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-restore-many-query-runner.service.ts @@ -12,6 +12,7 @@ import { CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util'; import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { @@ -49,15 +50,12 @@ export class CommonRestoreManyQueryRunnerService extends CommonBaseQueryRunnerSe commonQueryParser, } = queryRunnerContext; - const queryBuilder = repository.createQueryBuilder( - flatObjectMetadata.nameSingular, - ); - - commonQueryParser.applyFilterToBuilder( - queryBuilder, - flatObjectMetadata.nameSingular, - args.filter, - ); + const queryBuilder = buildMutationQueryBuilder({ + repository, + alias: flatObjectMetadata.nameSingular, + filter: args.filter, + commonQueryParser, + }); const columnsToReturn = buildColumnsToReturn({ select: args.selectedFieldsResult.select, @@ -100,13 +98,18 @@ export class CommonRestoreManyQueryRunnerService extends CommonBaseQueryRunnerSe args: CommonInput, queryRunnerContext: CommonBaseQueryRunnerContext, ): Promise> { - const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext; + const { + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + } = queryRunnerContext; return { ...args, filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), }; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts index 3f5692da0d8..5fbfb714fd6 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts @@ -11,6 +11,7 @@ import { CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util'; import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type'; import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { @@ -49,15 +50,12 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer commonQueryParser, } = queryRunnerContext; - const queryBuilder = repository.createQueryBuilder( - flatObjectMetadata.nameSingular, - ); - - commonQueryParser.applyFilterToBuilder( - queryBuilder, - flatObjectMetadata.nameSingular, - args.filter, - ); + const queryBuilder = buildMutationQueryBuilder({ + repository, + alias: flatObjectMetadata.nameSingular, + filter: args.filter, + commonQueryParser, + }); const columnsToReturn = buildColumnsToReturn({ select: args.selectedFieldsResult.select, @@ -112,6 +110,7 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer filter: this.filterArgProcessor.process({ filter: args.filter, flatObjectMetadata, + flatObjectMetadataMaps, flatFieldMetadataMaps, }), data: ( diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util.ts new file mode 100644 index 00000000000..384b8328ff2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util.ts @@ -0,0 +1,50 @@ +import { type ObjectLiteral } from 'typeorm'; + +import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; +import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; + +type BuildMutationQueryBuilderArgs = { + repository: WorkspaceRepository; + alias: string; + filter: Partial; + commonQueryParser: GraphqlQueryParser; +}; + +// TypeORM drops join attributes when a SelectQueryBuilder is morphed into +// UPDATE / DELETE / SoftDelete / Restore — the generated SQL would reference +// an alias without a FROM entry and Postgres would throw. When the filter +// adds joins we rewrite it as `id IN (SELECT id FROM ... JOIN ... WHERE ...)` +// so the joins live inside a self-contained subquery. +export const buildMutationQueryBuilder = ({ + repository, + alias, + filter, + commonQueryParser, +}: BuildMutationQueryBuilderArgs): WorkspaceSelectQueryBuilder => { + const filteredQueryBuilder = repository.createQueryBuilder(alias); + + commonQueryParser.applyFilterToBuilder(filteredQueryBuilder, alias, filter); + + const hasRelationTraversal = + filteredQueryBuilder.expressionMap.joinAttributes.length > 0; + + if (!hasRelationTraversal) { + return filteredQueryBuilder; + } + + // TypeORM auto-injects `deletedAt IS NULL` for SELECT-typed queries but + // not for mutation-typed ones, so the subquery (a SELECT) must opt out to + // mirror the semantics of the mutation it feeds — otherwise `restoreMany` + // would never find soft-deleted rows. + const idSubQueryBuilder = filteredQueryBuilder + .select(`${alias}.id`) + .withDeleted(); + + return repository + .createQueryBuilder(alias) + .where(`"${alias}"."id" IN (${idSubQueryBuilder.getQuery()})`) + .setParameters(idSubQueryBuilder.expressionMap.parameters); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts index c5cc4ca462a..850cd7dbc02 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts @@ -1,4 +1,9 @@ -import { Brackets, NotBrackets, type WhereExpressionBuilder } from 'typeorm'; +import { + Brackets, + NotBrackets, + type ObjectLiteral, + type WhereExpressionBuilder, +} from 'typeorm'; import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -16,36 +21,60 @@ export class GraphqlQueryFilterConditionParser { constructor( flatObjectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, + flatObjectMetadataMaps?: FlatEntityMaps, + depth = 0, ) { this.flatObjectMetadata = flatObjectMetadata; this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser( this.flatObjectMetadata, flatFieldMetadataMaps, + flatObjectMetadataMaps, + depth, ); } public parse( - // oxlint-disable-next-line @typescripttypescript/no-explicit-any - queryBuilder: WorkspaceSelectQueryBuilder, + queryBuilder: WorkspaceSelectQueryBuilder, objectNameSingular: string, filter: Partial, - // oxlint-disable-next-line @typescripttypescript/no-explicit-any - ): WorkspaceSelectQueryBuilder { + ): WorkspaceSelectQueryBuilder { if (!filter || Object.keys(filter).length === 0) { return queryBuilder; } return queryBuilder.where( new Brackets((qb) => { - Object.entries(filter).forEach(([key, value], index) => { - this.parseKeyFilter(qb, objectNameSingular, key, value, index === 0); - }); + this.applyFilterEntriesToWhereBrackets( + qb, + queryBuilder, + objectNameSingular, + filter, + ); }), ); } + public applyFilterEntriesToWhereBrackets( + innerQueryBuilder: WhereExpressionBuilder, + outerQueryBuilder: WorkspaceSelectQueryBuilder, + objectNameSingular: string, + filter: Partial, + ): void { + Object.entries(filter).forEach(([key, value], index) => { + this.parseKeyFilter( + innerQueryBuilder, + outerQueryBuilder, + objectNameSingular, + key, + value, + index === 0, + ); + }); + } + private parseKeyFilter( queryBuilder: WhereExpressionBuilder, + outerQueryBuilder: WorkspaceSelectQueryBuilder, objectNameSingular: string, key: string, // oxlint-disable-next-line @typescripttypescript/no-explicit-any @@ -61,6 +90,7 @@ export class GraphqlQueryFilterConditionParser { ([subFilterkey, subFilterValue], index) => { this.parseKeyFilter( qb2, + outerQueryBuilder, objectNameSingular, subFilterkey, subFilterValue, @@ -93,6 +123,7 @@ export class GraphqlQueryFilterConditionParser { ([subFilterkey, subFilterValue], index) => { this.parseKeyFilter( qb2, + outerQueryBuilder, objectNameSingular, subFilterkey, subFilterValue, @@ -124,6 +155,7 @@ export class GraphqlQueryFilterConditionParser { ([subFilterkey, subFilterValue], index) => { this.parseKeyFilter( qb, + outerQueryBuilder, objectNameSingular, subFilterkey, subFilterValue, @@ -144,6 +176,7 @@ export class GraphqlQueryFilterConditionParser { default: this.queryFilterFieldParser.parse( queryBuilder, + outerQueryBuilder, objectNameSingular, key, value, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index 0b867baae79..dec55b058c6 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -1,12 +1,19 @@ import { msg } from '@lingui/core/macro'; -import { compositeTypeDefinitions } from 'twenty-shared/types'; +import { + Brackets, + type ObjectLiteral, + type WhereExpressionBuilder, +} from 'typeorm'; +import { compositeTypeDefinitions, RelationType } from 'twenty-shared/types'; import { capitalize, isDefined } from 'twenty-shared/utils'; -import { type WhereExpressionBuilder } from 'typeorm'; +import { MAX_RELATION_FILTER_DEPTH } from 'src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant'; +import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { addRelationJoinAliasToQueryBuilder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util'; import { computeWhereConditionParts } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts'; import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; @@ -14,20 +21,30 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; +import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; + +import { GraphqlQueryFilterConditionParser } from './graphql-query-filter-condition.parser'; const ARRAY_OPERATORS = ['in', 'contains', 'notContains']; export class GraphqlQueryFilterFieldParser { private flatFieldMetadataMaps: FlatEntityMaps; + private flatObjectMetadataMaps?: FlatEntityMaps; private fieldIdByName: Record; private fieldIdByJoinColumnName: Record; + private depth: number; constructor( flatObjectMetadata: FlatObjectMetadata, flatFieldMetadataMaps: FlatEntityMaps, + flatObjectMetadataMaps?: FlatEntityMaps, + depth = 0, ) { this.flatFieldMetadataMaps = flatFieldMetadataMaps; + this.flatObjectMetadataMaps = flatObjectMetadataMaps; + this.depth = depth; const fieldMaps = buildFieldMapsFromFlatObjectMetadata( flatFieldMetadataMaps, @@ -40,6 +57,7 @@ export class GraphqlQueryFilterFieldParser { public parse( queryBuilder: WhereExpressionBuilder, + outerQueryBuilder: WorkspaceSelectQueryBuilder, objectNameSingular: string, key: string, // oxlint-disable-next-line @typescripttypescript/no-explicit-any @@ -47,6 +65,7 @@ export class GraphqlQueryFilterFieldParser { isFirst = false, useDirectTableReference = false, ): void { + const isFilterKeyARelation = isDefined(this.fieldIdByName[key]); const fieldMetadataId = this.fieldIdByName[`${key}`] || this.fieldIdByJoinColumnName[`${key}`]; @@ -59,6 +78,21 @@ export class GraphqlQueryFilterFieldParser { throw new Error(`Field metadata not found for field: ${key}`); } + if ( + isFilterKeyARelation && + isMorphOrRelationFlatFieldMetadata(fieldMetadata) && + fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE + ) { + return this.parseRelationSubFilter( + queryBuilder, + outerQueryBuilder, + objectNameSingular, + fieldMetadata, + filterValue, + isFirst, + ); + } + if (isCompositeFieldMetadataType(fieldMetadata.type)) { return this.parseCompositeFieldForFilter( queryBuilder, @@ -97,6 +131,85 @@ export class GraphqlQueryFilterFieldParser { } } + private parseRelationSubFilter( + queryBuilder: WhereExpressionBuilder, + outerQueryBuilder: WorkspaceSelectQueryBuilder, + parentAlias: string, + fieldMetadata: FlatFieldMetadata, + filterValue: Partial, + isFirst: boolean, + ): void { + if (this.depth >= MAX_RELATION_FILTER_DEPTH) { + throw new GraphqlQueryRunnerException( + `Relation filter nesting deeper than ${MAX_RELATION_FILTER_DEPTH} hop is not supported`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { + userFriendlyMessage: msg`Relation filters can only traverse one relation deep`, + }, + ); + } + + if (!isDefined(this.flatObjectMetadataMaps)) { + throw new GraphqlQueryRunnerException( + `Relation filter on "${fieldMetadata.name}" requires object metadata maps`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: msg`Relation filter is not supported here` }, + ); + } + + if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) { + throw new GraphqlQueryRunnerException( + `Relation filter on "${fieldMetadata.name}" is missing a target object`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: msg`Relation filter is misconfigured` }, + ); + } + + const targetObjectMetadata = + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldMetadata.relationTargetObjectMetadataId, + flatEntityMaps: this.flatObjectMetadataMaps, + }); + + if (!isDefined(targetObjectMetadata)) { + throw new GraphqlQueryRunnerException( + `Target object not found for relation "${fieldMetadata.name}"`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + { userFriendlyMessage: msg`Relation filter is misconfigured` }, + ); + } + + const joinAlias = fieldMetadata.name; + + addRelationJoinAliasToQueryBuilder({ + queryBuilder: outerQueryBuilder, + parentAlias, + relationName: joinAlias, + }); + + const childConditionParser = new GraphqlQueryFilterConditionParser( + targetObjectMetadata, + this.flatFieldMetadataMaps, + this.flatObjectMetadataMaps, + this.depth + 1, + ); + + const subBrackets = new Brackets((subQb) => { + childConditionParser.applyFilterEntriesToWhereBrackets( + subQb, + outerQueryBuilder, + joinAlias, + filterValue, + ); + }); + + if (isFirst) { + queryBuilder.where(subBrackets); + } else { + queryBuilder.andWhere(subBrackets); + } + } + private parseCompositeFieldForFilter( queryBuilder: WhereExpressionBuilder, fieldMetadata: FlatFieldMetadata, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index bab8cc14080..b844473bd89 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -20,6 +20,7 @@ import { GraphqlQuerySelectedFieldsParser, type GraphqlQuerySelectedFieldsResult, } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; +import { addRelationJoinAliasToQueryBuilder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; @@ -45,6 +46,7 @@ export class GraphqlQueryParser { this.filterConditionParser = new GraphqlQueryFilterConditionParser( this.flatObjectMetadata, this.flatFieldMetadataMaps, + this.flatObjectMetadataMaps, ); this.orderFieldParser = new GraphqlQueryOrderFieldParser( this.flatObjectMetadata, @@ -99,12 +101,17 @@ export class GraphqlQueryParser { return true; } - if (typeof value === 'object' && value !== null) { - if ( - this.checkForDeletedAtFilter(value as FindOptionsWhere) - ) { - return true; - } + // Only recurse into boolean-operator wrappers (and / or / not) — those + // are transparent w.r.t. which entity owns a deletedAt. Composite + // sub-field and relation-traversal nesting refers to a different + // entity's deletedAt, which must not widen the root query. + if ( + (key === 'and' || key === 'or' || key === 'not') && + typeof value === 'object' && + value !== null && + this.checkForDeletedAtFilter(value as FindOptionsWhere) + ) { + return true; } } @@ -124,12 +131,12 @@ export class GraphqlQueryParser { isForwardPagination, ); - // Add LEFT JOINs for relation ordering for (const joinInfo of parseResult.relationJoins) { - queryBuilder.leftJoin( - `${objectNameSingular}.${joinInfo.joinAlias}`, - joinInfo.joinAlias, - ); + addRelationJoinAliasToQueryBuilder({ + queryBuilder, + parentAlias: objectNameSingular, + relationName: joinInfo.joinAlias, + }); } queryBuilder.orderBy(parseResult.orderBy); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util.ts new file mode 100644 index 00000000000..74343e9a4f3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util.ts @@ -0,0 +1,25 @@ +import { type ObjectLiteral } from 'typeorm'; + +import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; + +type AddRelationJoinAliasToQueryBuilderArgs = { + queryBuilder: WorkspaceSelectQueryBuilder; + parentAlias: string; + relationName: string; +}; + +export const addRelationJoinAliasToQueryBuilder = ({ + queryBuilder, + parentAlias, + relationName, +}: AddRelationJoinAliasToQueryBuilderArgs): void => { + const alreadyJoined = queryBuilder.expressionMap.joinAttributes.some( + (joinAttribute) => joinAttribute.alias.name === relationName, + ); + + if (alreadyJoined) { + return; + } + + queryBuilder.leftJoin(`${parentAlias}.${relationName}`, relationName); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index bc0baf09dc3..afb22c82666 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -16,6 +16,7 @@ import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/co import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { addRelationJoinAliasToQueryBuilder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/utils/add-relation-join-alias.util'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; @@ -280,19 +281,12 @@ export class GroupByWithRecordsService { ); if (isNonEmptyString(orderByRawSQL)) { - const existingJoinAliases = new Set( - queryBuilder.expressionMap.joinAttributes.map( - (joinAttribute) => joinAttribute.alias.name, - ), - ); - for (const joinInfo of relationJoins) { - if (!existingJoinAliases.has(joinInfo.joinAlias)) { - queryBuilder.leftJoin( - `${flatObjectMetadata.nameSingular}.${joinInfo.joinAlias}`, - joinInfo.joinAlias, - ); - } + addRelationJoinAliasToQueryBuilder({ + queryBuilder, + parentAlias: flatObjectMetadata.nameSingular, + relationName: joinInfo.joinAlias, + }); } return queryBuilder.addSelect( diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts index 40c0bce53f0..cd7c0f8e01b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/filter-input/object-metadata-filter-gql-input-type.generator.ts @@ -14,6 +14,7 @@ import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-sch import { RelationFieldMetadataGqlInputTypeGenerator } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator'; import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; import { GqlTypesStorage } from 'src/engine/api/graphql/workspace-schema-builder/storages/gql-types.storage'; +import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-schema-builder/types/schema-generation-context.type'; import { computeFieldInputTypeOptions } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-field-input-type-options.util'; import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util'; import { computeEnumFieldGqlTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-enum-field-gql-type-key.util'; @@ -39,12 +40,18 @@ export class ObjectMetadataFilterGqlInputTypeGenerator { public buildAndStore( flatObjectMetadata: FlatObjectMetadata, fields: FlatFieldMetadata[], + context: SchemaGenerationContext, ) { const inputType = new GraphQLInputObjectType({ name: `${pascalCase(flatObjectMetadata.nameSingular)}${GqlInputTypeDefinitionKind.Filter.toString()}Input`, description: flatObjectMetadata.description, fields: () => - this.generateFields(flatObjectMetadata.nameSingular, fields, inputType), + this.generateFields( + flatObjectMetadata.nameSingular, + fields, + inputType, + context, + ), }) as GraphQLInputObjectType; const key = computeObjectMetadataInputTypeKey( @@ -59,6 +66,7 @@ export class ObjectMetadataFilterGqlInputTypeGenerator { objectNameSingular: string, fields: FlatFieldMetadata[], inputType: GraphQLInputObjectType, + context: SchemaGenerationContext, ): GraphQLInputFieldConfigMap { const allGeneratedFields: GraphQLInputFieldConfigMap = {}; @@ -81,6 +89,7 @@ export class ObjectMetadataFilterGqlInputTypeGenerator { { fieldMetadata, typeOptions, + context, }, ); } else if (isEnumFieldMetadataType(fieldMetadata.type)) { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts index ae4b12ed855..6ffd5f89cf6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/object-metadata-gql-input-type.generator.ts @@ -39,6 +39,7 @@ export class ObjectMetadataGqlInputTypeGenerator { this.objectMetadataFilterGqlInputTypeGenerator.buildAndStore( flatObjectMetadata, fields, + context, ); this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({ flatObjectMetadata, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts index 6ca0e9a4472..c682cbe1a3a 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/order-by-input/object-metadata-order-by-base.generator.ts @@ -36,7 +36,7 @@ export class ObjectMetadataOrderByBaseGenerator { fields: FlatFieldMetadata[]; logger: Logger; isForGroupBy?: boolean; - context?: SchemaGenerationContext; + context: SchemaGenerationContext; }): GraphQLInputFieldConfigMap { const allGeneratedFields: GraphQLInputFieldConfigMap = {}; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts index 51f7bc284f9..7011f160018 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/relation-field-metadata-gql-type.generator.ts @@ -78,11 +78,13 @@ export class RelationFieldMetadataGqlInputTypeGenerator { public generateSimpleRelationFieldFilterInputType({ fieldMetadata, typeOptions, + context, }: { fieldMetadata: FlatFieldMetadata< FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION >; typeOptions: { settings?: FlatFieldMetadata['settings'] }; + context: SchemaGenerationContext; }) { if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) return {}; @@ -109,6 +111,12 @@ export class RelationFieldMetadataGqlInputTypeGenerator { type, description: fieldMetadata.description, }, + ...this.getTargetRelationInputField({ + fieldMetadata, + context, + kind: GqlInputTypeDefinitionKind.Filter, + descriptionPrefix: 'Filter on fields of the related', + }), }; } @@ -121,13 +129,12 @@ export class RelationFieldMetadataGqlInputTypeGenerator { FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION >; isForGroupBy?: boolean; - context?: SchemaGenerationContext; + context: SchemaGenerationContext; }) { if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) return {}; - const { joinColumnName, fieldMetadataName } = - extractGraphQLRelationFieldNames(fieldMetadata); + const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata); const type = this.typeMapperService.mapToOrderByType(fieldMetadata.type); @@ -140,95 +147,88 @@ export class RelationFieldMetadataGqlInputTypeGenerator { throw new Error(message); } - const fields: GraphQLInputFieldConfigMap = { + return { [joinColumnName]: { type, description: fieldMetadata.description, }, + ...this.getTargetRelationInputField({ + fieldMetadata, + context, + kind: isForGroupBy + ? GqlInputTypeDefinitionKind.OrderByWithGroupBy + : GqlInputTypeDefinitionKind.OrderBy, + descriptionPrefix: 'Order by fields of the related', + }), }; - - if ( - isDefined(fieldMetadata.relationTargetObjectMetadataId) && - isDefined(context) - ) { - const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: fieldMetadata.relationTargetObjectMetadataId, - flatEntityMaps: context.flatObjectMetadataMaps, - }); - - if (isDefined(targetObjectMetadata)) { - const targetOrderByInputTypeKey = computeObjectMetadataInputTypeKey( - targetObjectMetadata.nameSingular, - isForGroupBy - ? GqlInputTypeDefinitionKind.OrderByWithGroupBy - : GqlInputTypeDefinitionKind.OrderBy, - ); - - const targetOrderByInputType = this.gqlTypesStorage.getGqlTypeByKey( - targetOrderByInputTypeKey, - ); - - if ( - isDefined(targetOrderByInputType) && - isInputObjectType(targetOrderByInputType) - ) { - fields[fieldMetadataName] = { - type: targetOrderByInputType, - description: `Order by fields of the related ${targetObjectMetadata.nameSingular}`, - }; - } - } - } - - return fields; } public generateSimpleRelationFieldGroupByInputType( fieldMetadata: FlatFieldMetadata< FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION >, - context?: SchemaGenerationContext, + context: SchemaGenerationContext, ): GraphQLInputFieldConfigMap { if (fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY) return {}; + return this.getTargetRelationInputField({ + fieldMetadata, + context, + kind: GqlInputTypeDefinitionKind.GroupBy, + descriptionPrefix: 'Group by fields of the related', + }); + } + + // Returns a single-entry map keyed by the relation field's GraphQL name, + // or an empty map when any lookup misses — callers splat it alongside + // their own fields. + private getTargetRelationInputField({ + fieldMetadata, + context, + kind, + descriptionPrefix, + }: { + fieldMetadata: FlatFieldMetadata< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >; + context: SchemaGenerationContext; + kind: GqlInputTypeDefinitionKind; + descriptionPrefix: string; + }): GraphQLInputFieldConfigMap { + if (!isDefined(fieldMetadata.relationTargetObjectMetadataId)) { + return {}; + } + + const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldMetadata.relationTargetObjectMetadataId, + flatEntityMaps: context.flatObjectMetadataMaps, + }); + + if (!isDefined(targetObjectMetadata)) { + return {}; + } + + const targetInputType = this.gqlTypesStorage.getGqlTypeByKey( + computeObjectMetadataInputTypeKey( + targetObjectMetadata.nameSingular, + kind, + ), + ); + + if (!isDefined(targetInputType) || !isInputObjectType(targetInputType)) { + return {}; + } + const { fieldMetadataName } = extractGraphQLRelationFieldNames(fieldMetadata); - const fields: GraphQLInputFieldConfigMap = {}; - - if ( - isDefined(fieldMetadata.relationTargetObjectMetadataId) && - isDefined(context) - ) { - const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: fieldMetadata.relationTargetObjectMetadataId, - flatEntityMaps: context.flatObjectMetadataMaps, - }); - - if (isDefined(targetObjectMetadata)) { - const targetGroupByInputTypeKey = computeObjectMetadataInputTypeKey( - targetObjectMetadata.nameSingular, - GqlInputTypeDefinitionKind.GroupBy, - ); - - const targetGroupByInputType = this.gqlTypesStorage.getGqlTypeByKey( - targetGroupByInputTypeKey, - ); - - if ( - isDefined(targetGroupByInputType) && - isInputObjectType(targetGroupByInputType) - ) { - fields[fieldMetadataName] = { - type: targetGroupByInputType, - description: `Group by fields of the related ${targetObjectMetadata.nameSingular}`, - }; - } - } - } - - return fields; + return { + [fieldMetadataName]: { + type: targetInputType, + description: `${descriptionPrefix} ${targetObjectMetadata.nameSingular}`, + }, + }; } public generateConnectRelationFieldInputType({ diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts index 6489fb09820..87d43136d3b 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util.ts @@ -90,10 +90,16 @@ const applyObjectRecordFilterToQueryBuilder = ({ return; } + // parseKeyFilter only uses the join surface, so widen back to ObjectLiteral + // here rather than threading the concrete T through every recursive call. + const outerQueryBuilderAsObjectLiteral = + queryBuilder as WorkspaceSelectQueryBuilder; + const whereCondition = new Brackets((qb) => { Object.entries(recordFilter).forEach(([key, value], index) => { parseKeyFilter({ queryBuilder: qb, + outerQueryBuilder: outerQueryBuilderAsObjectLiteral, objectNameSingular, key, value, @@ -113,6 +119,7 @@ const applyObjectRecordFilterToQueryBuilder = ({ const parseKeyFilter = ({ queryBuilder, + outerQueryBuilder, objectNameSingular, key, value, @@ -121,6 +128,7 @@ const parseKeyFilter = ({ useDirectTableReference = false, }: { queryBuilder: WhereExpressionBuilder; + outerQueryBuilder: WorkspaceSelectQueryBuilder; objectNameSingular: string; key: string; // oxlint-disable-next-line @typescripttypescript/no-explicit-any @@ -138,6 +146,7 @@ const parseKeyFilter = ({ ([subFilterKey, subFilterValue], subIndex) => { parseKeyFilter({ queryBuilder: qb2, + outerQueryBuilder, objectNameSingular, key: subFilterKey, value: subFilterValue, @@ -172,6 +181,7 @@ const parseKeyFilter = ({ ([subFilterKey, subFilterValue], subIndex) => { parseKeyFilter({ queryBuilder: qb2, + outerQueryBuilder, objectNameSingular, key: subFilterKey, value: subFilterValue, @@ -205,6 +215,7 @@ const parseKeyFilter = ({ ([subFilterKey, subFilterValue], subIndex) => { parseKeyFilter({ queryBuilder: qb, + outerQueryBuilder, objectNameSingular, key: subFilterKey, value: subFilterValue, @@ -227,6 +238,7 @@ const parseKeyFilter = ({ default: fieldParser.parse( queryBuilder, + outerQueryBuilder, objectNameSingular, key, value, diff --git a/packages/twenty-server/test/integration/graphql/suites/filter-by-relation-field.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/filter-by-relation-field.integration-spec.ts new file mode 100644 index 00000000000..7e78e7840a8 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/filter-by-relation-field.integration-spec.ts @@ -0,0 +1,460 @@ +import gql from 'graphql-tag'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; + +const TEST_COMPANY_IDS = { + AIRBNB: '20202020-cccc-4000-8000-000000000001', + STRIPE: '20202020-cccc-4000-8000-000000000002', + NOTION: '20202020-cccc-4000-8000-000000000003', +}; + +const TEST_PERSON_IDS = { + AIRBNB_ENGINEER: '20202020-dddd-4000-8000-000000000001', + AIRBNB_DESIGNER: '20202020-dddd-4000-8000-000000000002', + STRIPE_ENGINEER: '20202020-dddd-4000-8000-000000000003', + NOTION_ENGINEER: '20202020-dddd-4000-8000-000000000004', + UNAFFILIATED: '20202020-dddd-4000-8000-000000000005', +}; + +const TEST_ROCKET_IDS = { + FALCON: '20202020-cccc-4000-8000-100000000001', + STARSHIP: '20202020-cccc-4000-8000-100000000002', +}; + +const TEST_PET_IDS = { + FALCON_PET: '20202020-dddd-4000-8000-100000000001', + STARSHIP_PET: '20202020-dddd-4000-8000-100000000002', +}; + +const ALL_TEST_PERSON_IDS = Object.values(TEST_PERSON_IDS); +const ALL_TEST_PET_IDS = Object.values(TEST_PET_IDS); + +describe('Filter by relation field (e2e)', () => { + beforeAll(async () => { + const createCompanies = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: 'id name', + data: [ + { + id: TEST_COMPANY_IDS.AIRBNB, + name: 'Airbnb', + annualRecurringRevenue: { + amountMicros: 50_000_000_000_000, + currencyCode: 'USD', + }, + }, + { + id: TEST_COMPANY_IDS.STRIPE, + name: 'Stripe', + annualRecurringRevenue: { + amountMicros: 10_000_000_000_000, + currencyCode: 'USD', + }, + }, + { + id: TEST_COMPANY_IDS.NOTION, + name: 'Notion', + annualRecurringRevenue: { + amountMicros: 5_000_000_000_000, + currencyCode: 'USD', + }, + }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createCompanies); + + const createPeople = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + data: [ + { + id: TEST_PERSON_IDS.AIRBNB_ENGINEER, + companyId: TEST_COMPANY_IDS.AIRBNB, + jobTitle: 'Engineer', + }, + { + id: TEST_PERSON_IDS.AIRBNB_DESIGNER, + companyId: TEST_COMPANY_IDS.AIRBNB, + jobTitle: 'Designer', + }, + { + id: TEST_PERSON_IDS.STRIPE_ENGINEER, + companyId: TEST_COMPANY_IDS.STRIPE, + jobTitle: 'Engineer', + }, + { + id: TEST_PERSON_IDS.NOTION_ENGINEER, + companyId: TEST_COMPANY_IDS.NOTION, + jobTitle: 'Engineer', + }, + { + id: TEST_PERSON_IDS.UNAFFILIATED, + companyId: null, + jobTitle: 'Engineer', + }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createPeople); + + const createRockets = createManyOperationFactory({ + objectMetadataSingularName: 'rocket', + objectMetadataPluralName: 'rockets', + gqlFields: 'id name', + data: [ + { id: TEST_ROCKET_IDS.FALCON, name: 'FilterFalcon' }, + { id: TEST_ROCKET_IDS.STARSHIP, name: 'FilterStarship' }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createRockets); + + const createPets = createManyOperationFactory({ + objectMetadataSingularName: 'pet', + objectMetadataPluralName: 'pets', + gqlFields: 'id', + data: [ + { + id: TEST_PET_IDS.FALCON_PET, + name: 'FilterFalconPet', + polymorphicOwnerRocketId: TEST_ROCKET_IDS.FALCON, + }, + { + id: TEST_PET_IDS.STARSHIP_PET, + name: 'FilterStarshipPet', + polymorphicOwnerRocketId: TEST_ROCKET_IDS.STARSHIP, + }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createPets); + }); + + it('should filter people by company name (exact match)', async () => { + const queryData = { + query: gql` + query People($filter: PersonFilterInput) { + people(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { eq: 'Airbnb' } } }, + ], + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + + const ids = response.body.data.people.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(ids.sort()).toEqual( + [TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(), + ); + }); + + it('should filter people by company name with like operator', async () => { + const queryData = { + query: gql` + query People($filter: PersonFilterInput) { + people(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { like: '%irbnb%' } } }, + ], + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + + const ids = response.body.data.people.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(ids.sort()).toEqual( + [TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(), + ); + }); + + it('should combine a relation filter with a scalar filter on the root object', async () => { + const queryData = { + query: gql` + query People($filter: PersonFilterInput) { + people(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { eq: 'Airbnb' } } }, + { jobTitle: { eq: 'Designer' } }, + ], + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + + const ids = response.body.data.people.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(ids).toEqual([TEST_PERSON_IDS.AIRBNB_DESIGNER]); + }); + + it('should combine a relation filter with an order-by on the same relation (join dedupe)', async () => { + const queryData = { + query: gql` + query People( + $filter: PersonFilterInput + $orderBy: [PersonOrderByInput] + ) { + people(filter: $filter, orderBy: $orderBy, first: 10) { + edges { + node { + id + company { + name + } + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { like: '%i%' } } }, + ], + }, + orderBy: [{ company: { name: 'AscNullsLast' } }], + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + + const companyNames = response.body.data.people.edges.map( + (edge: { node: { company: { name: string } | null } }) => + edge.node.company?.name ?? null, + ); + + // Airbnb, Notion, Stripe all contain "i"; ascending order + expect(companyNames).toEqual(['Airbnb', 'Airbnb', 'Notion', 'Stripe']); + }); + + it('should filter on a composite sub-field of the related object without tripping the depth cap', async () => { + const queryData = { + query: gql` + query People($filter: PersonFilterInput) { + people(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { + company: { + annualRecurringRevenue: { + amountMicros: { gte: 20_000_000_000_000 }, + }, + }, + }, + ], + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + + const ids = response.body.data.people.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + // Only Airbnb (50M) is >= 20M; Stripe (10M) and Notion (5M) excluded. + expect(ids.sort()).toEqual( + [TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(), + ); + }); + + it('should reject relation filters nested deeper than one hop', async () => { + const queryData = { + query: gql` + query People($filter: PersonFilterInput) { + people(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + company: { + accountOwner: { + name: { firstName: { eq: 'Anything' } }, + }, + }, + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); + }); + + it('should not widen the root query when a relation block contains a deletedAt filter', async () => { + // A deletedAt nested inside a relation block belongs to the related + // entity — it must not call `withDeleted()` on the root builder and + // surface soft-deleted root rows. + const liveId = '20202020-dddd-4000-8000-000000000098'; + const softDeletedId = '20202020-dddd-4000-8000-000000000099'; + + await makeGraphqlAPIRequest( + createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + data: [ + { id: liveId, companyId: TEST_COMPANY_IDS.AIRBNB }, + { id: softDeletedId, companyId: TEST_COMPANY_IDS.AIRBNB }, + ], + upsert: true, + }), + ); + + await makeGraphqlAPIRequest( + deleteManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { id: { in: [softDeletedId] } }, + }), + ); + + const queryData = { + query: gql` + query People($filter: PersonFilterInput) { + people(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: [liveId, softDeletedId] } }, + { company: { deletedAt: { is: 'NULL' } } }, + ], + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + + const ids = response.body.data.people.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(ids).toEqual([liveId]); + }); + + it('should filter pets by a MORPH_RELATION target field (rocket name)', async () => { + const queryData = { + query: gql` + query Pets($filter: PetFilterInput) { + pets(filter: $filter, first: 10) { + edges { + node { + id + } + } + } + } + `, + variables: { + filter: { + and: [ + { id: { in: ALL_TEST_PET_IDS } }, + { polymorphicOwnerRocket: { name: { eq: 'FilterFalcon' } } }, + ], + }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.errors).toBeUndefined(); + + const ids = response.body.data.pets.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(ids).toEqual([TEST_PET_IDS.FALCON_PET]); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/morph-relation-field-filter-input-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/morph-relation-field-filter-input-validation.integration-spec.ts.snap index 82a1b00b535..3f71a678df1 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/morph-relation-field-filter-input-validation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/morph-relation-field-filter-input-validation.integration-spec.ts.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Filter input validation - MORPH_RELATION Gql filter input - failure MORPH_RELATION field type - should fail with filter : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1":{"eq":"6dd71a46-68fe-4420-82b3-0d5b00ad2642"}} 1`] = `"Cannot filter by relation field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1": use "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id" instead"`; +exports[`Filter input validation - MORPH_RELATION Gql filter input - failure MORPH_RELATION field type - should fail with filter : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1":{"eq":"6dd71a46-68fe-4420-82b3-0d5b00ad2642"}} 1`] = `"Object apiInputValidationTargetTestObject1 doesn't have any "eq" field."`; exports[`Filter input validation - MORPH_RELATION Gql filter input - failure MORPH_RELATION field type - should fail with filter : {"manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id":{"eq":"invalid-uuid"}} 1`] = `"Invalid UUID value 'invalid-uuid' for field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id""`; exports[`Filter input validation - MORPH_RELATION Rest filter input - failure MORPH_RELATION field type - should fail with filter : "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1[eq]:\\"6dd71a46-68fe-4420-82b3-0d5b00ad2642\\"" 1`] = ` [ - "Cannot filter by relation field "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1": use "manyToOneMorphRelationFieldApiInputValidationTargetTestObject1Id" instead", + "Object apiInputValidationTargetTestObject1 doesn't have any "eq" field.", ] `; diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/relation-field-filter-input-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/relation-field-filter-input-validation.integration-spec.ts.snap index 4e46775230c..c38ebd030c7 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/relation-field-filter-input-validation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/filter-validation/__snapshots__/relation-field-filter-input-validation.integration-spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Filter input validation - RELATION Gql filter input - failure RELATION field type - should fail with filter : {"manyToOneRelationField":{"eq":"6dd71a46-68fe-4420-82b3-0d5b00ad2642"}} 1`] = `"Cannot filter by relation field "manyToOneRelationField": use "manyToOneRelationFieldId" instead"`; +exports[`Filter input validation - RELATION Gql filter input - failure RELATION field type - should fail with filter : {"manyToOneRelationField":{"eq":"6dd71a46-68fe-4420-82b3-0d5b00ad2642"}} 1`] = `"Object apiInputValidationTargetTestObject1 doesn't have any "eq" field."`; exports[`Filter input validation - RELATION Gql filter input - failure RELATION field type - should fail with filter : {"manyToOneRelationFieldId":{"eq":"invalid-uuid"}} 1`] = `"Invalid UUID value 'invalid-uuid' for field "manyToOneRelationFieldId""`; @@ -10,7 +10,7 @@ exports[`Filter input validation - RELATION Gql filter input - failure RELATION exports[`Filter input validation - RELATION Rest filter input - failure RELATION field type - should fail with filter : "manyToOneRelationField[eq]:\\"6dd71a46-68fe-4420-82b3-0d5b00ad2642\\"" 1`] = ` [ - "Cannot filter by relation field "manyToOneRelationField": use "manyToOneRelationFieldId" instead", + "Object apiInputValidationTargetTestObject1 doesn't have any "eq" field.", ] `; diff --git a/packages/twenty-server/test/integration/graphql/suites/mutate-by-relation-field.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/mutate-by-relation-field.integration-spec.ts new file mode 100644 index 00000000000..45ca237fa51 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/mutate-by-relation-field.integration-spec.ts @@ -0,0 +1,341 @@ +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { deleteManyOperationFactory } from 'test/integration/graphql/utils/delete-many-operation-factory.util'; +import { destroyManyOperationFactory } from 'test/integration/graphql/utils/destroy-many-operation-factory.util'; +import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { restoreManyOperationFactory } from 'test/integration/graphql/utils/restore-many-operation-factory.util'; +import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util'; + +// Distinct ID prefix (eeee / ffff) from filter-by-relation-field.integration- +// spec.ts so the two suites can share the workspace database without colliding. +const TEST_COMPANY_IDS = { + AIRBNB: '20202020-eeee-4000-8000-000000000001', + STRIPE: '20202020-eeee-4000-8000-000000000002', + NOTION: '20202020-eeee-4000-8000-000000000003', +}; + +const TEST_PERSON_IDS = { + AIRBNB_ENGINEER: '20202020-ffff-4000-8000-000000000001', + AIRBNB_DESIGNER: '20202020-ffff-4000-8000-000000000002', + STRIPE_ENGINEER: '20202020-ffff-4000-8000-000000000003', + NOTION_ENGINEER: '20202020-ffff-4000-8000-000000000004', + UNAFFILIATED: '20202020-ffff-4000-8000-000000000005', +}; + +const ALL_TEST_PERSON_IDS = Object.values(TEST_PERSON_IDS); + +// Each of the four many-record mutations must support relation-traversal +// filters: the join survives into the generated UPDATE / DELETE / SoftDelete / +// Restore SQL via an `id IN (subquery)` rewrite. +describe('Mutate by relation field (e2e)', () => { + const resetFixtures = async () => { + const createCompanies = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: 'id name', + data: [ + { id: TEST_COMPANY_IDS.AIRBNB, name: 'AirbnbMutate' }, + { id: TEST_COMPANY_IDS.STRIPE, name: 'StripeMutate' }, + { id: TEST_COMPANY_IDS.NOTION, name: 'NotionMutate' }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createCompanies); + + const createPeople = createManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + data: [ + { + id: TEST_PERSON_IDS.AIRBNB_ENGINEER, + companyId: TEST_COMPANY_IDS.AIRBNB, + jobTitle: 'Engineer', + city: 'Original City', + }, + { + id: TEST_PERSON_IDS.AIRBNB_DESIGNER, + companyId: TEST_COMPANY_IDS.AIRBNB, + jobTitle: 'Designer', + city: 'Original City', + }, + { + id: TEST_PERSON_IDS.STRIPE_ENGINEER, + companyId: TEST_COMPANY_IDS.STRIPE, + jobTitle: 'Engineer', + city: 'Original City', + }, + { + id: TEST_PERSON_IDS.NOTION_ENGINEER, + companyId: TEST_COMPANY_IDS.NOTION, + jobTitle: 'Engineer', + city: 'Original City', + }, + { + id: TEST_PERSON_IDS.UNAFFILIATED, + companyId: null, + jobTitle: 'Engineer', + city: 'Original City', + }, + ], + upsert: true, + }); + + await makeGraphqlAPIRequest(createPeople); + }; + + beforeEach(async () => { + // Each test mutates state, so wipe + reseed before every one. + await makeGraphqlAPIRequest( + destroyManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { id: { in: ALL_TEST_PERSON_IDS } }, + }), + ); + + await makeGraphqlAPIRequest( + destroyManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: 'id', + filter: { id: { in: Object.values(TEST_COMPANY_IDS) } }, + }), + ); + + await resetFixtures(); + }); + + it('should updateMany via a relation traversal filter', async () => { + const updateOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id city', + data: { city: 'Updated City' }, + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { eq: 'AirbnbMutate' } } }, + ], + }, + }); + + const updateResponse = await makeGraphqlAPIRequest(updateOperation); + + expect(updateResponse.body.errors).toBeUndefined(); + + const updatedPeople = updateResponse.body.data.updatePeople; + + expect(updatedPeople).toHaveLength(2); + expect( + updatedPeople.map((person: { id: string }) => person.id).sort(), + ).toEqual( + [TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(), + ); + + const findOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id city', + filter: { id: { in: ALL_TEST_PERSON_IDS } }, + }); + + const findResponse = await makeGraphqlAPIRequest(findOperation); + + const cityByPersonId = Object.fromEntries( + findResponse.body.data.people.edges.map( + (edge: { node: { id: string; city: string } }) => [ + edge.node.id, + edge.node.city, + ], + ), + ); + + expect(cityByPersonId[TEST_PERSON_IDS.AIRBNB_ENGINEER]).toEqual( + 'Updated City', + ); + expect(cityByPersonId[TEST_PERSON_IDS.AIRBNB_DESIGNER]).toEqual( + 'Updated City', + ); + + // Non-Airbnb rows must stay untouched — the JOIN must not widen the + // mutation past the filter. + expect(cityByPersonId[TEST_PERSON_IDS.STRIPE_ENGINEER]).toEqual( + 'Original City', + ); + expect(cityByPersonId[TEST_PERSON_IDS.NOTION_ENGINEER]).toEqual( + 'Original City', + ); + expect(cityByPersonId[TEST_PERSON_IDS.UNAFFILIATED]).toEqual( + 'Original City', + ); + }); + + it('should deleteMany via a relation traversal filter (soft-delete)', async () => { + const deleteOperation = deleteManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id deletedAt', + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { eq: 'NotionMutate' } } }, + ], + }, + }); + + const deleteResponse = await makeGraphqlAPIRequest(deleteOperation); + + expect(deleteResponse.body.errors).toBeUndefined(); + + const deletedPeople = deleteResponse.body.data.deletePeople; + + expect(deletedPeople).toHaveLength(1); + expect(deletedPeople[0].id).toEqual(TEST_PERSON_IDS.NOTION_ENGINEER); + expect(deletedPeople[0].deletedAt).toBeTruthy(); + + const findOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { id: { in: ALL_TEST_PERSON_IDS } }, + }); + + const findResponse = await makeGraphqlAPIRequest(findOperation); + const remainingIds = findResponse.body.data.people.edges.map( + (edge: { node: { id: string } }) => edge.node.id, + ); + + expect(remainingIds.sort()).toEqual( + [ + TEST_PERSON_IDS.AIRBNB_ENGINEER, + TEST_PERSON_IDS.AIRBNB_DESIGNER, + TEST_PERSON_IDS.STRIPE_ENGINEER, + TEST_PERSON_IDS.UNAFFILIATED, + ].sort(), + ); + }); + + it('should restoreMany via a relation traversal filter', async () => { + // Soft-delete via a scalar filter so restore is the only step exercising + // the traversal path. + const deleteOperation = deleteManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { + id: { + in: [ + TEST_PERSON_IDS.AIRBNB_ENGINEER, + TEST_PERSON_IDS.AIRBNB_DESIGNER, + TEST_PERSON_IDS.STRIPE_ENGINEER, + ], + }, + }, + }); + + await makeGraphqlAPIRequest(deleteOperation); + + const restoreOperation = restoreManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id deletedAt', + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { eq: 'AirbnbMutate' } } }, + ], + }, + }); + + const restoreResponse = await makeGraphqlAPIRequest(restoreOperation); + + expect(restoreResponse.body.errors).toBeUndefined(); + + const restoredPeople = restoreResponse.body.data.restorePeople; + + expect(restoredPeople).toHaveLength(2); + expect( + restoredPeople.map((person: { id: string }) => person.id).sort(), + ).toEqual( + [TEST_PERSON_IDS.AIRBNB_ENGINEER, TEST_PERSON_IDS.AIRBNB_DESIGNER].sort(), + ); + restoredPeople.forEach((person: { deletedAt: string | null }) => { + expect(person.deletedAt).toBeNull(); + }); + + // The Stripe engineer was soft-deleted too but doesn't match the + // company filter — must remain deleted. + const findStripe = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { id: { in: [TEST_PERSON_IDS.STRIPE_ENGINEER] } }, + }); + + const findStripeResponse = await makeGraphqlAPIRequest(findStripe); + + expect(findStripeResponse.body.data.people.edges).toEqual([]); + }); + + it('should destroyMany via a relation traversal filter (hard-delete)', async () => { + const destroyOperation = destroyManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { + and: [ + { id: { in: ALL_TEST_PERSON_IDS } }, + { company: { name: { eq: 'StripeMutate' } } }, + ], + }, + }); + + const destroyResponse = await makeGraphqlAPIRequest(destroyOperation); + + expect(destroyResponse.body.errors).toBeUndefined(); + + const destroyedPeople = destroyResponse.body.data.destroyPeople; + + expect(destroyedPeople).toHaveLength(1); + expect(destroyedPeople[0].id).toEqual(TEST_PERSON_IDS.STRIPE_ENGINEER); + + // destroy is a hard-delete — the row must be gone even when querying + // with a deletedAt filter. + const findOperation = findManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id', + filter: { + id: { in: [TEST_PERSON_IDS.STRIPE_ENGINEER] }, + not: { deletedAt: { is: 'NULL' } }, + }, + }); + + const findResponse = await makeGraphqlAPIRequest(findOperation); + + expect(findResponse.body.data.people.edges).toEqual([]); + }); + + it('should keep scalar-only mutations working unchanged', async () => { + // Pins the no-traversal branch of the mutation builder helper: scalar + // filters keep emitting a direct WHERE without the IN-subquery wrap. + const updateOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + gqlFields: 'id city', + data: { city: 'Scalar Updated City' }, + filter: { id: { eq: TEST_PERSON_IDS.UNAFFILIATED } }, + }); + + const updateResponse = await makeGraphqlAPIRequest(updateOperation); + + expect(updateResponse.body.errors).toBeUndefined(); + expect(updateResponse.body.data.updatePeople).toHaveLength(1); + expect(updateResponse.body.data.updatePeople[0].city).toEqual( + 'Scalar Updated City', + ); + }); +});