Gql direct execution - Improvements (#18972)
#### Direct Execution __typename & null backfill ##### __typename filling The direct execution path now correctly derives __typename at every level of the GraphQL response: Connection types — CompanyConnection, CompanyEdge, Company, PageInfo GroupBy types — TaskGroupByConnection (was incorrectly producing TaskConnection) Composite fields — Links, FullName, Currency, etc. handled by a dedicated formatter (was inheriting the parent object's typename) Previously, __typename was derived from the object's universal identifier (a UUID), producing broken values like 20202020B3744779A56180086Cb2E17FConnection. ##### Null backfill Selected fields missing from the resolver result are backfilled with null, matching the standard Yoga schema behavior. ##### Integration test A new test runs the same findMany query (with __typename at all structural levels) through both paths — standard Yoga schema and direct execution — and asserts identical output via toStrictEqual.
This commit is contained in:
+6
@@ -1,9 +1,15 @@
|
||||
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
|
||||
import { FilterArgProcessorService } from 'src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service';
|
||||
import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service';
|
||||
import { OrderByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service';
|
||||
import { OrderByWithGroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service';
|
||||
import { QueryRunnerArgsFactory } from 'src/engine/api/common/common-args-processors/query-runner-args.factory';
|
||||
|
||||
export const CommonArgsProcessors = [
|
||||
DataArgProcessorService,
|
||||
FilterArgProcessorService,
|
||||
GroupByArgProcessorService,
|
||||
OrderByArgProcessorService,
|
||||
OrderByWithGroupByArgProcessorService,
|
||||
QueryRunnerArgsFactory,
|
||||
]; // TODO: Refacto-common Remove QueryRunnerArgsFactory
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { isNullEquivalentArrayFieldValue } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-null-equivalent-array-field-value.util';
|
||||
|
||||
export const transformArrayField = (
|
||||
value: string | string[] | null,
|
||||
): string[] | null => {
|
||||
if (typeof value === 'string') return [value];
|
||||
if (isString(value)) return [value];
|
||||
|
||||
return isNullEquivalentArrayFieldValue(value) ? null : value;
|
||||
};
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ObjectRecordGroupByForAtomicField,
|
||||
ObjectRecordGroupByForCompositeField,
|
||||
ObjectRecordGroupByForDateField,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GroupByArgProcessorService {
|
||||
process({
|
||||
groupBy,
|
||||
}: {
|
||||
groupBy:
|
||||
| ObjectRecordGroupByForAtomicField
|
||||
| ObjectRecordGroupByForCompositeField
|
||||
| ObjectRecordGroupByForDateField
|
||||
| Array<
|
||||
| ObjectRecordGroupByForAtomicField
|
||||
| ObjectRecordGroupByForCompositeField
|
||||
| ObjectRecordGroupByForDateField
|
||||
>;
|
||||
}): Array<
|
||||
| ObjectRecordGroupByForAtomicField
|
||||
| ObjectRecordGroupByForCompositeField
|
||||
| ObjectRecordGroupByForDateField
|
||||
> {
|
||||
if (Array.isArray(groupBy)) {
|
||||
return groupBy;
|
||||
}
|
||||
|
||||
return [groupBy];
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
import {
|
||||
ObjectRecordOrderByForCompositeField,
|
||||
ObjectRecordOrderByForScalarField,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class OrderByArgProcessorService {
|
||||
process({
|
||||
orderBy,
|
||||
}: {
|
||||
orderBy:
|
||||
| undefined
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderBy;
|
||||
}): ObjectRecordOrderBy | undefined {
|
||||
if (Array.isArray(orderBy) || !isDefined(orderBy)) {
|
||||
return orderBy;
|
||||
}
|
||||
|
||||
return [orderBy];
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
OrderByWithGroupBy,
|
||||
type AggregateOrderByWithGroupByField,
|
||||
type ObjectRecordOrderByForCompositeField,
|
||||
type ObjectRecordOrderByForRelationField,
|
||||
type ObjectRecordOrderByForScalarField,
|
||||
type ObjectRecordOrderByWithGroupByDateField,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class OrderByWithGroupByArgProcessorService {
|
||||
process({
|
||||
orderBy,
|
||||
}: {
|
||||
orderBy:
|
||||
| undefined
|
||||
| ObjectRecordOrderByForScalarField
|
||||
| ObjectRecordOrderByForCompositeField
|
||||
| ObjectRecordOrderByWithGroupByDateField
|
||||
| ObjectRecordOrderByForRelationField
|
||||
| AggregateOrderByWithGroupByField
|
||||
| OrderByWithGroupBy;
|
||||
}): OrderByWithGroupBy | undefined {
|
||||
if (Array.isArray(orderBy) || !isDefined(orderBy)) {
|
||||
return orderBy;
|
||||
}
|
||||
|
||||
return [orderBy];
|
||||
}
|
||||
}
|
||||
+9
@@ -6,6 +6,9 @@ import { QueryResultFieldValue } from 'src/engine/api/graphql/workspace-query-ru
|
||||
|
||||
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
|
||||
import { FilterArgProcessorService } from 'src/engine/api/common/common-args-processors/filter-arg-processor/filter-arg-processor.service';
|
||||
import { GroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/group-by-arg-processor.service';
|
||||
import { OrderByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-arg-processor/order-by-arg-processor.service';
|
||||
import { OrderByWithGroupByArgProcessorService } from 'src/engine/api/common/common-args-processors/order-by-with-group-by-arg-processor/order-by-with-group-by-arg-processor.service';
|
||||
import { QueryRunnerArgsFactory } from 'src/engine/api/common/common-args-processors/query-runner-args.factory';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
|
||||
import {
|
||||
@@ -64,6 +67,12 @@ export abstract class CommonBaseQueryRunnerService<
|
||||
@Inject()
|
||||
protected readonly filterArgProcessor: FilterArgProcessorService;
|
||||
@Inject()
|
||||
protected readonly groupByArgProcessor: GroupByArgProcessorService;
|
||||
@Inject()
|
||||
protected readonly orderByArgProcessor: OrderByArgProcessorService;
|
||||
@Inject()
|
||||
protected readonly orderByWithGroupByArgProcessor: OrderByWithGroupByArgProcessorService;
|
||||
@Inject()
|
||||
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
|
||||
@Inject()
|
||||
protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper;
|
||||
|
||||
+4
-1
@@ -13,7 +13,6 @@ import {
|
||||
ObjectRecordOrderBy,
|
||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
|
||||
import {
|
||||
CommonQueryRunnerException,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
countRelationFieldsInOrderBy,
|
||||
hasRelationFieldInOrderBy,
|
||||
} from 'src/engine/api/utils/validate-and-get-order-by.utils';
|
||||
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { 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';
|
||||
@@ -235,6 +235,9 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi
|
||||
|
||||
return {
|
||||
...args,
|
||||
orderBy: this.orderByArgProcessor.process({
|
||||
orderBy: args.orderBy,
|
||||
}),
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
|
||||
+9
@@ -405,6 +405,15 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
|
||||
|
||||
return {
|
||||
...args,
|
||||
groupBy: this.groupByArgProcessor.process({
|
||||
groupBy: args.groupBy,
|
||||
}),
|
||||
orderBy: this.orderByWithGroupByArgProcessor.process({
|
||||
orderBy: args.orderBy,
|
||||
}),
|
||||
orderByForRecords: this.orderByArgProcessor.process({
|
||||
orderBy: args.orderByForRecords,
|
||||
}),
|
||||
filter: this.filterArgProcessor.process({
|
||||
filter: args.filter,
|
||||
flatObjectMetadata,
|
||||
|
||||
+17
-6
@@ -36,10 +36,10 @@ import { assertUpdateOneArgs } from 'src/engine/api/graphql/direct-execution/uti
|
||||
import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
|
||||
import { buildWorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util';
|
||||
import { extractArgumentsFromAst } from 'src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util';
|
||||
import { graphQLBackfillNullsFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util';
|
||||
import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util';
|
||||
import { graphQLBuildPartialResolveInfo } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util';
|
||||
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
|
||||
import { graphQLFormatResultFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-format-result-from-selected-fields.util';
|
||||
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
import { CreateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory';
|
||||
@@ -67,7 +67,7 @@ import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
type DirectExecutionResult = {
|
||||
data: Record<string, unknown> | null;
|
||||
data?: Record<string, unknown>;
|
||||
errors?: GraphQLFormattedError[];
|
||||
};
|
||||
|
||||
@@ -214,12 +214,23 @@ export class DirectExecutionService {
|
||||
workspaceSchemaBuilderContext,
|
||||
});
|
||||
|
||||
graphQLBackfillNullsFromSelectedFields(
|
||||
const formattedResult = graphQLFormatResultFromSelectedFields(
|
||||
result,
|
||||
graphqlFields(graphqlPartialResolveInfo as GraphQLResolveInfo),
|
||||
graphqlFields(
|
||||
graphqlPartialResolveInfo as GraphQLResolveInfo,
|
||||
{},
|
||||
{ excludedFields: [] },
|
||||
),
|
||||
workspaceSchemaBuilderContext.flatObjectMetadata.nameSingular,
|
||||
{
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
objectIdByNameSingular,
|
||||
method: entry.method,
|
||||
},
|
||||
);
|
||||
|
||||
return { responseKey, result };
|
||||
return { responseKey, result: formattedResult };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -239,7 +250,7 @@ export class DirectExecutionService {
|
||||
|
||||
return { data };
|
||||
} catch (error) {
|
||||
return { data: null, errors: [this.formatError(error, req)] };
|
||||
return { errors: [this.formatError(error, req)] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -8,11 +8,11 @@ import {
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import {
|
||||
GraphqlDirectExecutionException,
|
||||
GraphqlDirectExecutionExceptionCode,
|
||||
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
|
||||
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
|
||||
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
export function assertGroupByArgs(
|
||||
@@ -49,9 +49,12 @@ export function assertGroupByArgs(
|
||||
}
|
||||
}
|
||||
|
||||
if (!('groupBy' in args) || !Array.isArray(args.groupBy)) {
|
||||
if (
|
||||
!('groupBy' in args) ||
|
||||
(!Array.isArray(args.groupBy) && !isObject(args.groupBy))
|
||||
) {
|
||||
throw new GraphqlDirectExecutionException(
|
||||
'Missing required argument: "groupBy" (array)',
|
||||
'Missing required argument: "groupBy" must be an array.',
|
||||
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
|
||||
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
|
||||
);
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// GraphQL schema execution automatically returns null for nullable fields
|
||||
// that are missing from the resolved object. Since direct execution bypasses
|
||||
// schema resolution, we need to explicitly set requested-but-missing fields
|
||||
// to null so the response shape matches what GraphQL would produce.
|
||||
export const graphQLBackfillNullsFromSelectedFields = (
|
||||
result: unknown,
|
||||
selectedFields: Record<string, object>,
|
||||
): unknown => {
|
||||
if (result === null || result === undefined || typeof result !== 'object') {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
return result.map((item) =>
|
||||
graphQLBackfillNullsFromSelectedFields(item, selectedFields),
|
||||
);
|
||||
}
|
||||
|
||||
const record = result as Record<string, unknown>;
|
||||
|
||||
for (const [key, subFields] of Object.entries(selectedFields)) {
|
||||
if (!(key in record)) {
|
||||
record[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasNestedFields =
|
||||
subFields &&
|
||||
typeof subFields === 'object' &&
|
||||
Object.keys(subFields).length > 0;
|
||||
|
||||
if (!hasNestedFields || record[key] === null || record[key] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(record[key])) {
|
||||
record[key] = (record[key] as unknown[]).map((item) =>
|
||||
graphQLBackfillNullsFromSelectedFields(
|
||||
item,
|
||||
subFields as Record<string, object>,
|
||||
),
|
||||
);
|
||||
} else if (typeof record[key] === 'object') {
|
||||
graphQLBackfillNullsFromSelectedFields(
|
||||
record[key],
|
||||
subFields as Record<string, object>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
import { isNull, isObject } from '@sniptt/guards';
|
||||
import { RelationType } from 'twenty-shared/types';
|
||||
import { isDefined, isEmptyObject, pascalCase } from 'twenty-shared/utils';
|
||||
|
||||
import { CONNECTION_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/connection-method-names';
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
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 { 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';
|
||||
|
||||
type GraphQLFormatInput = {
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
objectIdByNameSingular: Record<string, string>;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type GraphQLFormatContext = GraphQLFormatInput & {
|
||||
fieldMetadataByNameCache: Map<string, Map<string, FlatFieldMetadata>>;
|
||||
};
|
||||
|
||||
type GraphQLObjectTypeKind =
|
||||
| 'connection'
|
||||
| 'groupByConnection'
|
||||
| 'edge'
|
||||
| 'node'
|
||||
| 'pageInfo';
|
||||
|
||||
export const graphQLFormatResultFromSelectedFields = (
|
||||
result: unknown,
|
||||
selectedFields: Record<string, object>,
|
||||
objectNameSingular: string,
|
||||
input: GraphQLFormatInput,
|
||||
): unknown => {
|
||||
const context: GraphQLFormatContext = {
|
||||
...input,
|
||||
fieldMetadataByNameCache: new Map(),
|
||||
};
|
||||
|
||||
const objectTypeKind = inferObjectTypeKind(context.method);
|
||||
|
||||
return format(
|
||||
result,
|
||||
selectedFields,
|
||||
objectNameSingular,
|
||||
objectTypeKind,
|
||||
context,
|
||||
);
|
||||
};
|
||||
|
||||
const format = (
|
||||
value: unknown,
|
||||
selectedFields: Record<string, object>,
|
||||
objectNameSingular: string,
|
||||
objectTypeKind: GraphQLObjectTypeKind,
|
||||
context: GraphQLFormatContext,
|
||||
): unknown => {
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) =>
|
||||
format(item, selectedFields, objectNameSingular, objectTypeKind, context),
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
return backfillNullValuesAndComputeTypeName(
|
||||
value as Record<string, unknown>,
|
||||
selectedFields,
|
||||
objectNameSingular,
|
||||
objectTypeKind,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const backfillNullValuesAndComputeTypeName = (
|
||||
record: Record<string, unknown>,
|
||||
selectedFields: Record<string, object>,
|
||||
objectNameSingular: string,
|
||||
objectTypeKind: GraphQLObjectTypeKind,
|
||||
context: GraphQLFormatContext,
|
||||
): Record<string, unknown> => {
|
||||
const formatted: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, subFields] of Object.entries(selectedFields)) {
|
||||
if (key === '__typename') {
|
||||
formatted.__typename = deriveTypeName(objectNameSingular, objectTypeKind);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDefined(record[key])) {
|
||||
formatted[key] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = record[key];
|
||||
|
||||
const hasNestedFields = isDefined(subFields) && !isEmptyObject(subFields);
|
||||
|
||||
if (!hasNestedFields || isNull(value)) {
|
||||
formatted[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const childObjectTypeKind = CONNECTION_FIELD_TO_OBJECT_TYPE_KIND[key];
|
||||
|
||||
if (isDefined(childObjectTypeKind)) {
|
||||
formatted[key] = format(
|
||||
value,
|
||||
subFields as Record<string, object>,
|
||||
objectNameSingular,
|
||||
childObjectTypeKind,
|
||||
context,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relationInfo = findRelationInfo(objectNameSingular, key, context);
|
||||
|
||||
if (isDefined(relationInfo)) {
|
||||
formatted[key] = format(
|
||||
value,
|
||||
subFields as Record<string, object>,
|
||||
relationInfo.targetObjectNameSingular,
|
||||
relationInfo.objectTypeKind,
|
||||
context,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldMetadata = findFieldMetadataByName(
|
||||
objectNameSingular,
|
||||
key,
|
||||
context,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(fieldMetadata) &&
|
||||
isCompositeFieldMetadataType(fieldMetadata.type)
|
||||
) {
|
||||
formatted[key] = backfillNullValuesAndComputeTypeNameForCompositeField(
|
||||
value as Record<string, unknown>,
|
||||
subFields as Record<string, object>,
|
||||
fieldMetadata.type,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
formatted[key] = value;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
const backfillNullValuesAndComputeTypeNameForCompositeField = (
|
||||
record: Record<string, unknown>,
|
||||
selectedFields: Record<string, object>,
|
||||
fieldMetadataType: string,
|
||||
): Record<string, unknown> => {
|
||||
const formatted: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(selectedFields)) {
|
||||
if (key === '__typename') {
|
||||
formatted.__typename = pascalCase(fieldMetadataType);
|
||||
continue;
|
||||
}
|
||||
|
||||
formatted[key] = record[key] ?? null;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
const deriveTypeName = (
|
||||
objectNameSingular: string,
|
||||
objectTypeKind: GraphQLObjectTypeKind,
|
||||
): string => {
|
||||
const pascal = pascalCase(objectNameSingular);
|
||||
|
||||
switch (objectTypeKind) {
|
||||
case 'connection':
|
||||
return `${pascal}Connection`;
|
||||
case 'groupByConnection':
|
||||
return `${pascal}GroupByConnection`;
|
||||
case 'edge':
|
||||
return `${pascal}Edge`;
|
||||
case 'node':
|
||||
return pascal;
|
||||
case 'pageInfo':
|
||||
return 'PageInfo';
|
||||
}
|
||||
};
|
||||
|
||||
const inferObjectTypeKind = (method: string): GraphQLObjectTypeKind => {
|
||||
if (method === RESOLVER_METHOD_NAMES.GROUP_BY) {
|
||||
return 'groupByConnection';
|
||||
}
|
||||
|
||||
if (CONNECTION_METHOD_NAMES.has(method)) {
|
||||
return 'connection';
|
||||
}
|
||||
|
||||
return 'node';
|
||||
};
|
||||
|
||||
const CONNECTION_FIELD_TO_OBJECT_TYPE_KIND: Record<
|
||||
string,
|
||||
GraphQLObjectTypeKind
|
||||
> = {
|
||||
edges: 'edge',
|
||||
node: 'node',
|
||||
pageInfo: 'pageInfo',
|
||||
};
|
||||
|
||||
const getOrBuildFieldMetadataByNameMap = (
|
||||
objectNameSingular: string,
|
||||
context: GraphQLFormatContext,
|
||||
): Map<string, FlatFieldMetadata> => {
|
||||
const cached = context.fieldMetadataByNameCache.get(objectNameSingular);
|
||||
|
||||
if (isDefined(cached)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const map = new Map<string, FlatFieldMetadata>();
|
||||
const objectId = context.objectIdByNameSingular[objectNameSingular];
|
||||
|
||||
if (!isDefined(objectId)) {
|
||||
context.fieldMetadataByNameCache.set(objectNameSingular, map);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: objectId,
|
||||
flatEntityMaps: context.flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(flatObjectMetadata)) {
|
||||
context.fieldMetadataByNameCache.set(objectNameSingular, map);
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
for (const fieldId of flatObjectMetadata.fieldIds) {
|
||||
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldId,
|
||||
flatEntityMaps: context.flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (isDefined(fieldMetadata)) {
|
||||
map.set(fieldMetadata.name, fieldMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
context.fieldMetadataByNameCache.set(objectNameSingular, map);
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
const findFieldMetadataByName = (
|
||||
objectNameSingular: string,
|
||||
fieldName: string,
|
||||
context: GraphQLFormatContext,
|
||||
): FlatFieldMetadata | undefined => {
|
||||
return getOrBuildFieldMetadataByNameMap(objectNameSingular, context).get(
|
||||
fieldName,
|
||||
);
|
||||
};
|
||||
|
||||
type RelationInfo = {
|
||||
targetObjectNameSingular: string;
|
||||
objectTypeKind: GraphQLObjectTypeKind;
|
||||
};
|
||||
|
||||
const findRelationInfo = (
|
||||
objectNameSingular: string,
|
||||
fieldName: string,
|
||||
context: GraphQLFormatContext,
|
||||
): RelationInfo | undefined => {
|
||||
const fieldMetadata = findFieldMetadataByName(
|
||||
objectNameSingular,
|
||||
fieldName,
|
||||
context,
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(fieldMetadata) ||
|
||||
!isMorphOrRelationFlatFieldMetadata(fieldMetadata)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: fieldMetadata.relationTargetObjectMetadataId,
|
||||
flatEntityMaps: context.flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const objectTypeKind: GraphQLObjectTypeKind =
|
||||
fieldMetadata.settings.relationType === RelationType.ONE_TO_MANY
|
||||
? 'connection'
|
||||
: 'node';
|
||||
|
||||
return {
|
||||
targetObjectNameSingular: targetObjectMetadata.nameSingular,
|
||||
objectTypeKind,
|
||||
};
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
|
||||
|
||||
export const CONNECTION_METHOD_NAMES = new Set<string>([
|
||||
RESOLVER_METHOD_NAMES.FIND_MANY,
|
||||
RESOLVER_METHOD_NAMES.FIND_DUPLICATES,
|
||||
RESOLVER_METHOD_NAMES.CREATE_MANY,
|
||||
RESOLVER_METHOD_NAMES.UPDATE_MANY,
|
||||
RESOLVER_METHOD_NAMES.DELETE_MANY,
|
||||
RESOLVER_METHOD_NAMES.DESTROY_MANY,
|
||||
RESOLVER_METHOD_NAMES.RESTORE_MANY,
|
||||
]);
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import request from 'supertest';
|
||||
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
|
||||
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { updateFeatureFlag } from 'test/integration/metadata/suites/utils/update-feature-flag.util';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
const FIND_MANY_COMPANIES_WITH_TYPENAME = `
|
||||
query Companies($filter: CompanyFilterInput, $orderBy: [CompanyOrderByInput]) {
|
||||
companies(filter: $filter, orderBy: $orderBy) {
|
||||
__typename
|
||||
edges {
|
||||
__typename
|
||||
node {
|
||||
__typename
|
||||
id
|
||||
name
|
||||
domainName {
|
||||
__typename
|
||||
primaryLinkLabel
|
||||
primaryLinkUrl
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
__typename
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
describe('direct execution __typename filling (integration)', () => {
|
||||
const testCompanyId1 = randomUUID();
|
||||
const testCompanyId2 = randomUUID();
|
||||
|
||||
beforeAll(async () => {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED,
|
||||
value: false,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
const gqlFields = 'id name';
|
||||
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields,
|
||||
data: {
|
||||
id: testCompanyId1,
|
||||
name: 'TypeName Test Company A',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await makeGraphqlAPIRequest(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields,
|
||||
data: {
|
||||
id: testCompanyId2,
|
||||
name: 'TypeName Test Company B',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED,
|
||||
value: false,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
for (const id of [testCompanyId1, testCompanyId2]) {
|
||||
await makeGraphqlAPIRequest(
|
||||
destroyOneOperationFactory({
|
||||
objectMetadataSingularName: 'company',
|
||||
gqlFields: 'id',
|
||||
recordId: id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce identical __typename values with and without direct execution', async () => {
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
const variables = {
|
||||
filter: {
|
||||
id: { in: [testCompanyId1, testCompanyId2] },
|
||||
},
|
||||
orderBy: [{ name: 'AscNullsLast' }],
|
||||
};
|
||||
|
||||
// Run through standard GraphQL Yoga schema execution
|
||||
const yogaResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({ query: FIND_MANY_COMPANIES_WITH_TYPENAME, variables })
|
||||
.expect(200);
|
||||
|
||||
expect(yogaResponse.body.errors).toBeUndefined();
|
||||
expect(yogaResponse.body.data).toBeDefined();
|
||||
|
||||
const yogaResult = yogaResponse.body.data.companies;
|
||||
|
||||
await updateFeatureFlag({
|
||||
featureFlag: FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED,
|
||||
value: true,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
// Run through direct execution path
|
||||
const directResponse = await client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({ query: FIND_MANY_COMPANIES_WITH_TYPENAME, variables })
|
||||
.expect(200);
|
||||
|
||||
expect(directResponse.body.errors).toBeUndefined();
|
||||
expect(directResponse.body.data).toBeDefined();
|
||||
|
||||
const directResult = directResponse.body.data.companies;
|
||||
|
||||
expect(directResult).toStrictEqual(yogaResult);
|
||||
});
|
||||
});
|
||||
+1
@@ -414,6 +414,7 @@ export const successfulCreateInputByFieldMetadataType: {
|
||||
},
|
||||
{
|
||||
input: {
|
||||
name: 'position',
|
||||
position: undefined,
|
||||
},
|
||||
validateInput: (record: Record<string, any>) => {
|
||||
|
||||
Reference in New Issue
Block a user