From d9c8ae358753a9c52590aeb2f733f49799b24376 Mon Sep 17 00:00:00 2001 From: Milinda Dias Date: Thu, 30 Jul 2026 23:17:06 +0530 Subject: [PATCH 1/4] fix: initial cache tag composition --- .../directive-definition-data.ts | 22 + composition/src/errors/errors.ts | 39 ++ composition/src/errors/types/params.ts | 6 +- composition/src/router-configuration/types.ts | 8 + composition/src/router-configuration/utils.ts | 1 + composition/src/utils/string-constants.ts | 3 + composition/src/v1/constants/constants.ts | 16 + .../src/v1/constants/directive-definitions.ts | 22 + .../v1/normalization/normalization-factory.ts | 113 +++++ .../src/v1/normalization/types/types.ts | 5 + composition/src/v1/normalization/utils.ts | 40 +- .../tests/v1/directives/cache-tag.test.ts | 440 ++++++++++++++++++ 12 files changed, 712 insertions(+), 3 deletions(-) create mode 100644 composition/tests/v1/directives/cache-tag.test.ts diff --git a/composition/src/directive-definition-data/directive-definition-data.ts b/composition/src/directive-definition-data/directive-definition-data.ts index b6e683a92b..5ba26ea253 100644 --- a/composition/src/directive-definition-data/directive-definition-data.ts +++ b/composition/src/directive-definition-data/directive-definition-data.ts @@ -6,6 +6,7 @@ import { ASSUMED_SIZE, AUTHENTICATED, BOOLEAN_SCALAR, + CACHE_TAG, CHANNEL, CHANNELS, COMPOSE_DIRECTIVE, @@ -33,6 +34,7 @@ import { FIELD_DEFINITION_UPPER, FIELDS, FOR, + FORMAT, FROM, IMPORT, INACCESSIBLE, @@ -92,6 +94,7 @@ import { } from '../utils/string-constants'; import { AUTHENTICATED_DEFINITION, + CACHE_TAG_DEFINITION, COMPOSE_DIRECTIVE_DEFINITION, CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION, CONFIGURE_DESCRIPTION_DEFINITION, @@ -1045,3 +1048,22 @@ export const CACHE_POPULATE_DEFINITION_DATA = newDirectiveDefinitionData({ node: OPENFED_CACHE_POPULATE_DEFINITION, optionalArgumentNames: new Set([MAX_AGE]), }); + +export const CACHE_TAG_DEFINITION_DATA = newDirectiveDefinitionData({ + argumentDataByName: new Map([ + [ + FORMAT, + newDirectiveArgumentData({ + directive: `@${CACHE_TAG}`, + name: FORMAT, + namedTypeKind: Kind.SCALAR_TYPE_DEFINITION, + typeNode: REQUIRED_STRING_TYPE_NODE, + }), + ], + ]), + isRepeatable: true, + locations: new Set([FIELD_DEFINITION_UPPER]), + name: CACHE_TAG, + node: CACHE_TAG_DEFINITION, + requiredArgumentNames: new Set([FORMAT]), +}); \ No newline at end of file diff --git a/composition/src/errors/errors.ts b/composition/src/errors/errors.ts index 88e1baef6c..1a9b302b1d 100644 --- a/composition/src/errors/errors.ts +++ b/composition/src/errors/errors.ts @@ -10,6 +10,7 @@ import { type IncompatibleParentTypeMergeErrorParams, type IncompatibleTypeWithProvidesErrorMessageParams, type InvalidArgumentValueErrorParams, + type InvalidCacheTagArgumentTypeErrorParams, type InvalidCustomDirectiveErrorParams, type InvalidDirectiveLocationErrorParams, type InvalidEntityReturnTypeErrorParams, @@ -2165,3 +2166,41 @@ export function intersectingExcludeAndIncludeContractTagsError(tagNames: Array}", where a field of an Input Object argument` + + ` is referenced by a period-delimited path, e.g. "{$args.filter.category}".` + ); +} + +export function unbalancedCacheTagFormatErrorMessage(format: string): string { + return `The "format" argument defines an extra curly brace; received "${format}".`; +} + +export function invalidQueryRootFieldErrorMessage(): string { + return `The directive is valid only upon a Query root field.`; +} + +export function unsupportedFieldCacheTagNamespaceErrorMessage(namespace: string): string { + return `The "format" argument defines placeholder namespace "$${namespace}", but only "$args" is supported.`; +} + +export function undefinedCacheTagArgumentErrorMessage(reference: string): string { + return `The "format" argument references "$args.${reference}", which is not an argument of the field.`; +} + +export function invalidCacheTagArgumentTypeErrorMessage({ + reference, + typeString, +}: InvalidCacheTagArgumentTypeErrorParams): string { + return ( + `The "format" argument references "$args.${reference}", which is of type "${typeString}".` + + ` A referenced argument must be a single leaf value, i.e. a nullable or non-nullable scalar or Enum.` + ); +} diff --git a/composition/src/errors/types/params.ts b/composition/src/errors/types/params.ts index d2ab3050d6..5b6b6dd9dc 100644 --- a/composition/src/errors/types/params.ts +++ b/composition/src/errors/types/params.ts @@ -6,7 +6,6 @@ import { type SubgraphName, type TypeName, } from '../../types/types'; -import { directlyProvidedInterfaceFieldError } from '../errors'; export type IncompatibleMergedTypesErrorParams = { actualType: string; @@ -109,3 +108,8 @@ export type InvalidEntityReturnTypeErrorParams = { fieldCoords: string; returnTypeName: string; }; + +export type InvalidCacheTagArgumentTypeErrorParams = { + reference: string; + typeString: string; +}; diff --git a/composition/src/router-configuration/types.ts b/composition/src/router-configuration/types.ts index e55d3c7da9..4426b6504c 100644 --- a/composition/src/router-configuration/types.ts +++ b/composition/src/router-configuration/types.ts @@ -138,11 +138,19 @@ export type CachePopulateConfiguration = { operationType: OperationTypeNode; }; +export type CacheTagConfiguration = { + fieldName: FieldName; + format: string; + typeName: TypeName; +}; + export type EntityCachingConfiguration = { // Attached to the Mutation/Subscription type's ConfigurationData from @openfed__cacheInvalidate. cacheInvalidateConfigurations: Array; // Attached to the Mutation/Subscription type's ConfigurationData from @openfed__cachePopulate. cachePopulateConfigurations: Array; + // Attached to the Query root type's ConfigurationData from @cacheTag. + cacheTagConfigurations: Array; // Attached to an entity type's ConfigurationData (e.g. "Product") from @openfed__entityCache. entityCacheConfigurations: Array; }; diff --git a/composition/src/router-configuration/utils.ts b/composition/src/router-configuration/utils.ts index 54fb3b7bf0..c510ed6434 100644 --- a/composition/src/router-configuration/utils.ts +++ b/composition/src/router-configuration/utils.ts @@ -21,6 +21,7 @@ export function getOrInitializeEntityCaching(configurationData: ConfigurationDat configurationData.entityCaching = { cacheInvalidateConfigurations: [], cachePopulateConfigurations: [], + cacheTagConfigurations: [], entityCacheConfigurations: [], }; } diff --git a/composition/src/utils/string-constants.ts b/composition/src/utils/string-constants.ts index e1221b8204..b3027ea432 100644 --- a/composition/src/utils/string-constants.ts +++ b/composition/src/utils/string-constants.ts @@ -5,6 +5,7 @@ export const AS = 'as'; export const ASSUMED_SIZE = 'assumedSize'; export const AND_UPPER = 'AND'; export const ANY_SCALAR = '_Any'; +export const ARGS = 'args'; export const ARGUMENT = 'argument'; export const AUTHENTICATED = 'authenticated'; export const ARGUMENT_DEFINITION_UPPER = 'ARGUMENT_DEFINITION'; @@ -12,6 +13,7 @@ export const BOOLEAN = 'boolean'; export const BOOLEAN_SCALAR = 'Boolean'; export const OPENFED_CACHE_INVALIDATE = 'openfed__cacheInvalidate'; export const OPENFED_CACHE_POPULATE = 'openfed__cachePopulate'; +export const CACHE_TAG = 'cacheTag'; export const CHANNEL = 'channel'; export const CHANNELS = 'channels'; export const COMPOSE_DIRECTIVE = 'composeDirective'; @@ -62,6 +64,7 @@ export const FIELD_DEFINITION_UPPER = 'FIELD_DEFINITION'; export const FIRST_ORDINAL = '1st'; export const FLOAT_SCALAR = 'Float'; export const FOR = 'for'; +export const FORMAT = 'format'; export const FRAGMENT_DEFINITION_UPPER = 'FRAGMENT_DEFINITION'; export const FRAGMENT_SPREAD_UPPER = 'FRAGMENT_SPREAD'; export const FROM = 'from'; diff --git a/composition/src/v1/constants/constants.ts b/composition/src/v1/constants/constants.ts index a91e5bf469..ea52ba1f7f 100644 --- a/composition/src/v1/constants/constants.ts +++ b/composition/src/v1/constants/constants.ts @@ -8,6 +8,7 @@ import { CONNECT_FIELD_RESOLVER, OPENFED_CACHE_INVALIDATE, OPENFED_CACHE_POPULATE, + CACHE_TAG, COST, OPENFED_ENTITY_CACHE, DEPRECATED, @@ -46,6 +47,7 @@ import { import { type DirectiveName } from '../../types/types'; import { AUTHENTICATED_DEFINITION, + CACHE_TAG_DEFINITION, COMPOSE_DIRECTIVE_DEFINITION, CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION, CONFIGURE_DESCRIPTION_DEFINITION, @@ -87,6 +89,7 @@ export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap([ [AUTHENTICATED, AUTHENTICATED_DEFINITION], + [CACHE_TAG, CACHE_TAG_DEFINITION], [COMPOSE_DIRECTIVE, COMPOSE_DIRECTIVE_DEFINITION], [CONFIGURE_DESCRIPTION, CONFIGURE_DESCRIPTION_DEFINITION], [CONFIGURE_CHILD_DESCRIPTIONS, CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION], @@ -148,4 +151,17 @@ export const V2_DIRECTIVE_DEFINITION_BY_DIRECTIVE_NAME = new Map}`, which interpolates an argument of that field. + */ +export const CACHE_TAG_DEFINITION: DirectiveDefinitionNode = { + arguments: [ + { + kind: Kind.INPUT_VALUE_DEFINITION, + name: stringToNameNode(FORMAT), + type: REQUIRED_STRING_TYPE_NODE, + }, + ], + kind: Kind.DIRECTIVE_DEFINITION, + locations: stringArrayToNameNodeArray([FIELD_DEFINITION_UPPER]), + name: stringToNameNode(CACHE_TAG), + repeatable: true, +}; + // @openfed__cacheInvalidate on FIELD_DEFINITION export const OPENFED_CACHE_INVALIDATE_DEFINITION: DirectiveDefinitionNode = { kind: Kind.DIRECTIVE_DEFINITION, diff --git a/composition/src/v1/normalization/normalization-factory.ts b/composition/src/v1/normalization/normalization-factory.ts index ee2a905b9d..57de519750 100644 --- a/composition/src/v1/normalization/normalization-factory.ts +++ b/composition/src/v1/normalization/normalization-factory.ts @@ -43,6 +43,7 @@ import { import { extractLinkArgs, getConditionalFieldSetDirectiveName, + parseCacheTagFormat, getInitialFieldCoordsPath, getNormalizedFieldSet, initializeDirectiveDefinitionDatas, @@ -80,6 +81,7 @@ import { duplicateImplementedInterfaceError, duplicateTypeDefinitionError, duplicateUnionMemberDefinitionError, + emptyCacheTagFormatErrorMessage, entityCacheWithoutKeyErrorMessage, equivalentSourceAndTargetOverrideErrorMessage, expectedEntityError, @@ -110,6 +112,7 @@ import { invalidInterfaceImplementationError, invalidKeyFieldSetsEventDrivenErrorMessage, invalidMutationOrSubscriptionFieldCoordsErrorMessage, + invalidQueryRootFieldErrorMessage, invalidMutuallyExclusiveCacheDirectivesError, invalidNamedTypeError, invalidNatsStreamConfigurationDefinitionErrorMessage, @@ -139,6 +142,9 @@ import { listSizeSlicingArgumentSegmentNotFoundErrorMessage, listSizeSlicingArgumentSegmentNotInputObjectErrorMessage, maxAgeNotPositiveIntegerErrorMessage, + unsupportedFieldCacheTagNamespaceErrorMessage, + undefinedCacheTagArgumentErrorMessage, + invalidCacheTagArgumentTypeErrorMessage, multipleNamedTypeDefinitionError, negativeCacheTTLNotNonNegativeIntegerErrorMessage, noBaseScalarDefinitionError, @@ -186,6 +192,7 @@ import { buildASTSchema } from '../../buildASTSchema/buildASTSchema'; import { type CacheInvalidateConfiguration, type CachePopulateConfiguration, + type CacheTagConfiguration, type ConfigurationData, type Costs, type EntityCacheConfiguration, @@ -274,9 +281,11 @@ import { DEFAULT_CONSUMER_INACTIVE_THRESHOLD } from '../constants/integers'; import { type Warning } from '../../warnings/types'; import { type NormalizationResult } from '../../normalization/types'; import { + ARGS, ARGUMENT, ASSUMED_SIZE, AUTHENTICATED, + CACHE_TAG, CHANNEL, CHANNELS, COMPOSE_DIRECTIVE, @@ -299,6 +308,7 @@ import { EXTENDS, EXTERNAL, FIELDS, + FORMAT, FIRST_ORDINAL, HYPHEN_JOIN, INACCESSIBLE, @@ -4456,6 +4466,107 @@ export class NormalizationFactory { return true; } + extractFieldCacheTagDirectives(fieldData: FieldData) { + const directiveNodes = fieldData.directivesByName.get(CACHE_TAG); + if (!directiveNodes || directiveNodes.length < 1) { + return; + } + const { name: fieldName, originalParentTypeName, renamedParentTypeName: typeName } = fieldData; + const fieldCoords = `${originalParentTypeName}.${fieldName}`; + if (this.getOperationTypeNodeForRootTypeName(originalParentTypeName) !== OperationTypeNode.QUERY) { + this.errors.push( + invalidDirectiveError(CACHE_TAG, fieldCoords, FIRST_ORDINAL, [invalidQueryRootFieldErrorMessage()]), + ); + return; + } + const configurations: Array = []; + for (const [index, directiveNode] of directiveNodes.entries()) { + const ordinal = numberToOrdinal(index + 1); + const format = this.getCacheTagFormat(directiveNode); + if (format === undefined) { + this.errors.push(invalidDirectiveError(CACHE_TAG, fieldCoords, ordinal, [emptyCacheTagFormatErrorMessage()])); + continue; + } + const errorMessages: Array = []; + for (const { namespace, reference } of parseCacheTagFormat(format, errorMessages)) { + // We only allow `args.*` format + if (namespace !== ARGS) { + errorMessages.push(unsupportedFieldCacheTagNamespaceErrorMessage(namespace)); + continue; + } + const argumentData = this.getCacheTagArgumentData(fieldData, reference); + if (!argumentData) { + errorMessages.push(undefinedCacheTagArgumentErrorMessage(reference)); + continue; + } + if (!this.isValidCacheTagLeaf(argumentData)) { + errorMessages.push( + invalidCacheTagArgumentTypeErrorMessage({ + reference: reference, + typeString: printTypeNode(argumentData.type), + }), + ); + } + } + if (errorMessages.length > 0) { + this.errors.push(invalidDirectiveError(CACHE_TAG, fieldCoords, ordinal, errorMessages)); + continue; + } + configurations.push({ fieldName, format, typeName }); + } + if (configurations.length < 1) { + return; + } + const configurationData = getValueOrDefault(this.configurationDataByTypeName, typeName, () => + newConfigurationData(false, typeName), + ); + getOrInitializeEntityCaching(configurationData).cacheTagConfigurations.push(...configurations); + } + + isValidCacheTagLeaf({ namedTypeName, type }: FieldData | InputValueData): boolean { + if (isTypeNodeListType(type)) { + return false; + } + if (BASE_SCALARS.has(namedTypeName)) { + return true; + } + const namedTypeData = this.parentDefinitionDataByTypeName.get(namedTypeName); + if (!namedTypeData) { + return true; + } + return namedTypeData.kind === Kind.SCALAR_TYPE_DEFINITION || namedTypeData.kind === Kind.ENUM_TYPE_DEFINITION; + } + + getCacheTagFormat(directiveNode: ConstDirectiveNode): string | undefined { + const formatArgument = directiveNode.arguments?.find((argument) => argument.name.value === FORMAT); + if (!formatArgument || formatArgument.value.kind !== Kind.STRING || formatArgument.value.value === '') { + return; + } + return formatArgument.value.value; + } + + getCacheTagArgumentData(fieldData: FieldData, reference: string): InputValueData | undefined { + const path = reference.split(LITERAL_PERIOD); + let inputValueDataByName: Map | undefined = fieldData.argumentDataByName; + for (const [index, segment] of path.entries()) { + const inputValueData: InputValueData | undefined = inputValueDataByName?.get(segment); + if (!inputValueData) { + return; + } + // Whether the final segment is an interpolatable leaf value is assessed by the consumer. + if (index === path.length - 1) { + return inputValueData; + } + // We do not support lists at the moment + if (isTypeNodeListType(inputValueData.type)) { + return; + } + const namedTypeData = this.parentDefinitionDataByTypeName.get(inputValueData.namedTypeName); + inputValueDataByName = + namedTypeData?.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION ? namedTypeData.inputValueDataByName : undefined; + } + } + addFieldNamesToConfigurationData(fieldDataByFieldName: Map, configurationData: ConfigurationData) { const externalFieldNames = new Set(); for (const [fieldName, fieldData] of fieldDataByFieldName) { @@ -4531,6 +4642,8 @@ export class NormalizationFactory { const fieldCoords = `${data.originalParentTypeName}.${data.name}`; this.errors.push(invalidMutuallyExclusiveCacheDirectivesError(fieldCoords)); } + + this.extractFieldCacheTagDirectives(data); } normalize(document: DocumentNode): NormalizationResult { diff --git a/composition/src/v1/normalization/types/types.ts b/composition/src/v1/normalization/types/types.ts index 78c3888891..c8f99e6bdf 100644 --- a/composition/src/v1/normalization/types/types.ts +++ b/composition/src/v1/normalization/types/types.ts @@ -220,3 +220,8 @@ export type LinkImportData = { node?: ConstDirectiveNode; rename?: DirectiveName; }; + +export type CacheTagPlaceholder = { + namespace: string; + reference: string; +}; diff --git a/composition/src/v1/normalization/utils.ts b/composition/src/v1/normalization/utils.ts index cc23287050..d7cdeca0d4 100644 --- a/composition/src/v1/normalization/utils.ts +++ b/composition/src/v1/normalization/utils.ts @@ -17,6 +17,7 @@ import { argumentsInKeyFieldSetErrorMessage, duplicateFieldInFieldSetErrorMessage, inlineFragmentInFieldSetErrorMessage, + invalidCacheTagPlaceholderErrorMessage, invalidDirectiveError, invalidEventSubjectsArgumentErrorMessage, invalidFieldLinkDirectiveImportObjectError, @@ -32,6 +33,7 @@ import { nonIterableLinkDirectiveImportError, noPathLinkDirectiveUrlError, noVersionLinkDirectiveUrlError, + unbalancedCacheTagFormatErrorMessage, undefinedEventSubjectsArgumentErrorMessage, undefinedFieldInFieldSetErrorMessage, unexpectedArgumentErrorMessage, @@ -39,13 +41,19 @@ import { unknownTypeInFieldSetErrorMessage, unparsableFieldSetSelectionErrorMessage, } from '../../errors/errors'; -import { BASE_SCALARS, EDFS_ARGS_REGEXP } from '../constants/constants'; +import { + BASE_SCALARS, + CACHE_TAG_PLACEHOLDER_REGEXP, + CACHE_TAG_SEGMENT_REGEXP, + EDFS_ARGS_REGEXP, +} from '../constants/constants'; import { type RequiredFieldConfiguration } from '../../router-configuration/types'; import { type CompositeOutputData, type InputValueData } from '../../schema-building/types/types'; import { getTypeNodeNamedTypeName } from '../../schema-building/ast'; import { AUTHENTICATED_DEFINITION_DATA, CACHE_INVALIDATE_DEFINITION_DATA, + CACHE_TAG_DEFINITION_DATA, COMPOSE_DIRECTIVE_DEFINITION_DATA, CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION_DATA, CONFIGURE_DESCRIPTION_DEFINITION_DATA, @@ -83,6 +91,7 @@ import { import { AS, AUTHENTICATED, + CACHE_TAG, COMPOSE_DIRECTIVE, CONFIGURE_CHILD_DESCRIPTIONS, CONFIGURE_DESCRIPTION, @@ -128,7 +137,7 @@ import { URL_LOWER, } from '../../utils/string-constants'; import { getValueOrDefault, kindToNodeType, numberToOrdinal } from '../../utils/utils'; -import { type FieldSetData, type KeyFieldSetData, type LinkImportData } from './types/types'; +import { type CacheTagPlaceholder, type FieldSetData, type KeyFieldSetData, type LinkImportData } from './types/types'; import { type DirectiveName } from '../../types/types'; import { type ExtractImportUrlSegmentsResult, @@ -471,9 +480,36 @@ export function validateArgumentTemplateReferences( } } +/* Splits a @cacheTag `format` string into its placeholders, pushing an error message for each malformed + * segment. Only the shape of a placeholder is assessed here — whether the namespace is supported, and + * whether the reference resolves, is decided by the caller, which alone knows the field upon which the + * directive was defined. + */ +export function parseCacheTagFormat(format: string, errorMessages: Array): Array { + const placeholders: Array = []; + /* Each matched segment is removed from the remainder so that any curly brace left over belongs to an + * unclosed placeholder, e.g. "product-{$key.id", which would otherwise be silently treated as literal text. + */ + let remainder = format; + for (const match of format.matchAll(CACHE_TAG_SEGMENT_REGEXP)) { + remainder = remainder.replace(match[0], ''); + const placeholderMatch = CACHE_TAG_PLACEHOLDER_REGEXP.exec(match[1]); + if (!placeholderMatch) { + errorMessages.push(invalidCacheTagPlaceholderErrorMessage(match[1])); + continue; + } + placeholders.push({ namespace: placeholderMatch[1], reference: placeholderMatch[2] }); + } + if (remainder.includes('{') || remainder.includes('}')) { + errorMessages.push(unbalancedCacheTagFormatErrorMessage(format)); + } + return placeholders; +} + export function initializeDirectiveDefinitionDatas(): Map { return new Map([ [AUTHENTICATED, AUTHENTICATED_DEFINITION_DATA], + [CACHE_TAG, CACHE_TAG_DEFINITION_DATA], [COMPOSE_DIRECTIVE, COMPOSE_DIRECTIVE_DEFINITION_DATA], [CONFIGURE_DESCRIPTION, CONFIGURE_DESCRIPTION_DEFINITION_DATA], [CONFIGURE_CHILD_DESCRIPTIONS, CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION_DATA], diff --git a/composition/tests/v1/directives/cache-tag.test.ts b/composition/tests/v1/directives/cache-tag.test.ts new file mode 100644 index 0000000000..8c6f1245b9 --- /dev/null +++ b/composition/tests/v1/directives/cache-tag.test.ts @@ -0,0 +1,440 @@ +import { describe, expect, test } from 'vitest'; +import { + CACHE_TAG, + type CacheTagConfiguration, + emptyCacheTagFormatErrorMessage, + FIRST_ORDINAL, + invalidCacheTagArgumentTypeErrorMessage, + invalidCacheTagPlaceholderErrorMessage, + invalidDirectiveError, + invalidDirectiveLocationErrorMessage, + invalidQueryRootFieldErrorMessage, + ROUTER_COMPATIBILITY_VERSION_ONE, + type Subgraph, + type TypeName, + unbalancedCacheTagFormatErrorMessage, + undefinedCacheTagArgumentErrorMessage, + unsupportedFieldCacheTagNamespaceErrorMessage, +} from '../../../src'; +import { createSubgraphWithDefaultName, normalizeSubgraphFailure, normalizeSubgraphSuccess } from '../../utils/utils'; + +/* @cacheTag is modelled on the Apollo Federation v2.12 directive: + * directive @cacheTag(format: String!) repeatable on FIELD_DEFINITION | OBJECT + * Only FIELD_DEFINITION is supported here, and only upon a Query root field, where the sole supported + * placeholder is "{$args.}", which interpolates an argument of the field itself. A field of + * an Input Object argument is referenced by a period-delimited path, e.g. "{$args.filter.category}". + */ +describe('@cacheTag tests', () => { + describe('format validation tests', () => { + test('that a malformed placeholder is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products(searchKey: String!): [Product!]! @cacheTag(format: "{args.searchKey}-{$args}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + // Neither a missing "$" sigil nor a missing argument name forms a placeholder. + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + invalidCacheTagPlaceholderErrorMessage('args.searchKey'), + invalidCacheTagPlaceholderErrorMessage('$args'), + ]), + ); + }); + + test('that a placeholder with an empty path segment is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products(searchKey: String!): [Product!]! @cacheTag(format: "products-{$args.searchKey.}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + invalidCacheTagPlaceholderErrorMessage('$args.searchKey.'), + ]), + ); + }); + + test('that an unclosed placeholder is rejected rather than treated as literal text', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products(searchKey: String!): [Product!]! @cacheTag(format: "products-{$args.searchKey") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + unbalancedCacheTagFormatErrorMessage('products-{$args.searchKey'), + ]), + ); + }); + }); + + describe('field definition tests', () => { + test('that a static format on a root Query field produces a CacheTagConfiguration', () => { + expect( + getCacheTagConfigurations( + createSubgraphWithDefaultName(` + type Query { + products(searchKey: String!): [Product!]! @cacheTag(format: "products") + } + type Product @key(fields: "id") { + id: ID! + } + `), + 'Query', + ), + // The configuration is attached to the parent type and identifies the field it tags. + ).toStrictEqual([ + { fieldName: 'products', format: 'products', typeName: 'Query' }, + ] satisfies Array); + }); + + test('that the directive is repeatable upon a field definition', () => { + expect( + getCacheTagConfigurations( + createSubgraphWithDefaultName(` + type Query { + products: [Product!]! @cacheTag(format: "products") @cacheTag(format: "catalogue") + product(id: ID!): Product @cacheTag(format: "product") + } + type Product @key(fields: "id") { + id: ID! + } + `), + 'Query', + ), + ).toStrictEqual([ + { fieldName: 'products', format: 'products', typeName: 'Query' }, + { fieldName: 'products', format: 'catalogue', typeName: 'Query' }, + { fieldName: 'product', format: 'product', typeName: 'Query' }, + ] satisfies Array); + }); + + test('that a renamed Query root type is recognised', () => { + expect( + getCacheTagConfigurations( + createSubgraphWithDefaultName(` + schema { query: Queries } + type Queries { + products: [Product!]! @cacheTag(format: "products") + } + type Product @key(fields: "id") { + id: ID! + } + `), + // Root types are renamed to their default names, by which the ConfigurationData is keyed. + 'Query', + ), + ).toStrictEqual([ + { fieldName: 'products', format: 'products', typeName: 'Query' }, + ] satisfies Array); + }); + + /* A tag identifies a cached response, which only a Query root field produces, so any other field + * definition is rejected rather than silently ignored. + */ + test('that the directive upon a Mutation field is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { product(id: ID!): Product } + type Mutation { addProduct: Product @cacheTag(format: "products") } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Mutation.addProduct', FIRST_ORDINAL, [invalidQueryRootFieldErrorMessage()]), + ); + }); + + test('that the directive upon a Subscription field is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { product(id: ID!): Product } + type Subscription { productUpdated: Product @cacheTag(format: "products") } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Subscription.productUpdated', FIRST_ORDINAL, [ + invalidQueryRootFieldErrorMessage(), + ]), + ); + }); + + test('that the directive upon a field of a non-root Object is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { product(id: ID!): Product } + type Product @key(fields: "id") { + id: ID! + reviews: [String!]! @cacheTag(format: "reviews") + } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Product.reviews', FIRST_ORDINAL, [invalidQueryRootFieldErrorMessage()]), + ); + }); + + test('that a repeated directive upon an invalid field is reported once', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { product(id: ID!): Product } + type Product @key(fields: "id") { + id: ID! + reviews: [String!]! @cacheTag(format: "reviews") @cacheTag(format: "ratings") + } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Product.reviews', FIRST_ORDINAL, [invalidQueryRootFieldErrorMessage()]), + ); + }); + + test('that an "$args" placeholder referencing an argument is valid', () => { + expect( + getCacheTagConfigurations( + createSubgraphWithDefaultName(` + enum Region { EU US } + type Query { + products(searchKey: String!, region: Region): [Product!]! + @cacheTag(format: "products-{$args.searchKey}-{ $args.region }") + } + type Product @key(fields: "id") { + id: ID! + } + `), + 'Query', + ), + ).toStrictEqual([ + // An Enum argument is a valid reference, and the format is stored verbatim. + { fieldName: 'products', format: 'products-{$args.searchKey}-{ $args.region }', typeName: 'Query' }, + ] satisfies Array); + }); + + test('that an "$args" placeholder referencing an Input Object field is valid', () => { + expect( + getCacheTagConfigurations( + createSubgraphWithDefaultName(` + input Filter { category: String! nested: NestedFilter } + input NestedFilter { depth: Int! } + type Query { + products(filter: Filter!): [Product!]! + @cacheTag(format: "products-{$args.filter.category}-{$args.filter.nested.depth}") + } + type Product @key(fields: "id") { + id: ID! + } + `), + 'Query', + ), + ).toStrictEqual([ + { + fieldName: 'products', + format: 'products-{$args.filter.category}-{$args.filter.nested.depth}', + typeName: 'Query', + }, + ] satisfies Array); + }); + + test('that an "$args" placeholder referencing an undefined argument is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products(searchKey: String!): [Product!]! @cacheTag(format: "products-{$args.searchKeys}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + undefinedCacheTagArgumentErrorMessage('searchKeys'), + ]), + ); + }); + + test('that an "$args" placeholder referencing a non-leaf argument is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + input Filter { category: String! } + type Query { + products(filter: Filter!): [Product!]! @cacheTag(format: "products-{$args.filter}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + // The argument itself is an Input Object, so it cannot be interpolated into a tag. + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + invalidCacheTagArgumentTypeErrorMessage({ reference: 'filter', typeString: 'Filter!' }), + ]), + ); + }); + + test('that an "$args" placeholder referencing a list argument is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products(ids: [ID!]!): [Product!]! @cacheTag(format: "products-{$args.ids}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + invalidCacheTagArgumentTypeErrorMessage({ reference: 'ids', typeString: '[ID!]!' }), + ]), + ); + }); + + test('that an "$args" path that traverses a list is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + input Filter { category: String! } + type Query { + products(filters: [Filter!]!): [Product!]! @cacheTag(format: "products-{$args.filters.category}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + // A list of Input Objects yields no single value, so "filters.category" does not resolve. + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + undefinedCacheTagArgumentErrorMessage('filters.category'), + ]), + ); + }); + + test('that a namespace other than "$args" is rejected upon a field', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products(id: ID!): [Product!]! @cacheTag(format: "products-{$request.id}") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ + unsupportedFieldCacheTagNamespaceErrorMessage('request'), + ]), + ); + }); + + /* A key is a property of an entity rather than of the response a field-level tag identifies, so "$key" + * is rejected upon a Query root field even where the returned entity does declare that key field. + */ + test('that a "$key" placeholder is rejected upon a field that returns an entity', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + product(id: ID!): Product @cacheTag(format: "product-{$key.id}") + } + type Product @key(fields: "id") { + id: ID! + } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.product', FIRST_ORDINAL, [ + unsupportedFieldCacheTagNamespaceErrorMessage('key'), + ]), + ); + }); + + test('that a malformed format upon a field definition is rejected', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { + products: [Product!]! @cacheTag(format: "") + } + type Product @key(fields: "id") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [emptyCacheTagFormatErrorMessage()]), + ); + }); + }); + + describe('location tests', () => { + /* Apollo permits @cacheTag on FIELD_DEFINITION and OBJECT; only FIELD_DEFINITION is supported here, so + * an Object usage is rejected as an invalid location rather than silently ignored. + */ + test('that the directive is rejected on an Interface', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { node: Node } + interface Node @cacheTag(format: "node") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Node', FIRST_ORDINAL, [ + invalidDirectiveLocationErrorMessage(CACHE_TAG, 'INTERFACE'), + ]), + ); + }); + + test('that the directive is rejected on an Object', () => { + const { errors } = normalizeSubgraphFailure( + createSubgraphWithDefaultName(` + type Query { product(id: ID!): Product } + type Product @key(fields: "id") @cacheTag(format: "product") { id: ID! } + `), + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + invalidDirectiveError(CACHE_TAG, 'Product', FIRST_ORDINAL, [ + invalidDirectiveLocationErrorMessage(CACHE_TAG, 'OBJECT'), + ]), + ); + }); + }); +}); + +// Returns the CacheTagConfigurations for a type. Entity-caching config is nested under `.entityCaching`. +function getCacheTagConfigurations(subgraph: Subgraph, typeName: TypeName): Array | undefined { + const { configurationDataByTypeName } = normalizeSubgraphSuccess(subgraph, ROUTER_COMPATIBILITY_VERSION_ONE); + const configurationData = configurationDataByTypeName.get(typeName); + expect(configurationData).toBeDefined(); + return configurationData!.entityCaching?.cacheTagConfigurations; +} From 3ad826646d8c0571da1c015d6f1e047ffd5dcc63 Mon Sep 17 00:00:00 2001 From: Milinda Dias Date: Thu, 30 Jul 2026 23:47:26 +0530 Subject: [PATCH 2/4] fix: updates --- composition/src/errors/errors.ts | 8 +- composition/src/v1/constants/constants.ts | 8 - .../src/v1/constants/directive-definitions.ts | 6 - composition/src/v1/normalization/utils.ts | 7 +- .../tests/v1/directives/cache-tag.test.ts | 4 +- .../tests/v1/normalization-utils.test.ts | 196 +++++++++++++++++- 6 files changed, 205 insertions(+), 24 deletions(-) diff --git a/composition/src/errors/errors.ts b/composition/src/errors/errors.ts index 1a9b302b1d..fe6af53d45 100644 --- a/composition/src/errors/errors.ts +++ b/composition/src/errors/errors.ts @@ -2179,8 +2179,12 @@ export function invalidCacheTagPlaceholderErrorMessage(placeholder: string): str ); } -export function unbalancedCacheTagFormatErrorMessage(format: string): string { - return `The "format" argument defines an extra curly brace; received "${format}".`; +export function invalidCacheTagBraceErrorMessage(format: string): string { + return ( + `The "format" argument defines a curly brace outside of a placeholder;` + + ` a curly brace is valid only as the delimiter of a placeholder, e.g. "{$args.id}".` + + ` Received "${format}".` + ); } export function invalidQueryRootFieldErrorMessage(): string { diff --git a/composition/src/v1/constants/constants.ts b/composition/src/v1/constants/constants.ts index ea52ba1f7f..a58d63e8c0 100644 --- a/composition/src/v1/constants/constants.ts +++ b/composition/src/v1/constants/constants.ts @@ -151,16 +151,8 @@ export const V2_DIRECTIVE_DEFINITION_BY_DIRECTIVE_NAME = new Map}`, which interpolates an argument of that field. - */ export const CACHE_TAG_DEFINITION: DirectiveDefinitionNode = { arguments: [ { diff --git a/composition/src/v1/normalization/utils.ts b/composition/src/v1/normalization/utils.ts index d7cdeca0d4..e49692818b 100644 --- a/composition/src/v1/normalization/utils.ts +++ b/composition/src/v1/normalization/utils.ts @@ -33,7 +33,7 @@ import { nonIterableLinkDirectiveImportError, noPathLinkDirectiveUrlError, noVersionLinkDirectiveUrlError, - unbalancedCacheTagFormatErrorMessage, + invalidCacheTagBraceErrorMessage, undefinedEventSubjectsArgumentErrorMessage, undefinedFieldInFieldSetErrorMessage, unexpectedArgumentErrorMessage, @@ -487,9 +487,6 @@ export function validateArgumentTemplateReferences( */ export function parseCacheTagFormat(format: string, errorMessages: Array): Array { const placeholders: Array = []; - /* Each matched segment is removed from the remainder so that any curly brace left over belongs to an - * unclosed placeholder, e.g. "product-{$key.id", which would otherwise be silently treated as literal text. - */ let remainder = format; for (const match of format.matchAll(CACHE_TAG_SEGMENT_REGEXP)) { remainder = remainder.replace(match[0], ''); @@ -501,7 +498,7 @@ export function parseCacheTagFormat(format: string, errorMessages: Array placeholders.push({ namespace: placeholderMatch[1], reference: placeholderMatch[2] }); } if (remainder.includes('{') || remainder.includes('}')) { - errorMessages.push(unbalancedCacheTagFormatErrorMessage(format)); + errorMessages.push(invalidCacheTagBraceErrorMessage(format)); } return placeholders; } diff --git a/composition/tests/v1/directives/cache-tag.test.ts b/composition/tests/v1/directives/cache-tag.test.ts index 8c6f1245b9..d2316353ff 100644 --- a/composition/tests/v1/directives/cache-tag.test.ts +++ b/composition/tests/v1/directives/cache-tag.test.ts @@ -12,7 +12,7 @@ import { ROUTER_COMPATIBILITY_VERSION_ONE, type Subgraph, type TypeName, - unbalancedCacheTagFormatErrorMessage, + invalidCacheTagBraceErrorMessage, undefinedCacheTagArgumentErrorMessage, unsupportedFieldCacheTagNamespaceErrorMessage, } from '../../../src'; @@ -77,7 +77,7 @@ describe('@cacheTag tests', () => { expect(errors).toHaveLength(1); expect(errors[0]).toStrictEqual( invalidDirectiveError(CACHE_TAG, 'Query.products', FIRST_ORDINAL, [ - unbalancedCacheTagFormatErrorMessage('products-{$args.searchKey'), + invalidCacheTagBraceErrorMessage('products-{$args.searchKey'), ]), ); }); diff --git a/composition/tests/v1/normalization-utils.test.ts b/composition/tests/v1/normalization-utils.test.ts index 540a7a468a..317de86e65 100644 --- a/composition/tests/v1/normalization-utils.test.ts +++ b/composition/tests/v1/normalization-utils.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'vitest'; -import { getNormalizedFieldSet, parse } from '../../src'; +import { + getNormalizedFieldSet, + invalidCacheTagPlaceholderErrorMessage, + parse, + parseCacheTagFormat, + invalidCacheTagBraceErrorMessage, +} from '../../src'; describe('Utils tests', () => { test('that a deeply nested FieldSet is normalized', () => { @@ -21,3 +27,191 @@ describe('Utils tests', () => { ).toStrictEqual(`field { four one three { innerField { innerField1 innerField2 } } two }`); }); }); + +describe('format parsing tests', () => { + test('that a format without placeholders yields no placeholders', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that a placeholder is parsed into its namespace and reference', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.searchKey}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'searchKey' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that whitespace surrounding a placeholder is tolerated', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{ $args.searchKey }', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'searchKey' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that a period-delimited reference is preserved as a path', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.filter.category}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'filter.category' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that every placeholder of a format is parsed in order', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('{$args.a}-{$args.b}-{$args.c}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'a' }, + { namespace: 'args', reference: 'b' }, + { namespace: 'args', reference: 'c' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that the namespace is returned verbatim rather than validated', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$request.id}', errorMessages)).toStrictEqual([ + { namespace: 'request', reference: 'id' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that a placeholder without a "$" sigil is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{args.searchKey}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('args.searchKey')]); + }); + + test('that a placeholder without a reference is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$args')]); + }); + + test('that an empty placeholder is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('')]); + }); + + test('that a placeholder with an empty path segment is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.searchKey.}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$args.searchKey.')]); + }); + + test('that an unclosed placeholder is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.searchKey', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagBraceErrorMessage('products-{$args.searchKey')]); + }); + + test('that a stray closing brace is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagBraceErrorMessage('products}')]); + }); + + test('that a valid placeholder is still parsed alongside a malformed one', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('{$args.a}-{args.b}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'a' }, + ]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('args.b')]); + }); + + test('that a malformed placeholder and a brace outside a placeholder are both reported', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('{args.a}-{', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([ + invalidCacheTagPlaceholderErrorMessage('args.a'), + invalidCacheTagBraceErrorMessage('{args.a}-{'), + ]); + }); + + /* Whitespace is tolerated only around a placeholder as a whole; the reference itself must be a + * period-delimited path of GraphQL Names, so interior whitespace does not form a placeholder. + */ + test('that whitespace surrounding the period is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{ $args . name }', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage(' $args . name ')]); + }); + + test('that whitespace between the sigil and the namespace is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$ args.name}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$ args.name')]); + }); + + test('that consecutive periods are rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args..name}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$args..name')]); + }); + + test('that a namespace beginning with a digit is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$0args.name}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$0args.name')]); + }); + + test('that a path segment beginning with a digit is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.0name}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$args.0name')]); + }); + + test('that a character outside a GraphQL Name is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.na-me}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$args.na-me')]); + }); + + // The placeholder is anchored, so a valid prefix cannot carry a trailing remainder through. + test('that text trailing an otherwise valid placeholder is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$args.name extra}', errorMessages)).toStrictEqual([]); + expect(errorMessages).toStrictEqual([invalidCacheTagPlaceholderErrorMessage('$args.name extra')]); + }); + + test('that a leading underscore is a valid namespace and path segment', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{$_args._name}', errorMessages)).toStrictEqual([ + { namespace: '_args', reference: '_name' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that a newline surrounding a placeholder is tolerated', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('products-{\n$args.name\n}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'name' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that a placeholder wrapped in a second pair of braces is rejected', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('{{$args.name}}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'name' }, + ]); + expect(errorMessages).toStrictEqual([invalidCacheTagBraceErrorMessage('{{$args.name}}')]); + }); + + test('that an identical placeholder repeated in a format is parsed each time', () => { + const errorMessages: Array = []; + expect(parseCacheTagFormat('{$args.a}-{$args.a}', errorMessages)).toStrictEqual([ + { namespace: 'args', reference: 'a' }, + { namespace: 'args', reference: 'a' }, + ]); + expect(errorMessages).toStrictEqual([]); + }); + + test('that messages are appended to those already provided', () => { + const errorMessages: Array = ['existing']; + parseCacheTagFormat('products-{}', errorMessages); + expect(errorMessages).toStrictEqual(['existing', invalidCacheTagPlaceholderErrorMessage('')]); + }); +}); From 9612703e4097c5ee6bc85357cdaad513d74d22e0 Mon Sep 17 00:00:00 2001 From: Milinda Dias Date: Thu, 30 Jul 2026 23:55:44 +0530 Subject: [PATCH 3/4] fix: tests --- .../tests/v1/directives/cache-tag.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/composition/tests/v1/directives/cache-tag.test.ts b/composition/tests/v1/directives/cache-tag.test.ts index d2316353ff..fcf2717921 100644 --- a/composition/tests/v1/directives/cache-tag.test.ts +++ b/composition/tests/v1/directives/cache-tag.test.ts @@ -234,6 +234,25 @@ describe('@cacheTag tests', () => { ] satisfies Array); }); + test('that an "$args" placeholder referencing a custom Scalar argument is valid', () => { + expect( + getCacheTagConfigurations( + createSubgraphWithDefaultName(` + scalar DateTime + type Query { + products(after: DateTime): [Product!]! @cacheTag(format: "products-{$args.after}") + } + type Product @key(fields: "id") { + id: ID! + } + `), + 'Query', + ), + ).toStrictEqual([ + { fieldName: 'products', format: 'products-{$args.after}', typeName: 'Query' }, + ] satisfies Array); + }); + test('that an "$args" placeholder referencing an Input Object field is valid', () => { expect( getCacheTagConfigurations( From 5937f013b1c242076f177b8b39ff9a7bb056ce74 Mon Sep 17 00:00:00 2001 From: Milinda Dias Date: Thu, 30 Jul 2026 23:57:39 +0530 Subject: [PATCH 4/4] fix: cleanup --- .../src/directive-definition-data/directive-definition-data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composition/src/directive-definition-data/directive-definition-data.ts b/composition/src/directive-definition-data/directive-definition-data.ts index 5ba26ea253..aa8f06a837 100644 --- a/composition/src/directive-definition-data/directive-definition-data.ts +++ b/composition/src/directive-definition-data/directive-definition-data.ts @@ -1066,4 +1066,4 @@ export const CACHE_TAG_DEFINITION_DATA = newDirectiveDefinitionData({ name: CACHE_TAG, node: CACHE_TAG_DEFINITION, requiredArgumentNames: new Set([FORMAT]), -}); \ No newline at end of file +});