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:
+182
-1
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const MAX_RELATION_FILTER_DEPTH = 1;
|
||||
+159
-35
@@ -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,
|
||||
|
||||
+13
-10
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+13
-10
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+6
-1
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+6
-1
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+6
-1
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+13
-10
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+8
-9
@@ -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: (
|
||||
|
||||
+50
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user