From 16d2535784da070a2c6801100b370ee89f1b0ade Mon Sep 17 00:00:00 2001 From: Ivan Goncharov Date: Fri, 15 Jan 2021 16:58:27 +0200 Subject: [PATCH] Replace 'localeCompare' with function independent from locale (#2876) Fixes #2869 --- src/jsutils/__tests__/naturalCompare-test.js | 72 +++++++++++++++++++ src/jsutils/naturalCompare.js | 58 +++++++++++++++ src/jsutils/suggestionList.js | 4 +- src/utilities/findBreakingChanges.js | 8 ++- src/utilities/lexicographicSortSchema.js | 3 +- .../rules/FieldsOnCorrectTypeRule.js | 3 +- 6 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 src/jsutils/__tests__/naturalCompare-test.js create mode 100644 src/jsutils/naturalCompare.js diff --git a/src/jsutils/__tests__/naturalCompare-test.js b/src/jsutils/__tests__/naturalCompare-test.js new file mode 100644 index 0000000000..0aee2cae86 --- /dev/null +++ b/src/jsutils/__tests__/naturalCompare-test.js @@ -0,0 +1,72 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import naturalCompare from '../naturalCompare'; + +describe('naturalCompare', () => { + it('Handles empty strings', () => { + expect(naturalCompare('', '')).to.equal(0); + + expect(naturalCompare('', 'a')).to.equal(-1); + expect(naturalCompare('', '1')).to.equal(-1); + + expect(naturalCompare('a', '')).to.equal(1); + expect(naturalCompare('1', '')).to.equal(1); + }); + + it('Handles strings of different length', () => { + expect(naturalCompare('A', 'A')).to.equal(0); + expect(naturalCompare('A1', 'A1')).to.equal(0); + + expect(naturalCompare('A', 'AA')).to.equal(-1); + expect(naturalCompare('A1', 'A1A')).to.equal(-1); + + expect(naturalCompare('AA', 'A')).to.equal(1); + expect(naturalCompare('A1A', 'A1')).to.equal(1); + }); + + it('Handles numbers', () => { + expect(naturalCompare('0', '0')).to.equal(0); + expect(naturalCompare('1', '1')).to.equal(0); + + expect(naturalCompare('1', '2')).to.equal(-1); + expect(naturalCompare('2', '1')).to.equal(1); + + expect(naturalCompare('2', '11')).to.equal(-1); + expect(naturalCompare('11', '2')).to.equal(1); + }); + + it('Handles numbers with leading zeros', () => { + expect(naturalCompare('00', '00')).to.equal(0); + expect(naturalCompare('0', '00')).to.equal(-1); + expect(naturalCompare('00', '0')).to.equal(1); + + expect(naturalCompare('02', '11')).to.equal(-1); + expect(naturalCompare('11', '02')).to.equal(1); + + expect(naturalCompare('011', '200')).to.equal(-1); + expect(naturalCompare('200', '011')).to.equal(1); + }); + + it('Handles numbers embedded into names', () => { + expect(naturalCompare('a0a', 'a0a')).to.equal(0); + expect(naturalCompare('a0a', 'a9a')).to.equal(-1); + expect(naturalCompare('a9a', 'a0a')).to.equal(1); + + expect(naturalCompare('a00a', 'a00a')).to.equal(0); + expect(naturalCompare('a00a', 'a09a')).to.equal(-1); + expect(naturalCompare('a09a', 'a00a')).to.equal(1); + + expect(naturalCompare('a0a1', 'a0a1')).to.equal(0); + expect(naturalCompare('a0a1', 'a0a9')).to.equal(-1); + expect(naturalCompare('a0a9', 'a0a1')).to.equal(1); + + expect(naturalCompare('a10a11a', 'a10a11a')).to.equal(0); + expect(naturalCompare('a10a11a', 'a10a19a')).to.equal(-1); + expect(naturalCompare('a10a19a', 'a10a11a')).to.equal(1); + + expect(naturalCompare('a10a11a', 'a10a11a')).to.equal(0); + expect(naturalCompare('a10a11a', 'a10a11b')).to.equal(-1); + expect(naturalCompare('a10a11b', 'a10a11a')).to.equal(1); + }); +}); diff --git a/src/jsutils/naturalCompare.js b/src/jsutils/naturalCompare.js new file mode 100644 index 0000000000..5f624b70d8 --- /dev/null +++ b/src/jsutils/naturalCompare.js @@ -0,0 +1,58 @@ +/** + * Returns a number indicating whether a reference string comes before, or after, + * or is the same as the given string in natural sort order. + * + * See: https://en.wikipedia.org/wiki/Natural_sort_order + * + */ +export default function naturalCompare(aStr: string, bStr: string): number { + let aIdx = 0; + let bIdx = 0; + + while (aIdx < aStr.length && bIdx < bStr.length) { + let aChar = aStr.charCodeAt(aIdx); + let bChar = bStr.charCodeAt(bIdx); + + if (isDigit(aChar) && isDigit(bChar)) { + let aNum = 0; + do { + ++aIdx; + aNum = aNum * 10 + aChar - DIGIT_0; + aChar = aStr.charCodeAt(aIdx); + } while (isDigit(aChar) && aNum > 0); + + let bNum = 0; + do { + ++bIdx; + bNum = bNum * 10 + bChar - DIGIT_0; + bChar = bStr.charCodeAt(bIdx); + } while (isDigit(bChar) && bNum > 0); + + if (aNum < bNum) { + return -1; + } + + if (aNum > bNum) { + return 1; + } + } else { + if (aChar < bChar) { + return -1; + } + if (aChar > bChar) { + return 1; + } + ++aIdx; + ++bIdx; + } + } + + return aStr.length - bStr.length; +} + +const DIGIT_0 = 48; +const DIGIT_9 = 57; + +function isDigit(code: number): boolean { + return !isNaN(code) && DIGIT_0 <= code && code <= DIGIT_9; +} diff --git a/src/jsutils/suggestionList.js b/src/jsutils/suggestionList.js index 4128e24b07..33cc2f1dde 100644 --- a/src/jsutils/suggestionList.js +++ b/src/jsutils/suggestionList.js @@ -1,3 +1,5 @@ +import naturalCompare from './naturalCompare'; + /** * Given an invalid input string and a list of valid options, returns a filtered * list of valid options sorted based on their similarity with the input. @@ -19,7 +21,7 @@ export default function suggestionList( return Object.keys(optionsByDistance).sort((a, b) => { const distanceDiff = optionsByDistance[a] - optionsByDistance[b]; - return distanceDiff !== 0 ? distanceDiff : a.localeCompare(b); + return distanceDiff !== 0 ? distanceDiff : naturalCompare(a, b); }); } diff --git a/src/utilities/findBreakingChanges.js b/src/utilities/findBreakingChanges.js index 999f93feb9..9f74a2d95c 100644 --- a/src/utilities/findBreakingChanges.js +++ b/src/utilities/findBreakingChanges.js @@ -3,6 +3,7 @@ import objectValues from '../polyfills/objectValues'; import keyMap from '../jsutils/keyMap'; import inspect from '../jsutils/inspect'; import invariant from '../jsutils/invariant'; +import naturalCompare from '../jsutils/naturalCompare'; import { print } from '../language/printer'; import { visit } from '../language/visitor'; @@ -541,8 +542,11 @@ function stringifyValue(value: mixed, type: GraphQLInputType): string { const sortedAST = visit(ast, { ObjectValue(objectNode) { - const fields = [...objectNode.fields].sort((fieldA, fieldB) => - fieldA.name.value.localeCompare(fieldB.name.value), + // Make a copy since sort mutates array + const fields = [...objectNode.fields]; + + fields.sort((fieldA, fieldB) => + naturalCompare(fieldA.name.value, fieldB.name.value), ); return { ...objectNode, fields }; }, diff --git a/src/utilities/lexicographicSortSchema.js b/src/utilities/lexicographicSortSchema.js index 57cbff0c48..8170ac0d5b 100644 --- a/src/utilities/lexicographicSortSchema.js +++ b/src/utilities/lexicographicSortSchema.js @@ -4,6 +4,7 @@ import type { ObjMap } from '../jsutils/ObjMap'; import inspect from '../jsutils/inspect'; import invariant from '../jsutils/invariant'; import keyValMap from '../jsutils/keyValMap'; +import naturalCompare from '../jsutils/naturalCompare'; import type { GraphQLType, @@ -180,6 +181,6 @@ function sortBy( return array.slice().sort((obj1, obj2) => { const key1 = mapToKey(obj1); const key2 = mapToKey(obj2); - return key1.localeCompare(key2); + return naturalCompare(key1, key2); }); } diff --git a/src/validation/rules/FieldsOnCorrectTypeRule.js b/src/validation/rules/FieldsOnCorrectTypeRule.js index 5b60d53646..58149d90d5 100644 --- a/src/validation/rules/FieldsOnCorrectTypeRule.js +++ b/src/validation/rules/FieldsOnCorrectTypeRule.js @@ -2,6 +2,7 @@ import arrayFrom from '../../polyfills/arrayFrom'; import didYouMean from '../../jsutils/didYouMean'; import suggestionList from '../../jsutils/suggestionList'; +import naturalCompare from '../../jsutils/naturalCompare'; import { GraphQLError } from '../../error/GraphQLError'; @@ -122,7 +123,7 @@ function getSuggestedTypeNames( return 1; } - return typeA.name.localeCompare(typeB.name); + return naturalCompare(typeA.name, typeB.name); }) .map((x) => x.name); }