diff --git a/README.md b/README.md index 3bf189d..23d16bc 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,10 @@ real-world PHP projects. - **Unified Facade (`ArrayKit`)** - **Traits for DTO & Hooking** - **Pipeline for Collection Ops** -- **Global Helpers (`functions.php`)** +- **LazyCollection for Generator-Based Flows** +- **ArrayShape Validation Helper** +- **Laravel Compatibility Layer (`LaravelCompat\\Arr`, `LaravelCompat\\Collection`)** +- **Namespaced Helpers + Optional Globals** ## Modules @@ -34,6 +37,7 @@ real-world PHP projects. | **ArraySingle** | Helpers for single-dimensional arrays (set ops, mapWithKeys, countBy, min/max, paginate, duplicates, averages). | | **ArrayMulti** | Helpers for multi-dimensional arrays (flatten, collapse, depth, keyBy/indexBy, firstWhere, recursive sort/filter). | | **DotNotation** | Get/set/remove values using dot keys; wildcard support; escaped literal-dot paths; flatten & expand. | +| **ArrayShape** | Lightweight array-shape assertions for row validation (`require`). | | **BaseArrayHelper** | Internal shared base for consistent API across helpers. | | **ArraySharedOps** | Internal shared operations used by `ArraySingle` and `ArrayMulti` (`each/every/partition/skip*`). | @@ -53,6 +57,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`). | | **BaseCollectionTrait** | Shared collection behavior. | @@ -64,11 +69,12 @@ real-world PHP projects. | **DTOTrait** | Utility trait for DTO-like behavior: populate, extract, cast arrays/objects easily. | -### Global Helpers +### Helper Functions -| File | Description | -|-------------------|------------------------------------------------------------| -| **functions.php** | Global shortcut functions for frequent array/config tasks. | +| Helper Surface | Description | +|----------------|-------------| +| **`Infocyph\ArrayKit\*`** | Namespaced helper functions (`compare`, `array_get`, `array_set`, `collect`, `chain`) autoloaded by default. | +| **`functions.php`** | Optional global helper variants (manual include when needed). | ### ➤ Facade @@ -87,6 +93,18 @@ real-world PHP projects. composer require infocyph/arraykit ``` +```php + strtolower((string) ($row['email'] ?? ''))); // Collapse one level $collapsed = ArrayMulti::collapse($data); // [1, 2, 3, [4, 5]] @@ -182,6 +217,13 @@ $config->onGet('secure.key', fn($v) => decrypt($v)); // Use it $config->setWithHooks('auth.password', 'secret123'); $hashed = $config->getWithHooks('auth.password'); + +// Typed getters + state helpers +$port = $config->getInt('db.port', 3306); +$config->snapshot('before-runtime'); +$config->merge(['app' => ['env' => 'production']]); +$changed = $config->changed('before-runtime'); +$config->restore('before-runtime'); ``` ### Hooked Collection @@ -218,8 +260,43 @@ class UserDTO { $user = new UserDTO(); $user->fromArray(['name' => 'Alice', 'email' => 'alice@example.com']); $array = $user->toArray(); + +// Advanced hydration / export +$user->hydrate(['name' => 'Alice'], mapping: ['name' => 'full_name']); +$deep = $user->toArrayDeep(); +``` + +### Lazy + Shape + Compat + +```php +use Infocyph\ArrayKit\Array\ArrayShape; +use Infocyph\ArrayKit\ArrayKit; +use Infocyph\ArrayKit\LaravelCompat\Arr; + +$lazy = ArrayKit::lazyCollection(range(1, 10)) + ->filterLazy(fn ($v) => $v % 2 === 0) + ->take(3) + ->all(); // [2, 4, 6] + +$row = ArrayShape::require( + ['id' => 1, 'email' => 'a@example.com', 'roles' => ['admin']], + ['id' => 'int', 'email' => 'string', 'roles' => 'list'], +); + +$data = ['user' => ['name' => 'Alice']]; +Arr::set($data, 'user.role', 'admin'); ``` +## Behavior Notes + +- `ArrayMulti::flatten($array, 0)` keeps top-level values unchanged; `1` flattens one level; `INF` fully flattens. +- `ArraySingle::avg()`, `sum()`, `isPositive()`, and `isNegative()` only consider numeric values (non-numeric values are ignored). +- `ArraySingle::paginate()` requires `page >= 1` and `perPage >= 1` (throws `InvalidArgumentException` otherwise). +- Callback-based row helpers (`ArrayMulti::sortBy()`, `sum()`, `maxBy()`, `minBy()`) support `($row, $key)`. +- `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()`. + ## Security Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects. diff --git a/benchmarks/CoreBench.php b/benchmarks/CoreBench.php index 2dac984..c587af0 100644 --- a/benchmarks/CoreBench.php +++ b/benchmarks/CoreBench.php @@ -26,6 +26,8 @@ final class CoreBench private array $queryRows = []; + private array $queryRowsLarge = []; + private array $single = []; private array $singleAssoc = []; @@ -65,6 +67,20 @@ public function setUp(): void ['id' => 5, 'role' => 'viewer'], ]; + $this->queryRowsLarge = []; + for ($i = 0; $i < 10000; $i++) { + $this->queryRowsLarge[] = [ + 'id' => $i + 1, + 'group' => 'group-' . ($i % 100), + 'role' => match ($i % 4) { + 0 => 'admin', + 1 => 'editor', + 2 => 'viewer', + default => null, + }, + ]; + } + $this->dot = [ 'app' => ['name' => 'ArrayKit', 'env' => 'local'], 'db' => [ @@ -92,18 +108,46 @@ public function benchArrayMultiFlatten(): void ArrayMulti::flatten($this->nested); } + #[Subject] + public function benchArrayMultiFlattenDeep(): void + { + ArrayMulti::flatten([[[[1, 2], 3], [4, [5, [6]]]], 7]); + } + #[Subject] public function benchArrayMultiKeyBy(): void { ArrayMulti::keyBy($this->nested, 'id'); } + #[Subject] + public function benchArrayMultiKeyBy10k(): void + { + ArrayMulti::keyBy($this->queryRowsLarge, 'id'); + } + #[Subject] public function benchArrayMultiSkipUntil(): void { ArrayMulti::skipUntil($this->nested, static fn(array $row): bool => ($row['id'] ?? 0) >= 3); } + #[Subject] + public function benchArrayMultiUniqueNested(): void + { + ArrayMulti::unique([ + ['id' => 1, 'meta' => ['x' => 1]], + ['id' => 1, 'meta' => ['x' => 1]], + ['id' => 2, 'meta' => ['x' => 2]], + ], true); + } + + #[Subject] + public function benchArrayMultiWhereIn10k(): void + { + ArrayMulti::whereIn($this->queryRowsLarge, 'role', ['admin', 'editor']); + } + #[Subject] public function benchArrayMultiWhereInNull(): void { @@ -157,4 +201,16 @@ public function benchDotNotationGet(): void { DotNotation::get($this->dot, 'db.options.timeout'); } + + #[Subject] + public function benchDotNotationWildcardGet(): void + { + DotNotation::get([ + 'users' => [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ['name' => 'Cara'], + ], + ], 'users.*.name'); + } } diff --git a/composer.json b/composer.json index 64cd200..eb7ebdb 100644 --- a/composer.json +++ b/composer.json @@ -27,8 +27,7 @@ "Infocyph\\ArrayKit\\": "src/" }, "files": [ - "src/namespaced-functions.php", - "src/functions.php" + "src/namespaced-functions.php" ] }, "autoload-dev": { diff --git a/docs/array-helpers.rst b/docs/array-helpers.rst index b59f5be..4fd7dbf 100644 --- a/docs/array-helpers.rst +++ b/docs/array-helpers.rst @@ -98,14 +98,17 @@ ArraySingle: Search, Partition, Aggregation $v === 3); // 3 + $hasTwo = ArraySingle::contains($arr, 2); // true + $hasAll = ArraySingle::containsAll($arr, [1, 2, 3]); // true + $hasAny = ArraySingle::containsAny($arr, [99, 3]); // true [$even, $odd] = ArraySingle::partition($arr, fn ($v) => $v % 2 === 0); $dupes = ArraySingle::duplicates($arr); // [2] $unique = ArraySingle::unique($arr); // [1,2,3,4,5] - $sum = ArraySingle::sum($arr); // 17 - $avg = ArraySingle::avg($arr); // 17/6 + $sum = ArraySingle::sum($arr); // 17 (non-numeric ignored) + $avg = ArraySingle::avg($arr); // 17/6 (non-numeric ignored) $median = ArraySingle::median($arr); // 2.5 $mode = ArraySingle::mode($arr); // [2] @@ -117,13 +120,13 @@ ArraySingle: Numeric and Value Helpers $row['name'] === 'Alice'); + $firstRole = ArrayMulti::firstWhereIn($rows, 'role', ['editor', 'admin']); ArrayMulti: Grouping, Ordering, and Projection ---------------------------------------------- @@ -181,6 +191,11 @@ ArrayMulti: Grouping, Ordering, and Projection $indexed = ArrayMulti::keyBy($rows, 'team'); $counts = ArrayMulti::countBy($rows, 'team'); $sorted = ArrayMulti::sortBy($rows, 'score', true); // desc + $sortedByKey = ArrayMulti::sortBy($rows, fn ($row, $key) => $key); // callback receives row + key + $sortedMany = ArrayMulti::sortByMany($rows, [ + ['team', 'asc'], + ['score', 'desc'], + ]); $sortedRecursive = ArrayMulti::sortRecursive($rows); $scores = ArrayMulti::pluck($rows, 'score'); // [10,30,20] $transposed = ArrayMulti::transpose($rows); @@ -200,6 +215,8 @@ ArrayMulti: Row Set Operations ]; $unique = ArrayMulti::unique($rows); + $uniqueByName = ArrayMulti::uniqueBy($rows, 'name'); + $dupesByName = ArrayMulti::duplicatesBy($rows, 'name'); $minScore = ArrayMulti::min($rows, 'id'); $maxScore = ArrayMulti::max($rows, 'id'); $firstHigh = ArrayMulti::firstWhere($rows, 'id', '>=', 2); @@ -208,6 +225,30 @@ ArrayMulti: Row Set Operations $mappedKeys = ArrayMulti::mapWithKeys($rows, fn ($row) => [$row['id'] => $row['name']]); $reduced = ArrayMulti::reduce($rows, fn ($carry, $row) => $carry + $row['id'], 0); $sumById = ArrayMulti::sum($rows, 'id'); + $sumByCallback = ArrayMulti::sum($rows, fn ($row, $key) => $row['id'] + $key); + +ArrayShape Validation +--------------------- + +Use ``ArrayShape`` for lightweight shape assertions in row pipelines. + +.. code-block:: php + + 10, + 'email' => 'a@example.com', + 'roles' => ['admin', 'editor'], + ]; + + ArrayShape::require($row, [ + 'id' => 'int', + 'email' => 'string', + 'roles' => 'list', + 'nickname?' => 'string', // optional key + ]); BaseArrayHelper --------------- @@ -282,6 +323,11 @@ Behavior Notes - ``ArraySingle::isAssoc([])`` is ``false``; empty arrays are treated as non-associative. - ``ArraySingle::nth($array, $step, $offset)`` starts at ``$offset`` then takes every ``$step`` item. - ``ArraySingle::unique()`` has loose mode (default) and strict mode. +- ``ArraySingle::avg()``, ``sum()``, ``isPositive()``, and ``isNegative()`` ignore non-numeric values. +- ``ArraySingle::paginate()`` requires ``$page >= 1`` and ``$perPage >= 1``. - ``ArrayMulti::whereIn()`` / ``whereNotIn()`` treat ``null`` as a real value when the key exists. -- ``ArrayMulti::where()`` uses the global ``compare()`` helper semantics for operators. +- ``ArrayMulti::flatten($array, 0)`` returns unchanged top-level values. +- Use ``depthGuarded()``, ``flattenGuarded()``, and ``sortRecursiveGuarded()`` when processing untrusted/deep inputs. +- ``ArrayMulti`` callback helpers such as ``sortBy()``, ``sum()``, ``maxBy()``, ``minBy()`` support ``($row, $key)``. +- ``ArrayMulti::where()`` uses ``Infocyph\ArrayKit\compare()`` semantics for operators. - ``BaseArrayHelper::random()`` throws ``InvalidArgumentException`` when requested count exceeds array size. diff --git a/docs/collection.rst b/docs/collection.rst index a61bc3a..158043e 100644 --- a/docs/collection.rst +++ b/docs/collection.rst @@ -13,6 +13,7 @@ Available classes: - ``Infocyph\ArrayKit\Collection\Collection`` - ``Infocyph\ArrayKit\Collection\HookedCollection`` - ``Infocyph\ArrayKit\Collection\Pipeline`` +- ``Infocyph\ArrayKit\Collection\LazyCollection`` Creating Collections -------------------- @@ -21,6 +22,7 @@ Creating Collections 1, 'b' => 2]); @@ -29,7 +31,7 @@ Creating Collections $c2 = Collection::make(['x' => 10]); $c3 = Collection::from(['y' => 20]); - // Global helper (autoloaded from src/functions.php) + // Namespaced helper (autoloaded by default) $c4 = collect(['z' => 30]); Reading and Writing @@ -145,6 +147,7 @@ Every transformation method is exposed through ``Pipeline``. You can start it either with ``process()`` or directly by calling pipeline methods on collection (via ``__call``). Pipeline methods mutate the current collection instance and return that same instance for chaining. Use ``copy()`` or ``immutable()`` before chaining when you need functional-style non-mutating behavior. +You can also force immutable-style pipeline entry using ``immutableProcess()`` / ``pipeImmutable()``. .. code-block:: php @@ -170,8 +173,10 @@ Selection and filtering: - ``filter()``, ``reject()`` - ``where()``, ``whereCallback()`` - ``whereIn()``, ``whereNotIn()``, ``whereNull()``, ``whereNotNull()`` -- ``between()`` +- ``between()``, ``whereBetween()`` +- ``whereLike()``, ``whereStartsWith()``, ``whereEndsWith()``, ``whereContains()`` - ``firstWhere()`` +- ``firstWhereIn()`` Slicing and positional: @@ -183,12 +188,13 @@ Structure and reshape: - ``flatten()``, ``flattenByKey()``, ``collapse()`` - ``groupBy()``, ``keyBy()``, ``indexBy()``, ``pluck()``, ``transpose()`` - ``mapWithKeys()``, ``values()``, ``rekey()`` -- ``wrap()``, ``unWrap()``, ``unwrap()`` +- ``wrap()``, ``unWrap()`` Ordering and uniqueness: - ``sortBy()``, ``sortRecursive()``, ``shuffle()`` -- ``unique()``, ``duplicates()``, ``partition()`` +- ``sortByMany()`` +- ``unique()``, ``duplicates()``, ``uniqueBy()``, ``duplicatesBy()``, ``partition()`` - ``intersect()``, ``diff()``, ``symmetricDiff()``, ``same()`` Terminal methods (end chain with scalar/array/bool): @@ -226,7 +232,9 @@ Slicing and paging: .. code-block:: php paginate(1, 2)->all(); // first 2 items $everySecond = $list->nth(2)->all(); @@ -238,7 +246,9 @@ Grouping and reshaping: .. code-block:: php 'A', 'score' => 10], ['team' => 'B', 'score' => 20], ['team' => 'A', 'score' => 30], @@ -247,13 +257,37 @@ Grouping and reshaping: $grouped = $rows->groupBy('team')->all(); $scores = $rows->pluck('score')->all(); // [10, 20, 30] $sorted = $rows->sortBy('score', desc: true)->all(); + $sortedMany = $rows->sortByMany([ + ['team', 'asc'], + ['score', 'desc'], + ])->all(); + +LazyCollection +-------------- + +Use ``LazyCollection`` for generator-backed transformations over large iterables. + +.. code-block:: php + + filterLazy(fn ($v) => $v % 2 === 0) + ->mapLazy(fn ($v) => $v * 10) + ->take(5) + ->all(); + + // [20, 40, 60, 80, 100] Terminal calculations: .. code-block:: php process()->sum(); // 15 $median = $numbers->process()->median(); // 3 @@ -267,3 +301,6 @@ Behavior Notes - Terminal methods return scalar/array/bool and stop the chain. - Dot-notation works in collection accessors and in ``HookedCollection`` get/set overrides. - ``merge()`` follows PHP ``array_merge`` semantics (string-key overwrite, numeric append/reindex). +- ``paginate()`` throws ``InvalidArgumentException`` when ``page < 1`` or ``perPage < 1``. +- ``flatten(0)`` keeps top-level values unchanged; ``flatten(1)`` flattens one level. +- ``sum()`` and numeric min/max flows ignore non-numeric values. diff --git a/docs/config.rst b/docs/config.rst index 4582ef0..6482474 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -63,6 +63,22 @@ Reading Values $hasAny = $config->hasAny(['missing.path', 'queue.driver']); // true $required = $config->getOrFail('app.name'); // throws if missing +Typed Getters +------------- + +Use nullable/default-friendly typed getters when you want soft type access: + +.. code-block:: php + + getString('app.name'); + $port = $config->getInt('db.port', 3306); + $ratio = $config->getFloat('metrics.sample_ratio', 0.5); + $debug = $config->getBool('app.debug', false); + $hosts = $config->getList('cluster.hosts', []); + $cache = $config->getArray('cache', []); + $mode = $config->getEnum('app.mode', AppMode::class, AppMode::Prod); + Writing Values -------------- @@ -118,6 +134,24 @@ Removing Values $config->forget('cache.prefix'); $config->forget(['mail.host', 'mail.port']); +Merging, Snapshots, and Read-Only Mode +-------------------------------------- + +.. code-block:: php + + snapshot('before-runtime'); + + $config->merge(['app' => ['env' => 'production']]); // deep merge + $config->overlay(['features' => ['beta' => true]]); // top-level overlay + + $changed = $config->changed('before-runtime'); // true/false + $config->restore('before-runtime'); // rollback + + $config->readonly(); // lock writes + $locked = $config->isReadonly(); // true + $config->readonly(false); // unlock + Array-Value Helpers ------------------- @@ -219,6 +253,7 @@ Important behavior: - Namespace file must return an array. - 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. Method Summary -------------- @@ -231,6 +266,10 @@ Config methods: - ``set()``, ``fill()``, ``forget()`` - ``prepend()``, ``append()`` - ``replace()``, ``reload()`` +- ``getString()/getInt()/getFloat()/getBool()/getArray()/getList()/getEnum()`` +- ``merge()``, ``overlay()`` +- ``snapshot()``, ``restore()``, ``changed()`` +- ``readonly()``, ``isReadonly()`` LazyFileConfig methods: diff --git a/docs/dot-notation.rst b/docs/dot-notation.rst index 990bf0c..cb513fc 100644 --- a/docs/dot-notation.rst +++ b/docs/dot-notation.rst @@ -80,6 +80,16 @@ Fill vs Set DotNotation::fill($data, 'app.env', 'staging'); // does not overwrite DotNotation::fill($data, 'app.debug', true); // writes +Object properties (including null-valued properties) are treated as existing +when filling: + +.. code-block:: php + + (object) ['middle_name' => null]]; + DotNotation::fill($data, 'user.middle_name', 'X'); + // remains null; existing null property is not considered missing + Bulk Set/Fill ------------- @@ -154,6 +164,38 @@ Wildcards and Special Segments in get() $first = DotNotation::get($data, 'users.{first}.name'); // Alice $last = DotNotation::get($data, 'users.{last}.name'); // Bob +Wildcard and Path Utilities +--------------------------- + +.. code-block:: php + + [ + ['name' => 'Alice', 'email' => 'a@example.com'], + ['name' => 'Bob', 'email' => 'b@example.com'], + ], + ]; + + $hasWildcard = DotNotation::hasWildcard('users.*.email'); // true + $allPaths = DotNotation::paths($data); // ['users.0.name', ...] + $matched = DotNotation::matches($data, 'users.*.email'); // true + + DotNotation::rename($data, 'users.0.name', 'users.0.full_name'); + DotNotation::move($data, 'users.1.email', 'contacts.secondary'); + +Safe Traversal Limits +--------------------- + +Use ``getSafe()`` when path traversal depth or node count must be bounded. + +.. code-block:: php + + 'alice']); + $lazyCollection = ArrayKit::lazyCollection(range(1, 100)); $pipeline = ArrayKit::pipeline([1, 2, 3, 4]); Behavior Notes diff --git a/docs/installation.rst b/docs/installation.rst index 3ad8310..5c0a5d8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -14,4 +14,16 @@ ArrayKit has the following requirements: * **PHP 8.4+** -Autoload is PSR-4 based and includes global helper functions from ``src/functions.php``. +Autoload is PSR-4 based and loads namespaced helper functions from +``Infocyph\ArrayKit\*`` by default. + +Optional global helpers +----------------------- + +If you want global helper functions (``array_get()``, ``array_set()``, +``collect()``, ``chain()``), include ``src/functions.php`` manually: + +.. code-block:: php + + 'active', 'score' => 40, 'email' => 'a@example.com'], + ['status' => 'archived', 'score' => 10, 'email' => 'b@example.com'], + ]; + $like = ArrayMulti::whereLike($rows, 'email', '%@example.com'); + $first = ArrayMulti::firstWhereIn($rows, 'status', ['active', 'pending']); + $sortedMany = ArrayMulti::sortByMany($rows, [['status', 'asc'], ['score', 'desc']]); + DotNotation Example ------------------- @@ -81,14 +96,58 @@ Config + Hooks Example $config->set('auth.password', 'secret'); $config->onGet('auth.password', fn ($v) => strtoupper((string) $v)); echo $config->getWithHooks('auth.password'); // SECRET + $port = $config->getInt('db.port', 3306); + $config->snapshot('before'); + $config->merge(['app' => ['env' => 'production']]); + $config->restore('before'); + +LazyCollection Example +---------------------- + +.. code-block:: php + + mapLazy(fn ($v) => $v * 2) + ->filterLazy(fn ($v) => $v % 4 === 0) + ->takeUntil(fn ($v) => $v > 12) + ->all(); + +ArrayShape Example +------------------ -Global Helper Example ---------------------- +.. code-block:: php + + 1, 'email' => 'a@example.com', 'roles' => ['admin']], + ['id' => 'int', 'email' => 'string', 'roles' => 'list'], + ); + +Namespaced Helper Example +------------------------- .. code-block:: php ['name' => 'Alice']]; $name = array_get($data, 'user.name'); array_set($data, 'user.email', 'alice@example.com'); $c = collect([1, 2, 3]); + +Optional Global Helpers +----------------------- + +.. code-block:: php + + ['name' => 'Alice']]; $name = array_get($user, 'profile.name', 'Guest'); array_set($user, 'profile.email', 'alice@example.com'); @@ -51,11 +54,22 @@ Collection transformation pipeline: .. code-block:: php filter(fn ($v) => $v % 2 === 0) ->map(fn ($v) => $v * 10) ->all(); // [1 => 20, 3 => 40] +Behavior Highlights +------------------- + +- ``ArrayMulti::flatten($array, 0)`` keeps top-level values unchanged. +- ``ArraySingle::avg()``, ``sum()``, ``isPositive()``, and ``isNegative()`` ignore non-numeric values. +- ``ArraySingle::paginate()`` throws when ``page < 1`` or ``perPage < 1``. +- ``ArrayMulti::sortBy()`` / ``sum()`` / ``maxBy()`` / ``minBy()`` callbacks support ``($row, $key)``. +- Namespaced helper functions are autoloaded by default; global helpers are optional. + Runtime configuration with hooks: .. code-block:: php @@ -86,23 +100,25 @@ Exact API Signatures This appendix maps the current public API surface in ``src/`` one-to-one. -Global Helper Functions ------------------------ +Helper Functions +---------------- .. code-block:: php - function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool - function isCallable(mixed $value): bool - function array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed - function array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool - function collect(mixed $data = []): Collection - function chain(mixed $data): Pipeline + // Namespaced helpers (autoloaded by default) function Infocyph\ArrayKit\compare(mixed $retrieved, mixed $value, ?string $operator = null): bool function Infocyph\ArrayKit\array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed function Infocyph\ArrayKit\array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool function Infocyph\ArrayKit\collect(mixed $data = []): Collection function Infocyph\ArrayKit\chain(mixed $data): Pipeline + // Optional globals (manual include of src/functions.php) + function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool + function array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed + function array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool + function collect(mixed $data = []): Collection + function chain(mixed $data): Pipeline + ArrayKit Facade --------------- @@ -116,6 +132,7 @@ ArrayKit Facade public static function lazyConfig(string $directory, string $extension = 'php', array $items = []): LazyFileConfig public static function collection(mixed $data = []): Collection public static function hookedCollection(mixed $data = []): HookedCollection + public static function lazyCollection(mixed $data = []): LazyCollection public static function pipeline(mixed $data): Pipeline Facade ModuleProxy @@ -133,7 +150,6 @@ BaseArrayHelper public static function isMultiDimensional(mixed $array): bool public static function wrap(mixed $value): array public static function unWrap(mixed $value): mixed - public static function unwrap(mixed $value): mixed public static function haveAny(array $array, callable $callback): bool public static function isAll(array $array, callable $callback): bool public static function findKey(array $array, callable $callback): int|string|null @@ -197,6 +213,8 @@ ArraySingle public static function some(array $array, callable $callback): bool public static function every(array $array, callable $callback): bool public static function contains(array $array, mixed $valueOrCallback, bool $strict = false): bool + public static function containsAll(array $array, array $needles, bool $strict = false): bool + public static function containsAny(array $array, array $needles, bool $strict = false): bool public static function countBy(array $array, ?callable $by = null): array public static function sum(array $array, ?callable $callback = null): float|int public static function unique(array $array, bool $strict = false): array @@ -228,15 +246,23 @@ ArrayMulti public static function only(array $array, array|string $keys): array public static function collapse(array $array): array public static function depth(array $array): int + public static function depthGuarded(array $array, int $maxDepth = 256, int $maxNodes = 100000, bool $throwOnTooDeep = false): int public static function flatten(array $array, float|int $depth = \INF): array + public static function flattenGuarded(array $array, float|int $depth = \INF, int $maxDepth = 256, int $maxNodes = 100000, bool $throwOnTooDeep = false): array public static function flattenByKey(array $array): array public static function values(array $array): array public static function rekey(array $array, array|callable $mapper): array public static function sortRecursive(array $array, int $options = \SORT_REGULAR, bool $descending = false): array public static function first(array $array, ?callable $callback = null, mixed $default = null): mixed public static function firstWhere(array $array, string $key, mixed $operator = null, mixed $value = null, mixed $default = null): mixed + public static function firstWhereIn(array $array, string $key, array $values, bool $strict = false, mixed $default = null): mixed public static function last(array $array, ?callable $callback = null, mixed $default = null): mixed public static function between(array $array, string $key, float|int $from, float|int $to): array + public static function whereBetween(array $array, string $key, float|int $from, float|int $to): array + public static function whereLike(array $array, string $key, string $pattern, bool $caseSensitive = false): array + public static function whereStartsWith(array $array, string $key, string $prefix, bool $caseSensitive = true): array + public static function whereEndsWith(array $array, string $key, string $suffix, bool $caseSensitive = true): array + public static function whereContains(array $array, string $key, string $needle, bool $caseSensitive = true): array public static function whereCallback(array $array, ?callable $callback = null, mixed $default = null): mixed public static function where(array $array, string $key, mixed $operator = null, mixed $value = null): array public static function chunk(array $array, int $size, bool $preserveKeys = false): array @@ -247,6 +273,8 @@ ArrayMulti public static function every(array $array, callable $callback): bool public static function contains(array $array, mixed $valueOrCallback, bool $strict = false): bool public static function unique(array $array, bool $strict = false): array + public static function uniqueBy(array $array, string|callable $keyOrCallback, bool $strict = false): array + public static function duplicatesBy(array $array, string|callable $keyOrCallback, bool $strict = false): array public static function reject(array $array, mixed $callback = true): array public static function partition(array $array, callable $callback): array public static function skip(array $array, int $count): array @@ -268,12 +296,21 @@ ArrayMulti public static function mapWithKeys(array $array, callable $callback): array public static function sortBy(array $array, string|callable $by, bool $desc = false, int $options = \SORT_REGULAR): array public static function sortByDesc(array $array, string|callable $by, int $options = \SORT_REGULAR): array + public static function sortByMany(array $array, array $criteria): array + public static function sortRecursiveGuarded(array $array, int $options = \SORT_REGULAR, bool $descending = false, int $maxDepth = 256, int $maxNodes = 100000, bool $throwOnTooDeep = false): array public static function transpose(array $matrix): array public static function pluck(array $array, string $column, ?string $indexBy = null): array public static function mergeRecursiveDistinct(array $base, array $overrides): array public static function replaceRecursive(array $base, array $replacements): array public static function overlay(array $base, array $overlay): array +ArrayShape +------------------------------- + +.. code-block:: php + + public static function require(array $row, array $shape): array + DotNotation ----------------------------------- @@ -283,10 +320,16 @@ DotNotation public static function expand(array $array): array public static function has(array $array, array|string $keys): bool public static function hasAny(array $array, array|string $keys): bool + public static function hasWildcard(string $path): bool + public static function paths(array $array): array + public static function matches(array $array, string $path): bool public static function get(array $array, array|int|string|null $keys = null, mixed $default = null): mixed + public static function getSafe(array $array, array|int|string|null $keys = null, mixed $default = null, int $maxDepth = 256, int $maxNodes = 100000, bool $throwOnTooDeep = false): mixed public static function set(array &$array, array|string|null $keys = null, mixed $value = null, bool $overwrite = true): bool public static function fill(array &$array, array|string $keys, mixed $value = null): void public static function forget(array &$target, array|string|int|null $keys): void + public static function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + public static function move(array &$array, string $from, string $to, bool $overwrite = true): bool public static function string(array $array, string $key, mixed $default = null): string public static function integer(array $array, string $key, mixed $default = null): int public static function float(array $array, string $key, mixed $default = null): float @@ -313,6 +356,8 @@ Collection uses ``BaseCollectionTrait``. Public API: public function __call(string $method, array $arguments): mixed public function __invoke(): array public function process(): Pipeline + public function immutableProcess(): Pipeline + public function pipeImmutable(): Pipeline public function get(string|array $keys): mixed public function has(string|array $keys): bool public function hasAny(string|array $keys): bool @@ -395,7 +440,13 @@ Pipeline public function keyBy(string|callable $keyBy): Collection public function indexBy(string|callable $indexBy): Collection public function between(string $key, float|int $from, float|int $to): Collection + public function whereBetween(string $key, float|int $from, float|int $to): Collection + public function whereLike(string $key, string $pattern, bool $caseSensitive = false): Collection + public function whereStartsWith(string $key, string $prefix, bool $caseSensitive = true): Collection + public function whereEndsWith(string $key, string $suffix, bool $caseSensitive = true): Collection + public function whereContains(string $key, string $needle, bool $caseSensitive = true): Collection public function firstWhere(string $key, mixed $operator = null, mixed $value = null, mixed $default = null): mixed + public function firstWhereIn(string $key, array $values, bool $strict = false, mixed $default = null): mixed public function whereCallback(?callable $callback = null, mixed $default = null): Collection public function where(string $key, mixed $operator = null, mixed $value = null): Collection public function whereIn(string $key, array $values, bool $strict = false): Collection @@ -403,10 +454,10 @@ Pipeline public function whereNull(string $key): Collection public function whereNotNull(string $key): Collection public function sortBy(string|callable $by, bool $desc = false, int $options = SORT_REGULAR): Collection + public function sortByMany(array $criteria): Collection public function isMultiDimensional(): bool public function wrap(): Collection public function unWrap(): Collection - public function unwrap(): Collection public function shuffle(?int $seed = null): Collection public function sum(?callable $callback = null): float|int public function min(string|callable|null $keyOrCallback = null): float|int|null @@ -423,6 +474,8 @@ Pipeline public function maxBy(string|callable $keyOrCallback): mixed public function pluck(string $column, ?string $indexBy = null): Collection public function transpose(): Collection + public function uniqueBy(string|callable $keyOrCallback, bool $strict = false): Collection + public function duplicatesBy(string|callable $keyOrCallback, bool $strict = false): Collection public function mergeRecursiveDistinct(array $overlay): Collection public function replaceRecursive(array $replacements): Collection public function overlay(array $overlay): Collection @@ -445,6 +498,13 @@ Config uses ``BaseConfigTrait``. Public API: public function hasAny(string|array $keys): bool public function get(string|int|array|null $key = null, mixed $default = null): mixed public function getOrFail(string|int|array|null $key): mixed + public function getString(string|int|array|null $key, ?string $default = null): ?string + public function getInt(string|int|array|null $key, ?int $default = null): ?int + public function getFloat(string|int|array|null $key, ?float $default = null): ?float + public function getBool(string|int|array|null $key, ?bool $default = null): ?bool + public function getArray(string|int|array|null $key, ?array $default = null): ?array + public function getList(string|int|array|null $key, ?array $default = null): ?array + public function getEnum(string|int|array|null $key, string $enumClass, ?\UnitEnum $default = null): ?\UnitEnum public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool public function fill(string|array $key, mixed $value = null): bool public function forget(string|int|array $key): bool @@ -452,6 +512,13 @@ Config uses ``BaseConfigTrait``. Public API: public function append(string $key, mixed $value): bool public function replace(array $items): bool public function reload(array|string $source): bool + public function merge(array $items): bool + public function overlay(array $overlay): bool + public function snapshot(string $name = 'default'): bool + public function restore(string $name = 'default'): bool + public function changed(string $snapshot = 'default'): bool + public function readonly(bool $enabled = true): static + public function isReadonly(): bool LazyFileConfig -------------------------------------- @@ -492,7 +559,11 @@ DTOTrait public static function create(array $values): static public function fromArray(array $values): static + public function hydrate(array $values, array $mapping = [], bool $coerce = false): static + public function hydrateNested(array $values, array $mapping = [], bool $coerce = false): static public function toArray(): array + public function toArrayDeep(): array + public function replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static HookTrait ---------------------------------- @@ -501,3 +572,34 @@ HookTrait public function onGet(string $offset, callable $callback): static public function onSet(string $offset, callable $callback): static + +LazyCollection +---------------------------------- + +.. code-block:: php + + public static function from(iterable $source): self + public static function make(mixed $data = []): self + public function getIterator(): Traversable + public function cursor(): Generator + public function mapLazy(callable $callback): self + public function filterLazy(callable $callback): self + public function chunkLazy(int $size, bool $preserveKeys = false): self + 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 cbc0c7c..da9c11d 100644 --- a/docs/traits-and-helpers.rst +++ b/docs/traits-and-helpers.rst @@ -1,11 +1,12 @@ -Traits and Global Helpers -========================= +Traits and Helpers +================== This page covers reusable building blocks outside the core helper classes: - ``DTOTrait`` for data-transfer object hydration - ``HookTrait`` for key-based get/set transforms -- global helper functions from ``src/functions.php`` +- namespaced helper functions (autoloaded by default) +- optional global helper functions from ``src/functions.php`` DTOTrait -------- @@ -16,7 +17,11 @@ Main methods: - ``create(array $values): static`` (static constructor) - ``fromArray(array $values): static`` (hydrate current instance) +- ``hydrate(array $values, array $mapping = [], bool $coerce = false): static`` +- ``hydrateNested(array $values, array $mapping = [], bool $coerce = false): static`` - ``toArray(): array`` (export public properties) +- ``toArrayDeep(): array`` (recursive export) +- ``replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static`` Basic DTO Flow ~~~~~~~~~~~~~~ @@ -132,27 +137,34 @@ Hooks run in registration order: $config->setWithHooks('username', ' ALICE '); // becomes "alice" -Global Helper Functions ------------------------ +Helper Functions +---------------- -Global helpers are autoloaded by Composer (``autoload.files``). +By default, Composer autoloads the namespaced helper functions +(``Infocyph\ArrayKit\*``). Global helper functions are optional. -Available helpers: +Namespaced helpers (autoloaded): + +- ``Infocyph\ArrayKit\compare(mixed $retrieved, mixed $value, ?string $operator = null): bool`` +- ``Infocyph\ArrayKit\array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed`` +- ``Infocyph\ArrayKit\array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool`` +- ``Infocyph\ArrayKit\collect(mixed $data = []): Collection`` +- ``Infocyph\ArrayKit\chain(mixed $data): Pipeline`` + +Optional global helpers (manual include): - ``compare(mixed $retrieved, mixed $value, ?string $operator = null): bool`` -- ``isCallable(mixed $value): bool`` - ``array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed`` - ``array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool`` - ``collect(mixed $data = []): Collection`` - ``chain(mixed $data): Pipeline`` -Namespaced alternatives are also available to avoid global symbol collisions: +To enable optional global helpers: -- ``Infocyph\ArrayKit\compare()`` -- ``Infocyph\ArrayKit\array_get()`` -- ``Infocyph\ArrayKit\array_set()`` -- ``Infocyph\ArrayKit\collect()`` -- ``Infocyph\ArrayKit\chain()`` +.. code-block:: php + + ['name' => 'Alice']]; $name = array_get($data, 'user.name'); // Alice @@ -177,6 +192,9 @@ collect / chain .. code-block:: php filter(fn ($v) => $v % 2 === 0)->all(); // [1 => 2, 3 => 4] @@ -188,25 +206,37 @@ compare Helper .. code-block:: php '); // true compare(10, 10, '==='); // true compare('5', 5, '!=='); // true compare(10, 10); // true (default ==) -isCallable Helper -~~~~~~~~~~~~~~~~~ +When to Use These Helpers +------------------------- + +- Use ``DTOTrait`` for lightweight request/response data objects. +- Use ``HookTrait`` consumers when you need transparent value transforms. +- Use namespaced helper functions by default; include global helpers only when explicitly desired. + +Laravel Compatibility Layer +--------------------------- + +ArrayKit also ships optional Laravel-style wrappers: -``isCallable()`` is stricter than ``is_callable()`` for strings: +- ``Infocyph\ArrayKit\LaravelCompat\Arr`` +- ``Infocyph\ArrayKit\LaravelCompat\Collection`` .. code-block:: php true); // true - isCallable('strlen'); // false + use Infocyph\ArrayKit\LaravelCompat\Arr; + use Infocyph\ArrayKit\LaravelCompat\Collection as CompatCollection; -When to Use These Helpers -------------------------- + $data = ['user' => ['name' => 'Alice']]; + Arr::set($data, 'user.role', 'admin'); + $name = Arr::get($data, 'user.name'); -- Use ``DTOTrait`` for lightweight request/response data objects. -- Use ``HookTrait`` consumers when you need transparent value transforms. -- Use global functions for concise scripting-style access in app code. + $c = new CompatCollection([1, 2, 3]); + $all = $c->all(); diff --git a/src/Array/ArrayMulti.php b/src/Array/ArrayMulti.php index 1ed4b89..51ae260 100644 --- a/src/Array/ArrayMulti.php +++ b/src/Array/ArrayMulti.php @@ -63,6 +63,26 @@ public static function depth(array $array): int return self::measureDepth($array); } + /** + * Safe depth calculation with recursion/node guards. + * + * @param array $array + */ + public static function depthGuarded( + array $array, + int $maxDepth = 256, + int $maxNodes = 100000, + bool $throwOnTooDeep = false, + ): int { + if (empty($array)) { + return 0; + } + + $visitedNodes = 0; + + return self::measureDepthGuarded($array, 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep); + } + /** * @param array $array * @return array @@ -100,10 +120,16 @@ 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 { + if ($depth <= 0) { + return array_values($array); + } + $result = []; foreach ($array as $item) { if (!is_array($item)) { @@ -134,6 +160,28 @@ public static function flattenByKey(array $array): array return $results; } + /** + * Safe flatten with recursion/node guards. + * + * @param array $array + * @return array + */ + public static function flattenGuarded( + array $array, + float|int $depth = \INF, + int $maxDepth = 256, + int $maxNodes = 100000, + bool $throwOnTooDeep = false, + ): array { + if ($depth <= 0) { + return array_values($array); + } + + $visitedNodes = 0; + + return self::flattenIntoGuarded($array, $depth, 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep); + } + /** * @param array $array * @return array @@ -314,14 +362,16 @@ public static function transpose(array $matrix): array */ public static function unique(array $array, bool $strict = false): array { - $seen = []; + $seenFingerprints = []; $results = []; foreach ($array as $key => $row) { - $compareValue = is_array($row) ? serialize($row) : $row; - if (!in_array($compareValue, $seen, $strict)) { - $seen[] = $compareValue; - $results[$key] = $row; + $fingerprint = ArraySingleOps::fingerprint($row, $strict); + if (isset($seenFingerprints[$fingerprint])) { + continue; } + + $seenFingerprints[$fingerprint] = true; + $results[$key] = $row; } return $results; @@ -338,6 +388,29 @@ public static function values(array $array): array return array_values($array); } + private static function assertTraversalWithinLimits( + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max depth.'); + + return false; + } + + $visitedNodes++; + if ($maxNodes > 0 && $visitedNodes > $maxNodes) { + self::handleTraversalLimit($throwOnTooDeep, 'Array traversal exceeded max node count.'); + + return false; + } + + return true; + } + /** * @param array $array * @param array $results @@ -355,6 +428,50 @@ private static function flattenByKeyInto(array $array, array &$results): void } } + /** + * @param array $array + * @return array + */ + private static function flattenIntoGuarded( + array $array, + float|int $depth, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): array { + if (!self::assertTraversalWithinLimits($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + return []; + } + + $result = []; + foreach ($array as $item) { + if (!is_array($item)) { + $result[] = $item; + + continue; + } + + $values = ($depth === 1) + ? array_values($item) + : self::flattenIntoGuarded($item, $depth - 1, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep); + + foreach ($values as $value) { + $result[] = $value; + } + } + + return $result; + } + + private static function handleTraversalLimit(bool $throwOnTooDeep, string $message): void + { + if ($throwOnTooDeep) { + throw new \RuntimeException($message); + } + } + /** * @param array $array */ @@ -370,4 +487,34 @@ private static function measureDepth(array $array): int return $maxDepth; } + + /** + * @param array $array + */ + private static function measureDepthGuarded( + array $array, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): int { + if (!self::assertTraversalWithinLimits($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + return 0; + } + + $resolvedMaxDepth = 1; + foreach ($array as $value) { + if (!is_array($value) || $value === []) { + continue; + } + + $resolvedMaxDepth = max( + $resolvedMaxDepth, + self::measureDepthGuarded($value, $currentDepth + 1, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep) + 1, + ); + } + + return $resolvedMaxDepth; + } } diff --git a/src/Array/ArrayShape.php b/src/Array/ArrayShape.php new file mode 100644 index 0000000..7e1a10b --- /dev/null +++ b/src/Array/ArrayShape.php @@ -0,0 +1,76 @@ + $row + * @param array $shape + * @return array + */ + public static function require(array $row, array $shape): array + { + foreach ($shape as $key => $type) { + $optional = str_ends_with($key, '?'); + $resolvedKey = $optional ? substr($key, 0, -1) : $key; + if ($resolvedKey === '') { + throw new InvalidArgumentException('Shape keys must not be empty.'); + } + + if (!array_key_exists($resolvedKey, $row)) { + if ($optional) { + continue; + } + + throw new InvalidArgumentException("Missing required key [{$resolvedKey}]."); + } + + $value = $row[$resolvedKey]; + if (!self::matchesType($value, $type)) { + $actual = get_debug_type($value); + + throw new InvalidArgumentException("Key [{$resolvedKey}] expected type [{$type}], got [{$actual}]."); + } + } + + return $row; + } + + private static function matchesType(mixed $value, string $type): bool + { + $normalized = strtolower(trim($type)); + if ($normalized === 'mixed') { + return true; + } + + if (str_starts_with($normalized, 'list<') && str_ends_with($normalized, '>')) { + if (!is_array($value) || !array_is_list($value)) { + return false; + } + + $inner = substr($normalized, 5, -1); + + return array_all($value, fn($item) => self::matchesType($item, $inner)); + } + + return match ($normalized) { + 'int', 'integer' => is_int($value), + 'string' => is_string($value), + 'float', 'double' => is_float($value), + 'bool', 'boolean' => is_bool($value), + 'array' => is_array($value), + 'list' => is_array($value) && array_is_list($value), + 'numeric' => is_numeric($value), + 'scalar' => is_scalar($value), + 'null' => $value === null, + default => false, + }; + } +} diff --git a/src/Array/ArraySingle.php b/src/Array/ArraySingle.php index 62d3b36..dff7788 100644 --- a/src/Array/ArraySingle.php +++ b/src/Array/ArraySingle.php @@ -9,18 +9,35 @@ class ArraySingle { /** - * Calculate the average of an array of numbers. + * Calculate the average of numeric values in an array. * - * @param array $array The array of numbers to average. - * @return float|int The average of the numbers in the array. If the array is empty, 0 is returned. + * Non-numeric values are ignored. + * + * @param array $array The array to average. + * @return float|int The average of numeric values. If no numeric values exist, 0 is returned. */ public static function avg(array $array): float|int { - if (empty($array)) { - return 0; + $total = 0.0; + $count = 0; + + foreach ($array as $value) { + $numeric = self::toNumericOrNull($value); + if ($numeric === null) { + continue; + } + + $total += $numeric; + $count++; } - return array_sum($array) / count($array); + if ($count === 0) { + return 0.0; + } + + $avg = $total / $count; + + return fmod($avg, 1.0) === 0.0 ? (int) $avg : $avg; } /** @@ -291,7 +308,21 @@ public static function isList(array $array): bool */ public static function isNegative(array $array): bool { - return !empty($array) && max($array) < 0; + $hasNumeric = false; + + foreach ($array as $value) { + $numeric = self::toNumericOrNull($value); + if ($numeric === null) { + continue; + } + + $hasNumeric = true; + if ($numeric >= 0) { + return false; + } + } + + return $hasNumeric; } /** @@ -302,7 +333,21 @@ public static function isNegative(array $array): bool */ public static function isPositive(array $array): bool { - return !empty($array) && min($array) > 0; + $hasNumeric = false; + + foreach ($array as $value) { + $numeric = self::toNumericOrNull($value); + if ($numeric === null) { + continue; + } + + $hasNumeric = true; + if ($numeric <= 0) { + return false; + } + } + + return $hasNumeric; } /** @@ -367,6 +412,8 @@ public static function mapWithKeys(array $array, callable $callback): array /** * Return the largest numeric value in the array. * + * Non-numeric values are ignored. + * * @param array $array */ public static function max(array $array): float|int|null @@ -420,6 +467,8 @@ public static function median(array $array): float|int /** * Return the smallest numeric value in the array. * + * Non-numeric values are ignored. + * * @param array $array */ public static function min(array $array): float|int|null @@ -549,13 +598,23 @@ public static function only(array $array, array|string $keys): array * @param int $page The page number to retrieve (1-indexed). * @param int $perPage The number of items per page. * + * @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 { + if ($page < 1) { + throw new InvalidArgumentException('Page must be greater than or equal to 1.'); + } + + if ($perPage < 1) { + throw new InvalidArgumentException('Per-page value must be greater than or equal to 1.'); + } + return array_slice( $array, - max(0, ($page - 1) * $perPage), + ($page - 1) * $perPage, $perPage, true, ); @@ -856,10 +915,10 @@ public static function some(array $array, callable $callback): bool } /** - * Return the sum of all the elements in the array. + * Return the sum of numeric values in the array. * - * If a callback is provided, it will be executed for each element in the - * array and the return value will be added to the total. + * If a callback is provided, it is executed for each element and key. + * Non-numeric values are ignored in both direct and callback modes. * * @param array $array The array to sum. * @param callable|null $callback The callback to execute for each element. @@ -867,16 +926,29 @@ public static function some(array $array, callable $callback): bool */ public static function sum(array $array, ?callable $callback = null): float|int { + $total = 0.0; + if ($callback === null) { - return array_sum($array); + foreach ($array as $value) { + $numeric = self::toNumericOrNull($value); + if ($numeric === null) { + continue; + } + + $total += $numeric; + } + + return fmod($total, 1.0) === 0.0 ? (int) $total : $total; } - $total = 0; - foreach ($array as $value) { - $result = $callback($value); - if (is_numeric($result)) { - $total += (float) $result; + foreach ($array as $key => $value) { + $result = self::invokeValueCallback($callback, $value, $key); + $numeric = self::toNumericOrNull($result); + if ($numeric === null) { + continue; } + + $total += $numeric; } return fmod($total, 1.0) === 0.0 ? (int) $total : $total; @@ -938,13 +1010,33 @@ public static function values(array $array): array */ public static function where(array $array, ?callable $callback = null): array { - $flag = ($callback !== null) ? \ARRAY_FILTER_USE_BOTH : 0; + return ArraySingleOps::where($array, $callback); + } - return array_filter($array, $callback ?? fn($val) => (bool) $val, $flag); + private static function invokeValueCallback(callable $callback, mixed $value, int|string $key): mixed + { + try { + return $callback($value, $key); + } catch (\ArgumentCountError) { + return $callback($value); + } } private static function normalizeArrayKey(mixed $value): int|string { return ArraySharedOps::normalizeArrayKey($value); } + + private static function toNumericOrNull(mixed $value): ?float + { + if (is_int($value) || is_float($value)) { + return (float) $value; + } + + if (is_string($value) && is_numeric($value)) { + return (float) $value; + } + + return null; + } } diff --git a/src/Array/ArraySingleOps.php b/src/Array/ArraySingleOps.php index 3b24ba4..f9eaae4 100644 --- a/src/Array/ArraySingleOps.php +++ b/src/Array/ArraySingleOps.php @@ -13,7 +13,7 @@ final class ArraySingleOps public static function containsAll(array $array, array $needles, bool $strict): bool { if (!$strict) { - return array_all($needles, fn($needle) => in_array($needle, $array, false)); + return self::containsAllLoose($array, $needles); } $lookup = self::buildStrictLookup($array); @@ -31,7 +31,7 @@ public static function containsAll(array $array, array $needles, bool $strict): public static function containsAny(array $array, array $needles, bool $strict): bool { if (!$strict) { - return array_any($needles, fn($needle) => in_array($needle, $array, false)); + return self::containsAnyLoose($array, $needles); } $lookup = self::buildStrictLookup($array); @@ -50,15 +50,27 @@ public static function containsAny(array $array, array $needles, bool $strict): public static function diff(array $array, array $values, bool $strict): array { if (!$strict) { - return array_filter($array, static fn(mixed $value): bool => !in_array($value, $values, false)); + $results = []; + foreach ($array as $key => $value) { + if (!in_array($value, $values, false)) { + $results[$key] = $value; + } + } + + return $results; } $lookup = self::buildStrictLookup($values); + $results = []; + foreach ($array as $key => $value) { + if (isset($lookup[self::fingerprintStrict($value)])) { + continue; + } - return array_filter( - $array, - static fn(mixed $value): bool => !isset($lookup[self::fingerprintStrict($value)]), - ); + $results[$key] = $value; + } + + return $results; } /** @@ -89,6 +101,16 @@ public static function duplicates(array $array): array return $duplicates; } + /** + * Build a comparable fingerprint for a value. + */ + public static function fingerprint(mixed $value, bool $strict = true): string + { + return $strict + ? self::fingerprintStrict($value) + : self::fingerprintLoose($value); + } + /** * @param array $array * @param array $values @@ -97,15 +119,27 @@ public static function duplicates(array $array): array public static function intersect(array $array, array $values, bool $strict): array { if (!$strict) { - return array_filter($array, static fn(mixed $value): bool => in_array($value, $values, false)); + $results = []; + foreach ($array as $key => $value) { + if (in_array($value, $values, false)) { + $results[$key] = $value; + } + } + + return $results; } $lookup = self::buildStrictLookup($values); + $results = []; + foreach ($array as $key => $value) { + if (!isset($lookup[self::fingerprintStrict($value)])) { + continue; + } - return array_filter( - $array, - static fn(mixed $value): bool => isset($lookup[self::fingerprintStrict($value)]), - ); + $results[$key] = $value; + } + + return $results; } /** @@ -199,6 +233,54 @@ public static function unique(array $array, bool $strict): array return $result; } + /** + * @param array $array + * @return array + */ + public static function where(array $array, ?callable $callback): array + { + $results = []; + + if ($callback === null) { + foreach ($array as $key => $value) { + if ((bool) $value) { + $results[$key] = $value; + } + } + + return $results; + } + + foreach ($array as $key => $value) { + if ((bool) self::invokeValueCallback($callback, $value, $key)) { + $results[$key] = $value; + } + } + + return $results; + } + + /** + * Build a loose scalar lookup table when all values are scalar/null. + * + * @param array $array + * @return array|null + */ + private static function buildLooseLookup(array $array): ?array + { + $lookup = []; + foreach ($array as $value) { + $fingerprint = self::fingerprintLooseScalar($value); + if ($fingerprint === null) { + return null; + } + + $lookup[$fingerprint] = true; + } + + return $lookup; + } + /** * @param array $array * @return array @@ -213,6 +295,48 @@ private static function buildStrictLookup(array $array): array return $lookup; } + /** + * @param array $array + * @param array $needles + */ + private static function containsAllLoose(array $array, array $needles): bool + { + $lookup = self::buildLooseLookup($array); + if ($lookup === null) { + return array_all($needles, fn($needle) => in_array($needle, $array, false)); + } + + foreach ($needles as $needle) { + $fingerprint = self::fingerprintLooseScalar($needle); + if ($fingerprint === null || !isset($lookup[$fingerprint])) { + return false; + } + } + + return true; + } + + /** + * @param array $array + * @param array $needles + */ + private static function containsAnyLoose(array $array, array $needles): bool + { + $lookup = self::buildLooseLookup($array); + if ($lookup === null) { + return array_any($needles, fn($needle) => in_array($needle, $array, false)); + } + + foreach ($needles as $needle) { + $fingerprint = self::fingerprintLooseScalar($needle); + if ($fingerprint !== null && isset($lookup[$fingerprint])) { + return true; + } + } + + return false; + } + /** * @param array $array * @return array @@ -231,7 +355,20 @@ private static function countsByFingerprint(array $array, bool $strict): array /** * @param array $value */ - private static function fingerprintArray(array $value): string + private static function fingerprintArrayLoose(array $value): string + { + $parts = []; + foreach ($value as $key => $item) { + $parts[] = self::fingerprintLoose($key) . '=>' . self::fingerprintLoose($item); + } + + return implode('|', $parts); + } + + /** + * @param array $value + */ + private static function fingerprintArrayStrict(array $value): string { $parts = []; foreach ($value as $key => $item) { @@ -249,13 +386,25 @@ private static function fingerprintLoose(mixed $value): string return match (true) { is_int($value), is_float($value), is_bool($value), $value === null => 'numeric:' . (float) $value, is_string($value) => is_numeric($value) ? 'numeric:' . (float) $value : 'string:' . $value, - is_array($value) => 'array:' . self::fingerprintArray($value), - is_object($value) => 'object-value:' . self::fingerprintArray(get_object_vars($value)), + is_array($value) => 'array:' . self::fingerprintArrayLoose($value), + is_object($value) => 'object-value:' . self::fingerprintArrayLoose(get_object_vars($value)), is_resource($value) => 'resource:' . get_resource_type($value) . ':' . (int) $value, default => 'unknown:' . get_debug_type($value), }; } + /** + * Build a loose-comparison scalar fingerprint. + */ + private static function fingerprintLooseScalar(mixed $value): ?string + { + return match (true) { + is_int($value), is_float($value), is_bool($value), $value === null => 'numeric:' . (float) $value, + is_string($value) => is_numeric($value) ? 'numeric:' . (float) $value : 'string:' . $value, + default => null, + }; + } + /** * Build a strict fingerprint that preserves type distinctions. */ @@ -267,13 +416,22 @@ private static function fingerprintStrict(mixed $value): string is_int($value) => 'int:' . $value, is_float($value) => 'float:' . json_encode($value, JSON_PRESERVE_ZERO_FRACTION), is_string($value) => 'string:' . $value, - is_array($value) => 'array:' . self::fingerprintArray($value), + is_array($value) => 'array:' . self::fingerprintArrayStrict($value), is_object($value) => 'object:' . $value::class . ':' . spl_object_id($value), is_resource($value) => 'resource:' . get_resource_type($value) . ':' . (int) $value, default => 'unknown:' . get_debug_type($value), }; } + private static function invokeValueCallback(callable $callback, mixed $value, int|string $key): mixed + { + try { + return $callback($value, $key); + } catch (\ArgumentCountError) { + return $callback($value); + } + } + /** * @param array $array */ diff --git a/src/Array/BaseArrayHelper.php b/src/Array/BaseArrayHelper.php index 3381df0..1059d75 100644 --- a/src/Array/BaseArrayHelper.php +++ b/src/Array/BaseArrayHelper.php @@ -67,15 +67,31 @@ public static function any(array $array, callable $callback): bool */ public static function doReject(array $array, mixed $callback): array { + $results = []; + if (is_callable($callback)) { - return array_filter( - $array, - fn($val, $key) => !$callback($val, $key), - ARRAY_FILTER_USE_BOTH, - ); + foreach ($array as $key => $value) { + try { + $passes = (bool) $callback($value, $key); + } catch (\ArgumentCountError) { + $passes = (bool) $callback($value); + } + + if (!$passes) { + $results[$key] = $value; + } + } + + return $results; } - return array_filter($array, fn($val) => $val != $callback); + foreach ($array as $key => $value) { + if ($value != $callback) { + $results[$key] = $value; + } + } + + return $results; } /** diff --git a/src/Array/Concerns/ArrayMultiQuerySortTrait.php b/src/Array/Concerns/ArrayMultiQuerySortTrait.php index 34bd904..18c109f 100644 --- a/src/Array/Concerns/ArrayMultiQuerySortTrait.php +++ b/src/Array/Concerns/ArrayMultiQuerySortTrait.php @@ -6,6 +6,9 @@ use Infocyph\ArrayKit\Array\ArraySharedOps; use Infocyph\ArrayKit\Array\ArraySingle; +use Infocyph\ArrayKit\Array\ArraySingleOps; + +use function Infocyph\ArrayKit\compare; trait ArrayMultiQuerySortTrait { @@ -17,13 +20,19 @@ trait ArrayMultiQuerySortTrait */ public static function between(array $array, string $key, float|int $from, float|int $to): array { - return array_filter( - $array, - static fn(mixed $item): bool => is_array($item) + $results = []; + foreach ($array as $index => $item) { + if ( + is_array($item) && ArraySingle::exists($item, $key) && compare($item[$key], $from, '>=') - && compare($item[$key], $to, '<='), - ); + && compare($item[$key], $to, '<=') + ) { + $results[$index] = $item; + } + } + + return $results; } /** @@ -48,6 +57,17 @@ public static function countBy(array $array, string|callable $groupBy): array return $counts; } + /** + * Return duplicate rows by derived key (first duplicate occurrence onward). + * + * @param array $array + * @return array + */ + public static function duplicatesBy(array $array, string|callable $keyOrCallback, bool $strict = false): array + { + return self::collectByDerivedKey($array, $keyOrCallback, $strict, keepDuplicates: true); + } + /** * Return the first row where the key comparison matches. * @@ -79,6 +99,28 @@ public static function firstWhere( return $default; } + /** + * Return the first row where a key value is in the given value set. + * + * @param array $array + * @param array $values + */ + public static function firstWhereIn(array $array, string $key, array $values, bool $strict = false, mixed $default = null): mixed + { + $lookup = self::buildInLookup($values, $strict); + foreach ($array as $row) { + if (!is_array($row) || !array_key_exists($key, $row)) { + continue; + } + + if (self::inLookupContains($lookup, $values, $row[$key], $strict)) { + return $row; + } + } + + return $default; + } + /** * Group a 2D array by a given column or callback. * @@ -256,11 +298,24 @@ public static function pluck(array $array, string $column, ?string $indexBy = nu */ public static function reject(array $array, mixed $callback = true): array { + $results = []; if (is_callable($callback)) { - return array_filter($array, fn($row, $key) => !$callback($row, $key), \ARRAY_FILTER_USE_BOTH); + foreach ($array as $key => $row) { + if (!$callback($row, $key)) { + $results[$key] = $row; + } + } + + return $results; } - return array_filter($array, fn($row) => $row != $callback); + foreach ($array as $key => $row) { + if ($row != $callback) { + $results[$key] = $row; + } + } + + return $results; } /** @@ -276,6 +331,8 @@ public static function some(array $array, callable $callback): bool /** * Sort a 2D array by a specified column or using a callback function. * + * Callback receives ($row, $key). + * * @param array $array * @return array */ @@ -285,13 +342,36 @@ public static function sortBy( bool $desc = false, int $options = \SORT_REGULAR, ): array { - uasort($array, function ($a, $b) use ($by, $desc, $options) { - $valA = is_callable($by) ? $by($a) : (is_array($a) ? ($a[$by] ?? null) : null); - $valB = is_callable($by) ? $by($b) : (is_array($b) ? ($b[$by] ?? null) : null); + if (is_callable($by)) { + $scores = []; + foreach ($array as $key => $row) { + $scores[$key] = self::invokeRowCallback($by, $row, $key); + } + + uksort( + $array, + static function (int|string $leftKey, int|string $rightKey) use ($scores, $desc, $options): int { + $comparison = self::compareSortValues( + $scores[$leftKey] ?? null, + $scores[$rightKey] ?? null, + $options, + ); + + return self::applySortDirection($comparison, $desc); + }, + ); + + return $array; + } - $comparison = self::compareSortValues($valA, $valB, $options); + uasort($array, function ($a, $b) use ($by, $desc, $options) { + $valA = is_array($a) ? ($a[$by] ?? null) : null; + $valB = is_array($b) ? ($b[$by] ?? null) : null; - return $desc ? -$comparison : $comparison; + return self::applySortDirection( + self::compareSortValues($valA, $valB, $options), + $desc, + ); }); return $array; @@ -306,6 +386,29 @@ public static function sortByDesc(array $array, string|callable $by, int $option return static::sortBy($array, $by, true, $options); } + /** + * Sort by multiple sort rules. + * + * Each criterion can be: + * - ['column', 'asc'|'desc', SORT_*] + * - [callable, 'asc'|'desc', SORT_*] + * + * @param array $array + * @param array> $criteria + * @return array + */ + public static function sortByMany(array $array, array $criteria): array + { + if ($criteria === []) { + return $array; + } + + $normalized = self::normalizeSortByManyCriteria($criteria); + uasort($array, static fn(mixed $left, mixed $right): int => self::compareByManyCriteria($left, $right, $normalized)); + + return $array; + } + /** * Recursively sort a multidimensional array by keys/values. * @@ -336,21 +439,62 @@ public static function sortRecursive(array $array, int $options = \SORT_REGULAR, return $array; } + /** + * Safe recursive sort with depth/node guards. + * + * @param array $array + * @return array + */ + public static function sortRecursiveGuarded( + array $array, + int $options = \SORT_REGULAR, + bool $descending = false, + int $maxDepth = 256, + int $maxNodes = 100000, + bool $throwOnTooDeep = false, + ): array { + $visitedNodes = 0; + + return self::sortRecursiveWithGuards( + $array, + $options, + $descending, + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ); + } + /** * Calculate the sum of an array of values. * + * Callback receives ($row, $key). Non-numeric values are ignored. + * * @param array $array */ public static function sum(array $array, string|callable|null $keyOrCallback = null): float|int { $total = 0; - foreach ($array as $row) { - $total += self::extractSummableValue($row, $keyOrCallback); + foreach ($array as $key => $row) { + $total += self::extractSummableValue($row, $keyOrCallback, $key); } return fmod($total, 1.0) === 0.0 ? (int) $total : $total; } + /** + * Return unique rows by derived key. + * + * @param array $array + * @return array + */ + public static function uniqueBy(array $array, string|callable $keyOrCallback, bool $strict = false): array + { + return self::collectByDerivedKey($array, $keyOrCallback, $strict, keepDuplicates: false); + } + /** * Filter a 2D array by a single key's comparison (like "where 'age' > 18"). * @@ -365,12 +509,29 @@ public static function where(array $array, string $key, mixed $operator = null, } $operator = is_string($operator) ? $operator : null; - return array_filter( - $array, - static fn(mixed $item): bool => is_array($item) + $results = []; + foreach ($array as $index => $item) { + if ( + is_array($item) && ArraySingle::exists($item, $key) - && compare($item[$key], $value, $operator), - ); + && compare($item[$key], $value, $operator) + ) { + $results[$index] = $item; + } + } + + return $results; + } + + /** + * Filter rows where a key value falls inside an inclusive range. + * + * @param array $array + * @return array + */ + public static function whereBetween(array $array, string $key, float|int $from, float|int $to): array + { + return self::between($array, $key, $from, $to); } /** @@ -384,7 +545,48 @@ public static function whereCallback(array $array, ?callable $callback = null, m return empty($array) ? $default : $array; } - return array_filter($array, static fn(mixed $item, int|string $index): bool => (bool) $callback($item, $index), \ARRAY_FILTER_USE_BOTH); + $results = []; + foreach ($array as $index => $item) { + if ((bool) $callback($item, $index)) { + $results[$index] = $item; + } + } + + return $results; + } + + /** + * Filter rows where a key value contains a given substring. + * + * @param array $array + * @return array + */ + public static function whereContains(array $array, string $key, string $needle, bool $caseSensitive = true): array + { + $match = $caseSensitive ? $needle : strtolower($needle); + + return self::filterByTextMatch( + $array, + $key, + static fn(string $text): bool => str_contains($caseSensitive ? $text : strtolower($text), $match), + ); + } + + /** + * Filter rows where a key value ends with a given suffix. + * + * @param array $array + * @return array + */ + public static function whereEndsWith(array $array, string $key, string $suffix, bool $caseSensitive = true): array + { + $needle = $caseSensitive ? $suffix : strtolower($suffix); + + return self::filterByTextMatch( + $array, + $key, + static fn(string $text): bool => str_ends_with($caseSensitive ? $text : strtolower($text), $needle), + ); } /** @@ -396,12 +598,51 @@ public static function whereCallback(array $array, ?callable $callback = null, m */ public static function whereIn(array $array, string $key, array $values, bool $strict = false): array { - return array_filter( - $array, - static fn(mixed $row): bool => is_array($row) - && array_key_exists($key, $row) - && in_array($row[$key], $values, $strict), - ); + $results = []; + $lookup = self::buildInLookup($values, $strict); + + foreach ($array as $index => $row) { + if (!is_array($row) || !array_key_exists($key, $row)) { + continue; + } + + if (self::inLookupContains($lookup, $values, $row[$key], $strict)) { + $results[$index] = $row; + } + } + + return $results; + } + + /** + * Filter rows where a key value matches an SQL-like pattern ('%' and '_'). + * + * @param array $array + * @return array + */ + public static function whereLike(array $array, string $key, string $pattern, bool $caseSensitive = false): array + { + $quoted = preg_quote($pattern, '/'); + $regex = '/^' . str_replace(['%', '_'], ['.*', '.'], $quoted) . '$/' . ($caseSensitive ? '' : 'i'); + $results = []; + + foreach ($array as $index => $row) { + if (!is_array($row) || !array_key_exists($key, $row)) { + continue; + } + + $value = $row[$key]; + if (!is_scalar($value) && $value !== null) { + continue; + } + + $text = (string) $value; + if (preg_match($regex, $text) === 1) { + $results[$index] = $row; + } + } + + return $results; } /** @@ -413,12 +654,22 @@ public static function whereIn(array $array, string $key, array $values, bool $s */ public static function whereNotIn(array $array, string $key, array $values, bool $strict = false): array { - return array_filter( - $array, - static fn(mixed $row): bool => !is_array($row) - || !array_key_exists($key, $row) - || !in_array($row[$key], $values, $strict), - ); + $results = []; + $lookup = self::buildInLookup($values, $strict); + + foreach ($array as $index => $row) { + if (!is_array($row) || !array_key_exists($key, $row)) { + $results[$index] = $row; + + continue; + } + + if (!self::inLookupContains($lookup, $values, $row[$key], $strict)) { + $results[$index] = $row; + } + } + + return $results; } /** @@ -429,7 +680,7 @@ public static function whereNotIn(array $array, string $key, array $values, bool */ public static function whereNotNull(array $array, string $key): array { - return array_filter($array, static fn(mixed $row): bool => is_array($row) && isset($row[$key])); + return self::filterByNullState($array, $key, expectNull: false); } /** @@ -440,15 +691,31 @@ public static function whereNotNull(array $array, string $key): array */ public static function whereNull(array $array, string $key): array { - return array_filter( + return self::filterByNullState($array, $key, expectNull: true); + } + + /** + * Filter rows where a key value starts with a given prefix. + * + * @param array $array + * @return array + */ + public static function whereStartsWith(array $array, string $key, string $prefix, bool $caseSensitive = true): array + { + $needle = $caseSensitive ? $prefix : strtolower($prefix); + + return self::filterByTextMatch( $array, - static fn(mixed $row): bool => is_array($row) - && !empty($row) - && array_key_exists($key, $row) - && $row[$key] === null, + $key, + static fn(string $text): bool => str_starts_with($caseSensitive ? $text : strtolower($text), $needle), ); } + private static function applySortDirection(int $comparison, bool $desc): int + { + return $desc ? -$comparison : $comparison; + } + private static function asNumeric(mixed $value): float { if (is_int($value) || is_float($value)) { @@ -467,6 +734,117 @@ private static function asString(mixed $value): string return ArraySharedOps::asString($value); } + /** + * @param array $values + * @return array|null + */ + private static function buildInLookup(array $values, bool $strict): ?array + { + if ($strict) { + $lookup = []; + foreach ($values as $value) { + $lookup[ArraySingleOps::fingerprint($value, true)] = true; + } + + return $lookup; + } + + $lookup = []; + foreach ($values as $value) { + $fingerprint = self::looseScalarFingerprint($value); + if ($fingerprint === null) { + return null; + } + + $lookup[$fingerprint] = true; + } + + return $lookup; + } + + private static function canTraverse( + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): bool { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort exceeded max depth.'); + } + + return false; + } + + $visitedNodes++; + if ($maxNodes > 0 && $visitedNodes > $maxNodes) { + if ($throwOnTooDeep) { + throw new \RuntimeException('Recursive sort exceeded max node count.'); + } + + return false; + } + + return true; + } + + /** + * @param array $array + * @return array + */ + private static function collectByDerivedKey( + array $array, + string|callable $keyOrCallback, + bool $strict, + bool $keepDuplicates, + ): array { + $seen = []; + $results = []; + + foreach ($array as $index => $row) { + $derived = self::resolveDerivedValue($row, $keyOrCallback, $index); + $fingerprint = ArraySingleOps::fingerprint($derived, $strict); + $alreadySeen = isset($seen[$fingerprint]); + + if ($keepDuplicates) { + if ($alreadySeen) { + $results[$index] = $row; + } else { + $seen[$fingerprint] = true; + } + + continue; + } + + if ($alreadySeen) { + continue; + } + + $seen[$fingerprint] = true; + $results[$index] = $row; + } + + return $results; + } + + /** + * @param array $criteria + */ + private static function compareByManyCriteria(mixed $left, mixed $right, array $criteria): int + { + foreach ($criteria as $criterion) { + $leftValue = self::resolveSortByManyValue($left, $criterion['by'], 0); + $rightValue = self::resolveSortByManyValue($right, $criterion['by'], 1); + $comparison = self::compareSortValues($leftValue, $rightValue, $criterion['options']); + if ($comparison !== 0) { + return self::applySortDirection($comparison, $criterion['desc']); + } + } + + return 0; + } + /** * Compare two values according to PHP sort options. */ @@ -492,10 +870,10 @@ private static function compareSortValues(mixed $left, mixed $right, int $option }; } - private static function extractComparableValue(mixed $row, string|callable $keyOrCallback): ?float + private static function extractComparableValue(mixed $row, string|callable $keyOrCallback, int|string $key): ?float { if (is_callable($keyOrCallback)) { - $result = $keyOrCallback($row); + $result = self::invokeRowCallback($keyOrCallback, $row, $key); return is_numeric($result) ? (float) $result : null; } @@ -507,14 +885,28 @@ private static function extractComparableValue(mixed $row, string|callable $keyO return null; } - private static function extractSummableValue(mixed $row, string|callable|null $keyOrCallback): float + private static function extractRowTextValue(mixed $row, string $key): ?string + { + if (!is_array($row) || !array_key_exists($key, $row)) { + return null; + } + + $value = $row[$key]; + if (!is_scalar($value) && $value !== null) { + return null; + } + + return (string) $value; + } + + private static function extractSummableValue(mixed $row, string|callable|null $keyOrCallback, int|string $key): float { if ($keyOrCallback === null) { return is_numeric($row) ? (float) $row : 0.0; } if (is_callable($keyOrCallback)) { - $result = $keyOrCallback($row); + $result = self::invokeRowCallback($keyOrCallback, $row, $key); return is_numeric($result) ? (float) $result : 0.0; } @@ -526,6 +918,47 @@ private static function extractSummableValue(mixed $row, string|callable|null $k return 0.0; } + /** + * @param array $array + * @return array + */ + private static function filterByNullState(array $array, string $key, bool $expectNull): array + { + $results = []; + foreach ($array as $index => $row) { + if (!is_array($row) || !array_key_exists($key, $row)) { + continue; + } + + $isNull = $row[$key] === null; + if (($expectNull && $isNull) || (!$expectNull && !$isNull)) { + $results[$index] = $row; + } + } + + return $results; + } + + /** + * @param array $array + * @param callable(string): bool $matcher + * @return array + */ + private static function filterByTextMatch(array $array, string $key, callable $matcher): array + { + $results = []; + foreach ($array as $index => $row) { + $text = self::extractRowTextValue($row, $key); + if ($text === null || !$matcher($text)) { + continue; + } + + $results[$index] = $row; + } + + return $results; + } + private static function formatNumericResult(?float $value): float|int|null { if ($value === null) { @@ -535,11 +968,94 @@ private static function formatNumericResult(?float $value): float|int|null return fmod($value, 1.0) === 0.0 ? (int) $value : $value; } + /** + * @param array|null $lookup + * @param array $values + */ + private static function inLookupContains(?array $lookup, array $values, mixed $candidate, bool $strict): bool + { + if ($lookup !== null) { + if ($strict) { + return isset($lookup[ArraySingleOps::fingerprint($candidate, true)]); + } + + $fingerprint = self::looseScalarFingerprint($candidate); + + return $fingerprint !== null && isset($lookup[$fingerprint]); + } + + return in_array($candidate, $values, $strict); + } + + private static function invokeRowCallback(callable $callback, mixed $row, int|string $key): mixed + { + try { + return $callback($row, $key); + } catch (\ArgumentCountError) { + return $callback($row); + } + } + + private static function looseScalarFingerprint(mixed $value): ?string + { + return match (true) { + is_int($value), is_float($value), is_bool($value), $value === null => 'numeric:' . (float) $value, + is_string($value) => is_numeric($value) ? 'numeric:' . (float) $value : 'string:' . $value, + default => null, + }; + } + private static function normalizeArrayKey(mixed $value): int|string { return ArraySharedOps::normalizeArrayKey($value); } + /** + * @param array> $criteria + * @return array + */ + private static function normalizeSortByManyCriteria(array $criteria): array + { + $normalized = []; + foreach ($criteria as $criterion) { + $by = $criterion[0] ?? null; + if (!is_string($by) && !is_callable($by)) { + continue; + } + + $rawDirection = $criterion[1] ?? 'asc'; + $direction = is_string($rawDirection) ? strtolower($rawDirection) : 'asc'; + $desc = $direction === 'desc'; + $options = isset($criterion[2]) && is_int($criterion[2]) ? $criterion[2] : \SORT_REGULAR; + + $normalized[] = [ + 'by' => $by, + 'desc' => $desc, + 'options' => $options, + ]; + } + + return $normalized; + } + + private static function resolveDerivedValue(mixed $row, string|callable $keyOrCallback, int|string $index): mixed + { + if (is_callable($keyOrCallback)) { + return self::invokeRowCallback($keyOrCallback, $row, $index); + } + + return is_array($row) && array_key_exists($keyOrCallback, $row) ? $row[$keyOrCallback] : null; + } + + private static function resolveSortByManyValue(mixed $row, string|callable $by, int|string $key): mixed + { + if (is_callable($by)) { + return self::invokeRowCallback($by, $row, $key); + } + + return is_array($row) ? ($row[$by] ?? null) : null; + } + /** * @param array $array */ @@ -549,8 +1065,8 @@ private static function selectExtremeRow(array $array, string|callable $keyOrCal $bestScore = null; $found = false; - foreach ($array as $row) { - $score = self::extractComparableValue($row, $keyOrCallback); + foreach ($array as $key => $row) { + $score = self::extractComparableValue($row, $keyOrCallback, $key); if ($score === null) { continue; } @@ -572,8 +1088,8 @@ private static function selectExtremeValue(array $array, string|callable $keyOrC { $selected = null; - foreach ($array as $row) { - $value = self::extractComparableValue($row, $keyOrCallback); + foreach ($array as $key => $row) { + $value = self::extractComparableValue($row, $keyOrCallback, $key); if ($value === null) { continue; } @@ -585,4 +1101,55 @@ private static function selectExtremeValue(array $array, string|callable $keyOrC return $selected; } + + /** + * @param array $array + * @return array + */ + private static function sortRecursiveWithGuards( + array $array, + int $options, + bool $descending, + int $currentDepth, + int &$visitedNodes, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): array { + if (!self::canTraverse($currentDepth, $visitedNodes, $maxDepth, $maxNodes, $throwOnTooDeep)) { + return $array; + } + + foreach ($array as &$value) { + if (is_array($value)) { + $value = self::sortRecursiveWithGuards( + $value, + $options, + $descending, + $currentDepth + 1, + $visitedNodes, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ); + } + } + unset($value); + + if (ArraySingle::isAssoc($array)) { + $descending + ? krsort($array, $options) + : ksort($array, $options); + } else { + usort( + $array, + static fn(mixed $left, mixed $right): int => self::applySortDirection( + self::compareSortValues($left, $right, $options), + $descending, + ), + ); + } + + return $array; + } } diff --git a/src/Array/Concerns/DotNotationPublicApiTrait.php b/src/Array/Concerns/DotNotationPublicApiTrait.php index ff8a9fb..420749e 100644 --- a/src/Array/Concerns/DotNotationPublicApiTrait.php +++ b/src/Array/Concerns/DotNotationPublicApiTrait.php @@ -137,6 +137,48 @@ public static function get(array $array, array|int|string|null $keys = null, mix return self::getValue($array, $keys, $default); } + /** + * Safe get variant with traversal limits for deep or user-controlled data. + * + * @param array $array + * @param array|int|string|null $keys + */ + public static function getSafe( + array $array, + array|int|string|null $keys = null, + mixed $default = null, + int $maxDepth = 256, + int $maxNodes = 100000, + bool $throwOnTooDeep = false, + ): mixed { + if ($keys === null) { + return $array; + } + + if ($keys === []) { + return []; + } + + if (is_array($keys)) { + $results = []; + foreach ($keys as $k) { + $resolvedKey = (string) $k; + $results[$resolvedKey] = self::getValueSafe( + $array, + $resolvedKey, + $default, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ); + } + + return $results; + } + + return self::getValueSafe($array, $keys, $default, $maxDepth, $maxNodes, $throwOnTooDeep); + } + /** * @param array $array * @param array|string $keys @@ -181,6 +223,20 @@ public static function hasAny(array $array, array|string $keys): bool return array_any($keys, static fn(int|string $key): bool => self::has($array, (string) $key)); } + /** + * Determine whether a path includes wildcard/special selector segments. + */ + public static function hasWildcard(string $path): bool + { + foreach (self::splitPath($path) as $segment) { + if ($segment === '*' || $segment === '{first}' || $segment === '{last}') { + return true; + } + } + + return false; + } + /** * @param array $array */ @@ -194,6 +250,35 @@ public static function integer(array $array, string $key, mixed $default = null) return $value; } + /** + * Determine whether a path resolves to at least one existing value. + * + * For wildcard paths, returns true if any matched result is present. + * + * @param array $array + */ + public static function matches(array $array, string $path): bool + { + if (!self::hasWildcard($path)) { + return self::has($array, $path); + } + + $missing = self::missing(); + $resolved = self::get($array, $path, $missing); + + return self::containsResolvedValue($resolved, $missing); + } + + /** + * Move a dot path from one key to another. + * + * @param array $array + */ + public static function move(array &$array, string $from, string $to, bool $overwrite = true): bool + { + return self::rename($array, $from, $to, $overwrite); + } + /** * @param array $array */ @@ -226,6 +311,17 @@ public static function offsetUnset(array &$array, string $key): void self::forget($array, $key); } + /** + * Return all leaf paths from the array. + * + * @param array $array + * @return array + */ + public static function paths(array $array): array + { + return array_keys(self::flatten($array)); + } + /** * @param array $array * @param array|string $keys @@ -243,6 +339,28 @@ public static function pluck(array $array, array|string $keys, mixed $default = return $results; } + /** + * Rename a dot path from one key to another. + * + * @param array $array + */ + public static function rename(array &$array, string $from, string $to, bool $overwrite = true): bool + { + if (!self::has($array, $from)) { + return false; + } + + if (!$overwrite && self::has($array, $to)) { + return false; + } + + $value = self::get($array, $from); + self::set($array, $to, $value, $overwrite); + self::forget($array, $from); + + return true; + } + /** * @param array $array * @param array|string|null $keys @@ -297,4 +415,17 @@ public static function tap(array $array, callable $callback): array return $array; } + + private static function containsResolvedValue(mixed $value, object $missing): bool + { + if ($value === $missing) { + return false; + } + + if (!is_array($value)) { + return true; + } + + return array_any($value, fn($item) => self::containsResolvedValue($item, $missing)); + } } diff --git a/src/Array/DotNotation.php b/src/Array/DotNotation.php index c236d19..a593ca3 100644 --- a/src/Array/DotNotation.php +++ b/src/Array/DotNotation.php @@ -80,19 +80,18 @@ private static function forgetEach(array &$array, array $segments): void */ private static function getValue(mixed $target, int|string $key, mixed $default): mixed { - if (is_array($target) && (is_int($key) || ArraySingle::exists($target, $key))) { - return $target[$key]; - } - - $keyPath = (string) $key; - if (!str_contains($keyPath, '.') && !str_contains($keyPath, '\\')) { - return self::value($default); - } - - $missing = self::missing(); - $resolved = self::traverseGet($target, self::splitPath($keyPath), $default, $missing); + return self::resolveValue($target, $key, $default); + } - return $resolved === $missing ? self::value($default) : $resolved; + private static function getValueSafe( + mixed $target, + int|string $key, + mixed $default, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): mixed { + return self::resolveValue($target, $key, $default, $maxDepth, $maxNodes, $throwOnTooDeep); } /** @@ -130,6 +129,39 @@ private static function missing(): object return $missing; } + private static function resolveValue( + mixed $target, + int|string $key, + mixed $default, + ?int $maxDepth = null, + ?int $maxNodes = null, + bool $throwOnTooDeep = false, + ): mixed { + if (is_array($target) && ArraySingle::exists($target, $key)) { + return $target[$key]; + } + + $keyPath = (string) $key; + if (!str_contains($keyPath, '.') && !str_contains($keyPath, '\\')) { + return self::value($default); + } + + $missing = self::missing(); + $resolved = ($maxDepth === null || $maxNodes === null) + ? self::traverseGet($target, self::splitPath($keyPath), $default, $missing) + : self::traverseGetWithLimits( + $target, + self::splitPath($keyPath), + $default, + $missing, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + ); + + return $resolved === $missing ? self::value($default) : $resolved; + } + /** * Retrieve a value from an array using an exact key path. */ @@ -221,14 +253,15 @@ private static function setValueFallback(mixed &$target, string $segment, array private static function setValueObject(object &$target, string $segment, array $segments, mixed $value, bool $overwrite): void { $segment = self::unescapeSegment($segment); + $propertyExists = property_exists($target, $segment); if (!empty($segments)) { - if (!isset($target->{$segment})) { + if (!$propertyExists) { $target->{$segment} = []; } self::setValueBySegments($target->{$segment}, $segments, $value, $overwrite); } else { - if ($overwrite || !isset($target->{$segment})) { + if ($overwrite || !$propertyExists) { $target->{$segment} = $value; } } @@ -275,6 +308,34 @@ private static function traverseGet(mixed $target, array $segments, mixed $defau ); } + /** + * @param array $segments + */ + private static function traverseGetWithLimits( + mixed $target, + array $segments, + mixed $default, + object $missing, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + ): mixed { + $visitedNodes = 0; + + return DotNotationPathOps::traverseGet( + $target, + $segments, + $default, + $missing, + static fn(mixed $value): mixed => self::value($value), + $maxDepth, + $maxNodes, + $throwOnTooDeep, + 1, + $visitedNodes, + ); + } + /** * Convert escaped segment markers into literal key text. */ diff --git a/src/Array/DotNotationPathOps.php b/src/Array/DotNotationPathOps.php index a9cfb5a..f60ad59 100644 --- a/src/Array/DotNotationPathOps.php +++ b/src/Array/DotNotationPathOps.php @@ -11,11 +11,24 @@ final class DotNotationPathOps */ public static function accessSegment(mixed $target, int|string $segment, object $missing): mixed { - return match (true) { - is_array($target) && ArraySingle::exists($target, $segment) => $target[$segment], - is_object($target) && isset($target->{$segment}) => $target->{$segment}, - default => $missing, - }; + if (is_array($target) && ArraySingle::exists($target, $segment)) { + return $target[$segment]; + } + + if (!is_object($target)) { + return $missing; + } + + $property = (string) $segment; + if (!property_exists($target, $property) && !isset($target->{$property})) { + return $missing; + } + + try { + return $target->{$property}; + } catch (\Error) { + return $missing; + } } /** @@ -61,8 +74,6 @@ public static function splitPath(string $path): array { /** @var array> $cache */ static $cache = []; - /** @var array $cacheKeys */ - static $cacheKeys = []; $maxEntries = 1024; if (isset($cache[$path])) { @@ -106,13 +117,12 @@ public static function splitPath(string $path): array $segments[] = $current; - $cache[$path] = $segments; - $cacheKeys[] = $path; - if (count($cacheKeys) > $maxEntries) { - $evicted = array_shift($cacheKeys); - unset($cache[$evicted]); + if (count($cache) >= $maxEntries) { + $cache = []; } + $cache[$path] = $segments; + return $segments; } @@ -122,13 +132,43 @@ public static function splitPath(string $path): array * @param array $segments * @param callable(mixed): mixed $defaultResolver */ - public static function traverseGet(mixed $target, array $segments, mixed $default, object $missing, callable $defaultResolver): mixed - { + public static function traverseGet( + mixed $target, + array $segments, + mixed $default, + object $missing, + callable $defaultResolver, + int $maxDepth = 0, + int $maxNodes = 0, + bool $throwOnTooDeep = false, + int $currentDepth = 1, + int &$visitedNodes = 0, + ): mixed { + if ($maxDepth > 0 && $currentDepth > $maxDepth) { + return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max depth.'); + } + + $visitedNodes++; + if ($maxNodes > 0 && $visitedNodes > $maxNodes) { + return self::handleTraversalLimit($missing, $throwOnTooDeep, 'Dot path traversal exceeded max node count.'); + } + foreach ($segments as $index => $segment) { unset($segments[$index]); if ($segment === '*') { - return self::traverseWildcard($target, $segments, $default, $missing, $defaultResolver); + return self::traverseWildcard( + $target, + $segments, + $default, + $missing, + $defaultResolver, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $currentDepth, + $visitedNodes, + ); } $normalized = self::normalizeSegment($segment, $target); @@ -159,6 +199,15 @@ public static function unescapeSegment(string $segment): string ); } + private static function handleTraversalLimit(object $missing, bool $throwOnTooDeep, string $message): mixed + { + if ($throwOnTooDeep) { + throw new \RuntimeException($message); + } + + return $missing; + } + /** * Resolve the {first} segment for an array-like target. */ @@ -207,8 +256,18 @@ private static function resolveLast(mixed $target): string|int|null * @param array $segments * @param callable(mixed): mixed $defaultResolver */ - private static function traverseWildcard(mixed $target, array $segments, mixed $default, object $missing, callable $defaultResolver): mixed - { + private static function traverseWildcard( + mixed $target, + array $segments, + mixed $default, + object $missing, + callable $defaultResolver, + int $maxDepth, + int $maxNodes, + bool $throwOnTooDeep, + int $currentDepth, + int &$visitedNodes, + ): mixed { $target = is_object($target) && method_exists($target, 'all') ? $target->all() : $target; if (!is_array($target)) { @@ -217,7 +276,18 @@ private static function traverseWildcard(mixed $target, array $segments, mixed $ $result = []; foreach ($target as $item) { - $resolved = self::traverseGet($item, $segments, $default, $missing, $defaultResolver); + $resolved = self::traverseGet( + $item, + $segments, + $default, + $missing, + $defaultResolver, + $maxDepth, + $maxNodes, + $throwOnTooDeep, + $currentDepth + 1, + $visitedNodes, + ); $result[] = $resolved === $missing ? $defaultResolver($default) : $resolved; } if (in_array('*', $segments, true)) { diff --git a/src/ArrayKit.php b/src/ArrayKit.php index d182ca8..d482f78 100644 --- a/src/ArrayKit.php +++ b/src/ArrayKit.php @@ -10,6 +10,7 @@ use Infocyph\ArrayKit\Array\DotNotation; use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\HookedCollection; +use Infocyph\ArrayKit\Collection\LazyCollection; use Infocyph\ArrayKit\Collection\Pipeline; use Infocyph\ArrayKit\Config\Config; use Infocyph\ArrayKit\Config\LazyFileConfig; @@ -66,6 +67,14 @@ public static function hookedCollection(mixed $data = []): HookedCollection return HookedCollection::make($data); } + /** + * @return LazyCollection + */ + public static function lazyCollection(mixed $data = []): LazyCollection + { + return LazyCollection::make($data); + } + /** * @param array $items */ diff --git a/src/Collection/BaseCollectionTrait.php b/src/Collection/BaseCollectionTrait.php index 11436a1..a4d20b9 100644 --- a/src/Collection/BaseCollectionTrait.php +++ b/src/Collection/BaseCollectionTrait.php @@ -320,6 +320,14 @@ public function immutable(): static return $this->copy(); } + /** + * Create a pipeline that operates on a copied collection instance. + */ + public function immutableProcess(): Pipeline + { + return $this->copy()->process(); + } + /** * Determine if the collection is empty. */ @@ -509,6 +517,14 @@ public function offsetUnset(mixed $offset): void } } + /** + * Alias of immutableProcess(). + */ + public function pipeImmutable(): Pipeline + { + return $this->immutableProcess(); + } + /** * Create and return a new Pipeline instance using the current collection's data. * diff --git a/src/Collection/LazyCollection.php b/src/Collection/LazyCollection.php new file mode 100644 index 0000000..069b4f4 --- /dev/null +++ b/src/Collection/LazyCollection.php @@ -0,0 +1,200 @@ + + */ +final readonly class LazyCollection implements IteratorAggregate +{ + /** + * @param \Closure(): iterable $factory + */ + private function __construct(private \Closure $factory) {} + + /** + * @template TFromKey of array-key + * @template TFromValue + * @param iterable $source + * @return self + */ + public static function from(iterable $source): self + { + return new self(static fn(): iterable => $source); + } + + /** + * @return self + */ + public static function make(mixed $data = []): self + { + if (is_array($data)) { + return self::from($data); + } + + if ($data instanceof Traversable) { + return self::from(self::iterableToArray($data)); + } + + if ($data === null) { + return self::from([]); + } + + return self::from([$data]); + } + + /** + * @return array + */ + public function all(): array + { + return iterator_to_array($this->cursor(), true); + } + + /** + * @return self> + */ + public function chunkLazy(int $size, bool $preserveKeys = false): self + { + if ($size < 1) { + throw new \InvalidArgumentException('Chunk size must be at least 1.'); + } + + return new self(function () use ($size, $preserveKeys): Generator { + $chunk = []; + foreach ($this->cursor() as $key => $value) { + if ($preserveKeys) { + $chunk[$key] = $value; + } else { + $chunk[] = $value; + } + + if (count($chunk) === $size) { + yield $chunk; + $chunk = []; + } + } + + if ($chunk !== []) { + yield $chunk; + } + }); + } + + /** + * @return Generator + */ + public function cursor(): Generator + { + $factory = $this->factory; + foreach ($factory() as $key => $value) { + yield $key => $value; + } + } + + /** + * @param callable(TValue, TKey): bool $callback + * @return self + */ + public function filterLazy(callable $callback): self + { + return new self(function () use ($callback): Generator { + foreach ($this->cursor() as $key => $value) { + if ($callback($value, $key)) { + yield $key => $value; + } + } + }); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable + { + return $this->cursor(); + } + + /** + * @template TMapped + * @param callable(TValue, TKey): TMapped $callback + * @return self + */ + public function mapLazy(callable $callback): self + { + return new self(function () use ($callback): Generator { + foreach ($this->cursor() as $key => $value) { + yield $key => $callback($value, $key); + } + }); + } + + /** + * @return self + */ + public function take(int $limit): self + { + if ($limit < 0) { + throw new \InvalidArgumentException('Take limit must be zero or greater.'); + } + + return new self(function () use ($limit): Generator { + $count = 0; + foreach ($this->cursor() as $key => $value) { + if ($count >= $limit) { + break; + } + + yield $key => $value; + $count++; + } + }); + } + + /** + * @param callable(TValue, TKey): bool $callback + * @return self + */ + public function takeUntil(callable $callback): self + { + return new self(function () use ($callback): Generator { + foreach ($this->cursor() as $key => $value) { + if ($callback($value, $key)) { + break; + } + + yield $key => $value; + } + }); + } + + /** + * Normalize traversable input to array-key arrays for generic safety. + * + * @param Traversable $source + * @return array + */ + private static function iterableToArray(Traversable $source): array + { + $results = []; + foreach ($source as $key => $value) { + if (is_int($key) || is_string($key)) { + $results[$key] = $value; + + continue; + } + + $results[] = $value; + } + + return $results; + } +} diff --git a/src/Collection/Pipeline.php b/src/Collection/Pipeline.php index 3a0a694..e407c2a 100644 --- a/src/Collection/Pipeline.php +++ b/src/Collection/Pipeline.php @@ -21,8 +21,7 @@ public function __construct( ) {} /** - * Quick example: Check if at least one item passes a truth test, from ArraySingle::some or ArrayMulti::some - * Not chainable, returns bool. + * Check if at least one item passes a truth test. */ public function any(callable $callback): bool { @@ -119,8 +118,19 @@ public function duplicates(): Collection return $this->collection; } - /** Remove keys (inverse of only) */ /** + * Keep duplicate rows for a derived key. + */ + public function duplicatesBy(string|callable $keyOrCallback, bool $strict = false): Collection + { + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::duplicatesBy($working, $keyOrCallback, $strict), + ); + } + + /** + * Remove keys (inverse of only). + * * @param array|string $keys */ public function except(array|string $keys): Collection @@ -142,8 +152,7 @@ public function filter(callable $callback): Collection } /** - * Return the first item in a 2D array, or single-dim array, depending on usage. - * from ArrayMulti::first or direct approach. + * Return the first item from the working set. */ public function first(?callable $callback = null, mixed $default = null): mixed { @@ -158,6 +167,16 @@ public function firstWhere(string $key, mixed $operator = null, mixed $value = n return ArrayMulti::firstWhere($this->working, $key, $operator, $value, $default); } + /** + * Return the first row where key value is inside a list. + * + * @param array $values + */ + public function firstWhereIn(string $key, array $values, bool $strict = false, mixed $default = null): mixed + { + return ArrayMulti::firstWhereIn($this->working, $key, $values, $strict, $default); + } + /* |-------------------------------------------------------------------------- | ArrayMulti-based chainable methods (Multi-Dimensional usage) @@ -169,9 +188,9 @@ public function firstWhere(string $key, mixed $operator = null, mixed $value = n */ public function flatten(float|int $depth = \INF): Collection { - $this->working = ArrayMulti::flatten($this->working, $depth); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::flatten($working, $depth), + ); } /** @@ -179,9 +198,9 @@ public function flatten(float|int $depth = \INF): Collection */ public function flattenByKey(): Collection { - $this->working = ArrayMulti::flattenByKey($this->working); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::flattenByKey($working), + ); } /** @@ -189,9 +208,9 @@ public function flattenByKey(): Collection */ public function groupBy(string|callable $groupBy, bool $preserveKeys = false): Collection { - $this->working = ArrayMulti::groupBy($this->working, $groupBy, $preserveKeys); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::groupBy($working, $groupBy, $preserveKeys), + ); } /** @@ -199,9 +218,9 @@ public function groupBy(string|callable $groupBy, bool $preserveKeys = false): C */ public function indexBy(string|callable $indexBy): Collection { - $this->working = ArrayMulti::indexBy($this->working, $indexBy); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::indexBy($working, $indexBy), + ); } /** @@ -253,9 +272,9 @@ public function last(?callable $callback = null, mixed $default = null): mixed */ public function map(callable $callback): Collection { - $this->working = ArraySingle::map($this->working, $callback); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArraySingle::map($working, $callback), + ); } /** @@ -263,9 +282,9 @@ public function map(callable $callback): Collection */ public function mapWithKeys(callable $callback): Collection { - $this->working = ArraySingle::mapWithKeys($this->working, $callback); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArraySingle::mapWithKeys($working, $callback), + ); } /** @@ -297,9 +316,9 @@ public function median(): float|int */ public function mergeRecursiveDistinct(array $overlay): Collection { - return $this->mutateWorking( - fn(array $working): array => ArrayMulti::mergeRecursiveDistinct($working, $overlay), - ); + $this->working = ArrayMulti::mergeRecursiveDistinct($this->working, $overlay); + + return $this->collection; } /** @@ -318,8 +337,9 @@ public function minBy(string|callable $keyOrCallback): mixed return $this->selectExtremeBy($keyOrCallback, pickMax: false); } - /** Return the statistical mode(s) – TERMINATES chain (array) */ /** + * Return the statistical mode(s) – TERMINATES chain. + * * @return array */ public function mode(): array @@ -520,6 +540,18 @@ public function sortBy(string|callable $by, bool $desc = false, int $options = S return $this->collection; } + /** + * Sort rows by multiple column/callback criteria. + * + * @param array> $criteria + */ + public function sortByMany(array $criteria): Collection + { + $this->working = ArrayMulti::sortByMany($this->working, $criteria); + + return $this->collection; + } + /** * Recursively sort the array by keys/values, using ArrayMulti::sortRecursive. */ @@ -587,6 +619,16 @@ public function unique(bool $strict = false): Collection return $this->collection; } + /** + * Keep first rows for each unique derived key. + */ + public function uniqueBy(string|callable $keyOrCallback, bool $strict = false): Collection + { + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::uniqueBy($working, $keyOrCallback, $strict), + ); + } + /** * Inverse of when(): only run if $condition is false. */ @@ -643,6 +685,16 @@ public function where(string $key, mixed $operator = null, mixed $value = null): return $this->collection; } + /** + * Filter rows where key value is in the given range. + */ + public function whereBetween(string $key, float|int $from, float|int $to): Collection + { + $this->working = ArrayMulti::whereBetween($this->working, $key, $from, $to); + + return $this->collection; + } + /** * Filter using a custom callback on each row, using ArrayMulti::whereCallback. */ @@ -654,6 +706,26 @@ public function whereCallback(?callable $callback = null, mixed $default = null) return $this->collection; } + /** + * Filter rows where key contains a substring. + */ + public function whereContains(string $key, string $needle, bool $caseSensitive = true): Collection + { + $this->working = ArrayMulti::whereContains($this->working, $key, $needle, $caseSensitive); + + return $this->collection; + } + + /** + * Filter rows where key ends with a suffix. + */ + public function whereEndsWith(string $key, string $suffix, bool $caseSensitive = true): Collection + { + $this->working = ArrayMulti::whereEndsWith($this->working, $key, $suffix, $caseSensitive); + + return $this->collection; + } + /** * Filter rows where "column" matches one of the given values. * @@ -666,6 +738,16 @@ public function whereIn(string $key, array $values, bool $strict = false): Colle return $this->collection; } + /** + * Filter rows by SQL-like pattern matching. + */ + public function whereLike(string $key, string $pattern, bool $caseSensitive = false): Collection + { + $this->working = ArrayMulti::whereLike($this->working, $key, $pattern, $caseSensitive); + + return $this->collection; + } + /** * Filter rows where "column" is not in the given values. * @@ -683,9 +765,9 @@ public function whereNotIn(string $key, array $values, bool $strict = false): Co */ public function whereNotNull(string $key): Collection { - $this->working = ArrayMulti::whereNotNull($this->working, $key); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::whereNotNull($working, $key), + ); } /** @@ -693,7 +775,17 @@ public function whereNotNull(string $key): Collection */ public function whereNull(string $key): Collection { - $this->working = ArrayMulti::whereNull($this->working, $key); + return $this->mutateWorking( + fn(array $working): array => ArrayMulti::whereNull($working, $key), + ); + } + + /** + * Filter rows where key starts with a prefix. + */ + public function whereStartsWith(string $key, string $prefix, bool $caseSensitive = true): Collection + { + $this->working = ArrayMulti::whereStartsWith($this->working, $key, $prefix, $caseSensitive); return $this->collection; } @@ -703,9 +795,9 @@ public function whereNull(string $key): Collection */ public function wrap(): Collection { - $this->working = BaseArrayHelper::wrap($this->working); - - return $this->collection; + return $this->mutateWorking( + fn(array $working): array => BaseArrayHelper::wrap($working), + ); } /** diff --git a/src/Config/BaseConfigTrait.php b/src/Config/BaseConfigTrait.php index cc57447..5494980 100644 --- a/src/Config/BaseConfigTrait.php +++ b/src/Config/BaseConfigTrait.php @@ -5,7 +5,9 @@ namespace Infocyph\ArrayKit\Config; use Infocyph\ArrayKit\Array\DotNotation; +use InvalidArgumentException; use OutOfBoundsException; +use RuntimeException; use UnexpectedValueException; trait BaseConfigTrait @@ -15,6 +17,13 @@ trait BaseConfigTrait */ protected array $items = []; + protected bool $readOnly = false; + + /** + * @var array> + */ + protected array $snapshots = []; + /** * Retrieve all configuration items. * @@ -34,6 +43,8 @@ public function all(): array */ public function append(string $key, mixed $value): bool { + $this->assertWritable(); + $array = $this->get($key, []); if (!is_array($array)) { $array = []; @@ -44,6 +55,18 @@ public function append(string $key, mixed $value): bool return $this->set($key, $array); } + /** + * Detect whether config changed compared to a named snapshot. + */ + public function changed(string $snapshot = 'default'): bool + { + if (!array_key_exists($snapshot, $this->snapshots)) { + return true; + } + + return $this->snapshots[$snapshot] != $this->items; + } + /** * "Fill" config data where it's missing, i.e. DotNotation's fill logic. * @@ -52,6 +75,8 @@ public function append(string $key, mixed $value): bool */ public function fill(string|array $key, mixed $value = null): bool { + $this->assertWritable(); + DotNotation::fill($this->items, $key, $value); return true; @@ -65,6 +90,8 @@ public function fill(string|array $key, mixed $value = null): bool */ public function forget(string|int|array $key): bool { + $this->assertWritable(); + DotNotation::forget($this->items, $key); return true; @@ -83,6 +110,114 @@ public function get(string|int|array|null $key = null, mixed $default = null): m return DotNotation::get($this->items, $key, $default); } + /** + * Get an array value or fallback default when type does not match. + * + * @param string|int|array|null $key + * @param array|null $default + * @return array|null + */ + public function getArray(string|int|array|null $key, ?array $default = null): ?array + { + $value = $this->get($key, $default); + + return is_array($value) ? $value : $default; + } + + /** + * Get a bool value or fallback default when type does not match. + * + * @param string|int|array|null $key + */ + public function getBool(string|int|array|null $key, ?bool $default = null): ?bool + { + $value = $this->get($key, $default); + + return is_bool($value) ? $value : $default; + } + + /** + * 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 + * @return TEnum|null + */ + public function getEnum(string|int|array|null $key, string $enumClass, ?\UnitEnum $default = null): ?\UnitEnum + { + if (!enum_exists($enumClass)) { + throw new InvalidArgumentException("Enum class [{$enumClass}] does not exist."); + } + + $value = $this->get($key, null); + if ($value === null) { + return $default; + } + + if (is_subclass_of($enumClass, \BackedEnum::class)) { + if (!is_string($value) && !is_int($value)) { + return $default; + } + + return $enumClass::tryFrom($value) ?? $default; + } + + if (!is_string($value)) { + return $default; + } + + foreach ($enumClass::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + + return $default; + } + + /** + * Get a float value or fallback default when type does not match. + * + * @param string|int|array|null $key + */ + public function getFloat(string|int|array|null $key, ?float $default = null): ?float + { + $value = $this->get($key, $default); + + return is_float($value) ? $value : $default; + } + + /** + * Get an int value or fallback default when type does not match. + * + * @param string|int|array|null $key + */ + public function getInt(string|int|array|null $key, ?int $default = null): ?int + { + $value = $this->get($key, $default); + + return is_int($value) ? $value : $default; + } + + /** + * Get a list array value or fallback default when type does not match. + * + * @param string|int|array|null $key + * @param array|null $default + * @return array|null + */ + public function getList(string|int|array|null $key, ?array $default = null): ?array + { + $value = $this->get($key, $default); + if (!is_array($value) || !array_is_list($value)) { + return $default; + } + + return $value; + } + /** * Get a required configuration value or throw when missing. * @@ -100,6 +235,18 @@ public function getOrFail(string|int|array|null $key): mixed return $value; } + /** + * Get a string value or fallback default when type does not match. + * + * @param string|int|array|null $key + */ + public function getString(string|int|array|null $key, ?string $default = null): ?string + { + $value = $this->get($key, $default); + + return is_string($value) ? $value : $default; + } + /** * Check if one or multiple keys exist in the configuration (no wildcard). * @@ -122,6 +269,11 @@ public function hasAny(string|array $keys): bool return DotNotation::hasAny($this->items, $keys); } + public function isReadonly(): bool + { + return $this->readOnly; + } + /** * Load configuration directly from an array resource. * @@ -130,6 +282,8 @@ public function hasAny(string|array $keys): bool */ public function loadArray(array $resource): bool { + $this->assertWritable(); + if (count($this->items) === 0) { $this->items = $resource; @@ -147,6 +301,8 @@ public function loadArray(array $resource): bool */ public function loadFile(string $path): bool { + $this->assertWritable(); + if (count($this->items) === 0 && is_file($path) && is_readable($path)) { $loaded = include $path; if (!is_array($loaded)) { @@ -161,6 +317,29 @@ public function loadFile(string $path): bool return false; } + /** + * Merge a config array into current items. + * + * @param array $items + */ + public function merge(array $items): bool + { + $this->assertWritable(); + $this->items = array_replace_recursive($this->items, $items); + + return true; + } + + /** + * Overlay another config array on top of current items. + * + * @param array $overlay + */ + public function overlay(array $overlay): bool + { + return $this->merge($overlay); + } + /** * Prepend a value to a configuration array at the specified key. * (No direct wildcard usage, though underlying DotNotation can handle it if needed.) @@ -171,6 +350,8 @@ public function loadFile(string $path): bool */ public function prepend(string $key, mixed $value): bool { + $this->assertWritable(); + $array = $this->get($key, []); if (!is_array($array)) { $array = []; @@ -181,6 +362,16 @@ public function prepend(string $key, mixed $value): bool return $this->set($key, $array); } + /** + * Enable/disable read-only mode. + */ + public function readonly(bool $enabled = true): static + { + $this->readOnly = $enabled; + + return $this; + } + /** * Reload configuration from an array or file path, replacing existing data. * @@ -188,6 +379,8 @@ public function prepend(string $key, mixed $value): bool */ public function reload(array|string $source): bool { + $this->assertWritable(); + if (is_array($source)) { return $this->replace($source); } @@ -211,11 +404,28 @@ public function reload(array|string $source): bool */ public function replace(array $items): bool { + $this->assertWritable(); + $this->items = $items; return true; } + /** + * Restore configuration from a named snapshot. + */ + public function restore(string $name = 'default'): bool + { + $this->assertWritable(); + if (!array_key_exists($name, $this->snapshots)) { + return false; + } + + $this->items = $this->snapshots[$name]; + + return true; + } + /** * Set a configuration value by dot-notation key (wildcard support), * optionally controlling overwrite vs. fill-like behavior. @@ -229,6 +439,25 @@ public function replace(array $items): bool */ public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool { + $this->assertWritable(); + return DotNotation::set($this->items, $key, $value, $overwrite); } + + /** + * Save a named snapshot of current configuration. + */ + public function snapshot(string $name = 'default'): bool + { + $this->snapshots[$name] = $this->items; + + return true; + } + + protected function assertWritable(): void + { + if ($this->readOnly) { + throw new RuntimeException('Configuration is read-only.'); + } + } } diff --git a/src/Config/LazyFileConfig.php b/src/Config/LazyFileConfig.php index a97367e..34b10e6 100644 --- a/src/Config/LazyFileConfig.php +++ b/src/Config/LazyFileConfig.php @@ -44,6 +44,8 @@ public function all(): array */ public function fill(string|array $key, mixed $value = null): bool { + $this->assertWritable(); + if (is_array($key)) { foreach ($key as $path => $entry) { if (!is_string($path)) { @@ -65,6 +67,8 @@ public function fill(string|array $key, mixed $value = null): bool */ public function forget(string|int|array $key): bool { + $this->assertWritable(); + if (is_array($key)) { foreach ($key as $path) { $this->forgetPath((string) $path); @@ -178,6 +182,7 @@ public function preload(string|array $namespaces): static */ public function reload(array|string $source): bool { + $this->assertWritable(); $this->loadedNamespaces = []; return parent::reload($source); @@ -189,6 +194,7 @@ public function reload(array|string $source): bool */ public function replace(array $items): bool { + $this->assertWritable(); $this->loadedNamespaces = []; return parent::replace($items); @@ -200,6 +206,8 @@ public function replace(array $items): bool */ public function set(string|array|null $key = null, mixed $value = null, bool $overwrite = true): bool { + $this->assertWritable(); + if ($key === null) { throw new RuntimeException('At least one key is required for LazyFileConfig::set().'); } diff --git a/src/LaravelCompat/Arr.php b/src/LaravelCompat/Arr.php new file mode 100644 index 0000000..778c78a --- /dev/null +++ b/src/LaravelCompat/Arr.php @@ -0,0 +1,72 @@ + $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 new file mode 100644 index 0000000..5f98f93 --- /dev/null +++ b/src/LaravelCompat/Collection.php @@ -0,0 +1,7 @@ +', 'ne' => $retrieved != $value, - '<', 'lt' => $retrieved < $value, - '>', 'gt' => $retrieved > $value, - '<=', 'lte' => $retrieved <= $value, - '>=', 'gte' => $retrieved >= $value, - '===' => $retrieved === $value, - '!==' => $retrieved !== $value, - default => $retrieved == $value, - }; - } -} - -if (!function_exists('isCallable')) { - /** - * Determine if the given value is callable(but not a string). - */ - function isCallable(mixed $value): bool - { - return !is_string($value) && is_callable($value); + return arraykit_compare($retrieved, $value, $operator); } } @@ -58,7 +48,7 @@ function isCallable(mixed $value): bool */ function array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed { - return Infocyph\ArrayKit\Array\DotNotation::get($array, $key, $default); + return arraykit_array_get($array, $key, $default); } } @@ -78,7 +68,7 @@ function array_get(array $array, int|string|array|null $key = null, mixed $defau */ function array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool { - return Infocyph\ArrayKit\Array\DotNotation::set($array, $key, $value, $overwrite); + return arraykit_array_set($array, $key, $value, $overwrite); } } if (!function_exists('collect')) { @@ -89,7 +79,7 @@ function array_set(array &$array, string|array|null $key, mixed $value = null, b */ function collect(mixed $data = []): Collection { - return Collection::make($data); + return arraykit_collect($data); } } if (!function_exists('chain')) { @@ -100,6 +90,6 @@ function collect(mixed $data = []): Collection */ function chain(mixed $data): Pipeline { - return Collection::make($data)->process(); + return arraykit_chain($data); } } diff --git a/src/namespaced-functions.php b/src/namespaced-functions.php index 0491f24..7c140d4 100644 --- a/src/namespaced-functions.php +++ b/src/namespaced-functions.php @@ -4,13 +4,23 @@ namespace Infocyph\ArrayKit; +use Infocyph\ArrayKit\Array\DotNotation; use Infocyph\ArrayKit\Collection\Collection; use Infocyph\ArrayKit\Collection\Pipeline; if (!function_exists(__NAMESPACE__ . '\\compare')) { function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool { - return \compare($retrieved, $value, $operator); + return match ($operator) { + '!=', '<>', 'ne' => $retrieved != $value, + '<', 'lt' => $retrieved < $value, + '>', 'gt' => $retrieved > $value, + '<=', 'lte' => $retrieved <= $value, + '>=', 'gte' => $retrieved >= $value, + '===' => $retrieved === $value, + '!==' => $retrieved !== $value, + default => $retrieved == $value, + }; } } @@ -21,7 +31,7 @@ function compare(mixed $retrieved, mixed $value, ?string $operator = null): bool */ function array_get(array $array, int|string|array|null $key = null, mixed $default = null): mixed { - return \array_get($array, $key, $default); + return DotNotation::get($array, $key, $default); } } @@ -32,20 +42,20 @@ function array_get(array $array, int|string|array|null $key = null, mixed $defau */ function array_set(array &$array, string|array|null $key, mixed $value = null, bool $overwrite = true): bool { - return \array_set($array, $key, $value, $overwrite); + return DotNotation::set($array, $key, $value, $overwrite); } } if (!function_exists(__NAMESPACE__ . '\\collect')) { function collect(mixed $data = []): Collection { - return \collect($data); + return Collection::make($data); } } if (!function_exists(__NAMESPACE__ . '\\chain')) { function chain(mixed $data): Pipeline { - return \chain($data); + return Collection::make($data)->process(); } } diff --git a/src/traits/DTOTrait.php b/src/traits/DTOTrait.php index c99454e..867a541 100644 --- a/src/traits/DTOTrait.php +++ b/src/traits/DTOTrait.php @@ -4,6 +4,9 @@ namespace Infocyph\ArrayKit\traits; +use ReflectionNamedType; +use ReflectionProperty; + /** * Trait DTOTrait * @@ -44,16 +47,60 @@ public static function create(array $values): static * @param array $values Key-value pairs matching property names */ public function fromArray(array $values): static + { + return $this->hydrate($values); + } + + /** + * Populate DTO with optional key mapping and coercion. + * + * @param array $values + * @param array $mapping + */ + public function hydrate(array $values, array $mapping = [], bool $coerce = false): static + { + foreach ($values as $key => $value) { + $property = is_string($key) && isset($mapping[$key]) ? $mapping[$key] : $key; + if (!is_string($property) || !property_exists($this, $property)) { + continue; + } + + $this->assignProperty($property, $value, $coerce); + } + + return $this; + } + + /** + * Hydrate DTO using nested DTO constructors when property type is DTO-like. + * + * @param array $values + * @param array $mapping + */ + public function hydrateNested(array $values, array $mapping = [], bool $coerce = false): static { foreach ($values as $key => $value) { - if (property_exists($this, $key)) { - $this->{$key} = $value; + $property = is_string($key) && isset($mapping[$key]) ? $mapping[$key] : $key; + if (!is_string($property) || !property_exists($this, $property)) { + continue; } + + $value = $this->resolveNestedValue($property, $value); + $this->assignProperty($property, $value, $coerce); } return $this; } + /** + * @param array $values + * @param array $mapping + */ + public function replaceFromArray(array $values, array $mapping = [], bool $coerce = false): static + { + return $this->hydrate($values, $mapping, $coerce); + } + /** * Convert the current object’s public properties into an array. * @@ -61,8 +108,100 @@ public function fromArray(array $values): static */ public function toArray(): array { - // get_object_vars($this) returns an associative array - // of property name => value for all accessible (public) properties return get_object_vars($this); } + + /** + * Export DTO recursively (nested DTO/array values are converted to arrays). + * + * @return array + */ + public function toArrayDeep(): array + { + $result = []; + foreach (get_object_vars($this) as $key => $value) { + $result[$key] = $this->exportValue($value); + } + + return $result; + } + + private function assignProperty(string $property, mixed $value, bool $coerce): void + { + if (!$coerce) { + $this->{$property} = $value; + + return; + } + + $reflection = new ReflectionProperty($this, $property); + $type = $reflection->getType(); + if (!$type instanceof ReflectionNamedType || $type->isBuiltin() === false) { + $this->{$property} = $value; + + return; + } + + $resolved = match ($type->getName()) { + 'int' => is_numeric($value) ? (int) $value : $value, + 'float' => is_numeric($value) ? (float) $value : $value, + 'string' => is_scalar($value) || $value === null ? (string) $value : $value, + 'bool' => is_bool($value) ? $value : filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE), + 'array' => is_array($value) ? $value : [$value], + default => $value, + }; + + $this->{$property} = $resolved; + } + + private function exportValue(mixed $value): mixed + { + if (is_array($value)) { + $result = []; + foreach ($value as $key => $item) { + $result[$key] = $this->exportValue($item); + } + + return $result; + } + + if (is_object($value) && method_exists($value, 'toArrayDeep')) { + return $value->toArrayDeep(); + } + + if (is_object($value) && method_exists($value, 'toArray')) { + return $value->toArray(); + } + + return $value; + } + + private function resolveNestedValue(string $property, mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $reflection = new ReflectionProperty($this, $property); + $type = $reflection->getType(); + if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) { + return $value; + } + + $className = $type->getName(); + if (method_exists($className, 'create')) { + return $className::create($value); + } + + if (!class_exists($className)) { + return $value; + } + + $instance = new $className(); + if (method_exists($instance, 'fromArray')) { + return $instance->fromArray($value); + } + + return $value; + } } diff --git a/tests/Feature/ArrayKitFacadeTest.php b/tests/Feature/ArrayKitFacadeTest.php index d1ee1d6..15502c0 100644 --- a/tests/Feature/ArrayKitFacadeTest.php +++ b/tests/Feature/ArrayKitFacadeTest.php @@ -4,6 +4,7 @@ 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; @@ -90,9 +91,12 @@ function arrayKitDeleteDirectory(string $directory): void it('creates collection and pipeline helpers from the facade', function () { $collection = ArrayKit::collection([1, 2, 3]); $hooked = ArrayKit::hookedCollection(['name' => 'alice']); + $lazy = ArrayKit::lazyCollection([1, 2, 3]); $sum = ArrayKit::pipeline([1, 2, 3])->sum(); expect($collection)->toBeInstanceOf(Collection::class) ->and($hooked)->toBeInstanceOf(HookedCollection::class) + ->and($lazy)->toBeInstanceOf(LazyCollection::class) + ->and($lazy->mapLazy(fn (int $v) => $v * 2)->all())->toBe([2, 4, 6]) ->and($sum)->toBe(6); }); diff --git a/tests/Feature/ArrayMultiTest.php b/tests/Feature/ArrayMultiTest.php index fcfe300..6881417 100644 --- a/tests/Feature/ArrayMultiTest.php +++ b/tests/Feature/ArrayMultiTest.php @@ -23,6 +23,12 @@ expect($result)->toBe([1, 2, [3, 4], 5]); }); +it('returns top-level values unchanged when flatten depth is zero', function () { + $source = [1, [2, [3, 4]], 5]; + + expect(ArrayMulti::flatten($source, 0))->toBe([1, [2, [3, 4]], 5]); +}); + it('can get the depth of a nested array', function () { $source = [1, [2, [3]], 4]; $depth = ArrayMulti::depth($source); @@ -330,6 +336,15 @@ expect(ArrayMulti::sum($data, 'val'))->toBe(6); }); +it('passes row key to sum callback', function () { + $data = [ + 2 => ['val' => 1], + 4 => ['val' => 2], + ]; + + expect(ArrayMulti::sum($data, fn (array $row, int $key) => $row['val'] + $key))->toBe(9); +}); + // partition() it('partitions a 2D array using partition()', function () { $data = [ @@ -407,6 +422,49 @@ ->and(ArrayMulti::maxBy($rows, 'score'))->toBe(['id' => 2, 'score' => 70]); }); +it('passes row key to sortBy/maxBy/minBy callbacks', function () { + $rows = [ + 'z' => ['id' => 1, 'score' => 10], + 'a' => ['id' => 2, 'score' => 20], + ]; + + $sorted = ArrayMulti::sortBy( + $rows, + fn (array $row, string $key) => $key . ':' . $row['id'], + false, + SORT_STRING, + ); + + expect(array_keys($sorted))->toBe(['a', 'z']) + ->and(ArrayMulti::maxBy($rows, fn (array $row, string $key) => $row['score'] + ($key === 'z' ? 100 : 0))) + ->toBe(['id' => 1, 'score' => 10]) + ->and(ArrayMulti::minBy($rows, fn (array $row, string $key) => $row['score'] + ($key === 'z' ? 100 : 0))) + ->toBe(['id' => 2, 'score' => 20]); +}); + +it('supports unique() with nested arrays and objects in strict and loose modes', function () { + $firstObject = (object) ['id' => 1]; + $secondObject = (object) ['id' => 1]; + + $rows = [ + 0 => ['id' => 1], + 1 => ['id' => '1'], + 2 => $firstObject, + 3 => $secondObject, + 4 => ['id' => 1], + ]; + + expect(ArrayMulti::unique($rows))->toBe([ + 0 => ['id' => 1], + 2 => $firstObject, + ])->and(ArrayMulti::unique($rows, true))->toBe([ + 0 => ['id' => 1], + 1 => ['id' => '1'], + 2 => $firstObject, + 3 => $secondObject, + ]); +}); + it('supports values, rekey, and deep merge helpers', function () { $assoc = ['a' => ['v' => 1], 'b' => ['v' => 2]]; @@ -431,3 +489,90 @@ 'a' => ['b' => 1, 'c' => 2], ]); }); + +it('supports uniqueBy and duplicatesBy helpers', function () { + $rows = [ + ['id' => 1, 'email' => 'A@example.com'], + ['id' => 2, 'email' => 'a@example.com'], + ['id' => 3, 'email' => 'b@example.com'], + ]; + + expect(ArrayMulti::uniqueBy($rows, fn (array $row) => strtolower($row['email'])))->toBe([ + 0 => ['id' => 1, 'email' => 'A@example.com'], + 2 => ['id' => 3, 'email' => 'b@example.com'], + ])->and(ArrayMulti::duplicatesBy($rows, fn (array $row) => strtolower($row['email'])))->toBe([ + 1 => ['id' => 2, 'email' => 'a@example.com'], + ]); +}); + +it('supports sortByMany with mixed sort directions', function () { + $rows = [ + ['team' => 'A', 'score' => 10, 'id' => 2], + ['team' => 'A', 'score' => 20, 'id' => 1], + ['team' => 'B', 'score' => 15, 'id' => 4], + ['team' => 'B', 'score' => 15, 'id' => 3], + ]; + + $sorted = ArrayMulti::sortByMany($rows, [ + ['team', 'asc'], + ['score', 'desc'], + ['id', 'asc'], + ]); + + expect(array_values($sorted))->toBe([ + ['team' => 'A', 'score' => 20, 'id' => 1], + ['team' => 'A', 'score' => 10, 'id' => 2], + ['team' => 'B', 'score' => 15, 'id' => 3], + ['team' => 'B', 'score' => 15, 'id' => 4], + ]); +}); + +it('supports whereBetween/whereLike/whereStartsWith/whereEndsWith/whereContains/firstWhereIn', function () { + $rows = [ + ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + ['id' => 2, 'name' => 'Bob Stone', 'age' => 19, 'role' => 'editor'], + ['id' => 3, 'name' => 'Carol Jones', 'age' => 31, 'role' => 'viewer'], + ]; + + expect(ArrayMulti::whereBetween($rows, 'age', 20, 30))->toBe([ + 0 => ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + ]) + ->and(ArrayMulti::whereLike($rows, 'name', '%Stone'))->toBe([ + 1 => ['id' => 2, 'name' => 'Bob Stone', 'age' => 19, 'role' => 'editor'], + ]) + ->and(ArrayMulti::whereStartsWith($rows, 'name', 'Ali'))->toBe([ + 0 => ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + ]) + ->and(ArrayMulti::whereEndsWith($rows, 'name', 'Jones'))->toBe([ + 2 => ['id' => 3, 'name' => 'Carol Jones', 'age' => 31, 'role' => 'viewer'], + ]) + ->and(ArrayMulti::whereContains($rows, 'name', 'Coop'))->toBe([ + 0 => ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + ]) + ->and(ArrayMulti::firstWhereIn($rows, 'role', ['viewer', 'guest']))->toBe([ + 'id' => 3, + 'name' => 'Carol Jones', + 'age' => 31, + 'role' => 'viewer', + ]); +}); + +it('supports guarded depth/flatten/sort recursion limits', function () { + $deep = [[[['value']]]]; + + expect(ArrayMulti::depthGuarded($deep, maxDepth: 2, throwOnTooDeep: false))->toBe(2) + ->and(ArrayMulti::flattenGuarded($deep, \INF, maxDepth: 2, throwOnTooDeep: false))->toBe([]) + ->and(ArrayMulti::sortRecursiveGuarded($deep, maxDepth: 2, throwOnTooDeep: false)) + ->toBe($deep); +}); + +it('can throw when guarded recursion limits are exceeded', function () { + $deep = [[[['value']]]]; + + expect(fn () => ArrayMulti::depthGuarded($deep, maxDepth: 2, throwOnTooDeep: true)) + ->toThrow(\RuntimeException::class) + ->and(fn () => ArrayMulti::flattenGuarded($deep, \INF, maxDepth: 2, throwOnTooDeep: true)) + ->toThrow(\RuntimeException::class) + ->and(fn () => ArrayMulti::sortRecursiveGuarded($deep, maxDepth: 2, throwOnTooDeep: true)) + ->toThrow(\RuntimeException::class); +}); diff --git a/tests/Feature/ArrayShapeTest.php b/tests/Feature/ArrayShapeTest.php new file mode 100644 index 0000000..68d66bf --- /dev/null +++ b/tests/Feature/ArrayShapeTest.php @@ -0,0 +1,33 @@ + 10, + 'email' => 'a@example.com', + 'roles' => ['admin', 'editor'], + ]; + + expect(ArrayShape::require($row, [ + 'id' => 'int', + 'email' => 'string', + 'roles' => 'list', + ]))->toBe($row); +}); + +it('supports optional shape keys and throws on mismatches', function () { + $row = [ + 'id' => 10, + 'email' => 'a@example.com', + ]; + + expect(ArrayShape::require($row, [ + 'id' => 'int', + 'email' => 'string', + 'roles?' => 'list', + ]))->toBe($row) + ->and(fn () => ArrayShape::require($row, ['id' => 'string']))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/ArraySingleTest.php b/tests/Feature/ArraySingleTest.php index 032dfb6..74422ab 100644 --- a/tests/Feature/ArraySingleTest.php +++ b/tests/Feature/ArraySingleTest.php @@ -38,6 +38,12 @@ expect(ArraySingle::avg($nums))->toBe(5); }); +it('ignores non-numeric values when calculating average', function () { + $values = [2, '4', 'x', null, 6]; + + expect(ArraySingle::avg($values))->toBe(4); +}); + it('searches an array for a callback condition', function () { $data = [1, 2, 3, 4]; $key = ArraySingle::search($data, fn ($value) => $value === 3); @@ -62,6 +68,14 @@ ->toBe(12); }); +it('ignores non-numeric values in sum() and supports callback keys', function () { + $arr = [2 => 1, 4 => '2', 8 => 'x']; + + expect(ArraySingle::sum($arr))->toBe(3) + ->and(ArraySingle::sum($arr, fn ($value, $key) => is_numeric($value) ? ((float) $value + $key) : null)) + ->toBe(9); +}); + it('filters non-empty values without crashing on mixed data', function () { $arr = [1, '', 0, '0', null, false, 'hello']; @@ -157,6 +171,26 @@ ->and(ArraySingle::rekey(['first_name' => 'Ada'], ['first_name' => 'firstName']))->toBe(['firstName' => 'Ada']); }); +it('evaluates positivity and negativity using numeric values only', function () { + expect(ArraySingle::isPositive(['2', 3, 'x']))->toBeTrue() + ->and(ArraySingle::isPositive(['2', 0, 'x']))->toBeFalse() + ->and(ArraySingle::isNegative(['-2', -3, 'x']))->toBeTrue() + ->and(ArraySingle::isNegative(['-2', 1, 'x']))->toBeFalse() + ->and(ArraySingle::isPositive(['x', null]))->toBeFalse() + ->and(ArraySingle::isNegative(['x', null]))->toBeFalse(); +}); + +it('validates paginate() arguments', function () { + expect(fn () => ArraySingle::paginate([1, 2, 3], 0, 2))->toThrow(InvalidArgumentException::class) + ->and(fn () => ArraySingle::paginate([1, 2, 3], 1, 0))->toThrow(InvalidArgumentException::class); +}); + +it('paginates arrays for valid page and per-page values', function () { + $arr = [1, 2, 3, 4, 5]; + + expect(ArraySingle::paginate($arr, 2, 2))->toBe([2 => 3, 3 => 4]); +}); + it('supports intersect, diff, symmetricDiff and same helpers', function () { $left = [1, 2, 3, '3']; $right = [3, 4, '3']; diff --git a/tests/Feature/BucketCollectionTest.php b/tests/Feature/BucketCollectionTest.php index 99ab911..158fce5 100644 --- a/tests/Feature/BucketCollectionTest.php +++ b/tests/Feature/BucketCollectionTest.php @@ -69,3 +69,14 @@ expect($copy->all())->toBe(['a' => 1, 'b' => 2]) ->and($immutable->all())->toBe(['a' => 1, 'b' => 2]); }); + +it('supports immutableProcess and pipeImmutable pipeline entrypoints', function () { + $collection = new Collection([1, 2, 3, 4]); + + $processed = $collection->immutableProcess()->filter(fn (int $v) => $v % 2 === 0)->all(); + $processedAlias = $collection->pipeImmutable()->filter(fn (int $v) => $v > 2)->all(); + + expect($processed)->toBe([1 => 2, 3 => 4]) + ->and($processedAlias)->toBe([2 => 3, 3 => 4]) + ->and($collection->all())->toBe([1, 2, 3, 4]); +}); diff --git a/tests/Feature/ConfigTest.php b/tests/Feature/ConfigTest.php index 50c1686..5fe0f2c 100644 --- a/tests/Feature/ConfigTest.php +++ b/tests/Feature/ConfigTest.php @@ -4,6 +4,12 @@ use Infocyph\ArrayKit\Config\Config; +enum ConfigMode: string +{ + case Local = 'local'; + case Prod = 'prod'; +} + $config = new Config(); it('can load an array into config', function () use ($config) { @@ -48,3 +54,50 @@ expect($cfg->getOrFail('app.name'))->toBe('ArrayKit') ->and(fn () => $cfg->getOrFail('app.missing'))->toThrow(OutOfBoundsException::class); }); + +it('supports typed getters with default fallbacks', function () { + $cfg = new Config(); + $cfg->loadArray([ + 'app' => ['name' => 'ArrayKit', 'debug' => true], + 'port' => 8080, + 'ratio' => 0.75, + 'tags' => ['a', 'b'], + ]); + + expect($cfg->getString('app.name'))->toBe('ArrayKit') + ->and($cfg->getBool('app.debug'))->toBeTrue() + ->and($cfg->getInt('port'))->toBe(8080) + ->and($cfg->getFloat('ratio'))->toBe(0.75) + ->and($cfg->getArray('tags'))->toBe(['a', 'b']) + ->and($cfg->getList('tags'))->toBe(['a', 'b']) + ->and($cfg->getString('port', 'fallback'))->toBe('fallback'); +}); + +it('supports merge/overlay/snapshot/restore/changed/readonly', function () { + $cfg = new Config(); + $cfg->loadArray(['db' => ['host' => 'localhost', 'port' => 3306]]); + $cfg->snapshot('baseline'); + + $cfg->merge(['db' => ['port' => 3307]]); + expect($cfg->get('db.port'))->toBe(3307) + ->and($cfg->changed('baseline'))->toBeTrue(); + + $cfg->overlay(['db' => ['ssl' => true]]); + expect($cfg->get('db.ssl'))->toBeTrue(); + + $cfg->restore('baseline'); + expect($cfg->get('db.port'))->toBe(3306) + ->and($cfg->changed('baseline'))->toBeFalse(); + + $cfg->readonly(); + expect($cfg->isReadonly())->toBeTrue() + ->and(fn () => $cfg->set('db.host', '127.0.0.1'))->toThrow(RuntimeException::class); +}); + +it('supports getEnum for backed enums', function () { + $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); +}); diff --git a/tests/Feature/DTOTraitTest.php b/tests/Feature/DTOTraitTest.php index d90f36f..7c8f89c 100644 --- a/tests/Feature/DTOTraitTest.php +++ b/tests/Feature/DTOTraitTest.php @@ -4,6 +4,23 @@ use Infocyph\ArrayKit\traits\DTOTrait; +final class DTOTraitAddress +{ + use DTOTrait; + public string $city = ''; +} + +final class DTOTraitUser +{ + use DTOTrait; + public DTOTraitAddress $address; + + public function __construct() + { + $this->address = new DTOTraitAddress(); + } +} + it('can create a DTO from an array', function () { // Define a quick test class inline $dtoClass = new class { @@ -36,3 +53,27 @@ 'age' => 28, ]); }); + +it('supports hydrate mapping and scalar coercion', function () { + $dto = new class { + use DTOTrait; + public string $name = ''; + public int $age = 0; + }; + + $dto->hydrate(['full_name' => 'Carol', 'age' => '33'], ['full_name' => 'name'], true); + + expect($dto->toArray())->toBe([ + 'name' => 'Carol', + 'age' => 33, + ]); +}); + +it('supports nested DTO hydration and deep export', function () { + $dto = new DTOTraitUser(); + $dto->hydrateNested(['address' => ['city' => 'Paris']]); + + expect($dto->toArrayDeep())->toBe([ + 'address' => ['city' => 'Paris'], + ]); +}); diff --git a/tests/Feature/DotNotationTest.php b/tests/Feature/DotNotationTest.php index 3fb9dbb..2acad5e 100644 --- a/tests/Feature/DotNotationTest.php +++ b/tests/Feature/DotNotationTest.php @@ -138,12 +138,24 @@ expect(DotNotation::get($data, 'b', 'default'))->toBe('default'); }); +it('returns default for a missing integer key', function () { + $data = [0 => 'zero']; + + expect(DotNotation::get($data, 1, 'fallback'))->toBe('fallback'); +}); + it('returns null when key exists with null value', function () { $data = ['user' => ['middle_name' => null]]; expect(DotNotation::get($data, 'user.middle_name', 'fallback'))->toBeNull(); }); +it('returns null for existing object properties with null values', function () { + $data = ['user' => (object) ['middle_name' => null]]; + + expect(DotNotation::get($data, 'user.middle_name', 'fallback'))->toBeNull(); +}); + it('does not treat existing value equal to default as missing', function () { $data = ['app' => ['env' => 'local']]; @@ -228,6 +240,14 @@ expect($data['user']['name'])->toBe('Frank'); }); +it('does not overwrite existing null object properties when fill() is used', function () { + $data = ['user' => (object) ['middle_name' => null]]; + + DotNotation::fill($data, 'user.middle_name', 'George'); + + expect($data['user']->middle_name)->toBeNull(); +}); + it('fills missing keys when fill() is used', function () { $data = ['user' => []]; DotNotation::fill($data, 'user.email', 'frank@example.com'); @@ -263,6 +283,31 @@ ]); }); +it('supports hasWildcard, paths and matches helpers', function () { + $data = [ + 'users' => [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ], + 'meta' => ['count' => 2], + ]; + + expect(DotNotation::hasWildcard('users.*.name'))->toBeTrue() + ->and(DotNotation::hasWildcard('users.0.name'))->toBeFalse() + ->and(DotNotation::paths($data))->toContain('users.0.name', 'users.1.name', 'meta.count') + ->and(DotNotation::matches($data, 'users.*.name'))->toBeTrue() + ->and(DotNotation::matches($data, 'users.*.email'))->toBeFalse(); +}); + +it('supports rename and move helpers', function () { + $data = ['user' => ['name' => 'Alice', 'role' => 'admin']]; + + expect(DotNotation::rename($data, 'user.role', 'user.type'))->toBeTrue() + ->and($data)->toBe(['user' => ['name' => 'Alice', 'type' => 'admin']]) + ->and(DotNotation::move($data, 'user.type', 'profile.kind'))->toBeTrue() + ->and($data)->toBe(['user' => ['name' => 'Alice'], 'profile' => ['kind' => 'admin']]); +}); + // // Test type-specific retrieval: string, integer, float, boolean, arrayValue // @@ -379,3 +424,26 @@ ->toBeFalse() ->and($data)->toBe(['b' => 2]); }); + +it('supports safe get traversal limits with graceful fallback', function () { + $data = [ + 'users' => [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ], + ]; + + expect(DotNotation::getSafe($data, 'users.*.name', 'missing', maxDepth: 1))->toBe(['missing', 'missing']); +}); + +it('can throw on safe get traversal limit overflow', function () { + $data = [ + 'users' => [ + ['name' => 'Alice'], + ['name' => 'Bob'], + ], + ]; + + expect(fn () => DotNotation::getSafe($data, 'users.*.name', 'missing', maxDepth: 1, throwOnTooDeep: true)) + ->toThrow(RuntimeException::class); +}); diff --git a/tests/Feature/GlobalHelpersTest.php b/tests/Feature/GlobalHelpersTest.php index e3fd7b6..e1bff19 100644 --- a/tests/Feature/GlobalHelpersTest.php +++ b/tests/Feature/GlobalHelpersTest.php @@ -10,14 +10,21 @@ use function Infocyph\ArrayKit\collect as ns_collect; use function Infocyph\ArrayKit\compare as ns_compare; -it('guards global helper declarations with function_exists checks', function () { +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); + + 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'); expect($source)->toContain("if (!function_exists('compare'))") ->and($source)->toContain("if (!function_exists('array_get'))") ->and($source)->toContain("if (!function_exists('array_set'))") ->and($source)->toContain("if (!function_exists('collect'))") - ->and($source)->toContain("if (!function_exists('chain'))"); + ->and($source)->toContain("if (!function_exists('chain'))") + ->and($source)->not->toContain("if (!function_exists('isCallable'))"); }); it('provides namespaced helper alternatives', function () { diff --git a/tests/Feature/LaravelCompatTest.php b/tests/Feature/LaravelCompatTest.php new file mode 100644 index 0000000..6b8d723 --- /dev/null +++ b/tests/Feature/LaravelCompatTest.php @@ -0,0 +1,24 @@ + ['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 new file mode 100644 index 0000000..1f91084 --- /dev/null +++ b/tests/Feature/LazyCollectionTest.php @@ -0,0 +1,26 @@ +mapLazy(fn (int $value): int => $value * 2) + ->filterLazy(fn (int $value): bool => $value > 4) + ->take(3) + ->all(); + + expect($mapped)->toBe([2 => 6, 3 => 8, 4 => 10]); + + $chunks = $lazy->chunkLazy(2)->all(); + expect($chunks)->toBe([[1, 2], [3, 4], [5, 6]]); + + $until = $lazy->takeUntil(fn (int $value): bool => $value === 4)->all(); + expect($until)->toBe([1, 2, 3]); + + $cursor = iterator_to_array($lazy->cursor(), true); + expect($cursor)->toBe([1, 2, 3, 4, 5, 6]); +}); diff --git a/tests/Feature/PipelineTest.php b/tests/Feature/PipelineTest.php index dad4bc9..e68a330 100644 --- a/tests/Feature/PipelineTest.php +++ b/tests/Feature/PipelineTest.php @@ -68,3 +68,39 @@ ->and($single->copy()->unWrap()->all())->toBe(['only']) ->and($single->copy()->values()->all())->toBe(['only']); }); + +it('supports sortByMany and row-query convenience helpers in pipelines', function () { + $rows = Collection::make([ + ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + ['id' => 2, 'name' => 'Bob Stone', 'age' => 19, 'role' => 'editor'], + ['id' => 3, 'name' => 'Carol Jones', 'age' => 31, 'role' => 'viewer'], + ['id' => 4, 'name' => 'Alex King', 'age' => 25, 'role' => 'viewer'], + ]); + + expect($rows->copy()->sortByMany([ + ['age', 'asc'], + ['id', 'desc'], + ])->all())->toBe([ + 1 => ['id' => 2, 'name' => 'Bob Stone', 'age' => 19, 'role' => 'editor'], + 3 => ['id' => 4, 'name' => 'Alex King', 'age' => 25, 'role' => 'viewer'], + 0 => ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + 2 => ['id' => 3, 'name' => 'Carol Jones', 'age' => 31, 'role' => 'viewer'], + ]) + ->and($rows->copy()->whereBetween('age', 20, 26)->all())->toBe([ + 0 => ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + 3 => ['id' => 4, 'name' => 'Alex King', 'age' => 25, 'role' => 'viewer'], + ]) + ->and($rows->copy()->whereStartsWith('name', 'Al')->all())->toBe([ + 0 => ['id' => 1, 'name' => 'Alice Cooper', 'age' => 25, 'role' => 'admin'], + 3 => ['id' => 4, 'name' => 'Alex King', 'age' => 25, 'role' => 'viewer'], + ]) + ->and($rows->copy()->whereContains('name', 'Stone')->all())->toBe([ + 1 => ['id' => 2, 'name' => 'Bob Stone', 'age' => 19, 'role' => 'editor'], + ]) + ->and($rows->process()->firstWhereIn('role', ['viewer']))->toBe([ + 'id' => 3, + 'name' => 'Carol Jones', + 'age' => 31, + 'role' => 'viewer', + ]); +});