85 lines
2.4 KiB
PHP
85 lines
2.4 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use Illuminate\Support\Collection;
|
||
|
|
use Illuminate\Support\Facades\Cache;
|
||
|
|
use Lvl0\AnagramFinder\Core\Matcher;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Caches anagram lookups. A search scans the whole dictionary — 466k words —
|
||
|
|
* and the result is fixed for a given (letters, minLength, dictionary), so it
|
||
|
|
* only ever needs computing once.
|
||
|
|
*/
|
||
|
|
class AnagramSearch
|
||
|
|
{
|
||
|
|
private const DICTIONARY = __DIR__ . '/../../vendor/anagram-finder/core/assets/words.txt';
|
||
|
|
|
||
|
|
public function find(string $anagram, int $minLength = 3): Collection
|
||
|
|
{
|
||
|
|
$key = $this->key($anagram, $minLength);
|
||
|
|
|
||
|
|
// Cache a plain array: a serialised Collection comes back as
|
||
|
|
// __PHP_Incomplete_Class through the database store.
|
||
|
|
$words = Cache::rememberForever(
|
||
|
|
$key,
|
||
|
|
fn () => Matcher::findWords($this->normalise($anagram), $minLength)
|
||
|
|
->values()
|
||
|
|
->all(),
|
||
|
|
);
|
||
|
|
|
||
|
|
return new Collection($words);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Entries live forever; the dictionary hash in the key is the invalidation
|
||
|
|
* mechanism. Replacing words.txt makes every old key unreachable rather
|
||
|
|
* than stale, so nothing has to be flushed by hand.
|
||
|
|
*/
|
||
|
|
private function key(string $anagram, int $minLength): string
|
||
|
|
{
|
||
|
|
return sprintf(
|
||
|
|
'anagram:%s:%d:%s',
|
||
|
|
$this->dictionaryHash(),
|
||
|
|
$minLength,
|
||
|
|
$this->normalise($anagram),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sorted letters, so "eamstoxil" and "latoximse" share one entry — the same
|
||
|
|
* bag of letters is the same query.
|
||
|
|
*/
|
||
|
|
private function normalise(string $anagram): string
|
||
|
|
{
|
||
|
|
$letters = str_split(strtolower($anagram));
|
||
|
|
sort($letters);
|
||
|
|
|
||
|
|
return implode('', $letters);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Content-addressed, not mtime: the dictionary ships inside the image, so
|
||
|
|
* its mtime changes on every rebuild even when the words do not.
|
||
|
|
*
|
||
|
|
* Hashing 4.6MB per request would undo the saving, so the hash is itself
|
||
|
|
* cached under a key made of the cheap stat values that change whenever the
|
||
|
|
* file is replaced.
|
||
|
|
*/
|
||
|
|
private function dictionaryHash(): string
|
||
|
|
{
|
||
|
|
$path = self::DICTIONARY;
|
||
|
|
|
||
|
|
if (! is_file($path)) {
|
||
|
|
return 'nodict';
|
||
|
|
}
|
||
|
|
|
||
|
|
$stat = filemtime($path) . ':' . filesize($path);
|
||
|
|
|
||
|
|
return Cache::rememberForever(
|
||
|
|
"anagram:dict:{$stat}",
|
||
|
|
fn () => hash_file('xxh128', $path),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|