feat(twenty-server): one-hop relation filters in GraphQL API (#20527)

## 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 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-14 14:45:32 +02:00
committed by GitHub
parent a941f6fe01
commit af4765effe
25 changed files with 1565 additions and 191 deletions
@@ -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<FlatFieldMetadata>;
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<FlatObjectMetadata>;
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/);
});
});
});
@@ -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<T extends ObjectRecordFilter | undefined>({
filter,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: {
filter: T;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps?: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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<FlatObjectMetadata> | undefined,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
fieldIdByName: Record<string, string>,
fieldIdByJoinColumnName: Record<string, string>,
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<string, string>;
fieldIdByJoinColumnName: Record<string, string>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}):
| FlatFieldMetadata<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>
| undefined {
const resolvedByName = fieldIdByName[key];
if (!isDefined(resolvedByName) || isDefined(fieldIdByJoinColumnName[key])) {
return undefined;
}
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>(
{
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<FlatObjectMetadata> | undefined,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
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<FlatObjectMetadata>({
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<string, unknown>,
@@ -112,9 +270,7 @@ export class FilterArgProcessorService {
fieldIdByName: Record<string, string>,
fieldIdByJoinColumnName: Record<string, string>,
): Record<string, unknown> {
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,
@@ -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<DeleteManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<DeleteManyQueryArgs>> {
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
const {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
} = queryRunnerContext;
return {
...args,
filter: this.filterArgProcessor.process({
filter: args.filter,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
};
@@ -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<DestroyManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<DestroyManyQueryArgs>> {
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
const {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
} = queryRunnerContext;
return {
...args,
filter: this.filterArgProcessor.process({
filter: args.filter,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
};
@@ -231,7 +231,11 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
args: CommonInput<FindManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<FindManyQueryArgs>> {
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,
}),
};
@@ -116,13 +116,18 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
args: CommonInput<FindOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<FindOneQueryArgs>> {
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
const {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
} = queryRunnerContext;
return {
...args,
filter: this.filterArgProcessor.process({
filter: args.filter,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
};
@@ -412,7 +412,11 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
args: CommonInput<GroupByQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<GroupByQueryArgs>> {
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,
}),
};
@@ -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<RestoreManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<RestoreManyQueryArgs>> {
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
const {
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
} = queryRunnerContext;
return {
...args,
filter: this.filterArgProcessor.process({
filter: args.filter,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
};
@@ -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: (
@@ -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<ObjectLiteral>;
alias: string;
filter: Partial<ObjectRecordFilter>;
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<ObjectLiteral> => {
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);
};