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

View File

@@ -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/);
});
});
});

View File

@@ -0,0 +1 @@
export const MAX_RELATION_FILTER_DEPTH = 1;

View File

@@ -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,

View File

@@ -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,
}),
};

View File

@@ -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,
}),
};

View File

@@ -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,
}),
};

View File

@@ -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,
}),
};

View File

@@ -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,
}),
};

View File

@@ -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,
}),
};

View File

@@ -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: (

View File

@@ -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);
};

View File

@@ -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<FlatFieldMetadata>,
flatObjectMetadataMaps?: FlatEntityMaps<FlatObjectMetadata>,
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<any>,
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>,
objectNameSingular: string,
filter: Partial<ObjectRecordFilter>,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
): WorkspaceSelectQueryBuilder<any> {
): WorkspaceSelectQueryBuilder<ObjectLiteral> {
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<ObjectLiteral>,
objectNameSingular: string,
filter: Partial<ObjectRecordFilter>,
): void {
Object.entries(filter).forEach(([key, value], index) => {
this.parseKeyFilter(
innerQueryBuilder,
outerQueryBuilder,
objectNameSingular,
key,
value,
index === 0,
);
});
}
private parseKeyFilter(
queryBuilder: WhereExpressionBuilder,
outerQueryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>,
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,

View File

@@ -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<FlatFieldMetadata>;
private flatObjectMetadataMaps?: FlatEntityMaps<FlatObjectMetadata>;
private fieldIdByName: Record<string, string>;
private fieldIdByJoinColumnName: Record<string, string>;
private depth: number;
constructor(
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps?: FlatEntityMaps<FlatObjectMetadata>,
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<ObjectLiteral>,
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<ObjectLiteral>,
parentAlias: string,
fieldMetadata: FlatFieldMetadata,
filterValue: Partial<ObjectRecordFilter>,
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<FlatObjectMetadata>({
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,

View File

@@ -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<ObjectLiteral>)
) {
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<ObjectLiteral>)
) {
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);

View File

@@ -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<ObjectLiteral>;
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);
};

View File

@@ -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(

View File

@@ -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)) {

View File

@@ -39,6 +39,7 @@ export class ObjectMetadataGqlInputTypeGenerator {
this.objectMetadataFilterGqlInputTypeGenerator.buildAndStore(
flatObjectMetadata,
fields,
context,
);
this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({
flatObjectMetadata,

View File

@@ -36,7 +36,7 @@ export class ObjectMetadataOrderByBaseGenerator {
fields: FlatFieldMetadata[];
logger: Logger;
isForGroupBy?: boolean;
context?: SchemaGenerationContext;
context: SchemaGenerationContext;
}): GraphQLInputFieldConfigMap {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};

View File

@@ -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({

View File

@@ -90,10 +90,16 @@ const applyObjectRecordFilterToQueryBuilder = <T extends ObjectLiteral>({
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<ObjectLiteral>;
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 = <T extends ObjectLiteral>({
const parseKeyFilter = ({
queryBuilder,
outerQueryBuilder,
objectNameSingular,
key,
value,
@@ -121,6 +128,7 @@ const parseKeyFilter = ({
useDirectTableReference = false,
}: {
queryBuilder: WhereExpressionBuilder;
outerQueryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
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,

View File

@@ -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]);
});
});

View File

@@ -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.",
]
`;

View File

@@ -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.",
]
`;

View File

@@ -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',
);
});
});