diff --git a/README.md b/README.md index 733287c..1dc9a6b 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ ![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/ArrayKit) [![Documentation](https://img.shields.io/badge/Documentation-ArrayKit-blue?logo=readthedocs&logoColor=white)](https://docs.infocyph.com/projects/arraykit/) -**ArrayKit** is a modern **PHP 8.4+** library for elegant, high-performance **array manipulation**, **dot notation -utilities**, **dynamic configuration**, **hookable collections**, and more. -From shallow single arrays to deeply nested data structures — **ArrayKit** provides a fluent, reliable toolkit for -real-world PHP projects. +**ArrayKit** is a modern **PHP 8.4+** data toolkit for **array manipulation**, **dot-path access**, **collections**, +**configuration and dotenv parsing**, **hooks**, and **DTO hydration**. These capabilities work together around the +same application-data boundary: shaping values, accessing nested structures, and carrying that data through runtime +configuration or object models. ## Features at a Glance @@ -26,6 +26,7 @@ real-world PHP projects. - **LazyCollection for Generator-Based Flows** - **ArrayShape Validation Helper** - **Compiled Config + Lazy Namespace Cache** +- **Dotenv Parsing + Environment References** - **Namespaced Helpers + Optional Globals** ## Modules @@ -47,6 +48,8 @@ real-world PHP projects. |---------------------|---------------------------------------------------------------------------------------------------------------------| | **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. | +| **EnvParser** | Strict dotenv parser with variable expansion and circular-reference detection. | +| **Environment** | Process-environment reader and `EnvReference` factory for deferred configuration values. | | **BaseConfigTrait** | Shared config logic. | @@ -57,7 +60,7 @@ real-world PHP projects. | **Collection** | OOP array wrapper implementing `ArrayAccess`, `IteratorAggregate`, `Countable`, `JsonSerializable`. | | **HookedCollection** | Extends `Collection` with **on-get/on-set hooks** for real-time transformation of values. | | **Pipeline** | Functional-style pipeline for chaining operations on collections. | -| **LazyCollection** | Generator-backed lazy operations (`mapLazy`, `filterLazy`, `chunkLazy`, `take`, `takeUntil`). | +| **LazyCollection** | Repeatable lazy operations (`mapLazy`, `filterLazy`, `chunkLazy`, `take`, `takeUntil`), including one-shot generators and renewable factories. | | **BaseCollectionTrait** | Shared collection behavior. | @@ -253,7 +256,7 @@ echo $collection['role']; // Role: admin ### 🔹 DTO Trait Example ```php -use Infocyph\ArrayKit\traits\DTOTrait; +use Infocyph\ArrayKit\DTO\Concerns\DTOTrait; class UserDTO { use DTOTrait; diff --git a/composer.json b/composer.json index eb7ebdb..916754f 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,16 @@ { "name": "infocyph/arraykit", - "description": "A Collection of useful PHP array functions.", + "description": "PHP data and configuration primitives: arrays, collections, dot paths, dotenv, hooks, and DTO hydration.", "license": "MIT", "type": "library", "keywords": [ - "collection", "array", - "config" + "collection", + "config", + "dotenv", + "dot-notation", + "dto", + "pipeline" ], "authors": [ { diff --git a/docs/collection.rst b/docs/collection.rst index 158043e..b2c1b7d 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -266,11 +266,16 @@ LazyCollection -------------- Use ``LazyCollection`` for generator-backed transformations over large iterables. +Collections built with ``from()`` replay values already read from a one-shot +generator without eagerly materializing the source. That replay cache grows with +the portion consumed, so use ``fromFactory()`` for long-lived or unbounded +sources when each traversal can create a fresh iterable. .. code-block:: php filterLazy(fn ($v) => $v % 2 === 0) @@ -280,6 +285,18 @@ Use ``LazyCollection`` for generator-backed transformations over large iterables // [20, 40, 60, 80, 100] + $events = LazyCollection::from((function (): Generator { + yield 'created'; + yield 'updated'; + })()); + + $events->all(); // ['created', 'updated'] + $events->all(); // ['created', 'updated'] + + $fresh = LazyCollection::fromFactory(function (): Generator { + yield from fetchEvents(); + }); + Terminal calculations: .. code-block:: php diff --git a/docs/index.rst b/docs/index.rst index 8bc6e8a..0dd81ce 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,6 +5,8 @@ ArrayKit Manual ================= For `ArrayKit `_ |version|. Updated on |today|. +ArrayKit provides data and configuration primitives: array helpers, dot paths, +collections, dotenv support, hooks, and DTO hydration. Licensed under `MIT `_. diff --git a/docs/migration.rst b/docs/migration.rst index f7a6e67..f628e37 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -24,6 +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). +- ``LazyCollection::from()`` now safely replays consumed values from one-shot iterables; use ``fromFactory()`` for a fresh source on every traversal. - New optional helper: ``ArrayShape`` validator. Compatibility Notes diff --git a/docs/quick-usage.rst b/docs/quick-usage.rst index bc6f37f..b907cea 100644 --- a/docs/quick-usage.rst +++ b/docs/quick-usage.rst @@ -1,7 +1,9 @@ Quick Usage =========== -This page shows copy-paste examples for common ArrayKit operations. +This page shows copy-paste examples for common ArrayKit operations. The package +keeps its data-focused features together: shape and access arrays, transform +collections, hydrate configuration, and map values into DTOs. ArrayKit Facade Example ---------------------- diff --git a/docs/rule-reference.rst b/docs/rule-reference.rst index 4414c04..edeaec8 100644 --- a/docs/rule-reference.rst +++ b/docs/rule-reference.rst @@ -591,6 +591,7 @@ LazyCollection .. code-block:: php public static function from(iterable $source): self + public static function fromFactory(\Closure $factory): self public static function make(mixed $data = []): self public function getIterator(): Traversable public function cursor(): Generator diff --git a/docs/traits-and-helpers.rst b/docs/traits-and-helpers.rst index f4855a1..5972919 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -11,7 +11,7 @@ This page covers reusable building blocks outside the core helper classes: DTOTrait -------- -Namespace: ``Infocyph\ArrayKit\traits\DTOTrait`` +Namespace: ``Infocyph\ArrayKit\DTO\Concerns\DTOTrait`` Main methods: @@ -29,7 +29,7 @@ Basic DTO Flow .. code-block:: php $source); + return new self(self::replayableFactory($source)); + } + + /** + * Create a lazy collection from a factory that returns a fresh iterable for + * every traversal. + * + * @template TFactoryKey of array-key + * @template TFactoryValue + * + * @param \Closure(): iterable $factory + * @return self + */ + public static function fromFactory(\Closure $factory): self + { + return new self($factory); } /** @@ -43,7 +58,7 @@ public static function make(mixed $data = []): self } if ($data instanceof Traversable) { - return self::from(self::iterableToArray($data)); + return self::fromTraversable($data); } if ($data === null) { @@ -156,12 +171,12 @@ public function take(int $limit): self return new self(function () use ($limit): Generator { $count = 0; foreach ($this->cursor() as $key => $value) { - if ($count >= $limit) { - break; - } - yield $key => $value; $count++; + + if ($count >= $limit) { + return; + } } }); } @@ -184,24 +199,73 @@ public function takeUntil(callable $callback): self } /** - * Normalize traversable input to array-key arrays for generic safety. + * Normalize traversable input without forcing eager materialization. * - * @param Traversable $source - * @return array + * @param Traversable $source + * @return self */ - private static function iterableToArray(Traversable $source): array + private static function fromTraversable(Traversable $source): self { - $results = []; - foreach ($source as $key => $value) { - if (is_int($key) || is_string($key)) { - $results[$key] = $value; + return new self(self::replayableFactory($source)); + } - continue; - } + /** + * Adapt any iterable into a repeatable lazy source without eagerly + * materializing it. Values already consumed from a one-shot iterator are + * memoized, while later values are fetched only when a cursor needs them. + * + * @template TSourceKey of array-key + * @template TSourceValue + * + * @param iterable $source + * @return \Closure(): iterable + */ + private static function replayableFactory(iterable $source): \Closure + { + /** @var list $cache */ + $cache = []; + $sourceCursor = null; + $sourceAdvancePending = false; + $exhausted = false; - $results[] = $value; - } + return static function () use ($source, &$cache, &$sourceCursor, &$sourceAdvancePending, &$exhausted): Generator { + $position = 0; + + while (true) { + if (array_key_exists($position, $cache)) { + [$key, $value] = $cache[$position]; + yield $key => $value; + $position++; + + continue; + } + + if ($exhausted) { + return; + } + + $sourceCursor ??= (static function () use ($source): Generator { + yield from $source; + })(); + + if ($sourceAdvancePending) { + $sourceCursor->next(); + $sourceAdvancePending = false; + } + + if (!$sourceCursor->valid()) { + $exhausted = true; - return $results; + return; + } + + $entry = [$sourceCursor->key(), $sourceCursor->current()]; + $cache[] = $entry; + $sourceAdvancePending = true; + + yield $entry[0] => $entry[1]; + $position++; + } + }; } } diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index 7b125ba..b1f3bff 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -164,12 +164,7 @@ public function isLoaded(string $namespace): bool */ public function loadArray(array $resource): bool { - $loaded = parent::loadArray($resource); - if ($loaded) { - $this->syncLoadedNamespacesFromItems(); - } - - return $loaded; + return $this->syncLoadedNamespacesAfter(parent::loadArray($resource)); } /** @@ -191,12 +186,7 @@ public function loadedNamespaces(): array #[\Override] public function loadFile(string $path): bool { - $loaded = parent::loadFile($path); - if ($loaded) { - $this->syncLoadedNamespacesFromItems(); - } - - return $loaded; + return $this->syncLoadedNamespacesAfter(parent::loadFile($path)); } #[\Override] @@ -205,12 +195,7 @@ public function loadFile(string $path): bool */ public function merge(array $items): bool { - $merged = parent::merge($items); - if ($merged) { - $this->syncLoadedNamespacesFromItems(); - } - - return $merged; + return $this->syncLoadedNamespacesAfter(parent::merge($items)); } /** @@ -234,12 +219,8 @@ public function preload(string|array $namespaces): static public function reload(array|string $source): bool { $this->assertWritable(); - $reloaded = parent::reload($source); - if ($reloaded) { - $this->syncLoadedNamespacesFromItems(); - } - return $reloaded; + return $this->syncLoadedNamespacesAfter(parent::reload($source)); } #[\Override] @@ -249,23 +230,14 @@ public function reload(array|string $source): bool public function replace(array $items): bool { $this->assertWritable(); - $replaced = parent::replace($items); - if ($replaced) { - $this->syncLoadedNamespacesFromItems(); - } - return $replaced; + return $this->syncLoadedNamespacesAfter(parent::replace($items)); } #[\Override] public function restore(string $name = 'default'): bool { - $restored = parent::restore($name); - if ($restored) { - $this->syncLoadedNamespacesFromItems(); - } - - return $restored; + return $this->syncLoadedNamespacesAfter(parent::restore($name)); } #[\Override] @@ -540,4 +512,13 @@ protected function syncLoadedNamespacesFromItems(): void $this->loadedNamespaces[$namespace] = true; } } + + private function syncLoadedNamespacesAfter(bool $changed): bool + { + if ($changed) { + $this->syncLoadedNamespacesFromItems(); + } + + return $changed; + } } diff --git a/src/Config/Support/EnvValueResolver.php b/src/Config/Support/EnvValueResolver.php index 968c95c..e045dca 100644 --- a/src/Config/Support/EnvValueResolver.php +++ b/src/Config/Support/EnvValueResolver.php @@ -203,7 +203,6 @@ static function (array $matches) use ($items, &$resolved, $resolving): string { /** * @param array $matches - * @param array-key $key */ private static function stringMatch(array $matches, int|string $key): string { diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index 1f1f530..b57779e 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -4,12 +4,7 @@ use Infocyph\ArrayKit\Config\Config; use Infocyph\ArrayKit\Config\Support\Environment; - -enum ConfigMode: string -{ - case Local = 'local'; - case Prod = 'prod'; -} +use Infocyph\ArrayKit\Tests\Fixtures\ConfigMode; $config = new Config; @@ -53,7 +48,7 @@ enum ConfigMode: string $cfg->loadArray(['app' => ['name' => 'ArrayKit']]); expect($cfg->getOrFail('app.name'))->toBe('ArrayKit') - ->and(fn () => $cfg->getOrFail('app.missing'))->toThrow(OutOfBoundsException::class); + ->and(fn () => $cfg->getOrFail('app.missing'))->toThrow(\OutOfBoundsException::class); }); it('supports typed getters with default fallbacks', function () { @@ -92,7 +87,7 @@ enum ConfigMode: string $cfg->readonly(); expect($cfg->isReadonly())->toBeTrue() - ->and(fn () => $cfg->set('db.host', '127.0.0.1'))->toThrow(RuntimeException::class); + ->and(fn () => $cfg->set('db.host', '127.0.0.1'))->toThrow(\RuntimeException::class); }); it('supports getEnum for backed enums', function () { diff --git a/tests/Feature/DTOTraitTest.php b/tests/Feature/DTOTraitTest.php index 404f12f..68a1d9a 100644 --- a/tests/Feature/DTOTraitTest.php +++ b/tests/Feature/DTOTraitTest.php @@ -3,25 +3,7 @@ declare(strict_types=1); use Infocyph\ArrayKit\DTO\Concerns\DTOTrait; - -final class DTOTraitAddress -{ - use DTOTrait; - - public string $city = ''; -} - -final class DTOTraitUser -{ - use DTOTrait; - - public DTOTraitAddress $address; - - public function __construct() - { - $this->address = new DTOTraitAddress; - } -} +use Infocyph\ArrayKit\Tests\Fixtures\DTOTraitUser; it('can create a DTO from an array', function () { // Define a quick test class inline diff --git a/tests/Feature/LazyCollectionTest.php b/tests/Feature/LazyCollectionTest.php index f9459c7..00f97e7 100644 --- a/tests/Feature/LazyCollectionTest.php +++ b/tests/Feature/LazyCollectionTest.php @@ -38,3 +38,48 @@ expect($lazy->take(0)->all())->toBe([]) ->and($visited)->toBe(0); }); + +it('keeps traversable input lazy when constructed through make', function () { + $visited = 0; + + $lazy = LazyCollection::make((function () use (&$visited) { + $visited++; + yield 1; + })()); + + expect($visited)->toBe(0) + ->and($lazy->all())->toBe([1]) + ->and($lazy->all())->toBe([1]) + ->and($visited)->toBe(1); +}); + +it('can traverse a generator-backed collection more than once', function () { + $visited = 0; + + $lazy = LazyCollection::from((function () use (&$visited) { + $visited++; + yield 'first' => 1; + $visited++; + yield 'second' => 2; + })()); + + expect($lazy->take(1)->all())->toBe(['first' => 1]) + ->and($visited)->toBe(1) + ->and($lazy->all())->toBe(['first' => 1, 'second' => 2]) + ->and($lazy->all())->toBe(['first' => 1, 'second' => 2]) + ->and($visited)->toBe(2); +}); + +it('creates a new source for every factory-backed traversal', function () { + $created = 0; + + $lazy = LazyCollection::fromFactory(function () use (&$created): Generator { + $created++; + yield 1; + yield 2; + }); + + expect($lazy->all())->toBe([1, 2]) + ->and($lazy->all())->toBe([1, 2]) + ->and($created)->toBe(2); +}); diff --git a/tests/Feature/LazyFileConfigTest.php b/tests/Feature/LazyFileConfigTest.php index d7b62b3..8671232 100644 --- a/tests/Feature/LazyFileConfigTest.php +++ b/tests/Feature/LazyFileConfigTest.php @@ -305,6 +305,22 @@ function lazyConfigFlatIndex(string $directory): array expect($config->get('app'))->toBe(['name' => 'reloaded']); }); +it('keeps loaded namespaces synchronized after in-memory state changes', function () { + $config = new LazyFileConfig($this->configPath); + + $config->loadArray(['app' => ['name' => 'ArrayKit']]); + expect($config->loaded('app'))->toBeTrue(); + + $config->snapshot('before-replace'); + $config->replace(['cache' => ['driver' => 'file']]); + expect($config->loaded('app'))->toBeFalse() + ->and($config->loaded('cache'))->toBeTrue(); + + $config->restore('before-replace'); + expect($config->loaded('app'))->toBeTrue() + ->and($config->loaded('cache'))->toBeFalse(); +}); + it('supports namespace cache warmup and fallback retrieval', function () { lazyConfigWriteArrayFile($this->configPath, 'db', [ 'host' => 'localhost', diff --git a/tests/Fixtures/ConfigMode.php b/tests/Fixtures/ConfigMode.php new file mode 100644 index 0000000..80bd58a --- /dev/null +++ b/tests/Fixtures/ConfigMode.php @@ -0,0 +1,11 @@ +address = new DTOTraitAddress; + } +}