mirror of
https://github.com/twentyhq/twenty.git
synced 2026-05-19 22:07:21 -04:00
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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_RELATION_FILTER_DEPTH = 1;
|
||||
@@ -11,6 +11,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { MAX_RELATION_FILTER_DEPTH } from 'src/engine/api/common/common-args-processors/filter-arg-processor/constants/max-relation-filter-depth.constant';
|
||||
import { validateAndTransformOperatorAndValue } from 'src/engine/api/common/common-args-processors/filter-arg-processor/utils/validate-and-transform-operator-and-value.util';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
@@ -26,15 +27,31 @@ import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-module
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
|
||||
function throwUseJoinColumnInstead(key: string): never {
|
||||
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
|
||||
name: key,
|
||||
});
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Cannot filter by relation field "${key}": use "${joinColumnName}" instead`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid filter: use "${joinColumnName}" to filter by this relation field`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilterArgProcessorService {
|
||||
process<T extends ObjectRecordFilter | undefined>({
|
||||
filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
filter: T;
|
||||
flatObjectMetadata: FlatObjectMetadata;
|
||||
flatObjectMetadataMaps?: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): T {
|
||||
if (!isDefined(filter)) {
|
||||
@@ -50,18 +67,22 @@ export class FilterArgProcessorService {
|
||||
return this.validateAndTransformFilter(
|
||||
filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
0,
|
||||
) as T;
|
||||
}
|
||||
|
||||
private validateAndTransformFilter(
|
||||
filterObject: ObjectRecordFilter,
|
||||
flatObjectMetadata: FlatObjectMetadata,
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata> | undefined,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
fieldIdByName: Record<string, string>,
|
||||
fieldIdByJoinColumnName: Record<string, string>,
|
||||
depth: number,
|
||||
): ObjectRecordFilter {
|
||||
const transformedFilter: ObjectRecordFilter = {};
|
||||
|
||||
@@ -72,9 +93,11 @@ export class FilterArgProcessorService {
|
||||
this.validateAndTransformFilter(
|
||||
nestedFilter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
depth,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
@@ -84,9 +107,30 @@ export class FilterArgProcessorService {
|
||||
transformedFilter[key] = this.validateAndTransformFilter(
|
||||
value as ObjectRecordFilter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
depth,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldMetadataForRelation = this.resolveRelationFieldMetadataByName({
|
||||
key,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (isDefined(fieldMetadataForRelation)) {
|
||||
transformedFilter[key] = this.validateAndTransformRelationFilter(
|
||||
key,
|
||||
value,
|
||||
fieldMetadataForRelation,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
depth,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -104,6 +148,120 @@ export class FilterArgProcessorService {
|
||||
return transformedFilter;
|
||||
}
|
||||
|
||||
private resolveRelationFieldMetadataByName({
|
||||
key,
|
||||
fieldIdByName,
|
||||
fieldIdByJoinColumnName,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
key: string;
|
||||
fieldIdByName: Record<string, string>;
|
||||
fieldIdByJoinColumnName: Record<string, string>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}):
|
||||
| FlatFieldMetadata<
|
||||
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||
>
|
||||
| undefined {
|
||||
const resolvedByName = fieldIdByName[key];
|
||||
|
||||
if (!isDefined(resolvedByName) || isDefined(fieldIdByJoinColumnName[key])) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>(
|
||||
{
|
||||
flatEntityId: resolvedByName,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isDefined(fieldMetadata)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
isFlatFieldMetadataOfType(fieldMetadata, FieldMetadataType.RELATION) ||
|
||||
isFlatFieldMetadataOfType(fieldMetadata, FieldMetadataType.MORPH_RELATION)
|
||||
) {
|
||||
return fieldMetadata;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private validateAndTransformRelationFilter(
|
||||
key: string,
|
||||
filterValue: ObjectRecordFilter,
|
||||
fieldMetadata: FlatFieldMetadata<
|
||||
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
|
||||
>,
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata> | undefined,
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
|
||||
depth: number,
|
||||
): ObjectRecordFilter {
|
||||
if (fieldMetadata.settings?.relationType !== RelationType.MANY_TO_ONE) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Cannot filter by relation field "${key}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid filter: filtering by relation field "${key}" is not supported`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof filterValue !== 'object' || filterValue === null) {
|
||||
throwUseJoinColumnInstead(key);
|
||||
}
|
||||
|
||||
const targetObjectMetadataId = fieldMetadata.relationTargetObjectMetadataId;
|
||||
|
||||
if (
|
||||
!isDefined(flatObjectMetadataMaps) ||
|
||||
!isDefined(targetObjectMetadataId)
|
||||
) {
|
||||
throwUseJoinColumnInstead(key);
|
||||
}
|
||||
|
||||
const targetObjectMetadata =
|
||||
findFlatEntityByIdInFlatEntityMaps<FlatObjectMetadata>({
|
||||
flatEntityId: targetObjectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
throwUseJoinColumnInstead(key);
|
||||
}
|
||||
|
||||
if (depth >= MAX_RELATION_FILTER_DEPTH) {
|
||||
throw new CommonQueryRunnerException(
|
||||
`Relation filter nesting deeper than ${MAX_RELATION_FILTER_DEPTH} hop is not supported`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
|
||||
{
|
||||
userFriendlyMessage: msg`Relation filters can only traverse one relation deep`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
fieldIdByName: targetFieldIdByName,
|
||||
fieldIdByJoinColumnName: targetFieldIdByJoinColumnName,
|
||||
} = buildFieldMapsFromFlatObjectMetadata(
|
||||
flatFieldMetadataMaps,
|
||||
targetObjectMetadata,
|
||||
);
|
||||
|
||||
return this.validateAndTransformFilter(
|
||||
filterValue,
|
||||
targetObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
targetFieldIdByName,
|
||||
targetFieldIdByJoinColumnName,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
|
||||
private validateAndTransformFieldFilter(
|
||||
key: string,
|
||||
filterValue: Record<string, unknown>,
|
||||
@@ -112,9 +270,7 @@ export class FilterArgProcessorService {
|
||||
fieldIdByName: Record<string, string>,
|
||||
fieldIdByJoinColumnName: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
const resolvedByName = fieldIdByName[key];
|
||||
const resolvedByJoinColumn = fieldIdByJoinColumnName[key];
|
||||
const fieldMetadataId = resolvedByName ?? resolvedByJoinColumn;
|
||||
const fieldMetadataId = fieldIdByName[key] ?? fieldIdByJoinColumnName[key];
|
||||
|
||||
if (!isDefined(fieldMetadataId)) {
|
||||
const nameSingular = flatObjectMetadata.nameSingular;
|
||||
@@ -143,38 +299,6 @@ export class FilterArgProcessorService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(resolvedByName) &&
|
||||
!isDefined(resolvedByJoinColumn) &&
|
||||
(isFlatFieldMetadataOfType(fieldMetadata, FieldMetadataType.RELATION) ||
|
||||
isFlatFieldMetadataOfType(
|
||||
fieldMetadata,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
))
|
||||
) {
|
||||
if (fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE) {
|
||||
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
|
||||
name: key,
|
||||
});
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Cannot filter by relation field "${key}": use "${joinColumnName}" instead`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid filter: use "${joinColumnName}" to filter by this relation field`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new CommonQueryRunnerException(
|
||||
`Cannot filter by relation field "${key}"`,
|
||||
CommonQueryRunnerExceptionCode.INVALID_ARGS_FILTER,
|
||||
{
|
||||
userFriendlyMessage: msg`Invalid filter: filtering by relation field "${key}" is not supported`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
|
||||
return this.validateAndTransformCompositeFieldFilter(
|
||||
fieldMetadata,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util';
|
||||
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
|
||||
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
|
||||
import {
|
||||
@@ -49,15 +50,12 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer
|
||||
commonQueryParser,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
flatObjectMetadata.nameSingular,
|
||||
args.filter,
|
||||
);
|
||||
const queryBuilder = buildMutationQueryBuilder({
|
||||
repository,
|
||||
alias: flatObjectMetadata.nameSingular,
|
||||
filter: args.filter,
|
||||
commonQueryParser,
|
||||
});
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
@@ -99,13 +97,18 @@ export class CommonDeleteManyQueryRunnerService extends CommonBaseQueryRunnerSer
|
||||
args: CommonInput<DeleteManyQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<CommonInput<DeleteManyQueryArgs>> {
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
return {
|
||||
...args,
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util';
|
||||
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
|
||||
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
|
||||
import {
|
||||
@@ -49,15 +50,12 @@ export class CommonDestroyManyQueryRunnerService extends CommonBaseQueryRunnerSe
|
||||
commonQueryParser,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
flatObjectMetadata.nameSingular,
|
||||
args.filter,
|
||||
);
|
||||
const queryBuilder = buildMutationQueryBuilder({
|
||||
repository,
|
||||
alias: flatObjectMetadata.nameSingular,
|
||||
filter: args.filter,
|
||||
commonQueryParser,
|
||||
});
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
@@ -100,13 +98,18 @@ export class CommonDestroyManyQueryRunnerService extends CommonBaseQueryRunnerSe
|
||||
args: CommonInput<DestroyManyQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<CommonInput<DestroyManyQueryArgs>> {
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
return {
|
||||
...args,
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -231,7 +231,11 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
args: CommonInput<FindManyQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<CommonInput<FindManyQueryArgs>> {
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
return {
|
||||
...args,
|
||||
@@ -241,6 +245,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -116,13 +116,18 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
args: CommonInput<FindOneQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<CommonInput<FindOneQueryArgs>> {
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
return {
|
||||
...args,
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -412,7 +412,11 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
args: CommonInput<GroupByQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<CommonInput<GroupByQueryArgs>> {
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
return {
|
||||
...args,
|
||||
@@ -428,6 +432,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util';
|
||||
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
|
||||
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
|
||||
import {
|
||||
@@ -49,15 +50,12 @@ export class CommonRestoreManyQueryRunnerService extends CommonBaseQueryRunnerSe
|
||||
commonQueryParser,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
flatObjectMetadata.nameSingular,
|
||||
args.filter,
|
||||
);
|
||||
const queryBuilder = buildMutationQueryBuilder({
|
||||
repository,
|
||||
alias: flatObjectMetadata.nameSingular,
|
||||
filter: args.filter,
|
||||
commonQueryParser,
|
||||
});
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
@@ -100,13 +98,18 @@ export class CommonRestoreManyQueryRunnerService extends CommonBaseQueryRunnerSe
|
||||
args: CommonInput<RestoreManyQueryArgs>,
|
||||
queryRunnerContext: CommonBaseQueryRunnerContext,
|
||||
): Promise<CommonInput<RestoreManyQueryArgs>> {
|
||||
const { flatObjectMetadata, flatFieldMetadataMaps } = queryRunnerContext;
|
||||
const {
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
} = queryRunnerContext;
|
||||
|
||||
return {
|
||||
...args,
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CommonQueryRunnerExceptionCode,
|
||||
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { buildMutationQueryBuilder } from 'src/engine/api/common/common-query-runners/utils/build-mutation-query-builder.util';
|
||||
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
|
||||
import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type';
|
||||
import {
|
||||
@@ -49,15 +50,12 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
|
||||
commonQueryParser,
|
||||
} = queryRunnerContext;
|
||||
|
||||
const queryBuilder = repository.createQueryBuilder(
|
||||
flatObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(
|
||||
queryBuilder,
|
||||
flatObjectMetadata.nameSingular,
|
||||
args.filter,
|
||||
);
|
||||
const queryBuilder = buildMutationQueryBuilder({
|
||||
repository,
|
||||
alias: flatObjectMetadata.nameSingular,
|
||||
filter: args.filter,
|
||||
commonQueryParser,
|
||||
});
|
||||
|
||||
const columnsToReturn = buildColumnsToReturn({
|
||||
select: args.selectedFieldsResult.select,
|
||||
@@ -112,6 +110,7 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
data: (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type ObjectLiteral } from 'typeorm';
|
||||
|
||||
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { type GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
|
||||
|
||||
type BuildMutationQueryBuilderArgs = {
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
alias: string;
|
||||
filter: Partial<ObjectRecordFilter>;
|
||||
commonQueryParser: GraphqlQueryParser;
|
||||
};
|
||||
|
||||
// TypeORM drops join attributes when a SelectQueryBuilder is morphed into
|
||||
// UPDATE / DELETE / SoftDelete / Restore — the generated SQL would reference
|
||||
// an alias without a FROM entry and Postgres would throw. When the filter
|
||||
// adds joins we rewrite it as `id IN (SELECT id FROM ... JOIN ... WHERE ...)`
|
||||
// so the joins live inside a self-contained subquery.
|
||||
export const buildMutationQueryBuilder = ({
|
||||
repository,
|
||||
alias,
|
||||
filter,
|
||||
commonQueryParser,
|
||||
}: BuildMutationQueryBuilderArgs): WorkspaceSelectQueryBuilder<ObjectLiteral> => {
|
||||
const filteredQueryBuilder = repository.createQueryBuilder(alias);
|
||||
|
||||
commonQueryParser.applyFilterToBuilder(filteredQueryBuilder, alias, filter);
|
||||
|
||||
const hasRelationTraversal =
|
||||
filteredQueryBuilder.expressionMap.joinAttributes.length > 0;
|
||||
|
||||
if (!hasRelationTraversal) {
|
||||
return filteredQueryBuilder;
|
||||
}
|
||||
|
||||
// TypeORM auto-injects `deletedAt IS NULL` for SELECT-typed queries but
|
||||
// not for mutation-typed ones, so the subquery (a SELECT) must opt out to
|
||||
// mirror the semantics of the mutation it feeds — otherwise `restoreMany`
|
||||
// would never find soft-deleted rows.
|
||||
const idSubQueryBuilder = filteredQueryBuilder
|
||||
.select(`${alias}.id`)
|
||||
.withDeleted();
|
||||
|
||||
return repository
|
||||
.createQueryBuilder(alias)
|
||||
.where(`"${alias}"."id" IN (${idSubQueryBuilder.getQuery()})`)
|
||||
.setParameters(idSubQueryBuilder.expressionMap.parameters);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export class ObjectMetadataGqlInputTypeGenerator {
|
||||
this.objectMetadataFilterGqlInputTypeGenerator.buildAndStore(
|
||||
flatObjectMetadata,
|
||||
fields,
|
||||
context,
|
||||
);
|
||||
this.objectMetadataOrderByGqlInputTypeGenerator.buildAndStore({
|
||||
flatObjectMetadata,
|
||||
|
||||
@@ -36,7 +36,7 @@ export class ObjectMetadataOrderByBaseGenerator {
|
||||
fields: FlatFieldMetadata[];
|
||||
logger: Logger;
|
||||
isForGroupBy?: boolean;
|
||||
context?: SchemaGenerationContext;
|
||||
context: SchemaGenerationContext;
|
||||
}): GraphQLInputFieldConfigMap {
|
||||
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user