diff --git a/.gitignore b/.gitignore index 961285c..c151694 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .psalm-cache .phpunit.cache +.arraykit-cache .vscode .windsurf *~ @@ -16,3 +17,4 @@ patch.php test.php var vendor +.codex diff --git a/README.md b/README.md index 23d16bc..733287c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ real-world PHP projects. - **Pipeline for Collection Ops** - **LazyCollection for Generator-Based Flows** - **ArrayShape Validation Helper** -- **Laravel Compatibility Layer (`LaravelCompat\\Arr`, `LaravelCompat\\Collection`)** +- **Compiled Config + Lazy Namespace Cache** - **Namespaced Helpers + Optional Globals** ## Modules @@ -45,8 +45,8 @@ real-world PHP projects. | Class | Description | |---------------------|---------------------------------------------------------------------------------------------------------------------| -| **Config** | Dot-access configuration loader with explicit hook-aware variants (`getWithHooks`, `setWithHooks`, `fillWithHooks`). | -| **LazyFileConfig** | First-segment lazy loader (`db.host` loads `db.php` on demand) for lower memory usage on large config trees. | +| **Config** | Dot-access configuration loader with explicit hook-aware variants (`getWithHooks`, `setWithHooks`, `fillWithHooks`) plus compiled cache export/load and read memoization. | +| **LazyFileConfig** | First-segment lazy loader (`db.host` loads `db.php` on demand) with namespace cache files for structural reads and a flat leaf-index cache for exact scalar lookups. | | **BaseConfigTrait** | Shared config logic. | @@ -224,6 +224,11 @@ $config->snapshot('before-runtime'); $config->merge(['app' => ['env' => 'production']]); $changed = $config->changed('before-runtime'); $config->restore('before-runtime'); + +// Compiled cache export / load +$config->exportCache(__DIR__ . '/bootstrap/cache/config.php'); +$cached = new Config(); +$cached->loadCache(__DIR__ . '/bootstrap/cache/config.php'); ``` ### Hooked Collection @@ -266,12 +271,12 @@ $user->hydrate(['name' => 'Alice'], mapping: ['name' => 'full_name']); $deep = $user->toArrayDeep(); ``` -### Lazy + Shape + Compat +### Lazy + Shape + Cache ```php use Infocyph\ArrayKit\Array\ArrayShape; use Infocyph\ArrayKit\ArrayKit; -use Infocyph\ArrayKit\LaravelCompat\Arr; +use Infocyph\ArrayKit\Config\LazyFileConfig; $lazy = ArrayKit::lazyCollection(range(1, 10)) ->filterLazy(fn ($v) => $v % 2 === 0) @@ -283,8 +288,11 @@ $row = ArrayShape::require( ['id' => 'int', 'email' => 'string', 'roles' => 'list'], ); -$data = ['user' => ['name' => 'Alice']]; -Arr::set($data, 'user.role', 'admin'); +$config = new LazyFileConfig(__DIR__ . '/config', namespaceCacheDirectory: __DIR__ . '/bootstrap/cache/config'); +$config->warmNamespaceCache(['db', 'cache']); + +// Exact scalar leaf reads can hit bootstrap/cache/config/__flat.php first. +$host = $config->get('db.host'); ``` ## Behavior Notes @@ -296,6 +304,7 @@ Arr::set($data, 'user.role', 'admin'); - `DotNotation` treats existing `null` keys/properties as present (does not fall back to defaults). - `DotNotation::hasWildcard()`, `paths()`, `matches()`, `rename()`, and `move()` are available for wildcard/path operations. - For untrusted/deep payloads, use bounded traversal variants: `DotNotation::getSafe()`, `ArrayMulti::depthGuarded()`, `flattenGuarded()`, and `sortRecursiveGuarded()`. +- `LazyFileConfig` namespace cache writes one cache file per namespace plus a shared `__flat.php` file containing only final scalar/null leaf values for exact-key fast paths. ## Security diff --git a/docs/config.rst b/docs/config.rst index 6482474..33391d4 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -38,6 +38,8 @@ Important behavior: - If already loaded, they return ``false`` and do not overwrite existing items. - ``replace()`` always replaces in-memory config items. - ``reload()`` replaces from array or readable file path. +- ``exportCache()`` writes a compiled PHP cache file of current items. +- ``loadCache()`` loads a compiled PHP cache file through the normal file loader. - Facade-based config creation is documented in :doc:`facade`. Reading Values @@ -218,6 +220,30 @@ Use config as a mutable runtime container for app setup: $config->setWithHooks('app.timezone', ' utc '); $tz = $config->getWithHooks('app.timezone'); // UTC +Compiled Cache + Read Memoization +--------------------------------- + +``Config`` also supports two cache layers: + +- in-memory read memoization for repeated dot-path lookups +- compiled cache export/load through PHP files + +.. code-block:: php + + loadArray([ + 'app' => ['name' => 'ArrayKit'], + 'db' => ['host' => 'localhost'], + ]); + + $config->readCache(); // enabled by default + $config->exportCache(__DIR__.'/bootstrap/cache/config.php'); + + $cached = new Config(); + $cached->loadCache(__DIR__.'/bootstrap/cache/config.php'); + $name = $cached->get('app.name'); + LazyFileConfig -------------- @@ -254,6 +280,21 @@ Important behavior: - Missing namespace file returns the provided default. - ``replace()`` and ``reload()`` reset resolved-namespace tracking. - read-only mode applies to ``set/fill/forget/replace/reload``-style mutators. +- ``namespaceCache()`` configures an optional per-namespace cache directory. +- ``warmNamespaceCache()`` writes cached namespace files and a shared ``__flat.php`` exact-leaf index. +- Exact-key scalar reads check ``__flat.php`` first; structural, wildcard, and namespace reads fall back to namespace cache files. +- Runtime writes only affect in-memory state until cache files are explicitly rebuilt. + +.. code-block:: php + + warmNamespaceCache(['db', 'cache']); + $host = $config->get('db.host'); Method Summary -------------- @@ -266,6 +307,8 @@ Config methods: - ``set()``, ``fill()``, ``forget()`` - ``prepend()``, ``append()`` - ``replace()``, ``reload()`` +- ``exportCache()``, ``loadCache()`` +- ``readCache()``, ``readCacheEnabled()``, ``flushReadCache()`` - ``getString()/getInt()/getFloat()/getBool()/getArray()/getList()/getEnum()`` - ``merge()``, ``overlay()`` - ``snapshot()``, ``restore()``, ``changed()`` @@ -277,7 +320,10 @@ LazyFileConfig methods: - ``has()``, ``hasAny()`` - ``set()``, ``fill()``, ``forget()`` - ``preload()``, ``isLoaded()``, ``loaded()``, ``loadedNamespaces()`` +- ``namespaceCache()``, ``namespaceCacheDirectory()`` +- ``warmNamespaceCache()``, ``flushNamespaceCache()`` - ``replace()``, ``reload()`` +- ``exportCache()``, ``loadCache()`` - ``all()`` (throws by design) Hook-aware methods (Config and LazyFileConfig): diff --git a/docs/migration.rst b/docs/migration.rst index 54e6038..f7a6e67 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -12,6 +12,8 @@ Recent Additions - ``Collection`` now implements ``IteratorAggregate`` semantics for safe nested iteration and provides ``copy()`` / ``immutable()`` snapshots. - ``Config`` / ``LazyFileConfig`` now include ``replace()``, ``reload()``, and ``getOrFail()``. - ``LazyFileConfig`` includes ``loaded()`` alias for ``isLoaded()``. +- ``Config`` now supports compiled cache export/load plus in-memory read memoization. +- ``LazyFileConfig`` adds namespace-cache warming and full compiled-cache generation. - Namespaced helpers (``Infocyph\ArrayKit\*``) are now the default autoloaded helper surface; globals are optional via manual include of ``src/functions.php``. - ``ArrayMulti::flatten($array, 0)`` now returns unchanged top-level values. - ``ArraySingle::avg()``, ``sum()``, ``isPositive()``, and ``isNegative()`` now ignore non-numeric values consistently. @@ -22,7 +24,7 @@ Recent Additions - ``Config`` adds typed getters (``getString/getInt/getFloat/getBool/getArray/getList/getEnum``), merge/state helpers (``merge/overlay/snapshot/restore/changed``), and ``readonly()`` mode. - ``Collection`` adds ``immutableProcess()`` / ``pipeImmutable()`` explicit immutable-style pipeline entry. - ``ArrayKit`` facade adds ``lazyCollection()`` and package now includes ``LazyCollection`` (generator-backed operations). -- New optional helpers: ``ArrayShape`` validator and Laravel-compat layer (``LaravelCompat\\Arr``, ``LaravelCompat\\Collection``). +- New optional helper: ``ArrayShape`` validator. Compatibility Notes ------------------- diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index b7b5d12..f8f1e62 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -129,7 +129,7 @@ ArrayKit Facade public static function helper(): ModuleProxy public static function dot(): ModuleProxy public static function config(array $items = []): Config - public static function lazyConfig(string $directory, string $extension = 'php', array $items = []): LazyFileConfig + public static function lazyConfig(string $directory, string $extension = 'php', array $items = [], ?string $namespaceCacheDirectory = null): LazyFileConfig public static function collection(mixed $data = []): Collection public static function hookedCollection(mixed $data = []): HookedCollection public static function lazyCollection(mixed $data = []): LazyCollection @@ -514,6 +514,11 @@ Config uses ``BaseConfigTrait``. Public API: public function reload(array|string $source): bool public function merge(array $items): bool public function overlay(array $overlay): bool + public function exportCache(string $path): bool + public function loadCache(string $path): bool + public function readCache(bool $enabled = true): static + public function readCacheEnabled(): bool + public function flushReadCache(): static public function snapshot(string $name = 'default'): bool public function restore(string $name = 'default'): bool public function changed(string $snapshot = 'default'): bool @@ -537,6 +542,10 @@ LazyFileConfig loads top-level config files on first keyed access: public function isLoaded(string $namespace): bool public function loaded(string $namespace): bool public function loadedNamespaces(): array + public function namespaceCache(?string $directory): static + public function namespaceCacheDirectory(): ?string + public function warmNamespaceCache(string|array|null $namespaces = null): static + public function flushNamespaceCache(string|array|null $namespaces = null): static public function all(): array // throws (design choice) Config Hook-Aware Variants @@ -588,18 +597,3 @@ LazyCollection public function take(int $limit): self public function takeUntil(callable $callback): self public function all(): array - -Laravel Compatibility ----------------------------------- - -.. code-block:: php - - // Infocyph\ArrayKit\LaravelCompat\Arr - public static function get(iterable $array, string|int|array|null $key = null, mixed $default = null): mixed - public static function set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool - public static function has(iterable $array, int|string|array $keys): bool - public static function hasAny(iterable $array, int|string|array $keys): bool - public static function only(iterable $array, array|string $keys): array - public static function except(iterable $array, array|string $keys): array - - // Infocyph\ArrayKit\LaravelCompat\Collection extends Collection diff --git a/docs/traits-and-helpers.rst b/docs/traits-and-helpers.rst index da9c11d..f4855a1 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -223,20 +223,9 @@ When to Use These Helpers Laravel Compatibility Layer --------------------------- -ArrayKit also ships optional Laravel-style wrappers: +ArrayKit's default helper surface is namespaced: -- ``Infocyph\ArrayKit\LaravelCompat\Arr`` -- ``Infocyph\ArrayKit\LaravelCompat\Collection`` - -.. code-block:: php - - ['name' => 'Alice']]; - Arr::set($data, 'user.role', 'admin'); - $name = Arr::get($data, 'user.name'); - - $c = new CompatCollection([1, 2, 3]); - $all = $c->all(); +- ``Infocyph\ArrayKit\array_get`` +- ``Infocyph\ArrayKit\array_set`` +- ``Infocyph\ArrayKit\collect`` +- ``Infocyph\ArrayKit\chain`` diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 51ae260..7a854e8 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -121,7 +121,6 @@ public static function first(array $array, ?callable $callback = null, mixed $de /** * @param array $array * Depth semantics: 0 = unchanged top-level values, 1 = flatten one level, INF = fully flatten. - * * @return array */ public static function flatten(array $array, float|int $depth = \INF): array diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index dff7788..abeca58 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -50,7 +50,6 @@ public static function avg(array $array): float|int * @param array $array The array to be chunked. * @param int $size The size of each chunk. * @param bool $preserveKeys Whether to preserve the keys in the chunks. - * * @return array An array of arrays, each representing a chunk of the original array. */ public static function chunk(array $array, int $size, bool $preserveKeys = false): array @@ -71,7 +70,6 @@ public static function chunk(array $array, int $size, bool $preserveKeys = false * * @param array $keys The array of keys. * @param array $values The array of values. - * * @return array The combined array. */ public static function combine(array $keys, array $values): array @@ -199,7 +197,6 @@ public static function duplicates(array $array): array * * @param array $array The array to be iterated over. * @param callable $callback The callback function to apply to each element. - * * @return array The original array. */ public static function each(array $array, callable $callback): array @@ -370,7 +367,6 @@ public static function isUnique(array $array): bool * * @param array $array The array to be mapped over. * @param callable $callback The callback function to apply to each element. - * * @return array The array with each element transformed by the callback. */ public static function map(array $array, callable $callback): array @@ -548,8 +544,8 @@ public static function nonEmpty(array $array, bool $preserveKeys = false): array * @param array $array The array to slice. * @param int $step The "step" value (i.e. the interval between selected elements). * @param int $offset The offset from which to begin selecting elements. - * * @return array The sliced array. + * * @throws InvalidArgumentException If step is less than 1. */ public static function nth(array $array, int $step, int $offset = 0): array @@ -597,10 +593,9 @@ public static function only(array $array, array|string $keys): array * @param array $array The array to paginate. * @param int $page The page number to retrieve (1-indexed). * @param int $perPage The number of items per page. + * @return array The paginated slice of the array. * * @throws InvalidArgumentException If page/per-page are less than 1. - * - * @return array The paginated slice of the array. */ public static function paginate(array $array, int $page, int $perPage): array { @@ -766,7 +761,6 @@ public static function same(array $left, array $right, bool $strict = false): bo * @param array $array The array to search. * @param mixed $needle The value to search for, or a callable to use for * searching. - * * @return int|string|null The key of the value if found, or null if not found. */ public static function search(array $array, mixed $needle): int|string|null @@ -792,6 +786,7 @@ public static function search(array $array, mixed $needle): int|string|null * * @param array $array The array to split. * @return array A new array containing two child arrays: 'keys' and 'values'. + * * @example * $data = ['a' => 1, 'b' => 2, 'c' => 3]; * $keysAndValues = ArraySingle::separate($data); @@ -1005,7 +1000,6 @@ public static function values(array $array): array * This function should take two arguments, the value and the key of each * element in the array. The function should return true for elements that * should be kept, and false for elements that should be discarded. - * * @return array The filtered array. */ public static function where(array $array, ?callable $callback = null): array diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php index f9eaae4..6745ec8 100644 --- a/src/Array/ArraySingleOps.php +++ b/src/Array/ArraySingleOps.php @@ -212,22 +212,22 @@ public static function symmetricDiff(array $left, array $right, bool $strict): a public static function unique(array $array, bool $strict): array { if (!$strict) { - /** @var array $unique */ - $unique = array_values(array_unique($array, \SORT_REGULAR)); + /** @var array $unique */ + $unique = array_unique($array, \SORT_REGULAR); return $unique; } $seen = []; $result = []; - foreach ($array as $item) { + foreach ($array as $key => $item) { $fingerprint = self::fingerprintStrict($item); if (isset($seen[$fingerprint])) { continue; } $seen[$fingerprint] = true; - $result[] = $item; + $result[$key] = $item; } return $result; diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 1059d75..cb7f102 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -99,7 +99,6 @@ public static function doReject(array $array, mixed $callback): array * * @param array $array The array to search. * @param callable $callback The callback to use for searching. - * * @return int|string|null The key of the value if found, or null if not found. */ public static function findKey(array $array, callable $callback): int|string|null @@ -135,7 +134,6 @@ public static function forget(array &$array, int|string|array $keys): void * * @param array $array The array to search. * @param int|string|array $keys The key(s) to check for existence. - * * @return bool True if all the given keys exist in the array, false otherwise. */ public static function has(array $array, int|string|array $keys): bool @@ -221,7 +219,6 @@ public static function isMultiDimensional(mixed $array): bool * @param array $array The array from which to retrieve random items. * @param int|null $number The number of items to retrieve. If null, a single item is returned. * @param bool $preserveKeys Whether to preserve the keys from the original array. - * * @return mixed The retrieved item(s) from the array. * * @throws InvalidArgumentException If the user requested more items than the array contains. diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 18c109f..e7351e2 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -194,7 +194,14 @@ public static function last(array $array, ?callable $callback = null, mixed $def return empty($array) ? $default : end($array); } - return static::first(array_reverse($array, true), $callback, $default); + $resolved = $default; + foreach ($array as $key => $row) { + if ($callback($row, $key)) { + $resolved = $row; + } + } + + return $resolved; } /** diff --git a/src/ArrayKit.php b/src/ArrayKit.php index d482f78..0b06b62 100644 --- a/src/ArrayKit.php +++ b/src/ArrayKit.php @@ -78,9 +78,13 @@ public static function lazyCollection(mixed $data = []): LazyCollection /** * @param array $items */ - public static function lazyConfig(string $directory, string $extension = 'php', array $items = []): LazyFileConfig - { - return new LazyFileConfig($directory, $extension, $items); + public static function lazyConfig( + string $directory, + string $extension = 'php', + array $items = [], + ?string $namespaceCacheDirectory = null, + ): LazyFileConfig { + return new LazyFileConfig($directory, $extension, $items, $namespaceCacheDirectory); } public static function multi(): ModuleProxy diff --git a/src/Collection/BaseCollectionTrait.php b/src/Collection/BaseCollectionTrait.php index a4d20b9..74f1455 100644 --- a/src/Collection/BaseCollectionTrait.php +++ b/src/Collection/BaseCollectionTrait.php @@ -43,6 +43,7 @@ public function __construct(array $data = []) * @param string $method The name of the method being called. * @param array $arguments The arguments to pass to the method. * @return mixed The result of the method call. + * * @throws BadMethodCallException If the method does not exist. */ public function __call(string $method, array $arguments): mixed diff --git a/src/Collection/Collection.php b/src/Collection/Collection.php index f225979..106d6ec 100644 --- a/src/Collection/Collection.php +++ b/src/Collection/Collection.php @@ -17,6 +17,7 @@ * Inherits most of its behavior from BaseCollectionTrait. * * @phpstan-consistent-constructor + * * @implements ArrayAccess * @implements IteratorAggregate */ diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php index 069b4f4..d7b2fd3 100644 --- a/src/Collection/LazyCollection.php +++ b/src/Collection/LazyCollection.php @@ -11,6 +11,7 @@ /** * @template TKey of array-key * @template TValue + * * @implements IteratorAggregate */ final readonly class LazyCollection implements IteratorAggregate @@ -23,6 +24,7 @@ private function __construct(private \Closure $factory) {} /** * @template TFromKey of array-key * @template TFromValue + * * @param iterable $source * @return self */ @@ -125,6 +127,7 @@ public function getIterator(): Traversable /** * @template TMapped + * * @param callable(TValue, TKey): TMapped $callback * @return self */ @@ -146,6 +149,10 @@ public function take(int $limit): self throw new \InvalidArgumentException('Take limit must be zero or greater.'); } + if ($limit === 0) { + return self::from([]); + } + return new self(function () use ($limit): Generator { $count = 0; foreach ($this->cursor() as $key => $value) { diff --git a/src/Config/BaseConfigTrait.php b/src/Config/BaseConfigTrait.php index 5494980..82b0b75 100644 --- a/src/Config/BaseConfigTrait.php +++ b/src/Config/BaseConfigTrait.php @@ -17,8 +17,15 @@ trait BaseConfigTrait */ protected array $items = []; + protected bool $readCacheEnabled = true; + protected bool $readOnly = false; + /** + * @var array + */ + protected array $resolvedValueCache = []; + /** * @var array> */ @@ -67,6 +74,18 @@ public function changed(string $snapshot = 'default'): bool return $this->snapshots[$snapshot] != $this->items; } + public function exportCache(string $path): bool + { + $directory = dirname($path); + if ($directory !== '' && $directory !== '.' && !is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + return false; + } + + $export = var_export($this->items, true); + + return file_put_contents($path, "assertWritable(); + $this->flushReadCache(); DotNotation::fill($this->items, $key, $value); return true; } + public function flushReadCache(): static + { + $this->resolvedValueCache = []; + + return $this; + } + /** * Remove/unset a key (or keys) from configuration using dot notation + wildcard expansions. */ @@ -92,6 +119,7 @@ public function forget(string|int|array $key): bool { $this->assertWritable(); + $this->flushReadCache(); DotNotation::forget($this->items, $key); return true; @@ -107,7 +135,20 @@ public function forget(string|int|array $key): bool */ public function get(string|int|array|null $key = null, mixed $default = null): mixed { - return DotNotation::get($this->items, $key, $default); + if ($key === null) { + return $this->items; + } + + if (is_array($key)) { + $results = []; + foreach ($key as $path) { + $results[(string) $path] = $this->getResolvedValue($path, $default); + } + + return $results; + } + + return $this->getResolvedValue($key, $default); } /** @@ -140,6 +181,7 @@ public function getBool(string|int|array|null $key, ?bool $default = null): ?boo * Get an enum instance from a scalar stored config value. * * @template TEnum of \UnitEnum + * * @param string|int|array|null $key * @param class-string $enumClass * @param TEnum|null $default @@ -255,7 +297,7 @@ public function getString(string|int|array|null $key, ?string $default = null): */ public function has(string|array $keys): bool { - return DotNotation::has($this->items, $keys); + return array_all((array) $keys, fn($key) => $this->hasResolvedValue($key)); } /** @@ -266,7 +308,7 @@ public function has(string|array $keys): bool */ public function hasAny(string|array $keys): bool { - return DotNotation::hasAny($this->items, $keys); + return array_any((array) $keys, fn($key) => $this->hasResolvedValue($key)); } public function isReadonly(): bool @@ -286,6 +328,7 @@ public function loadArray(array $resource): bool if (count($this->items) === 0) { $this->items = $resource; + $this->flushReadCache(); return true; } @@ -293,6 +336,11 @@ public function loadArray(array $resource): bool return false; } + public function loadCache(string $path): bool + { + return $this->loadFile($path); + } + /** * Load configuration from a specified file path (PHP returning array). * @@ -310,6 +358,7 @@ public function loadFile(string $path): bool } $this->items = $loaded; + $this->flushReadCache(); return true; } @@ -325,6 +374,7 @@ public function loadFile(string $path): bool public function merge(array $items): bool { $this->assertWritable(); + $this->flushReadCache(); $this->items = array_replace_recursive($this->items, $items); return true; @@ -362,6 +412,22 @@ public function prepend(string $key, mixed $value): bool return $this->set($key, $array); } + public function readCache(bool $enabled = true): static + { + $this->readCacheEnabled = $enabled; + + if (!$enabled) { + $this->flushReadCache(); + } + + return $this; + } + + public function readCacheEnabled(): bool + { + return $this->readCacheEnabled; + } + /** * Enable/disable read-only mode. */ @@ -406,6 +472,7 @@ public function replace(array $items): bool { $this->assertWritable(); + $this->flushReadCache(); $this->items = $items; return true; @@ -421,6 +488,7 @@ public function restore(string $name = 'default'): bool return false; } + $this->flushReadCache(); $this->items = $this->snapshots[$name]; return true; @@ -441,6 +509,8 @@ public function set(string|array|null $key = null, mixed $value = null, bool $ov { $this->assertWritable(); + $this->flushReadCache(); + return DotNotation::set($this->items, $key, $value, $overwrite); } @@ -460,4 +530,51 @@ protected function assertWritable(): void throw new RuntimeException('Configuration is read-only.'); } } + + protected function getResolvedValue(int|string $key, mixed $default = null): mixed + { + $resolved = $this->resolveRawValue($key); + + return $resolved === $this->missingValueMarker() ? $this->resolveDefault($default) : $resolved; + } + + protected function hasResolvedValue(int|string $key): bool + { + return $this->resolveRawValue($key) !== $this->missingValueMarker(); + } + + protected function missingValueMarker(): object + { + static $missing; + + if (!is_object($missing)) { + $missing = new \stdClass(); + } + + return $missing; + } + + protected function resolveDefault(mixed $default): mixed + { + return $default instanceof \Closure ? $default() : $default; + } + + protected function resolveRawValue(int|string $key): mixed + { + if (!$this->readCacheEnabled) { + return DotNotation::get($this->items, $key, $this->missingValueMarker()); + } + + $cacheKey = $this->valueCacheKey($key); + if (array_key_exists($cacheKey, $this->resolvedValueCache)) { + return $this->resolvedValueCache[$cacheKey]; + } + + return $this->resolvedValueCache[$cacheKey] = DotNotation::get($this->items, $key, $this->missingValueMarker()); + } + + protected function valueCacheKey(int|string $key): string + { + return is_int($key) ? 'i:' . $key : 's:' . $key; + } } diff --git a/src/Config/Concerns/LazyFileConfigCacheTrait.php b/src/Config/Concerns/LazyFileConfigCacheTrait.php new file mode 100644 index 0000000..f34b1d1 --- /dev/null +++ b/src/Config/Concerns/LazyFileConfigCacheTrait.php @@ -0,0 +1,361 @@ + + */ + protected array $flatLeafIndex = []; + + protected bool $flatLeafIndexLoaded = false; + + protected ?string $namespaceCacheDirectory = null; + + /** + * @param string|array|null $namespaces + */ + public function flushNamespaceCache(string|array|null $namespaces = null): static + { + if ($this->namespaceCacheDirectory === null) { + return $this; + } + + if ($namespaces === null) { + $this->flushAllNamespaceCacheFiles(); + + return $this; + } + + foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { + $path = $this->cachedNamespacePath($namespace); + if ($path !== null && is_file($path)) { + unlink($path); + } + } + + $this->writeFlatLeafIndexFromCacheDirectory(); + + return $this; + } + + public function namespaceCache(?string $directory): static + { + $this->namespaceCacheDirectory = $directory !== null + ? rtrim($directory, DIRECTORY_SEPARATOR) + : null; + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + + return $this; + } + + public function namespaceCacheDirectory(): ?string + { + return $this->namespaceCacheDirectory; + } + + /** + * @param string|array|null $namespaces + */ + public function warmNamespaceCache(string|array|null $namespaces = null): static + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + throw new RuntimeException('Namespace cache directory is not configured.'); + } + + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create namespace cache directory [{$directory}]."); + } + + foreach ($this->resolveWarmNamespaces($namespaces) as $namespace) { + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items) || !is_array($this->items[$namespace])) { + throw new UnexpectedValueException("Lazy namespace [{$namespace}] must resolve to an array to be cached."); + } + + $export = var_export($this->items[$namespace], true); + $path = $this->cachedNamespacePath($namespace); + + if ($path === null || file_put_contents($path, "writeFlatLeafIndexFromCacheDirectory(); + + return $this; + } + + protected function cachedNamespacePath(string $namespace): ?string + { + if ($this->namespaceCacheDirectory === null) { + return null; + } + + return $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; + } + + /** + * @param array $namespaceData + * @param array $index + */ + protected function collectFlatLeafIndex(string $namespace, array $namespaceData, array &$index, string $prefix = ''): void + { + foreach ($namespaceData as $key => $value) { + $path = $prefix === '' + ? $namespace . '.' . $key + : $prefix . '.' . $key; + + if (is_array($value)) { + $this->collectFlatLeafIndex($namespace, $value, $index, $path); + + continue; + } + + if ($this->isCacheableLeafValue($value)) { + $this->addFlatLeafIndexValue($index, $path, $value); + } + } + } + + /** + * @return string[] + */ + protected function discoverNamespaces(): array + { + $namespaces = []; + + foreach (array_keys($this->items) as $namespace) { + if (is_string($namespace) && preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + $namespaces[$namespace] = true; + } + } + + if (!is_dir($this->directory)) { + return array_keys($namespaces); + } + + $entries = scandir($this->directory); + if ($entries === false) { + return array_keys($namespaces); + } + + $suffix = '.' . $this->extension; + foreach ($entries as $entry) { + if (!str_ends_with($entry, $suffix)) { + continue; + } + + $namespace = substr($entry, 0, -strlen($suffix)); + if ($namespace === '') { + continue; + } + + $namespaces[$this->normalizeNamespace($namespace)] = true; + } + + return array_keys($namespaces); + } + + protected function flatLeafIndexPath(): ?string + { + if ($this->namespaceCacheDirectory === null) { + return null; + } + + return $this->namespaceCacheDirectory . DIRECTORY_SEPARATOR . self::FLAT_INDEX_FILE; + } + + protected function flatLeafValue(string $path): mixed + { + $this->loadFlatLeafIndex(); + + return array_key_exists($path, $this->flatLeafIndex) + ? $this->flatLeafIndex[$path] + : $this->missingValueMarker(); + } + + protected function isCacheableLeafValue(mixed $value): bool + { + return $value === null + || is_bool($value) + || is_int($value) + || is_float($value) + || is_string($value); + } + + protected function isEligibleFlatLookupPath(string $path): bool + { + return str_contains($path, '.') + && !str_contains($path, '*') + && !str_contains($path, '\\') + && !str_contains($path, '{'); + } + + protected function loadFlatLeafIndex(): void + { + if ($this->flatLeafIndexLoaded) { + return; + } + + $this->flatLeafIndexLoaded = true; + $this->flatLeafIndex = []; + + $path = $this->flatLeafIndexPath(); + if ($path === null || !is_file($path) || !is_readable($path)) { + return; + } + + $loaded = include $path; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$path}] must return an array."); + } + + $this->flatLeafIndex = $this->filterFlatLeafIndex($loaded); + } + + /** + * @param string|array|null $namespaces + * @return string[] + */ + protected function resolveWarmNamespaces(string|array|null $namespaces): array + { + if ($namespaces === null) { + return $this->discoverNamespaces(); + } + + $resolved = []; + foreach ((array) $namespaces as $namespace) { + $resolved[] = $this->normalizeNamespace($namespace); + } + + return array_values(array_unique($resolved)); + } + + protected function writeFlatLeafIndexFromCacheDirectory(): void + { + $indexPath = $this->flatLeafIndexPath(); + $directory = $this->namespaceCacheDirectory; + + if ($indexPath === null || $directory === null) { + return; + } + + $index = $this->buildFlatLeafIndexFromDirectory($directory); + + ksort($index); + if (file_put_contents($indexPath, "flatLeafIndex = $index; + $this->flatLeafIndexLoaded = true; + } + + /** + * @param array $index + */ + private function addFlatLeafIndexValue(array &$index, string $path, mixed $value): void + { + if ( + $value === null + || is_bool($value) + || is_int($value) + || is_float($value) + || is_string($value) + ) { + $index[$path] = $value; + } + } + + /** + * @return array + */ + private function buildFlatLeafIndexFromDirectory(string $directory): array + { + $index = []; + $entries = scandir($directory); + if ($entries === false) { + return $index; + } + + $suffix = '.' . $this->extension; + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..' || $entry === self::FLAT_INDEX_FILE || !str_ends_with($entry, $suffix)) { + continue; + } + + $namespace = substr($entry, 0, -strlen($suffix)); + if ($namespace === '') { + continue; + } + + if (!preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + $loaded = include $path; + if (!is_array($loaded)) { + throw new UnexpectedValueException("Config file [{$path}] must return an array."); + } + + $this->collectFlatLeafIndex($namespace, $loaded, $index); + } + + return $index; + } + + /** + * @param array $loaded + * @return array + */ + private function filterFlatLeafIndex(array $loaded): array + { + $index = []; + + foreach ($loaded as $key => $value) { + if (!is_string($key) || !$this->isCacheableLeafValue($value)) { + continue; + } + + $this->addFlatLeafIndexValue($index, $key, $value); + } + + return $index; + } + + private function flushAllNamespaceCacheFiles(): void + { + $directory = $this->namespaceCacheDirectory; + if ($directory === null) { + return; + } + + $entries = scandir($directory); + if ($entries !== false) { + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $directory . DIRECTORY_SEPARATOR . $entry; + if (is_file($path)) { + unlink($path); + } + } + } + + $this->flatLeafIndex = []; + $this->flatLeafIndexLoaded = false; + } +} diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 34b10e6..7b125ba 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -5,12 +5,17 @@ namespace Infocyph\ArrayKit\Config; use Infocyph\ArrayKit\Array\DotNotation; +use Infocyph\ArrayKit\Config\Concerns\LazyFileConfigCacheTrait; use InvalidArgumentException; use RuntimeException; use UnexpectedValueException; class LazyFileConfig extends Config { + use LazyFileConfigCacheTrait; + + private const string FLAT_INDEX_FILE = '__flat.php'; + /** * @var array */ @@ -23,10 +28,15 @@ public function __construct( protected string $directory, protected string $extension = 'php', array $items = [], + ?string $namespaceCacheDirectory = null, ) { $this->directory = rtrim($directory, DIRECTORY_SEPARATOR); $this->extension = ltrim($extension, '.'); $this->items = $items; + $this->namespaceCacheDirectory = $namespaceCacheDirectory !== null + ? rtrim($namespaceCacheDirectory, DIRECTORY_SEPARATOR) + : null; + $this->syncLoadedNamespacesFromItems(); } #[\Override] @@ -45,6 +55,7 @@ public function all(): array public function fill(string|array $key, mixed $value = null): bool { $this->assertWritable(); + $this->flushReadCache(); if (is_array($key)) { foreach ($key as $path => $entry) { @@ -68,6 +79,7 @@ public function fill(string|array $key, mixed $value = null): bool public function forget(string|int|array $key): bool { $this->assertWritable(); + $this->flushReadCache(); if (is_array($key)) { foreach ($key as $path) { @@ -101,13 +113,13 @@ public function get(string|int|array|null $key = null, mixed $default = null): m $results = []; foreach ($key as $path) { - $results[(string) $path] = $this->getPath((string) $path, $default); + $results[(string) $path] = $this->getResolvedValue((string) $path, $default); } return $results; } - return $this->getPath($key, $default); + return $this->getResolvedValue($key, $default); } #[\Override] @@ -121,7 +133,7 @@ public function has(string|array $keys): bool return false; } - return array_all($keys, fn($path) => $this->hasPath($path)); + return array_all($keys, fn($path) => $this->hasResolvedValue($path)); } #[\Override] @@ -135,7 +147,7 @@ public function hasAny(string|array $keys): bool return false; } - return array_any($keys, fn($path) => $this->hasPath($path)); + return array_any($keys, fn($path) => $this->hasResolvedValue($path)); } /** @@ -146,6 +158,20 @@ public function isLoaded(string $namespace): bool return isset($this->loadedNamespaces[$this->normalizeNamespace($namespace)]); } + #[\Override] + /** + * @param array $resource + */ + public function loadArray(array $resource): bool + { + $loaded = parent::loadArray($resource); + if ($loaded) { + $this->syncLoadedNamespacesFromItems(); + } + + return $loaded; + } + /** * Alias of isLoaded(). */ @@ -162,6 +188,31 @@ public function loadedNamespaces(): array return array_keys($this->loadedNamespaces); } + #[\Override] + public function loadFile(string $path): bool + { + $loaded = parent::loadFile($path); + if ($loaded) { + $this->syncLoadedNamespacesFromItems(); + } + + return $loaded; + } + + #[\Override] + /** + * @param array $items + */ + public function merge(array $items): bool + { + $merged = parent::merge($items); + if ($merged) { + $this->syncLoadedNamespacesFromItems(); + } + + return $merged; + } + /** * Preload one or multiple top-level config namespaces. * @@ -183,9 +234,12 @@ public function preload(string|array $namespaces): static public function reload(array|string $source): bool { $this->assertWritable(); - $this->loadedNamespaces = []; + $reloaded = parent::reload($source); + if ($reloaded) { + $this->syncLoadedNamespacesFromItems(); + } - return parent::reload($source); + return $reloaded; } #[\Override] @@ -195,9 +249,23 @@ public function reload(array|string $source): bool public function replace(array $items): bool { $this->assertWritable(); - $this->loadedNamespaces = []; + $replaced = parent::replace($items); + if ($replaced) { + $this->syncLoadedNamespacesFromItems(); + } + + return $replaced; + } + + #[\Override] + public function restore(string $name = 'default'): bool + { + $restored = parent::restore($name); + if ($restored) { + $this->syncLoadedNamespacesFromItems(); + } - return parent::replace($items); + return $restored; } #[\Override] @@ -207,6 +275,7 @@ public function replace(array $items): bool public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool { $this->assertWritable(); + $this->flushReadCache(); if ($key === null) { throw new RuntimeException('At least one key is required for LazyFileConfig::set().'); @@ -255,11 +324,7 @@ protected function getPath(string $path, mixed $default): mixed $this->loadNamespace($namespace); if (!array_key_exists($namespace, $this->items)) { - if ($default instanceof \Closure) { - return $default(); - } - - return $default; + return $this->resolveDefault($default); } if ($rest === null || $rest === '') { @@ -267,7 +332,7 @@ protected function getPath(string $path, mixed $default): mixed } if (!is_array($this->items[$namespace])) { - return $default; + return $this->resolveDefault($default); } return DotNotation::get($this->items[$namespace], $rest, $default); @@ -301,7 +366,7 @@ protected function loadNamespace(string $namespace): void $this->loadedNamespaces[$namespace] = true; - $file = $this->resolveNamespaceFile($namespace); + $file = $this->resolveCachedNamespaceFile($namespace) ?? $this->resolveNamespaceFile($namespace); if ($file === null) { return; } @@ -332,6 +397,61 @@ protected function normalizeNamespace(string $namespace): string return $trimmed; } + protected function resolveCachedNamespaceFile(string $namespace): ?string + { + $path = $this->cachedNamespacePath($namespace); + + if ($path === null || !is_file($path) || !is_readable($path)) { + return null; + } + + return $path; + } + + protected function resolveLazyRawValue(string $path): mixed + { + [$namespace, $rest] = $this->splitPath($path); + + if (array_key_exists($namespace, $this->items)) { + if ($rest === null || $rest === '') { + return $this->items[$namespace]; + } + + if (!is_array($this->items[$namespace])) { + return $this->missingValueMarker(); + } + + return DotNotation::get($this->items[$namespace], $rest, $this->missingValueMarker()); + } + + if ($this->isLoaded($namespace)) { + return $this->missingValueMarker(); + } + + if ($rest !== null && $this->isEligibleFlatLookupPath($path)) { + $flatValue = $this->flatLeafValue($path); + if ($flatValue !== $this->missingValueMarker()) { + return $flatValue; + } + } + + $this->loadNamespace($namespace); + + if (!array_key_exists($namespace, $this->items)) { + return $this->missingValueMarker(); + } + + if ($rest === null || $rest === '') { + return $this->items[$namespace]; + } + + if (!is_array($this->items[$namespace])) { + return $this->missingValueMarker(); + } + + return DotNotation::get($this->items[$namespace], $rest, $this->missingValueMarker()); + } + protected function resolveNamespaceFile(string $namespace): ?string { $file = $this->directory . DIRECTORY_SEPARATOR . $namespace . '.' . $this->extension; @@ -343,6 +463,25 @@ protected function resolveNamespaceFile(string $namespace): ?string return $file; } + #[\Override] + protected function resolveRawValue(int|string $key): mixed + { + if (!is_string($key)) { + return parent::resolveRawValue($key); + } + + if (!$this->readCacheEnabled()) { + return $this->resolveLazyRawValue($key); + } + + $cacheKey = $this->valueCacheKey($key); + if (array_key_exists($cacheKey, $this->resolvedValueCache)) { + return $this->resolvedValueCache[$cacheKey]; + } + + return $this->resolvedValueCache[$cacheKey] = $this->resolveLazyRawValue($key); + } + protected function setPath(string $path, mixed $value, bool $overwrite): void { [$namespace, $rest] = $this->splitPath($path); @@ -387,4 +526,18 @@ protected function splitPath(string $path): array return [$namespace, $rest === '' ? null : $rest]; } + + protected function syncLoadedNamespacesFromItems(): void + { + parent::flushReadCache(); + $this->loadedNamespaces = []; + + foreach (array_keys($this->items) as $namespace) { + if (!is_string($namespace) || !preg_match('/^[A-Za-z0-9_-]+$/', $namespace)) { + continue; + } + + $this->loadedNamespaces[$namespace] = true; + } + } } diff --git a/src/LaravelCompat/Arr.php b/src/LaravelCompat/Arr.php deleted file mode 100644 index 778c78a..0000000 --- a/src/LaravelCompat/Arr.php +++ /dev/null @@ -1,72 +0,0 @@ - $array - * @param array|string $keys - * @return array - */ - public static function except(array $array, array|string $keys): array - { - return ArraySingle::except($array, $keys); - } - - /** - * @param array $array - * @param array|int|string|null $key - */ - public static function get(array $array, array|int|string|null $key = null, mixed $default = null): mixed - { - if (is_array($key)) { - /** @var array $key */ - $key = array_values($key); - } - - return DotNotation::get($array, $key, $default); - } - - /** - * @param array $array - * @param array|string $keys - */ - public static function has(array $array, array|string $keys): bool - { - return DotNotation::has($array, $keys); - } - - /** - * @param array $array - * @param array|string $keys - */ - public static function hasAny(array $array, array|string $keys): bool - { - return DotNotation::hasAny($array, $keys); - } - - /** - * @param array $array - * @param array|string $keys - * @return array - */ - public static function only(array $array, array|string $keys): array - { - return ArraySingle::only($array, $keys); - } - - /** - * @param array $array - * @param array|string|null $key - */ - public static function set(array &$array, array|string|null $key = null, mixed $value = null, bool $overwrite = true): bool - { - return DotNotation::set($array, $key, $value, $overwrite); - } -} diff --git a/src/LaravelCompat/Collection.php b/src/LaravelCompat/Collection.php deleted file mode 100644 index 5f98f93..0000000 --- a/src/LaravelCompat/Collection.php +++ /dev/null @@ -1,7 +0,0 @@ -each->not()->toBeUsed(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/ArrayKitFacadeTest.php b/tests/Feature/ArrayKitFacadeTest.php index 15502c0..ded2895 100644 --- a/tests/Feature/ArrayKitFacadeTest.php +++ b/tests/Feature/ArrayKitFacadeTest.php @@ -2,26 +2,26 @@ declare(strict_types=1); +use Infocyph\ArrayKit\ArrayKit; use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\HookedCollection; use Infocyph\ArrayKit\Collection\LazyCollection; use Infocyph\ArrayKit\Config\Config; use Infocyph\ArrayKit\Config\LazyFileConfig; use Infocyph\ArrayKit\Facade\ModuleProxy; -use Infocyph\ArrayKit\ArrayKit; function arrayKitWriteArrayFile(string $directory, string $name, array $contents): void { $export = var_export($contents, true); file_put_contents( - $directory . DIRECTORY_SEPARATOR . $name . '.php', + $directory.DIRECTORY_SEPARATOR.$name.'.php', "configPath = sys_get_temp_dir() - . DIRECTORY_SEPARATOR - . 'arraykit-facade-' - . uniqid('', true); + .DIRECTORY_SEPARATOR + .'arraykit-facade-' + .uniqid('', true); mkdir($this->configPath, 0777, true); }); @@ -82,10 +83,11 @@ function arrayKitDeleteDirectory(string $directory): void it('creates lazy config instances from the facade', function () { arrayKitWriteArrayFile($this->configPath, 'db', ['host' => 'localhost']); - $config = ArrayKit::lazyConfig($this->configPath); + $config = ArrayKit::lazyConfig($this->configPath, namespaceCacheDirectory: $this->configPath.DIRECTORY_SEPARATOR.'cache'); expect($config)->toBeInstanceOf(LazyFileConfig::class) - ->and($config->get('db.host'))->toBe('localhost'); + ->and($config->get('db.host'))->toBe('localhost') + ->and($config->namespaceCacheDirectory())->toBe($this->configPath.DIRECTORY_SEPARATOR.'cache'); }); it('creates collection and pipeline helpers from the facade', function () { diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php index 6881417..1acdb91 100644 --- a/tests/Feature/ArrayMultiTest.php +++ b/tests/Feature/ArrayMultiTest.php @@ -31,7 +31,7 @@ it('can get the depth of a nested array', function () { $source = [1, [2, [3]], 4]; - $depth = ArrayMulti::depth($source); + $depth = ArrayMulti::depth($source); expect($depth)->toBe(3); }); @@ -168,7 +168,7 @@ ['a' => 2], ['a' => 3], ]; - $result = ArrayMulti::first($data, fn($row) => $row['a'] > 1, 'default'); + $result = ArrayMulti::first($data, fn ($row) => $row['a'] > 1, 'default'); expect($result)->toBe(['a' => 2]); }); @@ -188,8 +188,7 @@ ['a' => 2], ['a' => 3], ]; - // When using last() with a callback, the array is reversed. The first element in reversed order matching the callback is returned. - $result = ArrayMulti::last($data, fn($row) => $row['a'] < 3, 'default'); + $result = ArrayMulti::last($data, fn ($row) => $row['a'] < 3, 'default'); expect($result)->toBe(['a' => 2]); }); @@ -215,7 +214,7 @@ ['a' => 2], ['a' => 3], ]; - $result = ArrayMulti::whereCallback($data, fn($row) => $row['a'] > 1); + $result = ArrayMulti::whereCallback($data, fn ($row) => $row['a'] > 1); expect($result)->toBe([ 1 => ['a' => 2], 2 => ['a' => 3], @@ -260,7 +259,7 @@ ['num' => 2], ['num' => 3], ]; - $result = ArrayMulti::map($data, fn($row) => $row['num'] * 10); + $result = ArrayMulti::map($data, fn ($row) => $row['num'] * 10); expect($result)->toBe([ 0 => 10, 1 => 20, @@ -289,7 +288,7 @@ ['num' => 3], ['num' => 4], ]; - $result = ArrayMulti::reduce($data, fn($carry, $row) => $carry + $row['num'], 0); + $result = ArrayMulti::reduce($data, fn ($carry, $row) => $carry + $row['num'], 0); expect($result)->toBe(9); }); @@ -300,7 +299,7 @@ ['flag' => false], ['flag' => true], ]; - expect(ArrayMulti::some($data, fn($row) => $row['flag']))->toBeTrue(); + expect(ArrayMulti::some($data, fn ($row) => $row['flag']))->toBeTrue(); }); // every() @@ -309,13 +308,13 @@ ['pass' => true], ['pass' => true], ]; - expect(ArrayMulti::every($data, fn($row) => $row['pass']))->toBeTrue(); + expect(ArrayMulti::every($data, fn ($row) => $row['pass']))->toBeTrue(); $data[1]['pass'] = false; - expect(ArrayMulti::every($data, fn($row) => $row['pass']))->toBeFalse(); + expect(ArrayMulti::every($data, fn ($row) => $row['pass']))->toBeFalse(); }); // contains() -//it('checks that contains() works with both value and callable', function () { +// it('checks that contains() works with both value and callable', function () { // $data = [ // ['id' => 1], // ['id' => 2], @@ -324,7 +323,7 @@ // expect(ArrayMulti::contains($data, 2))->toBeTrue(); // expect(ArrayMulti::contains($data, 4))->toBeFalse(); // expect(ArrayMulti::contains($data, fn($row) => $row['id'] === 3))->toBeTrue(); -//}); +// }); // sum() it('calculates the sum from a 2D array using sum()', function () { @@ -352,7 +351,7 @@ ['score' => 50], ['score' => 90], ]; - [$pass, $fail] = ArrayMulti::partition($data, fn($row) => $row['score'] >= 60); + [$pass, $fail] = ArrayMulti::partition($data, fn ($row) => $row['score'] >= 60); expect($pass)->toBe([ 0 => ['score' => 80], 2 => ['score' => 90], @@ -399,7 +398,7 @@ 11 => ['id' => 11, 'team' => 'B', 'score' => 70], 12 => ['id' => 12, 'team' => 'A', 'score' => 55], ]) - ->and(ArrayMulti::indexBy($rows, fn (array $row) => 'row_' . $row['id']))->toBe([ + ->and(ArrayMulti::indexBy($rows, fn (array $row) => 'row_'.$row['id']))->toBe([ 'row_10' => ['id' => 10, 'team' => 'A', 'score' => 40], 'row_11' => ['id' => 11, 'team' => 'B', 'score' => 70], 'row_12' => ['id' => 12, 'team' => 'A', 'score' => 55], @@ -430,7 +429,7 @@ $sorted = ArrayMulti::sortBy( $rows, - fn (array $row, string $key) => $key . ':' . $row['id'], + fn (array $row, string $key) => $key.':'.$row['id'], false, SORT_STRING, ); @@ -570,9 +569,9 @@ $deep = [[[['value']]]]; expect(fn () => ArrayMulti::depthGuarded($deep, maxDepth: 2, throwOnTooDeep: true)) - ->toThrow(\RuntimeException::class) + ->toThrow(RuntimeException::class) ->and(fn () => ArrayMulti::flattenGuarded($deep, \INF, maxDepth: 2, throwOnTooDeep: true)) - ->toThrow(\RuntimeException::class) + ->toThrow(RuntimeException::class) ->and(fn () => ArrayMulti::sortRecursiveGuarded($deep, maxDepth: 2, throwOnTooDeep: true)) - ->toThrow(\RuntimeException::class); + ->toThrow(RuntimeException::class); }); diff --git a/tests/Feature/ArraySharedOpsTest.php b/tests/Feature/ArraySharedOpsTest.php index 7078f16..8916068 100644 --- a/tests/Feature/ArraySharedOpsTest.php +++ b/tests/Feature/ArraySharedOpsTest.php @@ -41,7 +41,8 @@ }); it('normalizes array keys from mixed values', function () { - $stringable = new class() { + $stringable = new class + { public function __toString(): string { return 'obj-key'; diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php index 74422ab..00a1a9c 100644 --- a/tests/Feature/ArraySingleTest.php +++ b/tests/Feature/ArraySingleTest.php @@ -12,13 +12,13 @@ }); it('retrieves only specified keys', function () { - $data = ['name' => 'Alice', 'age' => 30, 'job' => 'Developer']; + $data = ['name' => 'Alice', 'age' => 30, 'job' => 'Developer']; $subset = ArraySingle::only($data, ['name', 'job']); expect($subset)->toBe(['name' => 'Alice', 'job' => 'Developer']); }); it('can detect if array is a list', function () { - $list = [10, 20, 30]; + $list = [10, 20, 30]; $assoc = ['a' => 1, 'b' => 2]; expect(ArraySingle::isList($list)) ->toBeTrue() @@ -46,7 +46,7 @@ it('searches an array for a callback condition', function () { $data = [1, 2, 3, 4]; - $key = ArraySingle::search($data, fn ($value) => $value === 3); + $key = ArraySingle::search($data, fn ($value) => $value === 3); expect($key)->toBe(2); }); @@ -92,7 +92,7 @@ it('removes duplicates from the array using unique()', function () { $arr = [1, 2, 2, 3, 3, 4]; expect(ArraySingle::unique($arr)) - ->toBe([1, 2, 3, 4]) + ->toBe([0 => 1, 1 => 2, 3 => 3, 5 => 4]) ->and(ArraySingle::unique([1, '1', 2, 3], true)) ->toBe([1, '1', 2, 3]); // Strict comparison }); @@ -100,9 +100,22 @@ it('handles unique() with mixed values in loose and strict modes', function () { $arr = [1, '1', true, [1], ['1']]; - expect(ArraySingle::unique($arr))->toBe([1, [1]]) + expect(ArraySingle::unique($arr))->toBe([0 => 1, 3 => [1]]) ->and(ArraySingle::unique($arr, true))->toBe([1, '1', true, [1], ['1']]); }); + +it('preserves original keys when removing duplicates', function () { + $assoc = ['a' => 1, 'b' => 1, 'c' => '1', 'd' => 2]; + + expect(ArraySingle::unique($assoc))->toBe([ + 'a' => 1, + 'd' => 2, + ])->and(ArraySingle::unique($assoc, true))->toBe([ + 'a' => 1, + 'c' => '1', + 'd' => 2, + ]); +}); it('slices the array using slice()', function () { $arr = [1, 2, 3, 4, 5]; expect(ArraySingle::slice($arr, 1, 3)) diff --git a/tests/Feature/BaseArrayHelperTest.php b/tests/Feature/BaseArrayHelperTest.php index ff448bb..145e32a 100644 --- a/tests/Feature/BaseArrayHelperTest.php +++ b/tests/Feature/BaseArrayHelperTest.php @@ -17,18 +17,18 @@ it('checks if at least one item meets a condition', function () { $data = [1, 2, 3]; - $res = BaseArrayHelper::haveAny($data, fn($val) => $val > 2); + $res = BaseArrayHelper::haveAny($data, fn ($val) => $val > 2); expect($res)->toBeTrue(); }); it('checks if all items meet a condition', function () { $data = [2, 4, 6]; - $res = BaseArrayHelper::isAll($data, fn($val) => $val % 2 === 0); + $res = BaseArrayHelper::isAll($data, fn ($val) => $val % 2 === 0); expect($res)->toBeTrue(); }); it('finds the first key matching a callback', function () { $data = ['a' => 10, 'b' => 15, 'c' => 20]; - $key = BaseArrayHelper::findKey($data, fn($val) => $val > 10); + $key = BaseArrayHelper::findKey($data, fn ($val) => $val > 10); expect($key)->toBe('b'); }); diff --git a/tests/Feature/BucketCollectionTest.php b/tests/Feature/BucketCollectionTest.php index 158fce5..d5f10bc 100644 --- a/tests/Feature/BucketCollectionTest.php +++ b/tests/Feature/BucketCollectionTest.php @@ -10,7 +10,7 @@ }); it('supports array access', function () { - $collection = new Collection(); + $collection = new Collection; $collection['x'] = 42; expect($collection['x'])->toBe(42); }); @@ -49,7 +49,7 @@ it('can filter and return a new collection', function () { $collection = new Collection([1, 2, 3, 4]); - $even = $collection->filter(fn ($val) => $val % 2 === 0); + $even = $collection->filter(fn ($val) => $val % 2 === 0); expect($even->all())->toBe([1 => 2, 3 => 4]); }); diff --git a/tests/Feature/ConfigHooksTest.php b/tests/Feature/ConfigHooksTest.php index 6149f55..94473c5 100644 --- a/tests/Feature/ConfigHooksTest.php +++ b/tests/Feature/ConfigHooksTest.php @@ -5,19 +5,19 @@ use Infocyph\ArrayKit\Config\Config; beforeEach(function () { - $this->config = new Config(); + $this->config = new Config; }); it('applies get hooks only when getWithHooks is used', function () { $this->config->set('site.title', 'ArRayKit'); - $this->config->onGet('site.title', fn($value) => strtolower((string) $value)); + $this->config->onGet('site.title', fn ($value) => strtolower((string) $value)); expect($this->config->get('site.title'))->toBe('ArRayKit') ->and($this->config->getWithHooks('site.title'))->toBe('arraykit'); }); it('applies set hooks only when setWithHooks is used', function () { - $this->config->onSet('user.name', fn($value) => strtoupper((string) $value)); + $this->config->onSet('user.name', fn ($value) => strtoupper((string) $value)); $this->config->set('user.name', 'john'); expect($this->config->get('user.name'))->toBe('john'); @@ -27,8 +27,8 @@ }); it('supports hook-aware bulk set and bulk get operations', function () { - $this->config->onSet('user.name', fn($value) => strtoupper((string) $value)); - $this->config->onGet('user.name', fn($value) => strtolower((string) $value)); + $this->config->onSet('user.name', fn ($value) => strtoupper((string) $value)); + $this->config->onGet('user.name', fn ($value) => strtolower((string) $value)); $this->config->setWithHooks([ 'user.name' => 'ALICE', @@ -43,8 +43,8 @@ it('supports hook-aware fill without overwriting existing keys', function () { $this->config->set('app.name', 'ArrayKit'); - $this->config->onSet('app.name', fn($value) => strtoupper((string) $value)); - $this->config->onSet('app.env', fn($value) => strtoupper((string) $value)); + $this->config->onSet('app.name', fn ($value) => strtoupper((string) $value)); + $this->config->onSet('app.env', fn ($value) => strtoupper((string) $value)); $this->config->fillWithHooks([ 'app.name' => 'should-not-replace', diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index 5fe0f2c..2e88285 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -10,9 +10,9 @@ enum ConfigMode: string case Prod = 'prod'; } -$config = new Config(); +$config = new Config; -it('can load an array into config', function () use ($config) { +it('can load an array into config', function () use ($config) { $success = $config->loadArray(['app' => ['name' => 'ArrayKit']]); expect($success) ->toBeTrue() @@ -36,7 +36,7 @@ enum ConfigMode: string }); it('supports replace and reload operations', function () { - $cfg = new Config(); + $cfg = new Config; $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); $cfg->replace(['app' => ['name' => 'ArrayKitX']]); @@ -48,7 +48,7 @@ enum ConfigMode: string }); it('supports getOrFail for required keys', function () { - $cfg = new Config(); + $cfg = new Config; $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); expect($cfg->getOrFail('app.name'))->toBe('ArrayKit') @@ -56,7 +56,7 @@ enum ConfigMode: string }); it('supports typed getters with default fallbacks', function () { - $cfg = new Config(); + $cfg = new Config; $cfg->loadArray([ 'app' => ['name' => 'ArrayKit', 'debug' => true], 'port' => 8080, @@ -74,7 +74,7 @@ enum ConfigMode: string }); it('supports merge/overlay/snapshot/restore/changed/readonly', function () { - $cfg = new Config(); + $cfg = new Config; $cfg->loadArray(['db' => ['host' => 'localhost', 'port' => 3306]]); $cfg->snapshot('baseline'); @@ -95,9 +95,49 @@ enum ConfigMode: string }); it('supports getEnum for backed enums', function () { - $cfg = new Config(); + $cfg = new Config; $cfg->loadArray(['app' => ['mode' => 'prod']]); expect($cfg->getEnum('app.mode', ConfigMode::class))->toBe(ConfigMode::Prod) ->and($cfg->getEnum('app.missing', ConfigMode::class, ConfigMode::Local))->toBe(ConfigMode::Local); }); + +it('supports compiled config cache export and reload', function () { + $cfg = new Config; + $cfg->loadArray([ + 'app' => ['name' => 'ArrayKit'], + 'db' => ['host' => 'localhost'], + ]); + + $cachePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'arraykit-config-cache-'.uniqid('', true).'.php'; + + try { + expect($cfg->exportCache($cachePath))->toBeTrue(); + + $loaded = new Config; + expect($loaded->loadCache($cachePath))->toBeTrue() + ->and($loaded->get('app.name'))->toBe('ArrayKit') + ->and($loaded->get('db.host'))->toBe('localhost'); + + $loaded->reload(['db' => ['host' => 'db.internal']]); + expect($loaded->get('db.host'))->toBe('db.internal'); + } finally { + if (is_file($cachePath)) { + unlink($cachePath); + } + } +}); + +it('memoizes reads without returning stale values after mutation', function () { + $cfg = new Config; + $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); + + expect($cfg->readCacheEnabled())->toBeTrue() + ->and($cfg->get('app.name'))->toBe('ArrayKit'); + + $cfg->set('app.name', 'ArrayKitX'); + expect($cfg->get('app.name'))->toBe('ArrayKitX') + ->and($cfg->has('app.name'))->toBeTrue() + ->and($cfg->readCache(false)->readCacheEnabled())->toBeFalse() + ->and($cfg->get('app.name'))->toBe('ArrayKitX'); +}); diff --git a/tests/Feature/DTOTraitTest.php b/tests/Feature/DTOTraitTest.php index 7c8f89c..a6c6c34 100644 --- a/tests/Feature/DTOTraitTest.php +++ b/tests/Feature/DTOTraitTest.php @@ -7,25 +7,30 @@ final class DTOTraitAddress { use DTOTrait; + public string $city = ''; } final class DTOTraitUser { use DTOTrait; + public DTOTraitAddress $address; public function __construct() { - $this->address = new DTOTraitAddress(); + $this->address = new DTOTraitAddress; } } it('can create a DTO from an array', function () { // Define a quick test class inline - $dtoClass = new class { + $dtoClass = new class + { use DTOTrait; + public string $name; + public int $age; }; @@ -35,14 +40,17 @@ public function __construct() $data = $dto->toArray(); expect($data)->toBe([ 'name' => 'Alice', - 'age' => 30, + 'age' => 30, ]); }); it('can hydrate an existing DTO instance from an array', function () { - $dto = new class { + $dto = new class + { use DTOTrait; + public string $name = ''; + public int $age = 0; }; @@ -55,9 +63,12 @@ public function __construct() }); it('supports hydrate mapping and scalar coercion', function () { - $dto = new class { + $dto = new class + { use DTOTrait; + public string $name = ''; + public int $age = 0; }; @@ -70,7 +81,7 @@ public function __construct() }); it('supports nested DTO hydration and deep export', function () { - $dto = new DTOTraitUser(); + $dto = new DTOTraitUser; $dto->hydrateNested(['address' => ['city' => 'Paris']]); expect($dto->toArrayDeep())->toBe([ diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php index 2acad5e..9ead31e 100644 --- a/tests/Feature/DotNotationTest.php +++ b/tests/Feature/DotNotationTest.php @@ -6,24 +6,24 @@ it('flattens a multi-level array into dot notation', function () { $source = ['user' => ['name' => 'Alice', 'roles' => ['admin', 'editor']]]; - $flat = DotNotation::flatten($source); + $flat = DotNotation::flatten($source); expect($flat)->toBe([ - 'user.name' => 'Alice', - 'user.roles.0' => 'admin', - 'user.roles.1' => 'editor', + 'user.name' => 'Alice', + 'user.roles.0' => 'admin', + 'user.roles.1' => 'editor', ]); }); it('expands a dot-notation array back to nested structure', function () { $dotArray = [ 'app.name' => 'MyApp', - 'app.env' => 'local', + 'app.env' => 'local', ]; $expanded = DotNotation::expand($dotArray); expect($expanded)->toBe([ 'app' => [ 'name' => 'MyApp', - 'env' => 'local', + 'env' => 'local', ], ]); }); @@ -210,7 +210,7 @@ $data = []; DotNotation::set($data, 'user.name', 'Diana'); expect($data)->toBe([ - 'user' => ['name' => 'Diana'] + 'user' => ['name' => 'Diana'], ]); }); @@ -224,13 +224,13 @@ $data = []; DotNotation::set($data, [ 'user.name' => 'Eve', - 'user.email' => 'eve@example.com' + 'user.email' => 'eve@example.com', ]); expect($data)->toBe([ 'user' => [ 'name' => 'Eve', - 'email' => 'eve@example.com' - ] + 'email' => 'eve@example.com', + ], ]); }); diff --git a/tests/Feature/GlobalHelpersTest.php b/tests/Feature/GlobalHelpersTest.php index e1bff19..839eaf0 100644 --- a/tests/Feature/GlobalHelpersTest.php +++ b/tests/Feature/GlobalHelpersTest.php @@ -4,6 +4,7 @@ use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\Pipeline; + use function Infocyph\ArrayKit\array_get as ns_array_get; use function Infocyph\ArrayKit\array_set as ns_array_set; use function Infocyph\ArrayKit\chain as ns_chain; @@ -11,13 +12,13 @@ use function Infocyph\ArrayKit\compare as ns_compare; it('autoloads only namespaced helper functions by default', function () { - $composer = json_decode((string) file_get_contents(__DIR__ . '/../../composer.json'), true, 512, JSON_THROW_ON_ERROR); + $composer = json_decode((string) file_get_contents(__DIR__.'/../../composer.json'), true, 512, JSON_THROW_ON_ERROR); expect($composer['autoload']['files'])->toBe(['src/namespaced-functions.php']); }); it('keeps global helper declarations guarded in optional file', function () { - $source = file_get_contents(__DIR__ . '/../../src/functions.php'); + $source = file_get_contents(__DIR__.'/../../src/functions.php'); expect($source)->toContain("if (!function_exists('compare'))") ->and($source)->toContain("if (!function_exists('array_get'))") diff --git a/tests/Feature/HookedCollectionTest.php b/tests/Feature/HookedCollectionTest.php index 063490a..37a8378 100644 --- a/tests/Feature/HookedCollectionTest.php +++ b/tests/Feature/HookedCollectionTest.php @@ -8,24 +8,24 @@ $collection = new HookedCollection(['title' => 'Hello']); // Hook to transform the title to uppercase on get - $collection->onGet('title', fn($value) => strtoupper($value)); + $collection->onGet('title', fn ($value) => strtoupper($value)); // Normal offsetGet triggers the hook: expect($collection['title'])->toBe('HELLO'); }); it('applies on set hook for a specific offset', function () { - $collection = new HookedCollection(); + $collection = new HookedCollection; // Hook to append "!!!" to any new value set for offset "shout" - $collection->onSet('shout', fn($value) => $value . '!!!'); + $collection->onSet('shout', fn ($value) => $value.'!!!'); $collection['shout'] = 'Hey'; expect($collection['shout'])->toBe('Hey!!!'); }); it('still behaves like a normal collection otherwise', function () { - $collection = new HookedCollection(); + $collection = new HookedCollection; $collection['test'] = 123; expect($collection['test'])->toBe(123); }); diff --git a/tests/Feature/LaravelCompatTest.php b/tests/Feature/LaravelCompatTest.php deleted file mode 100644 index 6b8d723..0000000 --- a/tests/Feature/LaravelCompatTest.php +++ /dev/null @@ -1,24 +0,0 @@ - ['name' => 'Alice']]; - - Arr::set($data, 'user.role', 'admin'); - - expect(Arr::get($data, 'user.name'))->toBe('Alice') - ->and(Arr::has($data, 'user.role'))->toBeTrue() - ->and(Arr::hasAny($data, ['user.email', 'user.role']))->toBeTrue() - ->and(Arr::only($data['user'], ['name']))->toBe(['name' => 'Alice']) - ->and(Arr::except($data['user'], ['role']))->toBe(['name' => 'Alice']); -}); - -it('provides a Laravel-compatible Collection class', function () { - $collection = new CompatCollection([1, 2, 3, 4]); - - expect($collection->filter(fn (int $v) => $v % 2 === 0)->all())->toBe([1 => 2, 3 => 4]); -}); diff --git a/tests/Feature/LazyCollectionTest.php b/tests/Feature/LazyCollectionTest.php index 1f91084..f9459c7 100644 --- a/tests/Feature/LazyCollectionTest.php +++ b/tests/Feature/LazyCollectionTest.php @@ -24,3 +24,17 @@ $cursor = iterator_to_array($lazy->cursor(), true); expect($cursor)->toBe([1, 2, 3, 4, 5, 6]); }); + +it('does not advance the source when take(0) is requested', function () { + $visited = 0; + + $lazy = LazyCollection::from((function () use (&$visited) { + $visited++; + yield 1; + $visited++; + yield 2; + })()); + + expect($lazy->take(0)->all())->toBe([]) + ->and($visited)->toBe(0); +}); diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index c8a5d8e..19565f0 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -8,14 +8,14 @@ function lazyConfigWriteArrayFile(string $directory, string $name, array $conten { $export = var_export($contents, true); file_put_contents( - $directory . DIRECTORY_SEPARATOR . $name . '.php', + $directory.DIRECTORY_SEPARATOR.$name.'.php', " $this->items)->call($config); } +/** + * @return array + */ +function lazyConfigFlatIndex(string $directory): array +{ + $path = $directory.DIRECTORY_SEPARATOR.'__flat.php'; + + if (! is_file($path)) { + return []; + } + + /** @var array $index */ + $index = include $path; + + return $index; +} + beforeEach(function () { $this->configPath = sys_get_temp_dir() - . DIRECTORY_SEPARATOR - . 'arraykit-lazy-config-' - . uniqid('', true); + .DIRECTORY_SEPARATOR + .'arraykit-lazy-config-' + .uniqid('', true); + + $this->cachePath = sys_get_temp_dir() + .DIRECTORY_SEPARATOR + .'arraykit-lazy-cache-' + .uniqid('', true); mkdir($this->configPath, 0777, true); + mkdir($this->cachePath, 0777, true); }); afterEach(function () { lazyConfigDeleteDirectory($this->configPath); + lazyConfigDeleteDirectory($this->cachePath); }); it('loads only the first namespace file on first dot-path access', function () { @@ -172,9 +197,18 @@ function lazyConfigItems(LazyFileConfig $config): array ->and(lazyConfigItems($config))->toBe([]); }); +it('evaluates closure defaults for missing or non-array nested paths', function () { + $config = new LazyFileConfig($this->configPath, items: [ + 'db' => 'scalar', + ]); + + expect($config->get('cache.driver', fn () => 'file'))->toBe('file') + ->and($config->get('db.host', fn () => 'fallback-host'))->toBe('fallback-host'); +}); + it('throws when a namespace file does not return an array', function () { file_put_contents( - $this->configPath . DIRECTORY_SEPARATOR . 'db.php', + $this->configPath.DIRECTORY_SEPARATOR.'db.php', "configPath, 'db', ['host' => 'localhost']); $config = new LazyFileConfig($this->configPath); - $config->onSet('db.host', fn($value) => strtoupper((string) $value)); - $config->onGet('db.host', fn($value) => strtolower((string) $value)); + $config->onSet('db.host', fn ($value) => strtoupper((string) $value)); + $config->onGet('db.host', fn ($value) => strtolower((string) $value)); $config->setWithHooks('db.host', 'INTERNAL'); @@ -206,8 +240,8 @@ function lazyConfigItems(LazyFileConfig $config): array lazyConfigWriteArrayFile($this->configPath, 'db', ['host' => 'localhost', 'port' => 3306]); $config = new LazyFileConfig($this->configPath); - $config->onSet('db.host', fn($value) => strtoupper((string) $value)); - $config->onGet('db.host', fn($value) => strtolower((string) $value)); + $config->onSet('db.host', fn ($value) => strtoupper((string) $value)); + $config->onGet('db.host', fn ($value) => strtolower((string) $value)); $config->setWithHooks([ 'db.host' => 'INTERNAL', @@ -234,3 +268,97 @@ function lazyConfigItems(LazyFileConfig $config): array $config->reload(['cache' => ['driver' => 'file']]); expect($config->get('cache.driver'))->toBe('file'); }); + +it('treats seeded in-memory namespaces as already resolved', function () { + lazyConfigWriteArrayFile($this->configPath, 'app', ['name' => 'from-file', 'debug' => true]); + + $config = new LazyFileConfig($this->configPath, items: [ + 'app' => ['name' => 'from-memory'], + ]); + + expect($config->loaded('app'))->toBeTrue() + ->and($config->get('app'))->toBe(['name' => 'from-memory']); +}); + +it('does not re-merge namespace files after replace merge or reload with in-memory data', function () { + lazyConfigWriteArrayFile($this->configPath, 'app', [ + 'name' => 'from-file', + 'debug' => true, + ]); + + $config = new LazyFileConfig($this->configPath); + + $config->replace([ + 'app' => ['name' => 'replaced'], + ]); + expect($config->get('app'))->toBe(['name' => 'replaced']); + + $config->merge([ + 'cache' => ['driver' => 'array'], + ]); + expect($config->get('cache'))->toBe(['driver' => 'array']); + + $config->reload([ + 'app' => ['name' => 'reloaded'], + ]); + expect($config->get('app'))->toBe(['name' => 'reloaded']); +}); + +it('supports namespace cache warmup and fallback retrieval', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', [ + 'host' => 'localhost', + 'port' => 3306, + 'options' => ['timeout' => 5], + ]); + + $config = new LazyFileConfig($this->configPath); + $config->namespaceCache($this->cachePath)->warmNamespaceCache('db'); + + unlink($this->configPath.DIRECTORY_SEPARATOR.'db.php'); + + $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + + expect($fresh->get('db.host'))->toBe('localhost') + ->and($fresh->get('db.port'))->toBe(3306) + ->and($fresh->get('db.options'))->toBe(['timeout' => 5]); +}); + +it('writes a flat leaf index containing only final scalar values', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', [ + 'host' => 'localhost', + 'port' => 3306, + 'options' => ['timeout' => 5], + 'replicas' => ['db-1', 'db-2'], + ]); + + $config = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + $config->warmNamespaceCache('db'); + + expect(lazyConfigFlatIndex($this->cachePath))->toBe([ + 'db.host' => 'localhost', + 'db.options.timeout' => 5, + 'db.port' => 3306, + 'db.replicas.0' => 'db-1', + 'db.replicas.1' => 'db-2', + ]); +}); + +it('can resolve exact scalar paths from the flat index when namespace structure is unavailable', function () { + lazyConfigWriteArrayFile($this->configPath, 'db', [ + 'host' => 'localhost', + 'options' => ['timeout' => 5], + ]); + + $config = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + $config->warmNamespaceCache('db'); + + unlink($this->configPath.DIRECTORY_SEPARATOR.'db.php'); + unlink($this->cachePath.DIRECTORY_SEPARATOR.'db.php'); + + $fresh = new LazyFileConfig($this->configPath, namespaceCacheDirectory: $this->cachePath); + + expect($fresh->get('db.host'))->toBe('localhost') + ->and($fresh->get('db.options.timeout'))->toBe(5) + ->and($fresh->get('db.options', 'missing'))->toBe('missing') + ->and($fresh->get('db', 'missing'))->toBe('missing'); +}); diff --git a/tests/Feature/PipelineTest.php b/tests/Feature/PipelineTest.php index e68a330..a31419e 100644 --- a/tests/Feature/PipelineTest.php +++ b/tests/Feature/PipelineTest.php @@ -16,7 +16,7 @@ 2 => ['id' => 2, 'team' => 'B', 'score' => 50], 3 => ['id' => 3, 'team' => 'A', 'score' => 40], ]) - ->and($rows->copy()->indexBy(fn (array $row) => 'r' . $row['id'])->all())->toBe([ + ->and($rows->copy()->indexBy(fn (array $row) => 'r'.$row['id'])->all())->toBe([ 'r1' => ['id' => 1, 'team' => 'A', 'score' => 30], 'r2' => ['id' => 2, 'team' => 'B', 'score' => 50], 'r3' => ['id' => 3, 'team' => 'A', 'score' => 40], diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..174d7fd --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,3 @@ +