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:
Etienne
2026-03-30 17:20:25 +02:00
committed by GitHub
parent 2a5aab0c44
commit 3d1c53ec9d
15 changed files with 611 additions and 63 deletions
@@ -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)] };
}
}
@@ -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 },
);
@@ -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;
};
@@ -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,
};
};