From 7e13b9d8fe3a0ba5fabd29fafd4e6a08a2c4eb7e Mon Sep 17 00:00:00 2001 From: StarpTech Date: Fri, 30 Jan 2026 10:14:03 +0100 Subject: [PATCH] feat(composition): support @cost and @listSize --- composition/src/utils/string-constants.ts | 9 +- composition/src/v1/constants/constants.ts | 6 + .../src/v1/constants/directive-definitions.ts | 82 ++++ composition/src/v1/constants/strings.ts | 4 + .../src/v1/federation/federation-factory.ts | 6 + .../directive-definition-data.ts | 89 ++++ composition/src/v1/normalization/utils.ts | 6 + composition/tests/v1/directives/cost.test.ts | 422 ++++++++++++++++++ .../tests/v1/directives/listSize.test.ts | 370 +++++++++++++++ composition/tests/v1/utils/utils.ts | 8 + demo/go.sum | 1 + 11 files changed, 1002 insertions(+), 1 deletion(-) create mode 100644 composition/tests/v1/directives/cost.test.ts create mode 100644 composition/tests/v1/directives/listSize.test.ts diff --git a/composition/src/utils/string-constants.ts b/composition/src/utils/string-constants.ts index 4be8364ef8..79d84b35a8 100644 --- a/composition/src/utils/string-constants.ts +++ b/composition/src/utils/string-constants.ts @@ -2,6 +2,7 @@ import { Kind } from 'graphql'; import { DirectiveName } from '../types/types'; export const AS = 'as'; +export const ASSUMED_SIZE = 'assumedSize'; export const AND_UPPER = 'AND'; export const ANY_SCALAR = '_Any'; export const ARGUMENT = 'argument'; @@ -19,6 +20,7 @@ export const CONSUMER_INACTIVE_THRESHOLD = 'consumerInactiveThreshold'; export const CONSUMER_NAME = 'consumerName'; export const CONNECT_FIELD_RESOLVER = 'connect__fieldResolver'; export const CONTEXT = 'context'; +export const COST = 'cost'; export const DEFAULT = 'default'; export const DEFAULT_EDFS_PROVIDER_ID = 'default'; export const DEFAULT_MUTATION = 'Mutation'; @@ -81,6 +83,7 @@ export const KEY = 'key'; export const LEFT_PARENTHESIS = '('; export const LEVELS = 'levels'; export const LINK = 'link'; +export const LIST_SIZE = 'listSize'; export const LINK_IMPORT = 'link__Import'; export const LINK_PURPOSE = 'link__Purpose'; export const LIST = 'list'; @@ -120,6 +123,7 @@ export const QUOTATION_JOIN = `", "`; export const REASON = 'reason'; export const REQUEST = 'request'; export const REQUIRE_FETCH_REASONS = 'openfed__requireFetchReasons'; +export const REQUIRE_ONE_SLICING_ARGUMENT = 'requireOneSlicingArgument'; export const REQUIRES = 'requires'; export const REQUIRES_SCOPES = 'requiresScopes'; export const RESOLVABLE = 'resolvable'; @@ -135,6 +139,8 @@ export const SEMANTIC_NON_NULL = 'semanticNonNull'; export const SERVICE_OBJECT = '_Service'; export const SERVICE_FIELD = '_service'; export const SHAREABLE = 'shareable'; +export const SIZED_FIELDS = 'sizedFields'; +export const SLICING_ARGUMENTS = 'slicingArguments'; export const SPECIFIED_BY = 'specifiedBy'; export const STREAM_CONFIGURATION = 'streamConfiguration'; export const STREAM_NAME = 'streamName'; @@ -159,6 +165,7 @@ export const UNION_UPPER = 'UNION'; export const URL_LOWER = 'url'; export const VALUES = 'values'; export const VARIABLE_DEFINITION_UPPER = 'VARIABLE_DEFINITION'; +export const WEIGHT = 'weight'; export const EXECUTABLE_DIRECTIVE_LOCATIONS = new Set([ FIELD_UPPER, @@ -172,7 +179,7 @@ export const EXECUTABLE_DIRECTIVE_LOCATIONS = new Set([ export const ROOT_TYPE_NAMES = new Set([MUTATION, QUERY, SUBSCRIPTION]); export const AUTHORIZATION_DIRECTIVES = new Set([AUTHENTICATED, REQUIRES_SCOPES]); -export const PERSISTED_CLIENT_DIRECTIVES = new Set([DEPRECATED, ONE_OF, SEMANTIC_NON_NULL]); +export const PERSISTED_CLIENT_DIRECTIVES = new Set([COST, DEPRECATED, LIST_SIZE, ONE_OF, SEMANTIC_NON_NULL]); export const INHERITABLE_DIRECTIVE_NAMES = new Set([EXTERNAL, REQUIRE_FETCH_REASONS, SHAREABLE]); export const IGNORED_FIELDS = new Set([ENTITIES_FIELD, SERVICE_FIELD]); diff --git a/composition/src/v1/constants/constants.ts b/composition/src/v1/constants/constants.ts index c9c8524539..a1b7dccd19 100644 --- a/composition/src/v1/constants/constants.ts +++ b/composition/src/v1/constants/constants.ts @@ -6,6 +6,7 @@ import { CONFIGURE_CHILD_DESCRIPTIONS, CONFIGURE_DESCRIPTION, CONNECT_FIELD_RESOLVER, + COST, DEPRECATED, EDFS_KAFKA_PUBLISH, EDFS_KAFKA_SUBSCRIBE, @@ -24,6 +25,7 @@ import { INTERFACE_OBJECT, KEY, LINK, + LIST_SIZE, ONE_OF, OVERRIDE, PROVIDES, @@ -45,6 +47,7 @@ import { CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION, CONFIGURE_DESCRIPTION_DEFINITION, CONNECT_FIELD_RESOLVER_DEFINITION, + COST_DEFINITION, DEPRECATED_DEFINITION, EDFS_KAFKA_PUBLISH_DEFINITION, EDFS_KAFKA_SUBSCRIBE_DEFINITION, @@ -59,6 +62,7 @@ import { INTERFACE_OBJECT_DEFINITION, KEY_DEFINITION, LINK_DEFINITION, + LIST_SIZE_DEFINITION, ONE_OF_DEFINITION, OVERRIDE_DEFINITION, PROVIDES_DEFINITION, @@ -81,6 +85,7 @@ export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap = new Set = new Set([ + COST, DEPRECATED, + LIST_SIZE, ONE_OF, SEMANTIC_NON_NULL, ]); diff --git a/composition/src/v1/federation/federation-factory.ts b/composition/src/v1/federation/federation-factory.ts index 620d7f90ce..5b11518d10 100644 --- a/composition/src/v1/federation/federation-factory.ts +++ b/composition/src/v1/federation/federation-factory.ts @@ -190,6 +190,7 @@ import { AUTHENTICATED, AUTHORIZATION_DIRECTIVES, CONDITION, + COST, DEPRECATED, ENUM_VALUE, FIELD, @@ -199,6 +200,7 @@ import { INPUT_OBJECT, LEFT_PARENTHESIS, LIST, + LIST_SIZE, NON_REPEATABLE_PERSISTED_DIRECTIVES, NOT_UPPER, OBJECT, @@ -243,8 +245,10 @@ import { singleFederatedInputFieldOneOfWarning } from '../warnings/warnings'; import { ExtractPersistedDirectivesParams, ValidateOneOfDirectiveParams } from './params'; import { AUTHENTICATED_DEFINITION, + COST_DEFINITION, DEPRECATED_DEFINITION, INACCESSIBLE_DEFINITION, + LIST_SIZE_DEFINITION, ONE_OF_DEFINITION, REQUIRES_SCOPES_DEFINITION, SEMANTIC_NON_NULL_DEFINITION, @@ -278,8 +282,10 @@ export class FederationFactory { parentTagDataByTypeName = new Map(); persistedDirectiveDefinitionByDirectiveName = new Map([ [AUTHENTICATED, AUTHENTICATED_DEFINITION], + [COST, COST_DEFINITION], [DEPRECATED, DEPRECATED_DEFINITION], [INACCESSIBLE, INACCESSIBLE_DEFINITION], + [LIST_SIZE, LIST_SIZE_DEFINITION], [ONE_OF, ONE_OF_DEFINITION], [REQUIRES_SCOPES, REQUIRES_SCOPES_DEFINITION], [SEMANTIC_NON_NULL, SEMANTIC_NON_NULL_DEFINITION], diff --git a/composition/src/v1/normalization/directive-definition-data.ts b/composition/src/v1/normalization/directive-definition-data.ts index 1be1bcb33d..168c47cbeb 100644 --- a/composition/src/v1/normalization/directive-definition-data.ts +++ b/composition/src/v1/normalization/directive-definition-data.ts @@ -4,6 +4,7 @@ import { DEFAULT_DEPRECATION_REASON, Kind } from 'graphql'; import { ARGUMENT_DEFINITION_UPPER, AS, + ASSUMED_SIZE, AUTHENTICATED, BOOLEAN_SCALAR, CHANNEL, @@ -14,6 +15,7 @@ import { CONFIGURE_DESCRIPTION, CONNECT_FIELD_RESOLVER, CONTEXT, + COST, DEFAULT_EDFS_PROVIDER_ID, DEPRECATED, DESCRIPTION_OVERRIDE, @@ -45,6 +47,7 @@ import { LINK, LINK_IMPORT, LINK_PURPOSE, + LIST_SIZE, NAME, OBJECT_UPPER, ONE_OF, @@ -54,6 +57,7 @@ import { PROVIDES, REASON, REQUIRE_FETCH_REASONS, + REQUIRE_ONE_SLICING_ARGUMENT, REQUIRES, REQUIRES_SCOPES, RESOLVABLE, @@ -63,6 +67,8 @@ import { SCOPES, SEMANTIC_NON_NULL, SHAREABLE, + SIZED_FIELDS, + SLICING_ARGUMENTS, SPECIFIED_BY, STREAM_CONFIGURATION, STRING_SCALAR, @@ -75,6 +81,7 @@ import { TOPICS, UNION_UPPER, URL_LOWER, + WEIGHT, } from '../../utils/string-constants'; import { AUTHENTICATED_DEFINITION, @@ -82,6 +89,7 @@ import { CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION, CONFIGURE_DESCRIPTION_DEFINITION, CONNECT_FIELD_RESOLVER_DEFINITION, + COST_DEFINITION, DEPRECATED_DEFINITION, EDFS_KAFKA_PUBLISH_DEFINITION, EDFS_KAFKA_SUBSCRIBE_DEFINITION, @@ -96,6 +104,7 @@ import { INTERFACE_OBJECT_DEFINITION, KEY_DEFINITION, LINK_DEFINITION, + LIST_SIZE_DEFINITION, ONE_OF_DEFINITION, OVERRIDE_DEFINITION, PROVIDES_DEFINITION, @@ -227,6 +236,31 @@ export const CONNECT_FIELD_RESOLVER_DEFINITION_DATA: DirectiveDefinitionData = { requiredArgumentNames: new Set([CONTEXT]), }; +export const COST_DEFINITION_DATA: DirectiveDefinitionData = { + argumentTypeNodeByName: new Map([ + [ + WEIGHT, + { + name: WEIGHT, + typeNode: REQUIRED_STRING_TYPE_NODE, + }, + ], + ]), + isRepeatable: false, + locations: new Set([ + ARGUMENT_DEFINITION_UPPER, + ENUM_UPPER, + FIELD_DEFINITION_UPPER, + INPUT_FIELD_DEFINITION_UPPER, + OBJECT_UPPER, + SCALAR_UPPER, + ]), + name: COST, + node: COST_DEFINITION, + optionalArgumentNames: new Set(), + requiredArgumentNames: new Set([WEIGHT]), +}; + export const DEPRECATED_DEFINITION_DATA: DirectiveDefinitionData = { argumentTypeNodeByName: new Map([ [ @@ -568,6 +602,61 @@ export const LINK_DEFINITION_DATA: DirectiveDefinitionData = { requiredArgumentNames: new Set([URL_LOWER]), }; +export const LIST_SIZE_DEFINITION_DATA: DirectiveDefinitionData = { + argumentTypeNodeByName: new Map([ + [ + ASSUMED_SIZE, + { + name: ASSUMED_SIZE, + typeNode: stringToNamedTypeNode(INT_SCALAR), + }, + ], + [ + SLICING_ARGUMENTS, + { + name: SLICING_ARGUMENTS, + typeNode: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: stringToNamedTypeNode(STRING_SCALAR), + }, + }, + }, + ], + [ + SIZED_FIELDS, + { + name: SIZED_FIELDS, + typeNode: { + kind: Kind.LIST_TYPE, + type: { + kind: Kind.NON_NULL_TYPE, + type: stringToNamedTypeNode(STRING_SCALAR), + }, + }, + }, + ], + [ + REQUIRE_ONE_SLICING_ARGUMENT, + { + name: REQUIRE_ONE_SLICING_ARGUMENT, + typeNode: stringToNamedTypeNode(BOOLEAN_SCALAR), + defaultValue: { + kind: Kind.BOOLEAN, + value: true, + }, + }, + ], + ]), + isRepeatable: false, + locations: new Set([FIELD_DEFINITION_UPPER]), + name: LIST_SIZE, + node: LIST_SIZE_DEFINITION, + optionalArgumentNames: new Set([ASSUMED_SIZE, SLICING_ARGUMENTS, SIZED_FIELDS, REQUIRE_ONE_SLICING_ARGUMENT]), + requiredArgumentNames: new Set(), +}; + export const PROVIDES_DEFINITION_DATA: DirectiveDefinitionData = { argumentTypeNodeByName: new Map([ [ diff --git a/composition/src/v1/normalization/utils.ts b/composition/src/v1/normalization/utils.ts index a7d474c894..bdcfb22cd8 100644 --- a/composition/src/v1/normalization/utils.ts +++ b/composition/src/v1/normalization/utils.ts @@ -26,6 +26,7 @@ import { CONFIGURE_CHILD_DESCRIPTIONS_DEFINITION_DATA, CONFIGURE_DESCRIPTION_DEFINITION_DATA, CONNECT_FIELD_RESOLVER_DEFINITION_DATA, + COST_DEFINITION_DATA, DEPRECATED_DEFINITION_DATA, EXTENDS_DEFINITION_DATA, EXTERNAL_DEFINITION_DATA, @@ -35,6 +36,7 @@ import { KAFKA_SUBSCRIBE_DEFINITION_DATA, KEY_DEFINITION_DATA, LINK_DEFINITION_DATA, + LIST_SIZE_DEFINITION_DATA, NATS_PUBLISH_DEFINITION_DATA, NATS_REQUEST_DEFINITION_DATA, NATS_SUBSCRIBE_DEFINITION_DATA, @@ -58,6 +60,7 @@ import { CONFIGURE_CHILD_DESCRIPTIONS, CONFIGURE_DESCRIPTION, CONNECT_FIELD_RESOLVER, + COST, DEPRECATED, EDFS_KAFKA_PUBLISH, EDFS_KAFKA_SUBSCRIBE, @@ -73,6 +76,7 @@ import { INTERFACE_OBJECT, KEY, LINK, + LIST_SIZE, LITERAL_PERIOD, ONE_OF, OVERRIDE, @@ -413,6 +417,7 @@ export function initializeDirectiveDefinitionDatas(): Map { + describe('normalization tests', () => { + test('that @cost is correctly normalized on a field definition', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithCostOnField, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + expensiveField: String! @cost(weight: "10") + } + `, + ), + ); + }); + + test('that @cost is correctly normalized on an argument definition', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithCostOnArgument, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + search(query: String! @cost(weight: "5")): [Result!]! + } + + type Result { + id: ID! + } + `, + ), + ); + }); + + test('that @cost is correctly normalized on an object type', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithCostOnObject, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + user: User! + } + + type User @cost(weight: "100") { + id: ID! + name: String! + } + `, + ), + ); + }); + + test('that @cost is correctly normalized on a scalar', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithCostOnScalar, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + scalar JSON @cost(weight: "50") + + type Query { + data: JSON! + } + `, + ), + ); + }); + + test('that @cost is correctly normalized on an enum', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithCostOnEnum, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + status: Status! + } + + enum Status @cost(weight: "1") { + ACTIVE + INACTIVE + } + `, + ), + ); + }); + + test('that @cost is correctly normalized on an input field definition', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithCostOnInputField, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + search(input: SearchInput!): [Result!]! + } + + type Result { + id: ID! + } + + input SearchInput { + query: String! @cost(weight: "5") + } + `, + ), + ); + }); + + test('that @cost with decimal weight value is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithDecimalCost, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + field: String! @cost(weight: "2.5") + } + `, + ), + ); + }); + + test('that @cost with negative weight value is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithNegativeCost, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + optimizedField(useCache: Boolean!): String! @cost(weight: "-5") + } + `, + ), + ); + }); + + test('that multiple @cost directives on different fields are correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithMultipleCosts, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + COST_DIRECTIVE + + ` + type Query { + cheap: String! @cost(weight: "1") + expensive: String! @cost(weight: "100") + medium: String! @cost(weight: "10") + } + `, + ), + ); + }); + }); + + describe('federation tests', () => { + test('that @cost is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithCostOnField], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + COST_DIRECTIVE + + ` + type Query { + expensiveField: String! @cost(weight: "10") + } + `, + ), + ); + }); + + test('that multiple @cost directives on different fields are preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithMultipleCosts], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + COST_DIRECTIVE + + ` + type Query { + cheap: String! @cost(weight: "1") + expensive: String! @cost(weight: "100") + medium: String! @cost(weight: "10") + } + `, + ), + ); + }); + + test('that @cost on object types is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithCostOnObject], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + COST_DIRECTIVE + + ` + type Query { + user: User! + } + + type User @cost(weight: "100") { + id: ID! + name: String! + } + `, + ), + ); + }); + + test('that @cost on scalars is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithCostOnScalar], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + COST_DIRECTIVE + + ` + scalar JSON @cost(weight: "50") + + type Query { + data: JSON! + } + `, + ), + ); + }); + + test('that @cost on enums is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithCostOnEnum], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + COST_DIRECTIVE + + ` + type Query { + status: Status! + } + + enum Status @cost(weight: "1") { + ACTIVE + INACTIVE + } + `, + ), + ); + }); + }); +}); + +const subgraphWithCostOnField: Subgraph = { + name: 'subgraph-cost-field', + url: '', + definitions: parse(` + type Query { + expensiveField: String! @cost(weight: "10") + } + `), +}; + +const subgraphWithCostOnArgument: Subgraph = { + name: 'subgraph-cost-argument', + url: '', + definitions: parse(` + type Query { + search(query: String! @cost(weight: "5")): [Result!]! + } + + type Result { + id: ID! + } + `), +}; + +const subgraphWithCostOnObject: Subgraph = { + name: 'subgraph-cost-object', + url: '', + definitions: parse(` + type Query { + user: User! + } + + type User @cost(weight: "100") { + id: ID! + name: String! + } + `), +}; + +const subgraphWithCostOnScalar: Subgraph = { + name: 'subgraph-cost-scalar', + url: '', + definitions: parse(` + type Query { + data: JSON! + } + + scalar JSON @cost(weight: "50") + `), +}; + +const subgraphWithCostOnEnum: Subgraph = { + name: 'subgraph-cost-enum', + url: '', + definitions: parse(` + type Query { + status: Status! + } + + enum Status @cost(weight: "1") { + ACTIVE + INACTIVE + } + `), +}; + +const subgraphWithCostOnInputField: Subgraph = { + name: 'subgraph-cost-input-field', + url: '', + definitions: parse(` + type Query { + search(input: SearchInput!): [Result!]! + } + + input SearchInput { + query: String! @cost(weight: "5") + } + + type Result { + id: ID! + } + `), +}; + +const subgraphWithDecimalCost: Subgraph = { + name: 'subgraph-cost-decimal', + url: '', + definitions: parse(` + type Query { + field: String! @cost(weight: "2.5") + } + `), +}; + +const subgraphWithNegativeCost: Subgraph = { + name: 'subgraph-cost-negative', + url: '', + definitions: parse(` + type Query { + optimizedField(useCache: Boolean!): String! @cost(weight: "-5") + } + `), +}; + +const subgraphWithMultipleCosts: Subgraph = { + name: 'subgraph-cost-multiple', + url: '', + definitions: parse(` + type Query { + cheap: String! @cost(weight: "1") + medium: String! @cost(weight: "10") + expensive: String! @cost(weight: "100") + } + `), +}; + +const subgraphACost: Subgraph = { + name: 'subgraph-a-cost', + url: '', + definitions: parse(` + type Query { + fieldA: String! @cost(weight: "10") + } + `), +}; + +const subgraphBCost: Subgraph = { + name: 'subgraph-b-cost', + url: '', + definitions: parse(` + type Query { + fieldB: String! @cost(weight: "20") + } + `), +}; diff --git a/composition/tests/v1/directives/listSize.test.ts b/composition/tests/v1/directives/listSize.test.ts new file mode 100644 index 0000000000..45eb21ad47 --- /dev/null +++ b/composition/tests/v1/directives/listSize.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, test } from 'vitest'; +import { parse, ROUTER_COMPATIBILITY_VERSION_ONE, Subgraph } from '../../../src'; +import { LIST_SIZE_DIRECTIVE, SCHEMA_QUERY_DEFINITION } from '../utils/utils'; +import { + federateSubgraphsSuccess, + normalizeString, + normalizeSubgraphSuccess, + schemaToSortedNormalizedString, +} from '../../utils/utils'; + +const NORMALIZATION_SCHEMA_QUERY = ` + schema { + query: Query + } +`; + +describe('@listSize directive tests', () => { + describe('normalization tests', () => { + test('that @listSize with assumedSize is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithAssumedSize, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + LIST_SIZE_DIRECTIVE + + ` + type Query { + users: [User!]! @listSize(assumedSize: 100) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that @listSize with slicingArguments is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithSlicingArguments, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + LIST_SIZE_DIRECTIVE + + ` + type Query { + users(first: Int, last: Int): [User!]! @listSize(slicingArguments: ["first", "last"]) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that @listSize with sizedFields is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithSizedFields, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + LIST_SIZE_DIRECTIVE + + ` + type Connection { + edges: [Edge!]! + nodes: [User!]! + } + + type Edge { + node: User! + } + + type Query { + usersConnection(first: Int): Connection! @listSize(slicingArguments: ["first"], sizedFields: ["edges", "nodes"]) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that @listSize with requireOneSlicingArgument is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess( + subgraphWithRequireOneSlicingArgument, + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + LIST_SIZE_DIRECTIVE + + ` + type Query { + users(after: String, first: Int): [User!]! @listSize(slicingArguments: ["first"], requireOneSlicingArgument: false) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that @listSize with all arguments is correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithAllArguments, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + LIST_SIZE_DIRECTIVE + + ` + type Connection { + edges: [Edge!]! + nodes: [User!]! + } + + type Edge { + node: User! + } + + type Query { + usersConnection(first: Int, last: Int): Connection! @listSize(assumedSize: 50, slicingArguments: ["first", "last"], sizedFields: ["edges", "nodes"], requireOneSlicingArgument: true) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that multiple @listSize directives on different fields are correctly normalized', () => { + const { schema } = normalizeSubgraphSuccess(subgraphWithMultipleListSize, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(schemaToSortedNormalizedString(schema)).toBe( + normalizeString( + NORMALIZATION_SCHEMA_QUERY + + LIST_SIZE_DIRECTIVE + + ` + type Post { + id: ID! + } + + type Query { + posts: [Post!]! @listSize(assumedSize: 20) + users: [User!]! @listSize(assumedSize: 100) + } + + type User { + id: ID! + } + `, + ), + ); + }); + }); + + describe('federation tests', () => { + test('that @listSize with assumedSize is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithAssumedSize], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + LIST_SIZE_DIRECTIVE + + ` + type Query { + users: [User!]! @listSize(assumedSize: 100) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that @listSize with slicingArguments is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithSlicingArguments], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + LIST_SIZE_DIRECTIVE + + ` + type Query { + users(first: Int, last: Int): [User!]! @listSize(slicingArguments: ["first", "last"]) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that @listSize with all arguments is preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithAllArguments], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + LIST_SIZE_DIRECTIVE + + ` + type Connection { + edges: [Edge!]! + nodes: [User!]! + } + + type Edge { + node: User! + } + + type Query { + usersConnection(first: Int, last: Int): Connection! @listSize(assumedSize: 50, slicingArguments: ["first", "last"], sizedFields: ["edges", "nodes"], requireOneSlicingArgument: true) + } + + type User { + id: ID! + } + `, + ), + ); + }); + + test('that multiple @listSize directives on different fields are preserved in federated schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithMultipleListSize], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + LIST_SIZE_DIRECTIVE + + ` + type Post { + id: ID! + } + + type Query { + posts: [Post!]! @listSize(assumedSize: 20) + users: [User!]! @listSize(assumedSize: 100) + } + + type User { + id: ID! + } + `, + ), + ); + }); + }); +}); + +const subgraphWithAssumedSize: Subgraph = { + name: 'subgraph-listsize-assumed', + url: '', + definitions: parse(` + type Query { + users: [User!]! @listSize(assumedSize: 100) + } + + type User { + id: ID! + } + `), +}; + +const subgraphWithSlicingArguments: Subgraph = { + name: 'subgraph-listsize-slicing', + url: '', + definitions: parse(` + type Query { + users(first: Int, last: Int): [User!]! @listSize(slicingArguments: ["first", "last"]) + } + + type User { + id: ID! + } + `), +}; + +const subgraphWithSizedFields: Subgraph = { + name: 'subgraph-listsize-sized', + url: '', + definitions: parse(` + type Query { + usersConnection(first: Int): Connection! @listSize(slicingArguments: ["first"], sizedFields: ["edges", "nodes"]) + } + + type Connection { + edges: [Edge!]! + nodes: [User!]! + } + + type Edge { + node: User! + } + + type User { + id: ID! + } + `), +}; + +const subgraphWithRequireOneSlicingArgument: Subgraph = { + name: 'subgraph-listsize-require', + url: '', + definitions: parse(` + type Query { + users(first: Int, after: String): [User!]! @listSize(slicingArguments: ["first"], requireOneSlicingArgument: false) + } + + type User { + id: ID! + } + `), +}; + +const subgraphWithAllArguments: Subgraph = { + name: 'subgraph-listsize-all', + url: '', + definitions: parse(` + type Query { + usersConnection(first: Int, last: Int): Connection! @listSize(assumedSize: 50, slicingArguments: ["first", "last"], sizedFields: ["edges", "nodes"], requireOneSlicingArgument: true) + } + + type Connection { + edges: [Edge!]! + nodes: [User!]! + } + + type Edge { + node: User! + } + + type User { + id: ID! + } + `), +}; + +const subgraphWithMultipleListSize: Subgraph = { + name: 'subgraph-listsize-multiple', + url: '', + definitions: parse(` + type Query { + users: [User!]! @listSize(assumedSize: 100) + posts: [Post!]! @listSize(assumedSize: 20) + } + + type User { + id: ID! + } + + type Post { + id: ID! + } + `), +}; diff --git a/composition/tests/v1/utils/utils.ts b/composition/tests/v1/utils/utils.ts index 75bb382a8d..e29a60a42f 100644 --- a/composition/tests/v1/utils/utils.ts +++ b/composition/tests/v1/utils/utils.ts @@ -13,6 +13,10 @@ export const CONNECT_FIELD_RESOLVER_DIRECTIVE = ` directive @connect__fieldResolver(context: openfed__FieldSet!) on FIELD_DEFINITION `; +export const COST_DIRECTIVE = ` + directive @cost(weight: String!) on ARGUMENT_DEFINITION | ENUM | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | SCALAR +`; + export const EDFS_NATS_PUBLISH_DIRECTIVE = ` directive @edfs__natsPublish(providerId: String! = "default", subject: String!) on FIELD_DEFINITION `; @@ -68,6 +72,10 @@ export const KEY_DIRECTIVE = ` directive @key(fields: openfed__FieldSet!, resolvable: Boolean = true) repeatable on INTERFACE | OBJECT `; +export const LIST_SIZE_DIRECTIVE = ` + directive @listSize(assumedSize: Int, requireOneSlicingArgument: Boolean = true, sizedFields: [String!], slicingArguments: [String!]) on FIELD_DEFINITION +`; + export const OPENFED_FIELD_SET = ` scalar openfed__FieldSet`; export const OPENFED_SCOPE = ` scalar openfed__Scope`; diff --git a/demo/go.sum b/demo/go.sum index 0f9aba5f4e..a9feca6c8e 100644 --- a/demo/go.sum +++ b/demo/go.sum @@ -98,6 +98,7 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec= github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= +github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=