diff --git a/migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php b/migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php new file mode 100644 index 00000000..31d02e84 --- /dev/null +++ b/migrations/2026_10_06_000000_add_subject_uuid_index_to_files_table.php @@ -0,0 +1,46 @@ +indexExists('files', 'files_subject_uuid_index')) { + return; + } + + Schema::table('files', function (Blueprint $table) { + $table->index('subject_uuid'); + }); + } + + public function down(): void + { + if (!$this->indexExists('files', 'files_subject_uuid_index')) { + return; + } + + Schema::table('files', function (Blueprint $table) { + $table->dropIndex(['subject_uuid']); + }); + } + + protected function indexExists(string $table, string $index): bool + { + try { + $indexes = Schema::getConnection() + ->getDoctrineSchemaManager() + ->listTableIndexes($table); + + return isset($indexes[$index]); + } catch (Throwable $e) { + return false; + } + } +}; diff --git a/src/Support/Utils.php b/src/Support/Utils.php index a26b916f..d548060f 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -1129,28 +1129,94 @@ public static function isNotScalar($target) } /** - * Returns the ISO2 country name by providing a countries full name. + * Cache key for the country name, ISO2 and currency lookup. + */ + public const COUNTRY_LOOKUP_CACHE_KEY = 'fleetbase:utils:country-lookup:v1'; + + /** + * Country lookup memoized for the current process. * - * @param string countryName + * @var array|null */ - public static function getCountryCodeByName(?string $countryName, ?string $defaultValue = null): ?string + protected static ?array $countryLookup = null; + + /** + * Name, ISO2 and primary currency for every country. + * + * Building this list loads and hydrates the full countries dataset, which costs + * between a fraction of a second and several seconds. It is built once, kept in + * the application cache and memoized per process, so per-record callers (for + * example a store resource resolving its country from its currency) stay cheap. + * + * @return array + */ + public static function getCountryLookup(): array { - if (static::isEmpty($countryName) || !is_string($countryName)) { - return $defaultValue; + if (static::$countryLookup !== null) { + return static::$countryLookup; } - $countries = new \PragmaRX\Countries\Package\Countries(); - $countries = $countries + try { + $cached = Cache::get(static::COUNTRY_LOOKUP_CACHE_KEY); + } catch (\Throwable $e) { + $cached = null; + } + + if (is_array($cached) && !empty($cached)) { + return static::$countryLookup = $cached; + } + + $lookup = (new \PragmaRX\Countries\Package\Countries()) ->all() ->map(function ($country) { return [ - 'name' => static::get($country, 'name.common'), - 'iso2' => static::get($country, 'cca2'), + 'name' => static::get($country, 'name.common'), + 'iso2' => static::get($country, 'cca2'), + 'currency' => static::resolveCurrencyCode(static::get($country, 'currencies', [])), ]; }) ->values() ->toArray(); - $countries = collect($countries); + + try { + Cache::put(static::COUNTRY_LOOKUP_CACHE_KEY, $lookup); + } catch (\Throwable $e) { + // The lookup still works without a cache; it is rebuilt once per process. + } + + return static::$countryLookup = $lookup; + } + + /** + * Forget the memoized country lookup, and optionally the cached copy. + */ + public static function flushCountryLookup(bool $forgetCache = true): void + { + static::$countryLookup = null; + + if (!$forgetCache) { + return; + } + + try { + Cache::forget(static::COUNTRY_LOOKUP_CACHE_KEY); + } catch (\Throwable $e) { + // Nothing cached to forget. + } + } + + /** + * Returns the ISO2 country name by providing a countries full name. + * + * @param string countryName + */ + public static function getCountryCodeByName(?string $countryName, ?string $defaultValue = null): ?string + { + if (static::isEmpty($countryName) || !is_string($countryName)) { + return $defaultValue; + } + + $countries = collect(static::getCountryLookup()); $data = $countries->first(function ($country) use ($countryName) { // @todo switch to string contains or like search @@ -1174,19 +1240,7 @@ public static function getCountryCodeByCurrency(?string $currencyCode, ?string $ return $defaultValue; } - $countries = new \PragmaRX\Countries\Package\Countries(); - $countries = $countries - ->all() - ->map(function ($country) { - return [ - 'name' => static::get($country, 'name.common'), - 'iso2' => static::get($country, 'cca2'), - 'currency' => static::resolveCurrencyCode(static::get($country, 'currencies', [])), - ]; - }) - ->values() - ->toArray(); - $countries = collect($countries); + $countries = collect(static::getCountryLookup()); $data = $countries->first(function ($country) use ($currencyCode) { return is_string($country['currency']) && strtolower($country['currency']) === strtolower($currencyCode); diff --git a/tests/Unit/Support/UtilsTest.php b/tests/Unit/Support/UtilsTest.php index d042ad81..faf29e8c 100644 --- a/tests/Unit/Support/UtilsTest.php +++ b/tests/Unit/Support/UtilsTest.php @@ -1234,3 +1234,65 @@ public function toArray($request): array Utils::deleteDirectory($noPsrRoot); } }); + +test('utils builds the country lookup once and serves it from cache', function () { + bind_test_container(); + Utils::flushCountryLookup(); + + // A cached lookup is used as is, without loading the countries dataset. + app('cache')->put(Utils::COUNTRY_LOOKUP_CACHE_KEY, [ + ['name' => 'Testland', 'iso2' => 'TL', 'currency' => 'TLD'], + ]); + + expect(Utils::getCountryCodeByCurrency('TLD'))->toBe('TL') + ->and(Utils::getCountryCodeByName('testland'))->toBe('TL') + ->and(Utils::getCountryCodeByCurrency('MNT', 'ZZ'))->toBe('ZZ'); + + // Memoized per process: the cache is not read again. + app('cache')->forget(Utils::COUNTRY_LOOKUP_CACHE_KEY); + expect(Utils::getCountryCodeByCurrency('TLD'))->toBe('TL'); + + // Flushing rebuilds from the dataset and caches the result. + Utils::flushCountryLookup(); + expect(Utils::getCountryCodeByCurrency('MNT'))->toBe('MN') + ->and(Utils::getCountryCodeByCurrency('TLD'))->toBeNull(); + + $cached = app('cache')->get(Utils::COUNTRY_LOOKUP_CACHE_KEY); + expect($cached)->toBeArray() + ->and(collect($cached)->firstWhere('iso2', 'SG'))->toMatchArray(['iso2' => 'SG', 'currency' => 'SGD']); + + // Flushing only the memo keeps the cached copy. + Utils::flushCountryLookup(false); + expect(app('cache')->get(Utils::COUNTRY_LOOKUP_CACHE_KEY))->toBe($cached); + + Utils::flushCountryLookup(); +}); + +test('utils country lookup works when the cache store is unavailable', function () { + $container = bind_test_container(); + $container->instance('cache', new class { + public function get(string $key, mixed $default = null): mixed + { + throw new RuntimeException('cache down'); + } + + public function put(string $key, mixed $value, mixed $ttl = null): bool + { + throw new RuntimeException('cache down'); + } + + public function forget(string $key): bool + { + throw new RuntimeException('cache down'); + } + }); + Illuminate\Support\Facades\Cache::clearResolvedInstances(); + Utils::flushCountryLookup(); + + expect(Utils::getCountryCodeByCurrency('SGD'))->toBe('SG') + ->and(Utils::getCountryCodeByName('Mongolia'))->toBe('MN'); + + Utils::flushCountryLookup(); + Illuminate\Support\Facades\Cache::clearResolvedInstances(); + bind_test_container(); +});