5 - Cache anagram results, keyed on the dictionary contents
This commit is contained in:
parent
24fa771d39
commit
b11bcc3f0b
3 changed files with 127 additions and 15 deletions
|
|
@ -3,13 +3,15 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\FindAnagramWordsRequest;
|
use App\Http\Requests\FindAnagramWordsRequest;
|
||||||
|
use App\Services\AnagramSearch;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Lvl0\AnagramFinder\Core\Matcher;
|
|
||||||
|
|
||||||
class AnagramController extends Controller
|
class AnagramController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly AnagramSearch $search) {}
|
||||||
|
|
||||||
public function index(): View
|
public function index(): View
|
||||||
{
|
{
|
||||||
return view('index');
|
return view('index');
|
||||||
|
|
@ -17,16 +19,30 @@ public function index(): View
|
||||||
|
|
||||||
public function find(FindAnagramWordsRequest $request): RedirectResponse
|
public function find(FindAnagramWordsRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$anagram = $request->get('anagram');
|
return redirect()->route('results', $request->validated('anagram'));
|
||||||
|
|
||||||
return redirect()->route('results', $anagram);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function results(Request $request)
|
public function results(string $anagram): View|RedirectResponse
|
||||||
{
|
{
|
||||||
$anagram = $request->anagram;
|
// The URL segment bypasses the form request, so it gets the same rules.
|
||||||
$matches = Matcher::findWords($anagram);
|
$anagram = strtolower($anagram);
|
||||||
|
|
||||||
return view('index', ['anagram' => $anagram, 'matches' => $matches]);
|
$validator = Validator::make(
|
||||||
|
['anagram' => $anagram],
|
||||||
|
(new FindAnagramWordsRequest)->rules(),
|
||||||
|
(new FindAnagramWordsRequest)->messages(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return redirect()
|
||||||
|
->route('home')
|
||||||
|
->withErrors($validator)
|
||||||
|
->withInput(['anagram' => $anagram]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('index', [
|
||||||
|
'anagram' => $anagram,
|
||||||
|
'matches' => $this->search->find($anagram),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,35 @@
|
||||||
|
|
||||||
class FindAnagramWordsRequest extends FormRequest
|
class FindAnagramWordsRequest extends FormRequest
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* Determine if the user is authorized to make this request.
|
|
||||||
*/
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
//
|
'anagram' => ['required', 'string', 'alpha:ascii', 'min:2', 'max:24'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'anagram.alpha' => 'Letters only — no digits, spaces or punctuation.',
|
||||||
|
'anagram.max' => 'That is longer than any word in the dictionary.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dictionary is lowercase and Matcher::check compares raw characters,
|
||||||
|
// so an uppercase anagram would silently return nothing.
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'anagram' => strtolower((string) $this->input('anagram', '')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
app/Services/AnagramSearch.php
Normal file
84
app/Services/AnagramSearch.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue