diff --git a/README.md b/README.md index 8657e360a..05ea95cea 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ scalar BigInt scalar Long +scalar SafeInt + scalar GUID scalar HexColorCode @@ -125,6 +127,7 @@ import { URLResolver, BigIntResolver, LongResolver, + SafeIntResolver, GUIDResolver, HexColorCodeResolver, HSLResolver, @@ -168,6 +171,7 @@ const myResolverMap = { UnsignedInt: UnsignedIntResolver, BigInt: BigIntResolver, Long: LongResolver, + SafeInt: SafeIntResolver, EmailAddress: EmailAddressResolver, URL: URLResolver, @@ -603,6 +607,10 @@ GraphQLError: Argument "num" has invalid value 9007199254740990. In order to support `BigInt` in `JSON.parse` and `JSON.stringify`, it is recommended to install this npm package together with this scalar. Otherwise, JavaScript will serialize the value as string. [json-bigint-patch](https://github.com/ardatan/json-bigint-patch) +### SafeInt + +This scalar behaves just like the native GraphQLInt scalar, but it allows integers that require more than 32-bits. Any integer that is considered "safe" in JavaScript (i.e. ± 9,007,199,254,740,991) is considered a valid value. + ### GUID A field whose value is a generic [Globally Unique Identifier](https://en.wikipedia.org/wiki/Universally_unique_identifier). diff --git a/src/index.ts b/src/index.ts index 3af5f24e0..35fde5f30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { GraphQLBigInt, GraphQLByte, GraphQLLong, + GraphQLSafeInt, GraphQLGUID, GraphQLHexadecimal, GraphQLHexColorCode, @@ -67,6 +68,7 @@ export { BigInt as BigIntTypeDefinition, Byte as ByteTypeDefinition, Long as LongTypeDefinition, + SafeInt as SafeIntDefinition, GUID as GUIDDefinition, Hexadecimal as HexadecimalTypeDefinition, HexColorCode as HexColorCodeDefinition, @@ -113,6 +115,7 @@ export { GraphQLBigInt as BigIntResolver, GraphQLByte as ByteResolver, GraphQLLong as LongResolver, + GraphQLSafeInt as SafeIntResolver, GraphQLGUID as GUIDResolver, GraphQLHexadecimal as HexadecimalResolver, GraphQLHexColorCode as HexColorCodeResolver, @@ -157,6 +160,7 @@ export const resolvers = { BigInt: GraphQLBigInt, Byte: GraphQLByte, Long: GraphQLLong, + SafeInt: GraphQLSafeInt, GUID: GraphQLGUID, Hexadecimal: GraphQLHexadecimal, HexColorCode: GraphQLHexColorCode, @@ -201,6 +205,7 @@ export { BigInt as BigIntMock, Byte as ByteMock, Long as LongMock, + SafeInt as SafeIntMock, GUID as GUIDMock, Hexadecimal as HexadecimalMock, HexColorCode as HexColorCodeMock, @@ -249,6 +254,7 @@ export { GraphQLBigInt, GraphQLByte, GraphQLLong, + GraphQLSafeInt, GraphQLGUID, GraphQLHexadecimal, GraphQLHexColorCode, diff --git a/src/mocks.ts b/src/mocks.ts index f4bd0673e..c7ee63aca 100644 --- a/src/mocks.ts +++ b/src/mocks.ts @@ -84,6 +84,7 @@ export const JSON = () => ({}); export const JSONObject = () => ({}); export const IBAN = () => 'NL55INGB4789170233'; export const Void = (): null => null; +export const SafeInt = () => Number.MAX_SAFE_INTEGER; export { DateMock as Date, diff --git a/src/scalars/SafeInt.ts b/src/scalars/SafeInt.ts new file mode 100644 index 000000000..e7a7ebe79 --- /dev/null +++ b/src/scalars/SafeInt.ts @@ -0,0 +1,82 @@ +import { + GraphQLError, + GraphQLScalarType, + GraphQLScalarTypeConfig, + Kind, + print, +} from 'graphql'; + +// eslint-disable-next-line @typescript-eslint/ban-types +function isObjectLike(value: any): value is Object { + return typeof value === 'object' && value !== null; +} + +function serializeObject(outputValue: any): any { + if (isObjectLike(outputValue)) { + if (typeof outputValue.valueOf === 'function') { + const valueOfResult = outputValue.valueOf(); + if (!isObjectLike(valueOfResult)) { + return valueOfResult; + } + } + if (typeof outputValue.toJSON === 'function') { + return outputValue.toJSON(); + } + } + return outputValue; +} + +function serializeSafeIntValue(outputValue: any): number { + const coercedValue = serializeObject(outputValue); + + if (typeof coercedValue === 'boolean') { + return coercedValue ? 1 : 0; + } + + let num = coercedValue; + if (typeof coercedValue === 'string' && coercedValue !== '') { + num = Number(coercedValue); + } + + if (!Number.isSafeInteger(num)) { + throw new GraphQLError( + `SafeInt cannot represent non-safe-integer value: ${num}`, + ); + } + return num; +} + +function parseSafeIntValue(inputValue: any): number { + if (!Number.isSafeInteger(inputValue)) { + throw new GraphQLError( + `SafeInt cannot represent non-safe-integer value: ${inputValue}`, + ); + } + return inputValue; +} + +export const GraphQLSafeIntConfig: GraphQLScalarTypeConfig< + number | string, + number +> = { + name: 'SafeInt', + description: + 'The `SafeInt` scalar type represents non-fractional signed whole numeric values that are ' + + 'considered safe as defined by the ECMAScript specification.', + specifiedByUrl: + 'https://www.ecma-international.org/ecma-262/#sec-number.issafeinteger', + serialize: serializeSafeIntValue, + parseValue: parseSafeIntValue, + parseLiteral(ast) { + if (ast.kind !== Kind.INT) { + throw new GraphQLError( + `SafeInt cannot represent non-integer value: ${print(ast)}`, + ast, + ); + } + const num = parseInt(ast.value, 10); + return parseSafeIntValue(num); + }, +}; + +export const GraphQLSafeInt = new GraphQLScalarType(GraphQLSafeIntConfig); diff --git a/src/scalars/index.ts b/src/scalars/index.ts index 46e4598d1..767500e4e 100644 --- a/src/scalars/index.ts +++ b/src/scalars/index.ts @@ -20,6 +20,7 @@ export { GraphQLURL } from './URL'; export { GraphQLBigInt } from './BigInt'; export { GraphQLByte } from './Byte'; export { GraphQLLong } from './Long'; +export { GraphQLSafeInt } from './SafeInt'; export { GraphQLGUID } from './GUID'; export { GraphQLHexadecimal } from './Hexadecimal'; export { GraphQLHexColorCode } from './HexColorCode'; diff --git a/src/typeDefs.ts b/src/typeDefs.ts index 0fb37baae..08f077893 100644 --- a/src/typeDefs.ts +++ b/src/typeDefs.ts @@ -31,6 +31,7 @@ export const PositiveInt = 'scalar PositiveInt'; export const PostalCode = 'scalar PostalCode'; export const RGB = `scalar RGB`; export const RGBA = `scalar RGBA`; +export const SafeInt = `scalar SafeInt`; export const URL = 'scalar URL'; export const USCurrency = `scalar USCurrency`; export const Currency = `scalar Currency`; @@ -77,6 +78,7 @@ export const typeDefs = [ Port, RGB, RGBA, + SafeInt, USCurrency, Currency, JSON, diff --git a/tests/SafeInt.test.ts b/tests/SafeInt.test.ts new file mode 100644 index 000000000..64c677cfd --- /dev/null +++ b/tests/SafeInt.test.ts @@ -0,0 +1,206 @@ +import { GraphQLSchema, GraphQLObjectType, graphql } from 'graphql'; +import { GraphQLSafeInt } from '../src/scalars/SafeInt'; + +describe('SafeInt', () => { + const Query = new GraphQLObjectType({ + name: 'Query', + fields: { + echo: { + type: GraphQLSafeInt, + args: { + num: { type: GraphQLSafeInt }, + }, + resolve: (_root, args) => args.num, + }, + inc: { + type: GraphQLSafeInt, + args: { + num: { type: GraphQLSafeInt }, + }, + resolve: (_root, args) => args.num + 1, + }, + dec: { + type: GraphQLSafeInt, + args: { + num: { type: GraphQLSafeInt }, + }, + resolve: (_root, args) => args.num - 1, + }, + valueOf: { + type: GraphQLSafeInt, + resolve: (_root) => ({ valueOf: () => 42 }), + }, + toJSON: { + type: GraphQLSafeInt, + resolve: (_root) => ({ toJSON: () => 42 }), + }, + isTrue: { + type: GraphQLSafeInt, + resolve: (_root) => true, + }, + isFalse: { + type: GraphQLSafeInt, + resolve: (_root) => false, + }, + isString: { + type: GraphQLSafeInt, + resolve: (_root) => '42', + }, + isEmptyString: { + type: GraphQLSafeInt, + resolve: (_root, args) => '', + }, + isFloat: { + type: GraphQLSafeInt, + resolve: () => 3.14, + }, + }, + }); + const schema = new GraphQLSchema({ + query: Query, + }); + + describe('valid', () => { + test('serialize', async () => { + const query = /* GraphQL */ ` + { + a: inc(num: 1) + b: inc(num: 9007199254740990) + c: dec(num: -1) + d: dec(num: -9007199254740990) + e: valueOf + f: toJSON + g: isTrue + h: isFalse + i: isString + } + `; + const { data, errors } = await graphql(schema, query); + + expect(data.a).toEqual(2); + expect(data.b).toEqual(9007199254740991); + expect(data.c).toEqual(-2); + expect(data.d).toEqual(-9007199254740991); + expect(data.e).toEqual(42); + expect(data.f).toEqual(42); + expect(data.g).toEqual(1); + expect(data.h).toEqual(0); + expect(data.i).toEqual(42); + expect(errors).toBeUndefined(); + }); + + test('parseValue', async () => { + const query = /* GraphQL */ ` + query($a: SafeInt!, $b: SafeInt!, $c: SafeInt!, $d: SafeInt!) { + a: echo(num: $a) + b: echo(num: $b) + c: echo(num: $c) + d: echo(num: $d) + } + `; + const variables = { + a: 1, + b: 9007199254740991, + c: -1, + d: -9007199254740991, + }; + const { data, errors } = await graphql(schema, query, {}, {}, variables); + + expect(data.a).toEqual(1); + expect(data.b).toEqual(9007199254740991); + expect(data.c).toEqual(-1); + expect(data.d).toEqual(-9007199254740991); + expect(errors).toBeUndefined(); + }); + + test('parseLiteral', async () => { + const query = /* GraphQL */ ` + { + a: echo(num: 1) + b: echo(num: 9007199254740991) + c: echo(num: -1) + d: echo(num: -9007199254740991) + } + `; + const { data, errors } = await graphql(schema, query); + + expect(data.a).toEqual(1); + expect(data.b).toEqual(9007199254740991); + expect(data.c).toEqual(-1); + expect(data.d).toEqual(-9007199254740991); + expect(errors).toBeUndefined(); + }); + }); + + describe('invalid', () => { + test('serialize', async () => { + const query = /* GraphQL */ ` + { + a: inc(num: 9007199254740991) + b: dec(num: -9007199254740991) + c: isEmptyString + d: isFloat + } + `; + const { errors } = await graphql(schema, query); + + expect(errors).toHaveLength(4); + expect(errors[0].message).toEqual( + 'SafeInt cannot represent non-safe-integer value: 9007199254740992', + ); + expect(errors[1].message).toEqual( + 'SafeInt cannot represent non-safe-integer value: -9007199254740992', + ); + expect(errors[2].message).toEqual( + 'SafeInt cannot represent non-safe-integer value: ', + ); + expect(errors[3].message).toEqual( + 'SafeInt cannot represent non-safe-integer value: 3.14', + ); + }); + + test('parseValue', async () => { + const query = /* GraphQL */ ` + query($a: SafeInt!, $b: SafeInt!) { + a: echo(num: $a) + b: echo(num: $b) + } + `; + const variables = { + a: 9007199254740992, + b: -9007199254740992, + }; + const { errors } = await graphql(schema, query, {}, {}, variables); + + expect(errors).toHaveLength(2); + expect(errors[0].message).toEqual( + 'Variable "$a" got invalid value 9007199254740992; SafeInt cannot represent non-safe-integer value: 9007199254740992', + ); + expect(errors[1].message).toEqual( + 'Variable "$b" got invalid value -9007199254740992; SafeInt cannot represent non-safe-integer value: -9007199254740992', + ); + }); + + test('parseLiteral', async () => { + const query = /* GraphQL */ ` + { + a: echo(num: 9007199254740992) + b: echo(num: -9007199254740992) + c: echo(num: "42") + } + `; + const { errors } = await graphql(schema, query); + + expect(errors).toHaveLength(3); + expect(errors[0].message).toEqual( + 'SafeInt cannot represent non-safe-integer value: 9007199254740992', + ); + expect(errors[1].message).toEqual( + 'SafeInt cannot represent non-safe-integer value: -9007199254740992', + ); + expect(errors[2].message).toEqual( + 'SafeInt cannot represent non-integer value: "42"', + ); + }); + }); +});