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:
+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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user