From 860042e973be2487efd108b2b75e7e8317fd0a6f Mon Sep 17 00:00:00 2001 From: Eduardo Camara Date: Wed, 6 May 2026 10:31:48 -0300 Subject: [PATCH 1/6] feat: add @coerce directive for surgical per-field scalar coercion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the @sanitize pattern from node-vtex-api: the SchemaDirectiveVisitor replaces the field's scalar type at schema-build time with a coercible version that accepts compatible-but-differently-typed values in parseValue/parseLiteral. Applied to ShippingItem.quantity (String → Int @coerce) as a concrete example. Co-Authored-By: Claude Sonnet 4.6 --- graphql/schema.graphql | 1 + graphql/types/Shipping.graphql | 2 +- node/directives/coerce.ts | 120 +++++++++++++++++++++++++++++++++ node/directives/index.ts | 2 + 4 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 node/directives/coerce.ts diff --git a/graphql/schema.graphql b/graphql/schema.graphql index efd26d519..3bb6e65cb 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -4,6 +4,7 @@ directive @withSegment on FIELD_DEFINITION directive @withOrderFormId on FIELD_DEFINITION directive @withCurrentProfile on FIELD_DEFINITION directive @toVtexAssets on FIELD_DEFINITION +directive @coerce on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION type Query { """ diff --git a/graphql/types/Shipping.graphql b/graphql/types/Shipping.graphql index 6224741f8..ce2cc85ae 100644 --- a/graphql/types/Shipping.graphql +++ b/graphql/types/Shipping.graphql @@ -88,6 +88,6 @@ type LogisticsItem { input ShippingItem { id: String - quantity: String + quantity: Int @coerce seller: String } diff --git a/node/directives/coerce.ts b/node/directives/coerce.ts new file mode 100644 index 000000000..ed8759025 --- /dev/null +++ b/node/directives/coerce.ts @@ -0,0 +1,120 @@ +import { + GraphQLArgument, + GraphQLFloat, + GraphQLInputField, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, + GraphQLString, +} from 'graphql' +import { Kind } from 'graphql/language' +import { SchemaDirectiveVisitor } from 'graphql-tools' + +const coercibleScalars: Record = { + Int: new GraphQLScalarType({ + name: 'Int', + description: GraphQLInt.description, + serialize: GraphQLInt.serialize, + parseValue: (value) => { + const parsed = parseInt(String(value), 10) + + if (isNaN(parsed)) { + throw new Error(`Int cannot represent non-integer value: ${value}`) + } + + return parsed + }, + parseLiteral: (ast) => { + if (ast.kind === Kind.INT) return parseInt(ast.value, 10) + if (ast.kind === Kind.STRING) { + const parsed = parseInt(ast.value, 10) + + if (!isNaN(parsed)) return parsed + } + + return null + }, + }), + + Float: new GraphQLScalarType({ + name: 'Float', + description: GraphQLFloat.description, + serialize: GraphQLFloat.serialize, + parseValue: (value) => { + const parsed = parseFloat(String(value)) + + if (isNaN(parsed)) { + throw new Error(`Float cannot represent non-numeric value: ${value}`) + } + + return parsed + }, + parseLiteral: (ast) => { + if (ast.kind === Kind.FLOAT || ast.kind === Kind.INT) + return parseFloat(ast.value) + if (ast.kind === Kind.STRING) { + const parsed = parseFloat(ast.value) + + if (!isNaN(parsed)) return parsed + } + + return null + }, + }), + + String: new GraphQLScalarType({ + name: 'String', + description: GraphQLString.description, + serialize: GraphQLString.serialize, + parseValue: (value) => String(value), + parseLiteral: (ast) => { + if ( + ast.kind === Kind.STRING || + ast.kind === Kind.INT || + ast.kind === Kind.FLOAT || + ast.kind === Kind.BOOLEAN + ) { + return String(ast.value) + } + + return null + }, + }), +} + +// Walks NonNull/List wrappers and replaces the inner named type with its coercible version. +function replaceWithCoercible( + type: GraphQLArgument['type'] +): GraphQLArgument['type'] { + if (type instanceof GraphQLNonNull) { + return new GraphQLNonNull(replaceWithCoercible(type.ofType) as any) + } + + if (type instanceof GraphQLList) { + return new GraphQLList(replaceWithCoercible(type.ofType) as any) + } + + if (type instanceof GraphQLScalarType && coercibleScalars[type.name]) { + return coercibleScalars[type.name] + } + + return type +} + +/** + * Directive that wraps Int, Float, and String scalar types with coercive + * parseValue/parseLiteral so the field accepts values of a compatible + * but differently-typed format (e.g. "20" as Int). + * + * Apply to INPUT_FIELD_DEFINITION or ARGUMENT_DEFINITION. + */ +export class Coerce extends SchemaDirectiveVisitor { + public visitInputFieldDefinition(field: GraphQLInputField) { + field.type = replaceWithCoercible(field.type) as any + } + + public visitArgumentDefinition(argument: GraphQLArgument) { + argument.type = replaceWithCoercible(argument.type) as any + } +} diff --git a/node/directives/index.ts b/node/directives/index.ts index f5c2fe79d..a68c5b09d 100644 --- a/node/directives/index.ts +++ b/node/directives/index.ts @@ -4,6 +4,7 @@ import { WithOrderFormId } from './withOrderFormId' import { ToVtexAssets } from './toVtexAssets' import { AuthorizationMetrics } from './authorizationMetrics' import { WithOwnerId } from './withOwnerId' +import { Coerce } from './coerce' export const schemaDirectives = { toVtexAssets: ToVtexAssets, @@ -12,4 +13,5 @@ export const schemaDirectives = { withOrderFormId: WithOrderFormId, withOwnerId: WithOwnerId, withAuthMetrics: AuthorizationMetrics, + coerce: Coerce, } From 44f70e4c43a002d0d0bf8cfb0ed8cf87626f56de Mon Sep 17 00:00:00 2001 From: Eduardo Camara Date: Wed, 6 May 2026 11:59:07 -0300 Subject: [PATCH 2/6] Add @coerce in others fields and update class Coerce --- graphql/schema.graphql | 2 +- graphql/types/Shipping.graphql | 4 +- node/directives/coerce.ts | 152 ++++++++++++++++++--------------- 3 files changed, 86 insertions(+), 72 deletions(-) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 3bb6e65cb..016e7d7e7 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -246,7 +246,7 @@ type Query { """ Category id """ - id: Int + id: Int @coerce ): Category @cacheControl(scope: SEGMENT, maxAge: MEDIUM) """ diff --git a/graphql/types/Shipping.graphql b/graphql/types/Shipping.graphql index ce2cc85ae..3839eadd3 100644 --- a/graphql/types/Shipping.graphql +++ b/graphql/types/Shipping.graphql @@ -88,6 +88,6 @@ type LogisticsItem { input ShippingItem { id: String - quantity: Int @coerce - seller: String + quantity: String @coerce + seller: String @coerce } diff --git a/node/directives/coerce.ts b/node/directives/coerce.ts index ed8759025..367008849 100644 --- a/node/directives/coerce.ts +++ b/node/directives/coerce.ts @@ -1,47 +1,43 @@ -import { - GraphQLArgument, - GraphQLFloat, - GraphQLInputField, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLScalarType, - GraphQLString, -} from 'graphql' -import { Kind } from 'graphql/language' import { SchemaDirectiveVisitor } from 'graphql-tools' -const coercibleScalars: Record = { - Int: new GraphQLScalarType({ - name: 'Int', - description: GraphQLInt.description, - serialize: GraphQLInt.serialize, - parseValue: (value) => { - const parsed = parseInt(String(value), 10) +// Use string literals instead of importing Kind from graphql — the constants are +// stable across versions, but importing from graphql 0.13.x would fail the check +// because @vtex/api uses graphql 14.x internally with its own separate module copy. +const KIND = { + INT: 'IntValue', + FLOAT: 'FloatValue', + STRING: 'StringValue', + BOOLEAN: 'BooleanValue', +} - if (isNaN(parsed)) { +const coercibleBehavior: Record< + string, + { parseValue(v: unknown): unknown; parseLiteral(ast: any): unknown } +> = { + Int: { + parseValue(value) { + const num = Number(value) + + if (!Number.isFinite(num) || !Number.isInteger(num)) { throw new Error(`Int cannot represent non-integer value: ${value}`) } - return parsed + return num }, - parseLiteral: (ast) => { - if (ast.kind === Kind.INT) return parseInt(ast.value, 10) - if (ast.kind === Kind.STRING) { - const parsed = parseInt(ast.value, 10) + parseLiteral(ast) { + if (ast.kind === KIND.INT) return parseInt(ast.value, 10) + if (ast.kind === KIND.STRING) { + const num = Number(ast.value) - if (!isNaN(parsed)) return parsed + if (Number.isFinite(num) && Number.isInteger(num)) return num } return null }, - }), + }, - Float: new GraphQLScalarType({ - name: 'Float', - description: GraphQLFloat.description, - serialize: GraphQLFloat.serialize, - parseValue: (value) => { + Float: { + parseValue(value) { const parsed = parseFloat(String(value)) if (isNaN(parsed)) { @@ -50,10 +46,10 @@ const coercibleScalars: Record = { return parsed }, - parseLiteral: (ast) => { - if (ast.kind === Kind.FLOAT || ast.kind === Kind.INT) + parseLiteral(ast) { + if (ast.kind === KIND.FLOAT || ast.kind === KIND.INT) return parseFloat(ast.value) - if (ast.kind === Kind.STRING) { + if (ast.kind === KIND.STRING) { const parsed = parseFloat(ast.value) if (!isNaN(parsed)) return parsed @@ -61,60 +57,78 @@ const coercibleScalars: Record = { return null }, - }), - - String: new GraphQLScalarType({ - name: 'String', - description: GraphQLString.description, - serialize: GraphQLString.serialize, - parseValue: (value) => String(value), - parseLiteral: (ast) => { + }, + + String: { + parseValue(value) { + return String(value) + }, + parseLiteral(ast) { if ( - ast.kind === Kind.STRING || - ast.kind === Kind.INT || - ast.kind === Kind.FLOAT || - ast.kind === Kind.BOOLEAN + ast.kind === KIND.STRING || + ast.kind === KIND.INT || + ast.kind === KIND.FLOAT || + ast.kind === KIND.BOOLEAN ) { return String(ast.value) } return null }, - }), + }, } -// Walks NonNull/List wrappers and replaces the inner named type with its coercible version. -function replaceWithCoercible( - type: GraphQLArgument['type'] -): GraphQLArgument['type'] { - if (type instanceof GraphQLNonNull) { - return new GraphQLNonNull(replaceWithCoercible(type.ofType) as any) - } +// Duck-typing instead of instanceof: @vtex/api ships graphql 14.x in its own +// node_modules while the app declares graphql 0.13.x. The two module instances +// are different JS objects, so `instanceof GraphQLScalarType` (imported from +// 0.13.x) always returns false for types built by the 14.x schema builder. +function isScalarType(type: any): boolean { + return ( + type != null && + typeof type.parseValue === 'function' && + typeof type.serialize === 'function' && + typeof type.parseLiteral === 'function' + ) +} - if (type instanceof GraphQLList) { - return new GraphQLList(replaceWithCoercible(type.ofType) as any) - } +// Follow the ofType chain to unwrap NonNull/List without instanceof checks. +function unwrapType(type: any): any { + return type?.ofType != null ? unwrapType(type.ofType) : type +} - if (type instanceof GraphQLScalarType && coercibleScalars[type.name]) { - return coercibleScalars[type.name] - } +function applyCoercion(fieldOrArg: any): void { + const baseType = unwrapType(fieldOrArg.type) + + if (!isScalarType(baseType)) return + + const behavior = coercibleBehavior[baseType.name] + + if (!behavior) return - return type + // Mutate parseValue/parseLiteral directly on the type object (which comes + // from graphql 14.x). healSchema only heals type REFERENCES (field.type + // pointers), not function properties on the type itself, so this mutation + // survives. The trade-off is that it affects all fields sharing this scalar + // type globally — acceptable since the goal is schema-wide coercion for the + // annotated scalar. + baseType.parseValue = behavior.parseValue + baseType.parseLiteral = behavior.parseLiteral } /** - * Directive that wraps Int, Float, and String scalar types with coercive - * parseValue/parseLiteral so the field accepts values of a compatible - * but differently-typed format (e.g. "20" as Int). + * Directive that applies lenient coercion to Int, Float, and String scalar + * types by patching their parseValue/parseLiteral functions in place. * - * Apply to INPUT_FIELD_DEFINITION or ARGUMENT_DEFINITION. + * Works despite the graphql version mismatch between @vtex/api (14.x) and the + * app (0.13.x) because it uses duck-typing and in-place mutation instead of + * instanceof checks and type-object replacement. */ export class Coerce extends SchemaDirectiveVisitor { - public visitInputFieldDefinition(field: GraphQLInputField) { - field.type = replaceWithCoercible(field.type) as any + public visitInputFieldDefinition(field: any) { + applyCoercion(field) } - public visitArgumentDefinition(argument: GraphQLArgument) { - argument.type = replaceWithCoercible(argument.type) as any + public visitArgumentDefinition(argument: any) { + applyCoercion(argument) } } From 390987bdf2fb43673fada87ef047fcea2e56b65b Mon Sep 17 00:00:00 2001 From: Eduardo Camara Date: Wed, 6 May 2026 14:32:48 -0300 Subject: [PATCH 3/6] Add directive in other fields --- graphql/schema.graphql | 18 +++++++++--------- graphql/types/OrderForm.graphql | 12 ++++++------ graphql/types/Profile.graphql | 8 +++++--- graphql/types/Shipping.graphql | 2 +- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 016e7d7e7..7aa0117a3 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -256,7 +256,7 @@ type Query { """ Category tree level. Default: 3 """ - treeLevel: Int = 3 + treeLevel: Int = 3 @coerce ): [Category] @cacheControl(scope: SEGMENT, maxAge: MEDIUM) """ @@ -266,7 +266,7 @@ type Query { """ Brand id """ - id: Int + id: Int @coerce ): Brand @cacheControl(scope: PUBLIC, maxAge: MEDIUM) """ @@ -289,7 +289,7 @@ type Query { """ geoCoordinates for freight calculator """ - geoCoordinates: [String] + geoCoordinates: [String] @coerce """ Country of postal code """ @@ -500,11 +500,11 @@ type Query { """ Desired latitude coordinate, for example: -22.944370 """ - lat: String! + lat: String! @coerce """ Desired longitude coordinate, for example: -43.182553 """ - long: String! + long: String! @coerce """ max distance of pickup points from given coordinates, in kilometers, default value 50 """ @@ -513,15 +513,15 @@ type Query { skuPickupSLAs( itemId: String seller: String - lat: String - long: String + lat: String @coerce + long: String @coerce country: String ): [CheckoutSLA] skuPickupSLA( itemId: String seller: String - lat: String - long: String + lat: String @coerce + long: String @coerce country: String pickupId: String ): CheckoutSLA diff --git a/graphql/types/OrderForm.graphql b/graphql/types/OrderForm.graphql index 28577e07f..e940c0de6 100644 --- a/graphql/types/OrderForm.graphql +++ b/graphql/types/OrderForm.graphql @@ -186,9 +186,9 @@ type OrderFormShippingData { scalar InputValues input OrderFormItemInput { - id: Int - index: Int - quantity: Int + id: Int @coerce + index: Int @coerce + quantity: Int @coerce seller: ID inputValues: InputValues options: [AssemblyOptionInput] @@ -197,16 +197,16 @@ input OrderFormItemInput { input OrderFormAddressInput { addressId: String addressType: String - postalCode: String + postalCode: String @coerce country: String receiverName: String city: String state: String street: String - number: String + number: String @coerce complement: String neighborhood: String - geoCoordinates: [Float] + geoCoordinates: [Float] @coerce isDisposable: Boolean } diff --git a/graphql/types/Profile.graphql b/graphql/types/Profile.graphql index 787d5826e..a695fac59 100644 --- a/graphql/types/Profile.graphql +++ b/graphql/types/Profile.graphql @@ -264,10 +264,12 @@ input AddressInput { neighborhood: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) country: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) state: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) - number: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) + number: String @coerce @sanitize(allowHTMLTags: false, stripIgnoreTag: true) street: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) - geoCoordinates: [Float] - postalCode: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) + geoCoordinates: [Float] @coerce + postalCode: String + @coerce + @sanitize(allowHTMLTags: false, stripIgnoreTag: true) city: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) reference: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) addressName: String @sanitize(allowHTMLTags: false, stripIgnoreTag: true) diff --git a/graphql/types/Shipping.graphql b/graphql/types/Shipping.graphql index 3839eadd3..e8bf98c33 100644 --- a/graphql/types/Shipping.graphql +++ b/graphql/types/Shipping.graphql @@ -87,7 +87,7 @@ type LogisticsItem { } input ShippingItem { - id: String + id: String @coerce quantity: String @coerce seller: String @coerce } From 9292ff12df18384317705dbb29fdc36058716838 Mon Sep 17 00:00:00 2001 From: Eduardo Camara Date: Wed, 6 May 2026 14:48:29 -0300 Subject: [PATCH 4/6] Add tests and include @coerce in other fields --- graphql/schema.graphql | 12 +- graphql/types/OrderForm.graphql | 2 +- graphql/types/Product.graphql | 4 +- node/__tests__/directives/coerce.test.ts | 296 +++++++++++++++++++++++ node/directives/coerce.ts | 4 + 5 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 node/__tests__/directives/coerce.test.ts diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 7aa0117a3..bd7ddd5f0 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -511,19 +511,19 @@ type Query { maxDistance: Int = 50 ): NearPickupPointQueryResponse skuPickupSLAs( - itemId: String - seller: String + itemId: String @coerce + seller: String @coerce lat: String @coerce long: String @coerce country: String ): [CheckoutSLA] skuPickupSLA( - itemId: String - seller: String + itemId: String @coerce + seller: String @coerce lat: String @coerce long: String @coerce country: String - pickupId: String + pickupId: String @coerce ): CheckoutSLA """ Get pickup point by its id @@ -772,7 +772,7 @@ type Mutation { @deprecated( reason: "Field is no longer needed. Checkout cookie is automatically taken into account now" ) - itemId: String + itemId: String @coerce assemblyOptionsId: String options: [AssemblyOptionInput] ): OrderForm @withOrderFormId @withOwnerId diff --git a/graphql/types/OrderForm.graphql b/graphql/types/OrderForm.graphql index e940c0de6..315f5b94f 100644 --- a/graphql/types/OrderForm.graphql +++ b/graphql/types/OrderForm.graphql @@ -195,7 +195,7 @@ input OrderFormItemInput { } input OrderFormAddressInput { - addressId: String + addressId: String @coerce addressType: String postalCode: String @coerce country: String diff --git a/graphql/types/Product.graphql b/graphql/types/Product.graphql index b360396fc..1788a901f 100644 --- a/graphql/types/Product.graphql +++ b/graphql/types/Product.graphql @@ -371,10 +371,10 @@ enum CrossSelingInputEnum { } input ItemInput { - itemId: ID + itemId: ID @coerce sellers: [SellerInput] } input SellerInput { - sellerId: ID + sellerId: ID @coerce } diff --git a/node/__tests__/directives/coerce.test.ts b/node/__tests__/directives/coerce.test.ts new file mode 100644 index 000000000..42c760591 --- /dev/null +++ b/node/__tests__/directives/coerce.test.ts @@ -0,0 +1,296 @@ +import { Coerce } from '../../directives/coerce' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Creates a duck-typed scalar mock (same shape graphql 14.x scalars expose). + * applyCoercion uses duck-typing instead of instanceof, so this is enough. + */ +function mockScalar(name: string) { + return { + name, + serialize: (v: any) => v, + parseValue: (v: any) => v, + parseLiteral: (ast: any) => ast.value, + } +} + +/** Wraps a type in a NonNull-like shell (has .ofType, no .parseValue). */ +function nonNull(type: any) { + return { ofType: type } +} + +/** Wraps a type in a List-like shell. */ +function list(type: any) { + return { ofType: type } +} + +/** + * Instantiates Coerce without going through the protected SchemaDirectiveVisitor + * constructor — applyCoercion is stateless (no this.schema access), so the + * instance state does not matter for these tests. + */ +function createCoerce(): Coerce { + return Object.create(Coerce.prototype) as Coerce +} + +function applyToField(type: any) { + createCoerce().visitInputFieldDefinition({ type } as any) + + return type +} + +function applyToArg(type: any) { + createCoerce().visitArgumentDefinition({ type } as any) + + return type +} + +// --------------------------------------------------------------------------- +// Visitor wiring +// --------------------------------------------------------------------------- + +describe('Coerce.visitInputFieldDefinition', () => { + it('patches parseValue and parseLiteral on the scalar type', () => { + const type = mockScalar('Int') + const originalParseValue = type.parseValue + const originalParseLiteral = type.parseLiteral + + applyToField(type) + + expect(type.parseValue).not.toBe(originalParseValue) + expect(type.parseLiteral).not.toBe(originalParseLiteral) + }) + + it('does nothing to types without parseValue (non-scalars)', () => { + const inputObjectType = { name: 'ShippingItem', getFields: () => ({}) } + + expect(() => applyToField(inputObjectType)).not.toThrow() + expect((inputObjectType as any).parseValue).toBeUndefined() + }) + + it('does nothing to scalars with unrecognised names', () => { + const idScalar = mockScalar('ID') + const original = idScalar.parseValue + + applyToField(idScalar) + + expect(idScalar.parseValue).toBe(original) + }) + + it('unwraps a NonNull wrapper and patches the inner type', () => { + const inner = mockScalar('Int') + const wrapped = nonNull(inner) + + applyToField(wrapped) + + expect(inner.parseValue('20')).toBe(20) + }) + + it('unwraps nested List → NonNull wrappers and patches the inner type', () => { + const inner = mockScalar('String') + const wrapped = list(nonNull(inner)) + + applyToField(wrapped) + + expect(inner.parseValue(42)).toBe('42') + }) +}) + +describe('Coerce.visitArgumentDefinition', () => { + it('patches the scalar type, same as visitInputFieldDefinition', () => { + const type = mockScalar('Float') + const original = type.parseValue + + applyToArg(type) + + expect(type.parseValue).not.toBe(original) + expect(type.parseValue('3.14')).toBe(3.14) + }) +}) + +// --------------------------------------------------------------------------- +// Int coercion +// --------------------------------------------------------------------------- + +describe('Int coercion — parseValue', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Int')) + }) + + it('accepts a string that represents a whole number', () => { + expect(type.parseValue('20')).toBe(20) + }) + + it('accepts a negative string integer', () => { + expect(type.parseValue('-5')).toBe(-5) + }) + + it('accepts a numeric integer', () => { + expect(type.parseValue(42)).toBe(42) + }) + + it('rejects a string float ("20.5")', () => { + expect(() => type.parseValue('20.5')).toThrow(/non-integer/) + }) + + it('rejects a numeric float (20.5)', () => { + expect(() => type.parseValue(20.5)).toThrow(/non-integer/) + }) + + it('rejects a non-numeric string', () => { + expect(() => type.parseValue('abc')).toThrow(/non-integer/) + }) + + it('rejects an empty string', () => { + expect(() => type.parseValue('')).toThrow(/non-integer/) + }) +}) + +describe('Int coercion — parseLiteral', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Int')) + }) + + it('accepts an IntValue AST node', () => { + expect(type.parseLiteral({ kind: 'IntValue', value: '20' })).toBe(20) + }) + + it('accepts a StringValue AST node with a whole-number string', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: '20' })).toBe(20) + }) + + it('rejects a StringValue AST node with a float string ("20.5")', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: '20.5' })).toBeNull() + }) + + it('rejects a FloatValue AST node', () => { + expect(type.parseLiteral({ kind: 'FloatValue', value: '20.5' })).toBeNull() + }) + + it('rejects an unrecognised AST kind', () => { + expect( + type.parseLiteral({ kind: 'BooleanValue', value: 'true' }) + ).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Float coercion +// --------------------------------------------------------------------------- + +describe('Float coercion — parseValue', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Float')) + }) + + it('accepts a string float ("3.14")', () => { + expect(type.parseValue('3.14')).toBe(3.14) + }) + + it('accepts a string integer ("20")', () => { + expect(type.parseValue('20')).toBe(20) + }) + + it('accepts a numeric float (3.14)', () => { + expect(type.parseValue(3.14)).toBe(3.14) + }) + + it('rejects a non-numeric string', () => { + expect(() => type.parseValue('abc')).toThrow(/non-numeric/) + }) +}) + +describe('Float coercion — parseLiteral', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('Float')) + }) + + it('accepts a FloatValue AST node', () => { + expect(type.parseLiteral({ kind: 'FloatValue', value: '3.14' })).toBe(3.14) + }) + + it('accepts an IntValue AST node', () => { + expect(type.parseLiteral({ kind: 'IntValue', value: '20' })).toBe(20) + }) + + it('accepts a StringValue AST node with numeric content', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: '3.14' })).toBe(3.14) + }) + + it('rejects a StringValue AST node with non-numeric content', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: 'abc' })).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// String coercion +// --------------------------------------------------------------------------- + +describe('String coercion — parseValue', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('String')) + }) + + it('accepts a number and converts it to string', () => { + expect(type.parseValue(20)).toBe('20') + }) + + it('accepts a float and converts it to string', () => { + expect(type.parseValue(3.14)).toBe('3.14') + }) + + it('accepts a boolean and converts it to string', () => { + expect(type.parseValue(true)).toBe('true') + }) + + it('passes a string through unchanged', () => { + expect(type.parseValue('hello')).toBe('hello') + }) +}) + +describe('String coercion — parseLiteral', () => { + let type: ReturnType + + beforeEach(() => { + type = applyToField(mockScalar('String')) + }) + + it('accepts a StringValue AST node', () => { + expect(type.parseLiteral({ kind: 'StringValue', value: 'hello' })).toBe( + 'hello' + ) + }) + + it('accepts an IntValue AST node and converts to string', () => { + expect(type.parseLiteral({ kind: 'IntValue', value: '20' })).toBe('20') + }) + + it('accepts a FloatValue AST node and converts to string', () => { + expect(type.parseLiteral({ kind: 'FloatValue', value: '3.14' })).toBe( + '3.14' + ) + }) + + it('accepts a BooleanValue AST node and converts to string', () => { + expect(type.parseLiteral({ kind: 'BooleanValue', value: 'true' })).toBe( + 'true' + ) + }) + + it('rejects an unrecognised AST kind', () => { + expect(type.parseLiteral({ kind: 'NullValue' })).toBeNull() + }) +}) diff --git a/node/directives/coerce.ts b/node/directives/coerce.ts index 367008849..d5096ca18 100644 --- a/node/directives/coerce.ts +++ b/node/directives/coerce.ts @@ -16,6 +16,10 @@ const coercibleBehavior: Record< > = { Int: { parseValue(value) { + if (typeof value === 'string' && value.trim() === '') { + throw new Error(`Int cannot represent non-integer value: ${value}`) + } + const num = Number(value) if (!Number.isFinite(num) || !Number.isInteger(num)) { From 1068d33694671ff27efd4db770c26102150274b2 Mon Sep 17 00:00:00 2001 From: Eduardo Camara Date: Wed, 6 May 2026 14:50:31 -0300 Subject: [PATCH 5/6] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb025674a..0f5ddf51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed +- Schema directive for surgical per-field scalar coercion + ## [2.175.1] - 2026-05-05 ### Changed From 4f5d3faceacc0265aa2c33f21a5d6cabd0bac4f0 Mon Sep 17 00:00:00 2001 From: Eduardo Camara Date: Wed, 6 May 2026 14:54:40 -0300 Subject: [PATCH 6/6] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5ddf51f..eb5a0671e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] ### Fixed -- Schema directive for surgical per-field scalar coercion +- Add @coerce Schema directive for surgical per-field scalar coercion ## [2.175.1] - 2026-05-05