Skip to content

Commit b6ac0ff

Browse files
authored
Align Utils::suggestionList() with the reference implementation (#1075)
1 parent ac8dbf0 commit b6ac0ff

File tree

9 files changed

+250
-38
lines changed

9 files changed

+250
-38
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ You can find and compare releases at the [GitHub release page](https://github.co
4141
- Throw if `Introspection::fromSchema()` returns no data
4242
- Reorganize abstract class `ASTValidationContext` to interface `ValidationContext`
4343
- Reorganize AST interfaces related to schema and type extensions
44+
- Align `Utils::suggestionList()` with the reference implementation (#1075)
4445

4546
### Added
4647

src/Utils/LexicalDistance.php

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace GraphQL\Utils;
4+
5+
/**
6+
* Computes the lexical distance between strings A and B.
7+
*
8+
* The "distance" between two strings is given by counting the minimum number
9+
* of edits needed to transform string A into string B. An edit can be an
10+
* insertion, deletion, or substitution of a single character, or a swap of two
11+
* adjacent characters.
12+
*
13+
* Includes a custom alteration from Damerau-Levenshtein to treat case changes
14+
* as a single edit which helps identify mis-cased values with an edit distance
15+
* of 1.
16+
*
17+
* This distance can be useful for detecting typos in input or sorting
18+
*
19+
* Unlike the native levenshtein() function that always returns int, LexicalDistance::measure() returns int|null.
20+
* It takes into account the threshold and returns null if the measured distance is bigger.
21+
*/
22+
class LexicalDistance
23+
{
24+
private string $input;
25+
26+
private string $inputLowerCase;
27+
28+
/**
29+
* List of char codes in the input string.
30+
*
31+
* @var array<int>
32+
*/
33+
private array $inputArray;
34+
35+
public function __construct(string $input)
36+
{
37+
$this->input = $input;
38+
$this->inputLowerCase = \strtolower($input);
39+
$this->inputArray = self::stringToArray($this->inputLowerCase);
40+
}
41+
42+
public function measure(string $option, float $threshold): ?int
43+
{
44+
if ($this->input === $option) {
45+
return 0;
46+
}
47+
48+
$optionLowerCase = \strtolower($option);
49+
50+
// Any case change counts as a single edit
51+
if ($this->inputLowerCase === $optionLowerCase) {
52+
return 1;
53+
}
54+
55+
$a = self::stringToArray($optionLowerCase);
56+
$b = $this->inputArray;
57+
58+
if (\count($a) < \count($b)) {
59+
$tmp = $a;
60+
$a = $b;
61+
$b = $tmp;
62+
}
63+
64+
$aLength = \count($a);
65+
$bLength = \count($b);
66+
67+
if ($aLength - $bLength > $threshold) {
68+
return null;
69+
}
70+
71+
/** @var array<array<int>> $rows */
72+
$rows = [];
73+
for ($i = 0; $i <= $bLength; ++$i) {
74+
$rows[0][$i] = $i;
75+
}
76+
77+
for ($i = 1; $i <= $aLength; ++$i) {
78+
$upRow = &$rows[($i - 1) % 3];
79+
$currentRow = &$rows[$i % 3];
80+
81+
$smallestCell = ($currentRow[0] = $i);
82+
for ($j = 1; $j <= $bLength; ++$j) {
83+
$cost = $a[$i - 1] === $b[$j - 1] ? 0 : 1;
84+
85+
$currentCell = \min(
86+
$upRow[$j] + 1, // delete
87+
$currentRow[$j - 1] + 1, // insert
88+
$upRow[$j - 1] + $cost, // substitute
89+
);
90+
91+
if ($i > 1 && $j > 1 && $a[$i - 1] === $b[$j - 2] && $a[$i - 2] === $b[$j - 1]) {
92+
// transposition
93+
$doubleDiagonalCell = $rows[($i - 2) % 3][$j - 2];
94+
$currentCell = \min($currentCell, $doubleDiagonalCell + 1);
95+
}
96+
97+
if ($currentCell < $smallestCell) {
98+
$smallestCell = $currentCell;
99+
}
100+
101+
$currentRow[$j] = $currentCell;
102+
}
103+
104+
// Early exit, since distance can't go smaller than smallest element of the previous row.
105+
if ($smallestCell > $threshold) {
106+
return null;
107+
}
108+
}
109+
110+
$distance = $rows[$aLength % 3][$bLength];
111+
112+
return $distance <= $threshold ? $distance : null;
113+
}
114+
115+
/**
116+
* Returns a list of char codes in the given string.
117+
*
118+
* @return array<int>
119+
*/
120+
private static function stringToArray(string $str): array
121+
{
122+
$array = [];
123+
foreach (\mb_str_split($str) as $char) {
124+
$array[] = \mb_ord($char);
125+
}
126+
127+
return $array;
128+
}
129+
}

src/Utils/Utils.php

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
use function array_map;
77
use function array_reduce;
88
use function array_slice;
9-
use function asort;
109
use function count;
1110
use function dechex;
1211
use function get_class;
@@ -20,7 +19,6 @@
2019
use function is_scalar;
2120
use function is_string;
2221
use function json_encode;
23-
use function levenshtein;
2422
use function mb_convert_encoding;
2523
use function mb_strlen;
2624
use function mb_substr;
@@ -31,7 +29,6 @@
3129
use function property_exists;
3230
use function range;
3331
use stdClass;
34-
use function strtolower;
3532
use function unpack;
3633

3734
class Utils
@@ -284,34 +281,30 @@ static function ($list, $index) use ($selected, $selectedLength): string {
284281
* Given an invalid input string and a list of valid options, returns a filtered
285282
* list of valid options sorted based on their similarity with the input.
286283
*
287-
* Includes a custom alteration from Damerau-Levenshtein to treat case changes
288-
* as a single edit which helps identify mis-cased values with an edit distance
289-
* of 1
290-
*
291284
* @param array<string> $options
292285
*
293286
* @return array<int, string>
294287
*/
295288
public static function suggestionList(string $input, array $options): array
296289
{
290+
/** @var array<string, int> $optionsByDistance */
297291
$optionsByDistance = [];
292+
$lexicalDistance = new LexicalDistance($input);
298293
$threshold = mb_strlen($input) * 0.4 + 1;
299294
foreach ($options as $option) {
300-
if ($input === $option) {
301-
$distance = 0;
302-
} else {
303-
$distance = (strtolower($input) === strtolower($option)
304-
? 1
305-
: levenshtein($input, $option));
306-
}
295+
$distance = $lexicalDistance->measure($option, $threshold);
307296

308-
if ($distance <= $threshold) {
297+
if ($distance !== null) {
309298
$optionsByDistance[$option] = $distance;
310299
}
311300
}
312301

313-
asort($optionsByDistance);
302+
\uksort($optionsByDistance, static function (string $a, string $b) use ($optionsByDistance) {
303+
$distanceDiff = $optionsByDistance[$a] - $optionsByDistance[$b];
304+
305+
return $distanceDiff !== 0 ? $distanceDiff : \strnatcmp($a, $b);
306+
});
314307

315-
return array_keys($optionsByDistance);
308+
return array_map('strval', array_keys($optionsByDistance));
316309
}
317310
}

tests/Error/ErrorTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ public function getNodes(): ?array
201201
self::assertEquals([1 => 2], $locatedError->getPositions());
202202
self::assertNotNull($locatedError->getSource());
203203

204-
$error = new class('msg', new NullValueNode([]), null, [], ) extends Error {
204+
$error = new class('msg', new NullValueNode([]), null, []) extends Error {
205205
public function getNodes(): ?array
206206
{
207207
return [new NullValueNode([])];

tests/Executor/DeferredFieldsTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,10 @@ public function testDeferredChaining(): void
592592
}
593593
');
594594

595-
$author1 = ['name' => 'John'/*, 'bestFriend' => ['name' => 'Dirk']*/];
596-
$author2 = ['name' => 'Jane'/*, 'bestFriend' => ['name' => 'Joe']*/];
597-
$author3 = ['name' => 'Joe'/*, 'bestFriend' => ['name' => 'Jane']*/];
598-
$author4 = ['name' => 'Dirk'/*, 'bestFriend' => ['name' => 'John']*/];
595+
$author1 = ['name' => 'John'/* , 'bestFriend' => ['name' => 'Dirk'] */];
596+
$author2 = ['name' => 'Jane'/* , 'bestFriend' => ['name' => 'Joe'] */];
597+
$author3 = ['name' => 'Joe'/* , 'bestFriend' => ['name' => 'Jane'] */];
598+
$author4 = ['name' => 'Dirk'/* , 'bestFriend' => ['name' => 'John'] */];
599599

600600
$story1 = ['title' => 'Story #8', 'author' => $author1];
601601
$story2 = ['title' => 'Story #3', 'author' => $author3];

tests/Type/EnumTypeTest.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,7 @@ public function testDoesNotAcceptValuesWithIncorrectCasing(): void
348348
'{ colorEnum(fromEnum: green) }',
349349
null,
350350
[
351-
// Improves upon the reference implementation
352-
'message' => 'Value "green" does not exist in "Color" enum. Did you mean the enum value "GREEN"?',
351+
'message' => 'Value "green" does not exist in "Color" enum. Did you mean the enum value "GREEN" or "RED"?',
353352
'locations' => [new SourceLocation(1, 23)],
354353
]
355354
);

tests/Type/ResolveInfoTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ public function testMergedFragmentsFieldSelection(): void
322322
'url' => true,
323323
],
324324
'replies' => [
325-
'body' => true, //this would be missing if not for the fix https://github.com/webonyx/graphql-php/pull/98
325+
'body' => true, // this would be missing if not for the fix https://github.com/webonyx/graphql-php/pull/98
326326
'author' => [
327327
'id' => true,
328328
'name' => true,

tests/Utils/BreakingChangesFinderTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function setUp(): void
3030
]);
3131
}
3232

33-
//DESCRIBE: findBreakingChanges
33+
// DESCRIBE: findBreakingChanges
3434

3535
/**
3636
* @see it('should detect if a type was removed or not')
@@ -1769,7 +1769,7 @@ public function testShouldDetectIfATypeWasAddedToAUnionType(): void
17691769
],
17701770
]);
17711771
// logially equivalent to type1; findTypesRemovedFromUnions should not
1772-
//treat this as different than type1
1772+
// treat this as different than type1
17731773
$type1a = new ObjectType([
17741774
'name' => 'Type1',
17751775
'fields' => [

0 commit comments

Comments
 (0)