Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BREAKING/BUGFIX Strict coercion of scalar types #1382

Merged
merged 2 commits into from
Jun 12, 2018
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/execution/__tests__/executor-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ describe('Execute: Handles basic execution tasks', () => {

const rootValue = { root: 'val' };

execute(schema, ast, rootValue, null, { var: 123 });
execute(schema, ast, rootValue, null, { var: 'abc' });

expect(Object.keys(info)).to.deep.equal([
'fieldName',
Expand All @@ -304,7 +304,7 @@ describe('Execute: Handles basic execution tasks', () => {
expect(info.schema).to.equal(schema);
expect(info.rootValue).to.equal(rootValue);
expect(info.operation).to.equal(ast.definitions[0]);
expect(info.variableValues).to.deep.equal({ var: '123' });
expect(info.variableValues).to.deep.equal({ var: 'abc' });
});

it('threads root value context correctly', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/execution/__tests__/variables-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ describe('Execute: Handles inputs', () => {
{
message:
'Variable "$value" got invalid value [1, 2, 3]; Expected type ' +
'String; String cannot represent an array value: [1, 2, 3]',
'String; String cannot represent a non string value: [1, 2, 3]',
locations: [{ line: 2, column: 16 }],
},
],
Expand Down
20 changes: 20 additions & 0 deletions src/jsutils/isFinite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Copyright (c) 2018-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
*/

declare function isFinite(value: mixed): boolean %checks(typeof value ===
'number');

/* eslint-disable no-redeclare */
// $FlowFixMe workaround for: https://github.com/facebook/flow/issues/4441
const isFinite =
Number.isFinite ||
function(value) {
return typeof value === 'number' && isFinite(value);
};
export default isFinite;
68 changes: 54 additions & 14 deletions src/type/__tests__/serialization-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('Type System: Scalar coercion', () => {
);
// Doesn't represent number
expect(() => GraphQLInt.serialize('')).to.throw(
'Int cannot represent non-integer value: (empty string)',
'Int cannot represent non-integer value: ""',
);
expect(() => GraphQLInt.serialize(NaN)).to.throw(
'Int cannot represent non-integer value: NaN',
Expand Down Expand Up @@ -95,26 +95,24 @@ describe('Type System: Scalar coercion', () => {
'Float cannot represent non numeric value: "one"',
);
expect(() => GraphQLFloat.serialize('')).to.throw(
'Float cannot represent non numeric value: (empty string)',
'Float cannot represent non numeric value: ""',
);
expect(() => GraphQLFloat.serialize([5])).to.throw(
'Float cannot represent an array value: [5]',
);
});

for (const scalar of [GraphQLString, GraphQLID]) {
it(`serializes output as ${scalar}`, () => {
expect(scalar.serialize('string')).to.equal('string');
expect(scalar.serialize(1)).to.equal('1');
expect(scalar.serialize(-1.1)).to.equal('-1.1');
expect(scalar.serialize(true)).to.equal('true');
expect(scalar.serialize(false)).to.equal('false');
it(`serializes output as String`, () => {
expect(GraphQLString.serialize('string')).to.equal('string');
expect(GraphQLString.serialize(1)).to.equal('1');
expect(GraphQLString.serialize(-1.1)).to.equal('-1.1');
expect(GraphQLString.serialize(true)).to.equal('true');
expect(GraphQLString.serialize(false)).to.equal('false');

expect(() => scalar.serialize([1])).to.throw(
'String cannot represent an array value: [1]',
);
});
}
expect(() => GraphQLString.serialize([1])).to.throw(
'String cannot represent an array value: [1]',
);
});

it('serializes output as Boolean', () => {
expect(GraphQLBoolean.serialize('string')).to.equal(true);
Expand All @@ -129,4 +127,46 @@ describe('Type System: Scalar coercion', () => {
'Boolean cannot represent an array value: [false]',
);
});

it('serializes output as ID', () => {
expect(GraphQLID.serialize('string')).to.equal('string');
expect(GraphQLID.serialize('false')).to.equal('false');
expect(GraphQLID.serialize('')).to.equal('');
expect(GraphQLID.serialize(123)).to.equal('123');
expect(GraphQLID.serialize(0)).to.equal('0');

const objValue = {
_id: 123,
valueOf() {
return this._id;
},
};
expect(GraphQLID.serialize(objValue)).to.equal('123');

const badObjValue = {
_id: false,
valueOf() {
return this._id;
},
};
expect(() => GraphQLID.serialize(badObjValue)).to.throw(
'ID cannot represent value: {_id: false, valueOf: [function valueOf]}',
);

expect(() => GraphQLID.serialize(true)).to.throw(
'ID cannot represent value: true',
);

expect(() => GraphQLID.serialize(-1.1)).to.throw(
'ID cannot represent value: -1.1',
);

expect(() => GraphQLID.serialize({})).to.throw(
'ID cannot represent value: {}',
);

expect(() => GraphQLID.serialize(['abc'])).to.throw(
'ID cannot represent value: ["abc"]',
);
});
});
117 changes: 88 additions & 29 deletions src/type/scalars.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

import inspect from '../jsutils/inspect';
import isFinite from '../jsutils/isFinite';
import isInteger from '../jsutils/isInteger';
import { GraphQLScalarType, isNamedType } from './definition';
import { Kind } from '../language/kinds';
Expand All @@ -20,38 +21,46 @@ import { Kind } from '../language/kinds';
const MAX_INT = 2147483647;
const MIN_INT = -2147483648;

function coerceInt(value: mixed): number {
function serializeInt(value: mixed): number {
if (Array.isArray(value)) {
throw new TypeError(
`Int cannot represent an array value: [${String(value)}]`,
`Int cannot represent an array value: ${inspect(value)}`,
);
}
if (value === '') {
const num = Number(value);
if (value === '' || !isInteger(num)) {
throw new TypeError(
'Int cannot represent non-integer value: (empty string)',
`Int cannot represent non-integer value: ${inspect(value)}`,
);
}
const num = Number(value);
if (!isInteger(num)) {
if (num > MAX_INT || num < MIN_INT) {
throw new TypeError(
'Int cannot represent non-integer value: ' + inspect(value),
`Int cannot represent non 32-bit signed integer value: ${inspect(value)}`,
);
}
return num;
}

if (num > MAX_INT || num < MIN_INT) {
function coerceInt(value: mixed): number {
if (!isInteger(value)) {
throw new TypeError(
'Int cannot represent non 32-bit signed integer value: ' + inspect(value),
`Int cannot represent non-integer value: ${inspect(value)}`,
);
}
return num;
if (value > MAX_INT || value < MIN_INT) {
throw new TypeError(
`Int cannot represent non 32-bit signed integer value: ${inspect(value)}`,
);
}
return value;
}

export const GraphQLInt = new GraphQLScalarType({
name: 'Int',
description:
'The `Int` scalar type represents non-fractional signed whole numeric ' +
'values. Int can represent values between -(2^31) and 2^31 - 1. ',
serialize: coerceInt,
serialize: serializeInt,
parseValue: coerceInt,
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
Expand All @@ -64,24 +73,28 @@ export const GraphQLInt = new GraphQLScalarType({
},
});

function coerceFloat(value: mixed): number {
function serializeFloat(value: mixed): number {
if (Array.isArray(value)) {
throw new TypeError(
`Float cannot represent an array value: [${String(value)}]`,
`Float cannot represent an array value: ${inspect(value)}`,
);
}
if (value === '') {
const num = Number(value);
if (value === '' || !isFinite(num)) {
throw new TypeError(
'Float cannot represent non numeric value: (empty string)',
`Float cannot represent non numeric value: ${inspect(value)}`,
);
}
const num = Number(value);
if (isFinite(num)) {
return num;
return num;
}

function coerceFloat(value: mixed): number {
if (!isFinite(value)) {
throw new TypeError(
`Float cannot represent non numeric value: ${inspect(value)}`,
);
}
throw new TypeError(
'Float cannot represent non numeric value: ' + inspect(value),
);
return value;
}

export const GraphQLFloat = new GraphQLScalarType({
Expand All @@ -90,7 +103,7 @@ export const GraphQLFloat = new GraphQLScalarType({
'The `Float` scalar type represents signed double-precision fractional ' +
'values as specified by ' +
'[IEEE 754](http://en.wikipedia.org/wiki/IEEE_floating_point). ',
serialize: coerceFloat,
serialize: serializeFloat,
parseValue: coerceFloat,
parseLiteral(ast) {
return ast.kind === Kind.FLOAT || ast.kind === Kind.INT
Expand All @@ -99,7 +112,7 @@ export const GraphQLFloat = new GraphQLScalarType({
},
});

function coerceString(value: mixed): string {
function serializeString(value: mixed): string {
Copy link
Member

@IvanGoncharov IvanGoncharov Jun 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make it strict similar to serializeID so it would expect only string and object with either valueOf or toString?
Or at least throw on serializeString({}) instead of returning [object Object]?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I agree the [object Object] scenario is useless - it should only string coerce if there's a toString implementation worth calling

if (Array.isArray(value)) {
throw new TypeError(
`String cannot represent an array value: ${inspect(value)}`,
Expand All @@ -108,38 +121,84 @@ function coerceString(value: mixed): string {
return String(value);
}

function coerceString(value: mixed): string {
if (typeof value !== 'string') {
throw new TypeError(
`String cannot represent a non string value: ${inspect(value)}`,
);
}
return value;
}

export const GraphQLString = new GraphQLScalarType({
name: 'String',
description:
'The `String` scalar type represents textual data, represented as UTF-8 ' +
'character sequences. The String type is most often used by GraphQL to ' +
'represent free-form human-readable text.',
serialize: coerceString,
serialize: serializeString,
parseValue: coerceString,
parseLiteral(ast) {
return ast.kind === Kind.STRING ? ast.value : undefined;
},
});

function coerceBoolean(value: mixed): boolean {
function serializeBoolean(value: mixed): boolean {
if (Array.isArray(value)) {
throw new TypeError(
`Boolean cannot represent an array value: [${String(value)}]`,
`Boolean cannot represent an array value: ${inspect(value)}`,
);
}
return Boolean(value);
}

function coerceBoolean(value: mixed): boolean {
if (typeof value !== 'boolean') {
throw new TypeError(
`Boolean cannot represent a non boolean value: ${inspect(value)}`,
);
}
return value;
}

export const GraphQLBoolean = new GraphQLScalarType({
name: 'Boolean',
description: 'The `Boolean` scalar type represents `true` or `false`.',
serialize: coerceBoolean,
serialize: serializeBoolean,
parseValue: coerceBoolean,
parseLiteral(ast) {
return ast.kind === Kind.BOOLEAN ? ast.value : undefined;
},
});

function serializeID(value: mixed): string {
// Support serializing objects with custom valueOf() functions - a common way
// to represent an object identifier (ex. MongoDB).
const result =
value &&
typeof value.valueOf === 'function' &&
value.valueOf !== Object.prototype.valueOf
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need this check since:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/valueOf

By default, the valueOf method is inherited by every object descended from Object. Every built-in core object overrides this method to return an appropriate value. If an object has no primitive value, valueOf returns the object itself.

So it would be enough to just call value.valueOf() and it default one it will fail the next check.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right, I'll double check by running the tests

? value.valueOf()
: value;
if (
typeof result !== 'string' &&
(typeof result !== 'number' || !isInteger(result))
) {
throw new TypeError(`ID cannot represent value: ${inspect(value)}`);
}
return String(result);
}

function coerceID(value: mixed): string {
if (
typeof value !== 'string' &&
(typeof value !== 'number' || !isInteger(value))
) {
throw new TypeError(`ID cannot represent value: ${inspect(value)}`);
}
return String(value);
}

export const GraphQLID = new GraphQLScalarType({
name: 'ID',
description:
Expand All @@ -148,8 +207,8 @@ export const GraphQLID = new GraphQLScalarType({
'response as a String; however, it is not intended to be human-readable. ' +
'When expected as an input type, any string (such as `"4"`) or integer ' +
'(such as `4`) input value will be accepted as an ID.',
serialize: coerceString,
parseValue: coerceString,
serialize: serializeID,
parseValue: coerceID,
parseLiteral(ast) {
return ast.kind === Kind.STRING || ast.kind === Kind.INT
? ast.value
Expand Down
7 changes: 3 additions & 4 deletions src/utilities/__tests__/astFromValue-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,9 @@ describe('astFromValue', () => {
value: '01',
});

expect(astFromValue(false, GraphQLID)).to.deep.equal({
kind: 'StringValue',
value: 'false',
});
expect(() => astFromValue(false, GraphQLID)).to.throw(
'ID cannot represent value: false',
);

expect(astFromValue(null, GraphQLID)).to.deep.equal({ kind: 'NullValue' });

Expand Down
Loading